Tutorial: sluis on AWS Lambda¶
You deploy the sluis issuer and module functions on AWS Lambda, on secrets layout v5, behind a mutual-TLS custom domain (preset aws-hybrid; aws-serverless has no Kubernetes beside it). State is in DynamoDB, blobs in S3, secrets in SSM. Tokens are signed by key pairs the function generates and wraps under a KMS key you supply. A second key encrypts the SSM parameters. Allow about an hour, most of it waiting for AWS.
Design: ports. Library arguments: Pulumi library. Keys: configuration.
What you need¶
| Item | Detail |
|---|---|
| AWS account | Credentials that create IAM, Lambda, API Gateway, DynamoDB, S3, KMS, SSM and EventBridge resources. Below: 111122223333, eu-central-1. |
| Apply role | The ARN of the IAM role you apply with (applyRoleArn). Only it may write the truststore. |
| KMS keys | Two symmetric keys, aliases alias/demo-sluis-sign and alias/demo-sluis-secrets. Step 0 creates them. The library looks the aliases up and creates no key. |
| ACM certificate | In that region, for access.example.test. You supply its ARN. |
| Audit queue | URL and ARN of an existing audit installation's SQS ingest queue (Audit.Use, v1.74 or later). Without it the library installs audit beside the function: see Audit. |
| Tools | Go, pulumi logged in to a backend, gh, openssl, curl, and sluisctl from the release's sluisctl_<version>_<os>_<arch> archive. |
0. Create the keys¶
The keys exist before the stack that grants on them. A symmetric key with an alias and the account's default key policy is enough. The function's role uses the sign key under the context {instance, purpose: sign}, and the secrets key through SSM only.
for NAME in sign secrets; do
KEY=$(aws kms create-key --description "demo sluis: $NAME" --query KeyMetadata.KeyId --output text)
aws kms enable-key-rotation --key-id "$KEY"
aws kms create-alias --alias-name "alias/demo-sluis-$NAME" --target-key-id "$KEY"
done
1. Write the installation¶
installation.yaml holds what you know about the installation and no secret. It is held to schemas/config/installation.schema.json.
apiVersion: sluis.truvity.github.io/installation/v1
instance: demo
shape: lambda
preset: aws-hybrid
issuer:
url: https://access.example.test
secureCookies: true
aws:
account: "111122223333"
region: eu-central-1
bucket: demo-sluis-111122223333
signingKey:
kmsWrapped: {} # the key is LambdaArgs.Keys.Sign (step 4), written as keys.sign
recovery: {enabled: true} # the way in before any directory is connected
console: {client: access-console}
access:
groups:
all:sluis:operator:
members: [[email protected]]
all:sluis:viewer:
matchers: [{email_domain: example.test}]
clients:
access-console:
kind: public
redirects: [https://access.example.test/console/callback]
requires: [all:sluis:viewer]
instance names the installation, and its SSM root is /sluis/demo. bucket and the tables sluis-demo-<module> are what the library creates in step 4.
2. Render it and read the result¶
sluisctl render --installation installation.yaml --out rendered
Expect no output and exit status 0. rendered/ holds sluis.yaml (the service document) and policy.yaml. In sluis.yaml, check preset: aws-hybrid, the S3 bucket and secrets: {source: ssm, root: /sluis/demo, layout: v5}. A mistake in the installation stops here and names the key. The library renders the same documents in step 4. Do not deploy rendered/.
3. Fetch the release and pin its digest¶
Use one release for the Pulumi library and the binary. This tutorial needs v1.77.1 or later, where layout v5 is the only one; it uses v1.77.1.
VERSION=1.77.1 # the release you chose, without the v
gh release download "v$VERSION" --repo truvity/sluis --dir dist \
--pattern "sluis-issuer_${VERSION}_linux_arm64.zip" --pattern checksums.txt
grep "sluis-issuer_${VERSION}_linux_arm64.zip" dist/checksums.txt
Expect one line: a 64-character SHA-256 and the file name. Copy the digest into main.go as a constant and commit it. Do not read it from dist/checksums.txt in the program. The library checks the zip against the constant and deploys it byte for byte.
4. Write the Pulumi program¶
In an empty Pulumi Go project (pulumi new aws-go), add the library at the release's tag and copy installation.yaml beside main.go. The truststore is the PEM of the CAs a client certificate must chain to. Make a test CA and client certificate:
go get github.com/truvity/sluis/deploy/[email protected] github.com/truvity/sluis/deploy/pulumi/edge/[email protected] \
github.com/truvity/[email protected]
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 30 \
-keyout ca.key -out truststore.pem -subj "/CN=demo-client-ca"
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout client.key -out client.csr -subj "/CN=demo-client"
openssl x509 -req -in client.csr -CA truststore.pem -CAkey ca.key -CAcreateserial -days 30 -out client.crt
main.go:
package main
import (
"os"
"github.com/pulumi/pulumi-aws/sdk/v7/go/aws"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
sluisconfig "github.com/truvity/sluis/config"
sluispulumi "github.com/truvity/sluis/deploy/pulumi"
edge "github.com/truvity/sluis/deploy/pulumi/edge/cloudflare"
)
const (
region = "eu-central-1"
account = "111122223333"
version = "1.77.1"
lambdaSHA256 = "<the digest from step 3>"
certArn = "<the ACM certificate's ARN>"
applyRoleArn = "<the ARN of the IAM role you apply with>"
auditQueueURL = "<the audit ingest queue URL>"
auditQueueArn = "<the audit ingest queue ARN>"
)
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
provider, err := aws.NewProvider(ctx, "aws", &aws.ProviderArgs{Region: pulumi.String(region)})
if err != nil {
return err
}
withAWS := pulumi.Providers(provider)
// The blob bucket also holds the truststore, so it is versioned and guards
// that prefix: only the apply role may write it.
store, err := sluispulumi.NewStorage(ctx, "demo", &sluispulumi.StorageArgs{
BucketName: "demo-sluis-" + account, Versioning: true,
ProtectedPrefixes: []sluispulumi.ProtectedPrefix{edge.Guard(applyRoleArn)},
}, withAWS)
if err != nil {
return err
}
// One table per module, named sluis-demo-<module>.
states, err := sluispulumi.NewStates(ctx, "demo", &sluispulumi.StatesArgs{
Modules: sluispulumi.ModuleSet{Instance: "demo"},
}, withAWS)
if err != nil {
return err
}
in, err := sluisconfig.LoadInstallation("installation.yaml")
if err != nil {
return err
}
pem, err := os.ReadFile("truststore.pem")
if err != nil {
return err
}
l, err := sluispulumi.NewLambda(ctx, "demo", &sluispulumi.LambdaArgs{
Installation: in,
Package: "dist/sluis-issuer_" + version + "_linux_arm64.zip",
PackageSHA256: lambdaSHA256,
Storage: store.Grant(),
State: states.Grant(),
Audit: &sluispulumi.AuditArgs{Use: &sluispulumi.AuditUse{QueueURL: auditQueueURL, QueueArn: auditQueueArn}},
Keys: &sluispulumi.KeysArgs{Sign: "alias/demo-sluis-sign", Secrets: "alias/demo-sluis-secrets"}, // the keys from step 0, by alias
}, withAWS)
if err != nil {
return err
}
// The front door: the custom domain with mutual TLS, in front of the API.
front, err := edge.NewEdge(ctx, "demo", &edge.Args{
FrontDoor: l.FrontDoor(),
DomainName: "access.example.test",
CertificateArn: pulumi.String(certArn),
TruststorePEM: string(pem),
Storage: store,
}, withAWS)
if err != nil {
return err
}
ctx.Export("domainTarget", front.DomainTarget)
ctx.Export("functionName", l.FunctionName)
return nil
})
}
NewLambda fills in what the installation leaves out and refuses a value that disagrees with the arguments, naming the argument. It replaces the deprecated Config, Policy and PolicyPath arguments.
5. Preview, then apply¶
pulumi preview
Expect only creates:
-
the bucket and a table per module
-
the functions with their roles and layers
-
the HTTP API
-
the custom domain with the truststore object
-
the SSM parameters for the recovery password and the state secret
-
the schedules
Then apply:
pulumi up
Expect domainTarget and functionName in the outputs. If the apply refuses the package, the message names the digest or version: the zip must match the library's release.
6. Point DNS at it and ask the issuer¶
Create a CNAME from access.example.test to domainTarget. A request without a client certificate never reaches the function. Ask with the certificate you made:
curl --cert client.crt --key client.key https://access.example.test/.well-known/openid-configuration
Expect JSON whose issuer is https://access.example.test. A TLS failure means the certificate does not chain to truststore.pem or the CNAME has not propagated. Read the function's log:
aws logs tail /aws/lambda/sluis-demo-issuer --since 15m
The log group is /aws/lambda/<function name>, sluis-<instance>-issuer unless the installation sets aws.functions.issuer (or aws.functionName). A document the loader refuses stops the cold start and is named there.
7. Sign in¶
The library generated the recovery password. Read it from a terminal:
sluisctl recovery password --instance demo --region eu-central-1 --show
Import client.crt and client.key into a browser as a PKCS#12 file. Open https://access.example.test/console/login, expand Recovery sign-in and paste the password. You are recovery, an operator. The attempt's audit trail is in Recovery on Lambda.
Next¶
-
Connect a directory: Google Workspace. Then connect each relying party: how-to index.
-
Add the backup module: back up and restore on layout v5.
-
Operate on Lambda: Lambda reference.