Azure — Dynamic Service Principal Engine
Generates temporary Azure AD (Entra ID) service principals with role assignments. When a lease is requested, the engine creates an Azure AD application, creates a service principal, generates a client secret, and assigns an Azure RBAC role at the subscription scope. When the lease expires or is revoked, the engine deletes the application (which cascades to the service principal and credentials).
Capabilities
| Capability | Description |
|---|---|
engine:dynamic_credentials | Creates and revokes Azure service principals |
host:store:read | Reads bootstrap config from Arcan's encrypted store |
host:store:write | Persists metadata (app IDs) for revocation |
host:audit | Emits audit events for credential lifecycle |
Configuration
Bootstrap config provided during arcan plugin setup azure:
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string | Yes | Azure AD tenant ID (directory ID) |
client_id | string | Yes | Client ID of the admin service principal |
client_secret | string | Yes | Client secret of the admin service principal |
subscription_id | string | Yes | Azure subscription ID for role assignments |
max_ttl | string | No | Maximum lease duration (default: 24h) |
The admin service principal must have these permissions:
Microsoft Graph API permissions (Application type):
Application.ReadWrite.All— create/delete applications and service principalsAppRoleAssignment.ReadWrite.All— manage app role assignments
Azure RBAC (at subscription scope):
User Access Administrator— to assign roles to created service principals
Roles
| Role | Azure Role Definition | Description |
|---|---|---|
reader | acdd72a7-3385-48ef-bd42-f606fba81ae7 | Read access to all subscription resources |
contributor | b24988ac-6180-42a0-ab88-20f7382dd24c | Full access except role assignments |
owner | 8e3af657-a8ff-443c-a75c-2fe8c4bcb635 | Full access including role assignments |
Operations
ping — Verify Credentials
Acquires an OAuth2 token from Azure AD to confirm the admin service principal credentials are valid.
Request:
{"method": "ping", "params": null}
Response (success):
{"data": {"status": "healthy"}}
Response (failure):
{"error": "ping failed: failed to acquire token: 401 Unauthorized"}
generate — Create Service Principal + Credentials
Creates an Azure AD application, creates a service principal from it, adds a password credential, and assigns the requested RBAC role at subscription scope.
Request:
{
"method": "generate",
"params": {
"role": "contributor",
"ttl": "2h"
}
}
Response (success):
{
"data": {
"client_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"client_secret": "generated-secret-value",
"tenant_id": "11111111-2222-3333-4444-555555555555",
"subscription_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"app_object_id": "ffffffff-0000-1111-2222-333333333333",
"sp_object_id": "44444444-5555-6666-7777-888888888888",
"role": "contributor",
"display_name": "arcan-a1b2c3d4"
}
}
validate (Revoke) — Delete Application
Deletes the Azure AD application, which cascades to delete the service principal and all associated credentials and role assignments.
Request:
{
"method": "validate",
"params": {
"app_object_id": "ffffffff-0000-1111-2222-333333333333"
}
}
Response (success):
{
"data": {
"valid": true,
"message": "application ffffffff-0000-1111-2222-333333333333 deleted (cascades to service principal and credentials)"
}
}
Go Plugin Source
Complete, copy-paste ready. Uses direct HTTP calls to Microsoft Graph and Azure Resource Manager APIs (no Azure SDK — keeps the binary small and dependency-light).
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"getarcan.dev/arcan/sdk"
)
type bootstrapConfig struct {
TenantID string `json:"tenant_id"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
SubscriptionID string `json:"subscription_id"`
}
type generateParams struct {
Role string `json:"role"`
TTL string `json:"ttl"`
}
type revokeParams struct {
AppObjectID string `json:"app_object_id"`
}
var knownRoles = map[string]string{
"reader": "acdd72a7-3385-48ef-bd42-f606fba81ae7",
"contributor": "b24988ac-6180-42a0-ab88-20f7382dd24c",
"owner": "8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
}
type AzureEngine struct {
cfg bootstrapConfig
client *http.Client
}
func (e *AzureEngine) Describe() sdk.Descriptor {
return sdk.Descriptor{
Name: "azure",
Version: "0.1.0",
DisplayName: "Azure",
Description: "Dynamic Azure AD service principal credentials with RBAC role assignments",
Capabilities: []string{
"engine:dynamic_credentials",
"host:store:read",
"host:store:write",
"host:audit",
},
}
}
func (e *AzureEngine) Ping() error {
_, err := e.getToken(context.Background(), "https://graph.microsoft.com/.default")
return err
}
func (e *AzureEngine) Generate(params json.RawMessage) (*sdk.SecretResult, error) {
var p generateParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid generate params: %w", err)
}
roleDefID, ok := knownRoles[p.Role]
if !ok {
return nil, fmt.Errorf("unknown role %q — valid roles: reader, contributor, owner", p.Role)
}
ctx := context.Background()
displayName := fmt.Sprintf("arcan-%s", randomSuffix(8))
// 1. Get a Graph API token.
graphToken, err := e.getToken(ctx, "https://graph.microsoft.com/.default")
if err != nil {
return nil, fmt.Errorf("failed to get Graph token: %w", err)
}
// 2. Create the Azure AD application.
appBody := map[string]any{"displayName": displayName}
appResp, err := e.graphPost(ctx, graphToken, "/v1.0/applications", appBody)
if err != nil {
return nil, fmt.Errorf("create application failed: %w", err)
}
appObjectID := appResp["id"].(string)
appID := appResp["appId"].(string)
// 3. Create the service principal.
spBody := map[string]any{"appId": appID}
spResp, err := e.graphPost(ctx, graphToken, "/v1.0/servicePrincipals", spBody)
if err != nil {
e.graphDelete(ctx, graphToken, "/v1.0/applications/"+appObjectID)
return nil, fmt.Errorf("create service principal failed: %w", err)
}
spObjectID := spResp["id"].(string)
// 4. Add a password credential to the application.
pwBody := map[string]any{
"passwordCredential": map[string]any{
"displayName": "arcan-generated",
},
}
pwResp, err := e.graphPost(ctx, graphToken, "/v1.0/applications/"+appObjectID+"/addPassword", pwBody)
if err != nil {
e.graphDelete(ctx, graphToken, "/v1.0/applications/"+appObjectID)
return nil, fmt.Errorf("add password failed: %w", err)
}
clientSecret := pwResp["secretText"].(string)
// 5. Assign the RBAC role at subscription scope.
armToken, err := e.getToken(ctx, "https://management.azure.com/.default")
if err != nil {
e.graphDelete(ctx, graphToken, "/v1.0/applications/"+appObjectID)
return nil, fmt.Errorf("failed to get ARM token: %w", err)
}
roleAssignmentID := generateUUID()
scope := fmt.Sprintf("/subscriptions/%s", e.cfg.SubscriptionID)
roleAssignURL := fmt.Sprintf(
"https://management.azure.com%s/providers/Microsoft.Authorization/roleAssignments/%s?api-version=2022-04-01",
scope, roleAssignmentID,
)
roleBody := map[string]any{
"properties": map[string]any{
"roleDefinitionId": fmt.Sprintf("%s/providers/Microsoft.Authorization/roleDefinitions/%s", scope, roleDefID),
"principalId": spObjectID,
"principalType": "ServicePrincipal",
},
}
if err := e.armPut(ctx, armToken, roleAssignURL, roleBody); err != nil {
e.graphDelete(ctx, graphToken, "/v1.0/applications/"+appObjectID)
return nil, fmt.Errorf("role assignment failed: %w", err)
}
return &sdk.SecretResult{
Data: map[string]any{
"client_id": appID,
"client_secret": clientSecret,
"tenant_id": e.cfg.TenantID,
"subscription_id": e.cfg.SubscriptionID,
"app_object_id": appObjectID,
"sp_object_id": spObjectID,
"role": p.Role,
"display_name": displayName,
},
}, nil
}
func (e *AzureEngine) Validate(params json.RawMessage) (*sdk.ValidationResult, error) {
var p revokeParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid revoke params: %w", err)
}
if p.AppObjectID == "" {
return nil, fmt.Errorf("app_object_id is required for revocation")
}
ctx := context.Background()
graphToken, err := e.getToken(ctx, "https://graph.microsoft.com/.default")
if err != nil {
return nil, fmt.Errorf("failed to get Graph token: %w", err)
}
// Deleting the application cascades to delete the service principal,
// credentials, and role assignments.
if err := e.graphDelete(ctx, graphToken, "/v1.0/applications/"+p.AppObjectID); err != nil {
return nil, fmt.Errorf("delete application failed: %w", err)
}
return &sdk.ValidationResult{
Valid: true,
Message: fmt.Sprintf("application %s deleted (cascades to service principal and credentials)", p.AppObjectID),
}, nil
}
// --- Azure HTTP helpers ---
func (e *AzureEngine) getToken(ctx context.Context, scope string) (string, error) {
tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", e.cfg.TenantID)
data := url.Values{
"grant_type": {"client_credentials"},
"client_id": {e.cfg.ClientID},
"client_secret": {e.cfg.ClientSecret},
"scope": {scope},
}
req, _ := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := e.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", fmt.Errorf("token request failed: %s %s", resp.Status, string(body))
}
var result struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
return result.AccessToken, nil
}
func (e *AzureEngine) graphPost(ctx context.Context, token, path string, body any) (map[string]any, error) {
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, "POST", "https://graph.microsoft.com"+path, bytes.NewReader(jsonBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := e.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s: %s", resp.Status, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, err
}
return result, nil
}
func (e *AzureEngine) graphDelete(ctx context.Context, token, path string) error {
req, _ := http.NewRequestWithContext(ctx, "DELETE", "https://graph.microsoft.com"+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := e.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 && resp.StatusCode != 404 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%s: %s", resp.Status, string(body))
}
return nil
}
func (e *AzureEngine) armPut(ctx context.Context, token, fullURL string, body any) error {
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, "PUT", fullURL, bytes.NewReader(jsonBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := e.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%s: %s", resp.Status, string(respBody))
}
return nil
}
func main() {
cfg := loadBootstrapConfig()
sdk.Serve(&AzureEngine{
cfg: cfg,
client: &http.Client{Timeout: 30 * time.Second},
})
}
func loadBootstrapConfig() bootstrapConfig {
raw := os.Getenv("ARCAN_ENGINE_CONFIG")
if raw == "" {
fmt.Fprintln(os.Stderr, "ARCAN_ENGINE_CONFIG not set")
os.Exit(1)
}
var cfg bootstrapConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
fmt.Fprintf(os.Stderr, "invalid config: %v\n", err)
os.Exit(1)
}
return cfg
}
func randomSuffix(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)[:n]
}
func generateUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
Build & Install
# Initialize module
mkdir arcan-engine-azure && cd arcan-engine-azure
go mod init getarcan.dev/engines/azure
# Get dependencies
go get getarcan.dev/arcan/sdk
# Build (no external Azure SDK needed — uses HTTP directly)
go build -o arcan-engine-azure .
# Install into Arcan plugin directory
cp arcan-engine-azure ~/.arcan/plugins/
# Register with Arcan
arcan plugin register azure --path ~/.arcan/plugins/arcan-engine-azure
Usage Example
# Bootstrap the engine
arcan plugin setup azure \
--config '{"tenant_id":"...","client_id":"...","client_secret":"...","subscription_id":"..."}'
# Ping to verify connectivity
echo '{"method":"ping","params":null}' | arcan-engine-azure
# → {"data":{"status":"healthy"}}
# Generate temporary contributor credentials
echo '{"method":"generate","params":{"role":"contributor","ttl":"2h"}}' | arcan-engine-azure
# → {"data":{"client_id":"...","client_secret":"...","tenant_id":"...","app_object_id":"...",...}}
# Revoke credentials
echo '{"method":"validate","params":{"app_object_id":"ffffffff-0000-1111-2222-333333333333"}}' | arcan-engine-azure
# → {"data":{"valid":true,"message":"application ... deleted ..."}}
Security Notes
- The admin service principal should have the minimum permissions listed above. Do not use a Global Administrator.
- Created applications are prefixed with
arcan-for easy identification. - Deleting the application cascades to the service principal and all credentials, ensuring clean revocation.
- The engine uses direct HTTP calls to Azure APIs (no Azure SDK dependency) to keep the binary small and auditable.
- OAuth2 tokens are acquired per-operation and not cached, ensuring fresh authentication on each call.
- On generate failure, the engine rolls back partially created resources (application, service principal).