AWS Lambda¶
See sluis on AWS Lambda and the Pulumi library. Decided in ADR 0026, ADR 0036, ADR 0037.
Package¶
| Asset | Content |
|---|---|
sluis-issuer_<version>_linux_arm64.zip |
Issuer, signer, console, providers |
sluis-cloudflare_<version>_linux_arm64.zip |
Cloudflare |
sluis-github_<version>_linux_arm64.zip, sluis-slack_<version>_linux_arm64.zip |
The GitHub and Slack modules: the controllers' passes and the console's provider pages; GitHub also mints installation tokens |
sluis-backup_<version>_linux_arm64.zip |
Backup, and restore (backup.role) |
sluis-google_<version>_linux_arm64.zip |
The Google module: the directory refresh, the console's Workspace service and the sign-in |
| Build | bootstrap pinned to its module: a zip refuses another's document |
| Minimum | MinPackageVersion 1.63; binary and library share a minor |
Events¶
| Limit | Value |
|---|---|
| Timeout | Function.TimeoutSeconds, default 300 s; API Gateway cuts a request at 30 s whatever it says |
| Memory | Function.MemoryMB, default 512 MB |
Other kind |
Refused |
| Retries | None (MaximumRetryAttempts: 0); the next tick runs the pass again |
| State | Must be shared: a pass refuses memory, so such a function fails at its first event |
| Source | internal/lambdaapp/events.go |
| Event | Runs | Sent by |
|---|---|---|
| API Gateway HTTP API, payload 2.0 | Issuer and console mux. REST API and ALB shapes are refused | API Gateway |
{"kind":"tick","controller":"github"\|"slack","target":"<id>"} |
One pass of one target | EventBridge Scheduler, one schedule per target |
{"kind":"run","controller":"github"\|"slack","target":"<id>"} |
The same pass, "run now" | The console, through the invoke trigger adapter (InvocationType: Event) |
{"kind":"clients"} |
One reconcile of the generated client secrets, and nothing else | EventBridge Scheduler |
{"kind":"refresh"}, {"kind":"refresh","workspace":"<id>"} |
The Google function: one directory refresh of every workspace, or of one, under the refresh lease (DirectoryRefresh.Rate, 15 minutes). On the issuer the old name of clients, accepted for one release: the same reconcile, and a refresh only where the process hosts the directory |
EventBridge Scheduler |
{"kind":"cloudflare"} |
The Cloudflare function: rotate the credentials that are due (CloudflareRotation) |
EventBridge Scheduler |
{"kind":"check"} |
Each declared secret is read; a missing one fails it | A deploy |
{"kind":"backup"}, {"kind":"backup","resume":true} |
The backup module's function only: a backup; resume answers idle since format 2; the library no longer creates a resume schedule. The restore function's events are in backup |
EventBridge Scheduler |
A target is a GitHub organisation, github:links or a Slack workspace. A pass holds its DynamoDB lease and returns {"kind":"tick","target":"acme","outcome":"ran"}.
outcome |
Meaning |
|---|---|
ran |
The pass finished |
contended |
Another invocation holds the lease; success, no retry |
unknown |
A run for an undeclared target |
A failed pass, or a tick for an unrun target, returns an error.
| HTTP mapping | Behavior |
|---|---|
| Request | rawPath, rawQueryString, method, host; headers lower-cased; cookies joined to Cookie; base64 body decoded; source IP is RemoteAddr |
| Response | Header values comma-joined; Set-Cookie goes in cookies; non-UTF-8 or encoded bodies are base64 |
| Failures | Panic 500, unreadable request 400, response over 6 MB 502 |
The Google module's calls¶
| Method | Caller class (alias) | Does |
|---|---|---|
console.list_workspaces, begin_connect, reconnect, complete_consent, upload_key, set_served_domains, set_synced_groups, probe, refresh, disconnect |
console (live-console) |
The Workspace pages: list, connect by consent or by service-account key, change a connected workspace |
identify, sign_in_url |
issuer (live-issuer) |
The Google sign-in |
| Behavior | Value |
|---|---|
| Who may call | A console method refuses every class but console, the sign-in every class but issuer; the class is the alias, never the payload |
| Refusal | not authorised in every case; the reason is in the module's log |
| Module owns | The OAuth client secret, the service-account keys, the workspace records, the refresh writer; the issuer holds none and reads only the snapshot |
| Document keys | google.oauthClient (hostedDomains lists the Workspace domains a sign-in's hd must be in; a sign-in needs a verified address either way), google.publicURL (the address the redirects are built from), audit |
| Sign-in depends on the function | Every Google sign-in calls sign_in_url and identify; there is no provisioned concurrency, so the first sign-in after idle waits for the cold start (storage, secrets, every workspace reader) |
| Sign-in failure | Each call is bounded at 25 s; the issuer shows a "Sign-in unavailable" page with a retry. A throttled or removed function fails every Google sign-in; recovery and a gateway's forwarded identity do not use it |
| Cold start | Provisioned concurrency of 1 on live-issuer removes it; the estate decides |
| Consent callback | The issuer verifies the signed state and forwards the code to complete_consent once: no platform retry, deadline about 25 s, because a spent code cannot be used again |
| Key upload | The key passes through the issuer in memory, at most 64 KB, never logged |
| Residual | A compromised issuer can still call these methods (connect or disconnect a directory); it cannot read a key or a client secret |
Environment¶
| Variable | Meaning |
|---|---|
SLUIS_CONFIG |
Service document, /opt/sluis/sluis.yaml. The library sets it |
OTEL_* |
OpenTelemetry's own. Use the OTLP Lambda layer; Telemetry.Env also accepts the layer's SLUIS_* settings, OPENTELEMETRY_* and AWS_LAMBDA_EXEC_WRAPPER. The deprecated ACCESS_ROSTER_* prefix of the layer's settings is still accepted |
No other variable, flag or secret exists. The start refuses SLUIS_ROLE, SLUIS_CONFIG_FILE, SLUIS_SECRET_FILES, ssm:<path> values and retired variables.
Configuration is a layer¶
One immutable layer, <prefix>-config, mounted last. A policy change needs a manual pulumi up.
/opt/sluis/sluis.yaml the service document (`sluis/v3`, controllers included)
/opt/sluis/policy.yaml the policy document `policy.file` names
/opt/sluis/verify-keys/<index>.pem with `VerifyOnly`, the public keys `signingKey.verifyOnly` names
The library renders both from LambdaArgs.Installation (inputs) and writes these keys. A document that sets them is refused.
| Key | Written value |
|---|---|
apiVersion, policy.file |
Fixed |
secrets |
{source: ssm, root: /sluis/<instance>, region, layout: v5}, plus kmsKeyId: <ParameterKeyArn> when set. Another kmsKeyId or layout is refused |
ports.dynamodb.tables |
The tables of the modules the function reads, from TableNames |
recovery.passwordSecret, recovery.verifierSecret, recovery.enabled, conformance.verifierSecret |
enabled from Recovery. verifierSecret is recovery/verifier (conformance/verifier with conformance.enabled); the password is not named |
State secret name under signingKey.kms or signingKey.kmsWrapped |
Generated |
adapters.trigger.settings.github, .slack |
With invoke: this function |
signingKey.verifyOnly |
From VerifyOnly: file under /opt/sluis/verify-keys/, kid, alg, until (RFC 3339) |
# /opt/sluis/sluis.yaml
apiVersion: sluis.truvity.github.io/sluis/v3
platform: { aws: true, runtime: lambda } # or: preset: aws-serverless
adapters:
state: { adapter: dynamodb, settings: { tables: { oidc: sluis-acme-oidc, google: sluis-acme-google } } }
blobs: { adapter: s3, settings: { bucket: sluis-blobs, prefix: blobs/ } }
trigger: { adapter: invoke, settings: { github: "sluis:live", slack: "sluis:live" } }
Lambda refuses legacy adapters; audit uses sqs.
Instance and the SSM root¶
Instance is lower-case letters, digits and dashes, not private or export. See storage layout.
/sluis/<instance>/internal/<module>/... a module's secrets, read by its function alone
/sluis/<instance>/external/<module>/<id> documents consumers read
Each secret is read when first used and every secrets.refresh (1 minute) after. See secrets.
| Generated secret | Value | Rotation |
|---|---|---|
internal/oidc/state-secret |
Base64 of 32 random bytes, as {"value":"<base64>"}; output StateSecretParameter |
pulumi up --replace on the RandomBytes resource; signs out all in-flight sign-ins. An apply never regenerates it |
internal/oidc/recovery-password |
40 random letters and digits, as {"value":"<base64>"} |
Read with sluisctl recovery password (recovery). An apply never regenerates it |
internal/oidc/recovery-verifier |
The recovery account's SRP credential, {"salt","verifier","iterations"} in the secret store's document, derived from the password under a random salt kept with it; output RecoveryVerifierParameter. The service reads it, never the password |
Follows the password. An apply that does not rotate the password rewrites nothing |
internal/oidc/conformance-password and .../conformance-verifier, with conformance.enabled |
The conformance account's password and credential, made as above; output ConformancePasswordParameter |
pulumi up --replace on <name>-conformance-password. Read with sluisctl recovery password --account conformance |
internal/backup/restore-identity |
The age identity that opens the backups, with NewBackup; output RestoreIdentityParameter (Pulumi library) |
pulumi up --replace on <name>-restore-identity-seed; older backups then need an ExtraRecipients identity |
IAM: one role¶
One role and inline policy, <FunctionName>, serve the issuer; every other function has its own (module roles).
| Grant | Scope |
|---|---|
| Permissions boundary | PermissionsBoundaryArn |
logs:CreateLogStream, logs:PutLogEvents |
Own log group |
ModuleRoleStatements of the hosted modules |
Each module's table, with writes of the maintenance partition denied; /sluis/<instance>/internal/<module>/* and /sluis/<instance>/external/<module>/*; the <module>/ blob prefix (module roles) |
kms:Encrypt, Decrypt, GenerateDataKey |
ParameterKeyArn, through SSM only, for the role's own parameters |
sqs:SendMessage |
The audit ingest queue |
kms:Sign, kms:GetPublicKey |
Both signing keys; not with WrappedSigning |
kms:GenerateDataKeyPairWithoutPlaintext, kms:Decrypt |
The symmetric key under context purpose=sluis-signing, with WrappedSigning (signing key policy) |
lambda:InvokeFunction |
The function itself |
sts:GetWebIdentityToken |
*; with WebIdentityAudience, that audience only |
The library makes <prefix>-scheduler with lambda:InvokeFunction. Consumers are granted on their own side (ADR 0041).
How a controller authenticates to the console¶
A controller presents the role's web identity token as bearer, reused under four minutes. The issuer refuses an iat older than maxAge (5 minutes).
| Door | Audience | Set in |
|---|---|---|
Token exchange (/token) |
exchange.aws.audience |
Policy document |
| Console API | console.awsAudience, default <issuerURL>/console |
Service document |
Both controllers' console.auth.aws.audience must equal console.awsAudience.
# the service document
console:
awsAudience: https://sluis.example/console # optional: this is the default
controllers:
github:
console: { auth: { aws: { audience: https://sluis.example/console } } } # = console.awsAudience
slack:
console: { auth: { aws: { audience: https://sluis.example/console } } }
# the policy document
exchange:
aws:
audience: sluis-exchange
accounts: [...]
Allow sts:GetWebIdentityToken for WebIdentityAudience and enable outbound federation:
{"Effect":"Allow","Action":"sts:GetWebIdentityToken","Resource":"*",
"Condition":{"ForAllValues:StringEquals":{"sts:IdentityTokenAudience":["https://sluis.example/console"]}}}
List the account in exchange.aws.accounts (aws iam get-outbound-web-identity-federation-info) and entitle the role:
groups:
all:sluis:viewer:
matchers:
- aws: { account: "111122223333", role: sluis } # <FunctionName>
| Setting | Behavior |
|---|---|
AdditionalWebIdentityAudiences |
Extra exact audiences after the console's. Empty, duplicate, or with empty WebIdentityAudience: refused. Any code under the role can mint all listed audiences |
| Same audience for both doors | console.awsAudience equal to exchange.aws.audience: the issuer refuses to start |
| Outbound federation | Enable it in the account; the library does not. Otherwise STS answers OutboundWebIdentityFederationDisabled |
aws matcher without role, or * |
Admits every role of the account. The issuer warns at start |