Skip to main content

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

CapabilityDescription
engine:dynamic_credentialsCreates and revokes Azure service principals
host:store:readReads bootstrap config from Arcan's encrypted store
host:store:writePersists metadata (app IDs) for revocation
host:auditEmits audit events for credential lifecycle

Configuration

Bootstrap config provided during arcan plugin setup azure:

FieldTypeRequiredDescription
tenant_idstringYesAzure AD tenant ID (directory ID)
client_idstringYesClient ID of the admin service principal
client_secretstringYesClient secret of the admin service principal
subscription_idstringYesAzure subscription ID for role assignments
max_ttlstringNoMaximum 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 principals
  • AppRoleAssignment.ReadWrite.All — manage app role assignments

Azure RBAC (at subscription scope):

  • User Access Administrator — to assign roles to created service principals

Roles

RoleAzure Role DefinitionDescription
readeracdd72a7-3385-48ef-bd42-f606fba81ae7Read access to all subscription resources
contributorb24988ac-6180-42a0-ab88-20f7382dd24cFull access except role assignments
owner8e3af657-a8ff-443c-a75c-2fe8c4bcb635Full 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).