Skip to main content

GCP — Dynamic Service Account Engine

Generates temporary GCP service account keys with IAM role bindings. When a lease is requested, the engine creates a service account, generates a JSON key, and binds the requested IAM role at the project level. When the lease expires or is revoked, the engine removes the IAM binding, deletes the key, and deletes the service account.

Capabilities

CapabilityDescription
engine:dynamic_credentialsCreates and revokes GCP service accounts with keys
host:store:readReads bootstrap config from Arcan's encrypted store
host:store:writePersists metadata (service account emails) for revocation
host:auditEmits audit events for credential lifecycle

Configuration

Bootstrap config provided during arcan plugin setup gcp:

FieldTypeRequiredDescription
project_idstringYesGCP project ID
credentials_jsonstringYesJSON key of the admin service account (entire file content, JSON-escaped)
max_ttlstringNoMaximum lease duration (default: 24h)

The admin service account must have these IAM roles on the project:

  • roles/iam.serviceAccountAdmin — create/delete service accounts
  • roles/iam.serviceAccountKeyAdmin — create/delete service account keys
  • roles/resourcemanager.projectIamAdmin — manage project IAM policy bindings

Roles

RoleIAM RoleDescription
viewerroles/viewerRead-only access to all project resources
editorroles/editorRead-write access to most project resources
ownerroles/ownerFull access including IAM and billing
customUser-provided roleAny IAM role name (e.g., roles/storage.admin)

Operations

ping — Verify Credentials

Calls projects.get on the configured project to confirm the admin service account credentials are valid and have access.

Request:

{"method": "ping", "params": null}

Response (success):

{"data": {"status": "healthy"}}

Response (failure):

{"error": "ping failed: 403 Forbidden: caller does not have resourcemanager.projects.get access"}

generate — Create Service Account + Key

Creates a GCP service account with a unique ID, generates a JSON key, and binds the requested IAM role at the project level.

Request:

{
"method": "generate",
"params": {
"role": "editor",
"ttl": "4h"
}
}

For custom roles:

{
"method": "generate",
"params": {
"role": "custom",
"iam_role": "roles/storage.objectViewer",
"ttl": "1h"
}
}

Response (success):

{
"data": {
"email": "[email protected]",
"key_json": "eyJ0eXBlIjoic2VydmljZV9hY2NvdW50Iiw...",
"project_id": "my-project",
"iam_role": "roles/editor",
"unique_id": "123456789012345678"
}
}

The key_json field is the base64-encoded service account JSON key file. Decode it to use with gcloud auth activate-service-account or the GOOGLE_APPLICATION_CREDENTIALS environment variable.

validate (Revoke) — Delete Service Account

Removes the IAM binding, deletes all keys, and deletes the service account.

Request:

{
"method": "validate",
"params": {
"email": "[email protected]",
"iam_role": "roles/editor"
}
}

Response (success):

{
"data": {
"valid": true,
"message": "service account [email protected] revoked: IAM binding removed, keys deleted, account deleted"
}
}

Go Plugin Source

Complete, copy-paste ready. Uses direct HTTP calls to GCP REST APIs with OAuth2 JWT authentication (no Google Cloud SDK dependency).

package main

import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"

"github.com/golang-jwt/jwt/v5"

"getarcan.dev/arcan/sdk"
)

type bootstrapConfig struct {
ProjectID string `json:"project_id"`
CredentialsJSON string `json:"credentials_json"`
}

type saCredentials struct {
ClientEmail string `json:"client_email"`
PrivateKey string `json:"private_key"`
TokenURI string `json:"token_uri"`
}

type generateParams struct {
Role string `json:"role"`
TTL string `json:"ttl"`
IAMRole string `json:"iam_role"`
}

type revokeParams struct {
Email string `json:"email"`
IAMRole string `json:"iam_role"`
}

var knownRoles = map[string]string{
"viewer": "roles/viewer",
"editor": "roles/editor",
"owner": "roles/owner",
}

type GCPEngine struct {
cfg bootstrapConfig
creds saCredentials
client *http.Client
}

func (e *GCPEngine) Describe() sdk.Descriptor {
return sdk.Descriptor{
Name: "gcp",
Version: "0.1.0",
DisplayName: "GCP",
Description: "Dynamic GCP service account credentials with IAM role bindings",
Capabilities: []string{
"engine:dynamic_credentials",
"host:store:read",
"host:store:write",
"host:audit",
},
}
}

func (e *GCPEngine) Ping() error {
ctx := context.Background()
token, err := e.getToken(ctx, "https://www.googleapis.com/auth/cloud-platform")
if err != nil {
return fmt.Errorf("failed to get token: %w", err)
}

url := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v1/projects/%s", e.cfg.ProjectID)
req, _ := http.NewRequestWithContext(ctx, "GET", url, 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 != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%s: %s", resp.Status, string(body))
}
return nil
}

