Skip to content

Secrets

A document names a secret; secrets says how the name is delivered. Keys ending in Secret hold a name (valkey.passwordSecret: valkey/password), File and Dir keys a path. No key takes a value (model).

The sources

A name is /-separated segments of letters, digits, ., _ and -.

secrets:
  source: ssm          # env | file | ssm
  root: /sluis/example    # file: a directory; ssm: the installation's root
  region: eu-west-1    # ssm only
  refresh: 1m          # ssm only
  kmsKeyId: alias/example   # ssm only: the key every PutParameter the service makes is encrypted with
  layout: v5           # ssm only: required, the one layout, see "SSM layout"
  grace: 24h           # ssm only: how long a rotated client secret's previous value is accepted
source A name is delivered as Read
env (the default) the variable SLUIS_SECRET_<NAME>: the name upper-cased, every character that is not a letter or a digit an underscore (valkey/password is SLUIS_SECRET_VALKEY_PASSWORD). Two names may not share a variable once, at start; for a local run
file the file <root>/<name> on every use, so a rotated Secret the platform mounts takes effect without a restart. The chart's secrets value projects each name as a file under /var/run/sluis/secrets
ssm a SecureString of AWS SSM Parameter Store below <root>, at the name's address in the SSM layout that one parameter, decrypted, on first use and again once refresh (1m) has passed. Nothing is read at start

An undelivered name fails the request that reads it, with the name in the error and never its value. sluis check --config <file> finds it first. Kubernetes defaults to file, Lambda to ssm. See OpenBao.

The names

Name Held by What it is
clients/<client-id>/secret a confidential client of the policy the client's secret. The service reads one per confidential client of the policy it loaded
providers/google/<provider>/client-id, .../client-secret oauthClient.provider the Google OAuth client registered for the directory
issuer/state-secret signingKey.kms.stateSecret, signingKey.kmsWrapped.stateSecret the sign-in state secret
recovery/password recovery.passwordSecret the recovery password. A document with source: ssm does not name it
recovery/verifier, conformance/verifier recovery.verifierSecret, conformance.verifierSecret the SRP credential of an account: {"salt","verifier","iterations"}. sluisctl recovery verifier makes one
directory/<id>/key directory.workspaces[].keySecret a declared workspace's service-account key
valkey/password valkey.passwordSecret the shared store's password

SSM layout

Layout v5 (ADR 0072) keeps each value once, module first. secrets.root is /sluis/<instance>:

/sluis/<instance>/internal/<module>/<name>   that module's secrets: seeded by an operator, generated by the Pulumi library or written by sluis
/sluis/<instance>/external/<module>/<id>     one typed document per address: the public contract
Name Read at, below <root>/
clients/<client-id>/secret internal/oidc/clients/<client-id>
providers/google/<provider>/client-id, .../client-secret internal/oidc/signin/<provider>/client-id, .../client-secret
the state secret, the recovery and conformance verifiers internal/oidc/state-secret, internal/oidc/recovery-verifier, internal/oidc/conformance-verifier, whatever the document names
any other name (valkey/password, a workspace's keySecret) internal/config/<name>

Only sluis reads internal/. external/ is granted per consumer on exact addresses. The ssm Secrets adapter takes the same root; another is refused. private, export, internal and external are reserved names (storage layout).

IAM per function: AWS Lambda.

One key

secrets.kmsKeyId is the KeyId of every PutParameter the service makes.

sluis secrets rekey --config <file> [--dry-run] [--json] [--include-recent] [--include-unknown] [--by <name>] [--no-audit] moves every other SecureString below secrets.root to it.

It Detail
Without a key Unset, SSM uses the AWS-managed key. The Pulumi library refuses to build without a customer key
Reads and writes DescribeParameters, then ten at a time GetParameters with decryption and PutParameter with overwrite and KeyId. Name, value, tier and description stay. It waits between batches, doubles the wait on throttling, and never prints a value
Compares key ARNs SSM reports a KeyId as it was written: an alias, an alias ARN, a key id or a key ARN. Each distinct one and secrets.kmsKeyId are resolved once with kms:DescribeKey and the ARNs compared, so a parameter on the key under another spelling is current and is not read. alias/aws/ssm is another key. A key DescribeKey refuses is compared by spelling; what that leaves open is unknown: listed, not re-written without --include-unknown
Makes A new version with the same value. Earlier versions keep their old key until SSM drops them (it keeps 100). Rotate a secret that must leave the old key
Leaves A parameter modified within secrets.grace: its previous version is a rotated client secret's overlap. --include-recent writes it anyway
Races A parameter that changes during the run is skipped, or reported with both versions. Run it while nothing writes secrets; a second run finishes the rest
Audits A real run records roster.secrets.rekeyed before the first write and refuses if it cannot. --no-audit warns and prints the record to stderr

secrets.layout: v5 is required with source: ssm. Layouts v3 and v4 are removed (upgrade to v1.77.1).

The external documents

Every field is a JSON string. A breaking change is a new address, external/<kind>.v2/<id>. Schemas: schemas/external/; goldens: internal/secretstore/testdata/.

Kind Address Fields Schema
oidc/v1 external/oidc/<client> schema, client-id, client-secret oidc.v1.schema.json
github/v1 external/github/<app>; a runner App is external/github/runner-<tier>-<org> schema, app_id, installation_id, private_key github.v1.schema.json
slack/v1 external/slack/<app> schema, bot_token slack.v1.schema.json
cloudflare/v1 external/cloudflare/<preset> schema, expires_on, and token or access_key_id, secret_access_key, endpoint. For R2 the access key id is the token's id and the secret is the hex SHA-256 of its value cloudflare.v1.schema.json
{"schema":"oidc/v1","client-id":"example-rp","client-secret":"example-secret-value"}

A catalogue GitHub App's id may not begin runner-. A consumer reads a field with remoteRef: {key: <address>, property: <field>}.

What each value is

Value Address
The names above (secrets.source: ssm) the SSM layout
The console session key, Google Workspace keys, links, GitHub Apps' keys, Slack workspaces and Apps <root>/internal/<module>/..., a {"value": "<base64>"} document; with another Secrets store, credentials/<module>/<kind>/<id>/<ref> (storage layout)
A generated client's secret the oidc/v1 document external/oidc/<id>; a rotation is one write, and the previous secret is the document's previous revision while the current one is younger than secrets.grace. A rotation with no overlap refuses the old secret at once
A confidential client's secret an operator seeded the same oidc/v1 document
An installed runner App, or a catalogue App with export: true the github/v1 document external/github/<app>, ids from the App's record; a pending App's key stays internal until it is installed
A catalogue Slack App the bot token is the slack/v1 document external/slack/<id>; the client secret stays internal

A failing client secret is rechecked once against a fresh read (once per five seconds per client).