Skip to content

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