func (e *GCPEngine) 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)
}

iamRole, err := e.resolveRole(p.Role, p.IAMRole)
if err != nil {
return nil, err
}

ctx := context.Background()
token, err := e.getToken(ctx, "https://www.googleapis.com/auth/cloud-platform")
if err != nil {
return nil, fmt.Errorf("failed to get token: %w", err)
}

accountID := fmt.Sprintf("arcan-%s", randomSuffix(8))
// GCP service account IDs must be 6-30 chars, lowercase, start with letter.
if len(accountID) > 30 {
accountID = accountID[:30]
}

// 1. Create the service account.
saURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/serviceAccounts", e.cfg.ProjectID)
saBody := map[string]any{
"accountId": accountID,
"serviceAccount": map[string]any{
"displayName": accountID,
"description": "Managed by Arcan",
},
}
saResp, err := e.apiPost(ctx, token, saURL, saBody)
if err != nil {
return nil, fmt.Errorf("create service account failed: %w", err)
}
email := saResp["email"].(string)
uniqueID := saResp["uniqueId"].(string)

// 2. Create a key for the service account.
keyURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/serviceAccounts/%s/keys",
e.cfg.ProjectID, email)
keyBody := map[string]any{
"keyAlgorithm": "KEY_ALG_RSA_2048",
"privateKeyType": "TYPE_GOOGLE_CREDENTIALS_FILE",
}
keyResp, err := e.apiPost(ctx, token, keyURL, keyBody)
if err != nil {
e.deleteServiceAccount(ctx, token, email)
return nil, fmt.Errorf("create key failed: %w", err)
}
// The key is returned as base64-encoded JSON.
keyData := keyResp["privateKeyData"].(string)

// 3. Bind the IAM role at project level.
if err := e.addIAMBinding(ctx, token, email, iamRole); err != nil {
e.deleteServiceAccount(ctx, token, email)
return nil, fmt.Errorf("IAM binding failed: %w", err)
}

return &sdk.SecretResult{
Data: map[string]any{
"email": email,
"key_json": keyData,
"project_id": e.cfg.ProjectID,
"iam_role": iamRole,
"unique_id": uniqueID,
},
}, nil
}

func (e *GCPEngine) 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.Email == "" {
return nil, fmt.Errorf("email is required for revocation")
}
if !strings.Contains(p.Email, "arcan-") {
return nil, fmt.Errorf("refusing to revoke non-arcan service account %q", p.Email)
}

ctx := context.Background()
token, err := e.getToken(ctx, "https://www.googleapis.com/auth/cloud-platform")
if err != nil {
return nil, fmt.Errorf("failed to get token: %w", err)
}

// 1. Remove IAM binding (if role provided).
if p.IAMRole != "" {
_ = e.removeIAMBinding(ctx, token, p.Email, p.IAMRole)
}

// 2. Delete the service account (cascades to all keys).
if err := e.deleteServiceAccount(ctx, token, p.Email); err != nil {
return nil, fmt.Errorf("delete service account failed: %w", err)
}

return &sdk.ValidationResult{
Valid: true,
Message: fmt.Sprintf("service account %s revoked: IAM binding removed, keys deleted, account deleted", p.Email),
}, nil
}

// --- GCP API helpers ---

