Vintner

Cloud Credential Management

How Tendril assumes temporary credentials per job for AWS, GCP, and Azure.

Cloud Credential Management

Tendril assumes temporary cloud credentials at the start of every job and clears them when the job completes. No credentials persist between jobs.

Per-Job Credential Lifecycle

Job start
  └── Read cloud_identity from job configuration
       └── Provider-specific credential assumption
            └── Set environment variables
                 └── Execute Terraform (uses env vars)
                      └── defer: Clear credentials

The defer ensures credentials are always cleared, even if the job fails or panics.

AWS: STS AssumeRole

Input from cloud_identity:

  • role_arn — ARN of the IAM role in the user's AWS account
  • external_id — shared secret for confused deputy prevention

Execution:

func AssumeRole(roleArn, externalId string) error {
    stsClient := sts.NewFromConfig(cfg)
    result, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{
        RoleArn:         &roleArn,
        ExternalId:      &externalId,
        RoleSessionName: aws.String("trellis-tendril"),
        DurationSeconds: aws.Int32(3600), // 1 hour
    })
    // Set environment variables
    os.Setenv("AWS_ACCESS_KEY_ID", *result.Credentials.AccessKeyId)
    os.Setenv("AWS_SECRET_ACCESS_KEY", *result.Credentials.SecretAccessKey)
    os.Setenv("AWS_SESSION_TOKEN", *result.Credentials.SessionToken)
    return nil
}

Cleanup:

defer func() {
    os.Unsetenv("AWS_ACCESS_KEY_ID")
    os.Unsetenv("AWS_SECRET_ACCESS_KEY")
    os.Unsetenv("AWS_SESSION_TOKEN")
}()

Terraform reads these environment variables automatically via the AWS provider.

GCP: Workload Identity Federation

Input from cloud_identity:

  • project_id — GCP project ID
  • wif_config — Workload Identity Federation pool and provider configuration

Execution:

func ActivateGcpWIF(projectId string, wifConfig WIFConfig) (cleanup func(), err error) {
    // Write credential configuration file
    credFile := filepath.Join(tmpDir, "gcp-wif-credential.json")
    writeCredentialConfig(credFile, wifConfig)

    // Set environment variable
    os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", credFile)
    os.Setenv("GOOGLE_PROJECT", projectId)

    return func() {
        os.Remove(credFile)
        os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS")
        os.Unsetenv("GOOGLE_PROJECT")
    }, nil
}

The credential configuration file tells the GCP SDK to exchange an OIDC token for temporary GCP credentials via the STS endpoint. No service account key file is involved.

Azure: Federated Identity

Input from cloud_identity:

  • tenant_id — Azure Entra ID tenant
  • client_id — App Registration client ID
  • subscription_id — Target Azure subscription

Execution:

func ActivateAzureFederated(tenantId, clientId, subscriptionId string) (cleanup func(), err error) {
    // Write OIDC token to file
    tokenFile := filepath.Join(tmpDir, "azure-oidc-token")
    writeOIDCToken(tokenFile)

    // Set environment variables
    os.Setenv("AZURE_TENANT_ID", tenantId)
    os.Setenv("AZURE_CLIENT_ID", clientId)
    os.Setenv("AZURE_SUBSCRIPTION_ID", subscriptionId)
    os.Setenv("AZURE_FEDERATED_TOKEN_FILE", tokenFile)

    return func() {
        os.Remove(tokenFile)
        os.Unsetenv("AZURE_TENANT_ID")
        os.Unsetenv("AZURE_CLIENT_ID")
        os.Unsetenv("AZURE_SUBSCRIPTION_ID")
        os.Unsetenv("AZURE_FEDERATED_TOKEN_FILE")
    }, nil
}

The Azure provider (azurerm) reads these environment variables and uses the OIDC token file for federated authentication.

Security Properties

PropertyGuarantee
No persistent credentialsEnv vars are cleared after every job via defer
No credential sharingEach job gets its own assumed session
Time-boundedAWS sessions expire after 1 hour
ScopedCredentials only have permissions defined in the IAM role / service account / app registration
RevocableUser deletes the IAM role / WIF config / App Registration → instant revocation
No file leaksTemporary credential files are deleted in defer cleanup

CloudIdentity Structure

The cloud_identities table stores provider-specific credentials in a JSONB column:

AWS:

{
  "role_arn": "arn:aws:iam::123456789012:role/GrapeProvisionerRole",
  "external_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

GCP:

{
  "project_id": "my-gcp-project",
  "wif_config": {
    "pool_id": "trellis-pool",
    "provider_id": "trellis-provider",
    "service_account_email": "trellis@my-gcp-project.iam.gserviceaccount.com"
  }
}

Azure:

{
  "tenant_id": "12345678-1234-1234-1234-123456789012",
  "client_id": "abcdefgh-abcd-abcd-abcd-abcdefghijkl",
  "subscription_id": "87654321-4321-4321-4321-210987654321"
}

On this page