Automated tenant onboarding and offboarding
Connect the two planes. When a system admin registers a tenant in the control plane, the application plane provisions it automatically: it creates the tenant’s admin user and reports the tenant’s configuration back. Deleting the registration deprovisions it: users, group and data are removed.
Until now, tenants existed only because we created users by hand with dev-create-tenant-user.sh.
The flow
Section titled “The flow”The provisioning job is SBT’s ProvisioningScriptJob in the bootstrap stack: Step Functions runs server/scripts/provision-tenant.sh in CodeBuild with tenantId, tier, tenantName and email as environment variables. The script reads the pooled stack’s outputs, creates tenant-admin-<tenantId> with custom:tenantId, custom:userRole and custom:tenantTier, creates the group <tenantId> and adds the admin to it. It exports tenantConfig={userPoolId, appClientId, apiGatewayUrl}, tenantStatus=Complete and registrationStatus=Complete, which become the provisionSuccess event. The control plane receives it through an EventBridge API destination (PATCH /tenant-registrations/{id}, using the machine OAuth client) and serves the config publicly at GET /tenant-config/{tenantName}.
Offboarding is the mirror image: DELETE /tenant-registrations/{id} → offboardingRequest → deprovision-tenant.sh deletes the tenant’s users and group, deletes every item in shards <tenantId>-1..9 of the product and order tables → deprovisionSuccess → registration status Deleted.
Why a CodeBuild job running a bash script?
Section titled “Why a CodeBuild job running a bash script?”SBT’s “core application plane” deliberately makes provisioning just a script. That way the same mechanism can do anything: create a user (pooled tiers, here) or run cdk deploy for a whole new stack (Platinum, step 09). CodeBuild provides a long-running environment with the AWS CLI, Node, Python and Docker. Step Functions wires it to EventBridge and turns the script’s exported env vars into the outgoing event.
What gets provisioned per tier
Section titled “What gets provisioned per tier”| Tier | Provisioning does | Tenant uses |
|---|---|---|
| Basic / Standard / Premium | Admin user + group in the existing pooled user pool | pooled API, pooled tables |
| Platinum | (step 09) deploys a dedicated stack first | its own stack |
The tenant admin gets a Cognito email with a temporary password, and the first sign-in must set a new one.
IAM: least privilege for the jobs
Section titled “IAM: least privilege for the jobs”The original reference solution gives both CodeBuild jobs "Action": "*". Here each job gets only what its script needs: read the tenant stacks’ outputs, the Cognito admin calls on user pools, and for deprovisioning Query/DeleteItem on tenant-template tables. Step 09 widens provisioning, because deploying a CDK stack genuinely needs broad permissions.
- server/scripts/provision-tenant.sh, server/scripts/deprovision-tenant.sh: the job scripts.
set -e, because CodeBuild runs each file as a single command. - server/cdk/lib/bootstrap-template/application-plane-jobs.ts:
EventManageron the control plane bus,ProvisioningScriptJob,DeprovisioningScriptJob,CoreApplicationPlane, and IAM. - server/cdk/lib/bootstrap-template/bootstrap-template-stack.ts: now takes
eventBusArnfrom the control plane stack. - scripts/onboard-tenant.sh, scripts/offboard-tenant.sh: call the control plane API as the system admin and wait for the job.
Deploy
Section titled “Deploy”./scripts/deploy.sh you@example.comIf you deploy stacks one at a time, deploy ControlPlaneStack before serverless-saas-bootstrap. The bootstrap stack imports the event bus ARN that the control plane now exports. Otherwise you get No export named ControlPlaneStack:ExportsOutput...EventBus....
Verify
Section titled “Verify”ADMIN_PASSWORD='Passw0rd!Saas' ./scripts/onboard-tenant.sh acme acme-admin@example.com BASICResult from the test deployment, about 2 minutes later:
{ "tenantName": "acme", "tier": "BASIC", "tenantStatus": "Complete", "tenantId": "e2f1f038-254c-4336-8ded-9e62a83eaf61", "tenantConfig": "{\"userPoolId\":\"us-east-1_a1M1hAn8y\",\"appClientId\":\"q33c...\",\"apiGatewayUrl\":\"https://0xlsgzuyxh.execute-api.us-east-1.amazonaws.com/prod/\"}" }The public lookup the SaaS web app will use (step 11). No token is needed:
curl -s ${CONTROL_PLANE_API}tenant-config/acme# {"userPoolId":"us-east-1_a1M1hAn8y","appClientId":"q33c...","apiGatewayUrl":"https://0xlsgzuyxh.../prod/"}The provisioned admin tenant-admin-<tenantId> has status FORCE_CHANGE_PASSWORD and attributes custom:tenantId=<tenantId>, custom:userRole=TenantAdmin, custom:tenantTier=BASIC. After setting a password (or using the emailed one), they can use the API right away:
| Call | Result |
|---|---|
/whoami |
tenantId: e2f1..., tenantTier: BASIC, userRole: TenantAdmin |
| create a product, list | ["Acme Anvil"] |
/users |
["tenant-admin-e2f1..."] |
Offboard:
ADMIN_PASSWORD='Passw0rd!Saas' ./scripts/offboard-tenant.sh acme# {"registrationStatus":"Deleted","sbtaws_active":false,...}Afterwards the user and the group are gone (UserNotFoundException, Group not found), and the product and order tables hold no e2f1...-* shards (only the dev tenants tenant-a and tenant-b remain).
Events on the bus (/aws/events/serverless-saas-event-bus-watcher):
sbt_aws_onboardingRequest acmesbt_aws_provisionSuccess 326b44d6-...sbt_aws_offboardingRequest acmesbt_aws_deprovisionSuccess 326b44d6-...SBT 0.9.5 keeps serving
GET /tenant-config/acmeafter offboarding, because the tenant record is only marked inactive. It’s harmless here since the tenant’s users no longer exist, but a production system should filter inactive tenants out of that lookup.
Step 09 adds the Platinum tier: provisioning deploys a dedicated copy of the tenant template stack for the tenant.