func (e *GCPEngine) getToken(ctx context.Context, scope string) (string, error) {
block, _ := pem.Decode([]byte(e.creds.PrivateKey))
if block == nil {
return "", fmt.Errorf("failed to decode private key PEM")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse private key: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not RSA")
}

now := time.Now().UTC()
claims := jwt.MapClaims{
"iss": e.creds.ClientEmail,
"scope": scope,
"aud": e.creds.TokenURI,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signed, err := jwtToken.SignedString(rsaKey)
if err != nil {
return "", fmt.Errorf("failed to sign JWT: %w", err)
}

data := fmt.Sprintf("grant_type=urn%%3Aietf%%3Aparams%%3Aoauth%%3Agrant-type%%3Ajwt-bearer&assertion=%s", signed)
req, _ := http.NewRequestWithContext(ctx, "POST", e.creds.TokenURI, strings.NewReader(data))
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 *GCPEngine) apiPost(ctx context.Context, token, url string, body any) (map[string]any, error) {
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, "POST", url, 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 *GCPEngine) deleteServiceAccount(ctx context.Context, token, email string) error {
url := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/serviceAccounts/%s",
e.cfg.ProjectID, email)
req, _ := http.NewRequestWithContext(ctx, "DELETE", url, 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 *GCPEngine) addIAMBinding(ctx context.Context, token, email, role string) error {
return e.modifyIAMPolicy(ctx, token, func(policy map[string]any) {
member := fmt.Sprintf("serviceAccount:%s", email)
bindings, _ := policy["bindings"].([]any)

// Check if a binding for this role already exists.
for _, b := range bindings {
binding := b.(map[string]any)
if binding["role"] == role {
members := binding["members"].([]any)
binding["members"] = append(members, member)
return
}
}
// Add a new binding.
policy["bindings"] = append(bindings, map[string]any{
"role": role,
"members": []any{member},
})
})
}

func (e *GCPEngine) removeIAMBinding(ctx context.Context, token, email, role string) error {
return e.modifyIAMPolicy(ctx, token, func(policy map[string]any) {
member := fmt.Sprintf("serviceAccount:%s", email)
bindings, _ := policy["bindings"].([]any)

for _, b := range bindings {
binding := b.(map[string]any)
if binding["role"] == role {
members := binding["members"].([]any)
filtered := make([]any, 0, len(members))
for _, m := range members {
if m.(string) != member {
filtered = append(filtered, m)
}
}
binding["members"] = filtered
return
}
}
})
}

func (e *GCPEngine) modifyIAMPolicy(ctx context.Context, token string, modify func(map[string]any)) error {
projectURL := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v1/projects/%s", e.cfg.ProjectID)

// Get current policy.
getBody := map[string]any{"options": map[string]any{"requestedPolicyVersion": 3}}
policyResp, err := e.apiPost(ctx, token, projectURL+":getIamPolicy", getBody)
if err != nil {
return fmt.Errorf("getIamPolicy failed: %w", err)
}

// Modify policy.
modify(policyResp)

// Set updated policy.
setBody := map[string]any{"policy": policyResp}
_, err = e.apiPost(ctx, token, projectURL+":setIamPolicy", setBody)
if err != nil {
return fmt.Errorf("setIamPolicy failed: %w", err)
}
return nil
}

func (e *GCPEngine) resolveRole(role, customRole string) (string, error) {
if role == "custom" {
if customRole == "" {
return "", fmt.Errorf("custom role requires iam_role parameter")
}
return customRole, nil
}
r, ok := knownRoles[role]
if !ok {
return "", fmt.Errorf("unknown role %q — valid roles: viewer, editor, owner, custom", role)
}
return r, nil
}

func main() {
cfg := loadBootstrapConfig()

var creds saCredentials
if err := json.Unmarshal([]byte(cfg.CredentialsJSON), &creds); err != nil {
fmt.Fprintf(os.Stderr, "invalid credentials_json: %v\n", err)
os.Exit(1)
}
if creds.TokenURI == "" {
creds.TokenURI = "https://oauth2.googleapis.com/token"
}

sdk.Serve(&GCPEngine{
cfg: cfg,
creds: creds,
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]
}

Build & Install

# Initialize module
mkdir arcan-engine-gcp && cd arcan-engine-gcp
go mod init getarcan.dev/engines/gcp

# Get dependencies
go get getarcan.dev/arcan/sdk
go get github.com/golang-jwt/jwt/v5

# Build (only one external dep beyond the SDK — golang-jwt for SA auth)
go build -o arcan-engine-gcp .

# Install into Arcan plugin directory
cp arcan-engine-gcp ~/.arcan/plugins/

# Register with Arcan
arcan plugin register gcp --path ~/.arcan/plugins/arcan-engine-gcp

Usage Example

# Bootstrap the engine with admin service account
arcan plugin setup gcp \
--config '{"project_id":"my-project","credentials_json":"{...}"}'

# Ping to verify connectivity
echo '{"method":"ping","params":null}' | arcan-engine-gcp
# → {"data":{"status":"healthy"}}

# Generate temporary editor credentials
echo '{"method":"generate","params":{"role":"editor","ttl":"4h"}}' | arcan-engine-gcp
# → {"data":{"email":"[email protected]","key_json":"base64...","iam_role":"roles/editor",...}}

# Decode and use the key
echo '{"method":"generate","params":{"role":"viewer","ttl":"1h"}}' | arcan-engine-gcp | \
jq -r '.data.key_json' | base64 -d > /tmp/sa-key.json
export GOOGLE_APPLICATION_CREDENTIALS=/tmp/sa-key.json

# Revoke credentials
echo '{"method":"validate","params":{"email":"[email protected]","iam_role":"roles/editor"}}' | arcan-engine-gcp
# → {"data":{"valid":true,"message":"service account ... revoked: ..."}}

Security Notes

  • The admin service account should have only the three IAM roles listed above. Do not use an Owner service account.
  • Created service accounts are prefixed with arcan- for easy identification and policy scoping.
  • The engine refuses to revoke any service account whose email does not contain arcan-.
  • IAM policy modifications use read-modify-write with the etag returned by getIamPolicy to prevent race conditions (GCP enforces this server-side).
  • On generate failure, the engine rolls back partially created resources (service account, keys).
  • Service account JSON keys are returned base64-encoded. Consumers must decode before use.
  • The engine authenticates to GCP using JWT-based service account authentication (no Google SDK dependency), keeping the binary small and the auth flow auditable.