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
| Capability | Description |
|---|---|
engine:dynamic_credentials | Creates and revokes GCP service accounts with keys |
host:store:read | Reads bootstrap config from Arcan's encrypted store |
host:store:write | Persists metadata (service account emails) for revocation |
host:audit | Emits audit events for credential lifecycle |
Configuration
Bootstrap config provided during arcan plugin setup gcp:
| Field | Type | Required | Description |
|---|---|---|---|
project_id | string | Yes | GCP project ID |
credentials_json | string | Yes | JSON key of the admin service account (entire file content, JSON-escaped) |
max_ttl | string | No | Maximum lease duration (default: 24h) |
The admin service account must have these IAM roles on the project:
roles/iam.serviceAccountAdmin— create/delete service accountsroles/iam.serviceAccountKeyAdmin— create/delete service account keysroles/resourcemanager.projectIamAdmin— manage project IAM policy bindings
Roles
| Role | IAM Role | Description |
|---|---|---|
viewer | roles/viewer | Read-only access to all project resources |
editor | roles/editor | Read-write access to most project resources |
owner | roles/owner | Full access including IAM and billing |
custom | User-provided role | Any 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
getIamPolicyto 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.