Service accounts

A service account is a non-human principal — a bot, a CI runner, a backend daemon. It owns access keys, gets policies attached, shows up in audit logs. The right primitive whenever code (not a person) needs to call Huudis-protected APIs. The Go SDK exposes them on client.IAM. For the HTTP surface see API → Service accounts; for the credential model see Access keys.

Field on the Client

client.IAM — the service-account methods sit alongside the user methods. No Update — service accounts are immutable past creation. Rename by creating fresh + cutover.

Methods

ListServiceAccounts

Signature. func (r *IamResource) ListServiceAccounts(ctx context.Context, auth RequestAuth) ([]IamServiceAccount, error)

Lists every service account in the workspace.

sas, _ := client.IAM.ListServiceAccounts(ctx, huudis.RequestAuth{})
for _, sa := range sas {
    fmt.Println(sa.ID, sa.Name, sa.Description)
}

GetServiceAccount

Signature. func (r *IamResource) GetServiceAccount(ctx context.Context, id string, auth RequestAuth) (*IamServiceAccount, error)

Fetches one service account by ID.

sa, _ := client.IAM.GetServiceAccount(ctx, "sa_01HX...", huudis.RequestAuth{})

CreateServiceAccount

Signature. func (r *IamResource) CreateServiceAccount(ctx context.Context, in CreateServiceAccountInput, auth RequestAuth) (*IamServiceAccount, error)

Creates a service account. Name must be unique inside the workspace and is used in the ARN (arn:huudis:iam::{accountId}:service-account/{name}) — pick something stable.

sa, err := client.IAM.CreateServiceAccount(ctx, huudis.CreateServiceAccountInput{
    Name:        "github-actions-deploy",
    Description: "Deploys saas-acme from main branch CI",
}, huudis.RequestAuth{})
if err != nil {
    return err
}

A freshly created service account has no credentials and no permissions. The two next steps:

  1. Mint an access key with client.IAM.CreateAccessKey(...).
  2. Attach a policy with client.IAM.AttachPolicy(...).

DeleteServiceAccount

Signature. func (r *IamResource) DeleteServiceAccount(ctx context.Context, id string, auth RequestAuth) error

Removes the service account. Cascades to its access keys (all revoked + deleted) and its policy attachments (all detached). Audit records keep the ARN as a stable string, so deleted SAs still resolve in the audit UI.

_ = client.IAM.DeleteServiceAccount(ctx, "sa_01HX...", huudis.RequestAuth{})

Types

type IamServiceAccount struct {
    ID          string `json:"id"`           // "sa_..."
    AccountID   string `json:"accountId"`
    Name        string `json:"name"`
    Description string `json:"description,omitempty"`
    CreatedAt   string `json:"createdAt"`    // ISO-8601
}

For the ARN format and per-field rules see API → Service accounts.

Common patterns

Provision a CI principal end-to-end

The full ritual — service account + access key + scoped policy — wrapped:

type CIPrincipal struct {
    AccessKeyID     string
    SecretAccessKey string
}

func provisionCiPrincipal(ctx context.Context, c *huudis.Client, name, policyID string) (*CIPrincipal, error) {
    sa, err := c.IAM.CreateServiceAccount(ctx, huudis.CreateServiceAccountInput{
        Name:        name,
        Description: "CI principal for " + name,
    }, huudis.RequestAuth{})
    if err != nil {
        return nil, err
    }
    arn := fmt.Sprintf("arn:huudis:iam::%s:service-account/%s", sa.AccountID, sa.Name)
    key, err := c.IAM.CreateAccessKey(ctx, huudis.CreateAccessKeyInput{
        PrincipalArn: arn,
        Description:  "initial",
    }, huudis.RequestAuth{})
    if err != nil {
        return nil, err
    }
    if _, err := c.IAM.AttachPolicy(ctx, huudis.AttachPolicyInput{
        PolicyID:     policyID,
        PrincipalArn: arn,
    }, huudis.RequestAuth{}); err != nil {
        return nil, err
    }
    return &CIPrincipal{
        AccessKeyID:     key.ID,
        SecretAccessKey: key.SecretAccessKey, // SAVE NOW
    }, nil
}

One service account per service

The shape that scales: one SA per downstream consumer, never a shared "deploy" SA used by ten services. The blast radius of a leaked key is then one service, not your whole infra.

for _, s := range []string{"huudis-frontend", "huudis-backend", "huudis-cli", "huudis-cron"} {
    _, _ = client.IAM.CreateServiceAccount(ctx, huudis.CreateServiceAccountInput{
        Name:        s + "-runner",
        Description: "CI runner for " + s,
    }, huudis.RequestAuth{})
}

Inventory unused service accounts

Cross-reference with access keys' LastUsedAt:

func unusedSas(ctx context.Context, c *huudis.Client, maxDays int) ([]huudis.IamServiceAccount, error) {
    sas, err := c.IAM.ListServiceAccounts(ctx, huudis.RequestAuth{})
    if err != nil {
        return nil, err
    }
    cutoff := time.Now().Add(-time.Duration(maxDays) * 24 * time.Hour)
    var stale []huudis.IamServiceAccount
    for _, sa := range sas {
        arn := fmt.Sprintf("arn:huudis:iam::%s:service-account/%s", sa.AccountID, sa.Name)
        keys, _ := c.IAM.ListAccessKeys(ctx, huudis.ListAccessKeysParams{PrincipalArn: arn}, huudis.RequestAuth{})
        var lastUsed time.Time
        for _, k := range keys {
            if k.LastUsedAt == "" {
                continue
            }
            t, _ := time.Parse(time.RFC3339, k.LastUsedAt)
            if t.After(lastUsed) {
                lastUsed = t
            }
        }
        if lastUsed.Before(cutoff) {
            stale = append(stale, sa)
        }
    }
    return stale, nil
}

Migrate from a user-owned key to a service account

Common when a teammate's personal access key has accumulated machine usage:

func migrateToSa(ctx context.Context, c *huudis.Client, userArn, newSaName string) (string, error) {
    attachments, err := c.IAM.ListPolicyAttachments(ctx, huudis.ListPolicyAttachmentsParams{
        PrincipalArn: userArn,
    }, huudis.RequestAuth{})
    if err != nil {
        return "", err
    }
    sa, err := c.IAM.CreateServiceAccount(ctx, huudis.CreateServiceAccountInput{Name: newSaName}, huudis.RequestAuth{})
    if err != nil {
        return "", err
    }
    newArn := fmt.Sprintf("arn:huudis:iam::%s:service-account/%s", sa.AccountID, sa.Name)
    for _, a := range attachments {
        if _, err := c.IAM.AttachPolicy(ctx, huudis.AttachPolicyInput{
            PolicyID:     a.PolicyID,
            PrincipalArn: newArn,
        }, huudis.RequestAuth{}); err != nil {
            return "", err
        }
    }
    return newArn, nil
}

Errors

Code Status Cause
validation_error 400 Name uses illegal characters (must match [a-z0-9-]{3,64}), description too long.
conflict 409 Name already used in this workspace.
not_found 404 SA ID doesn't exist or lives in another workspace.
forbidden 403 Caller isn't an admin/owner.

Next

Huudis Docs · Service accounts | Huudis