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).