The tenant-facing SaaS app
Build the product itself: a React app where a tenant’s users sign in and manage products, orders and users. The same deployed app serves every tenant, pooled or silo. It discovers which user pool and API to use when the user types their tenant name.
How one app serves every tenant
Section titled “How one app serves every tenant”The lookup returns, for example, {"userPoolId":"us-east-1_a1M1hAn8y","appClientId":"q33c...","apiGatewayUrl":"https://0xlsgzuyxh.../prod/"}.
For a Platinum tenant step 2 returns the silo stack’s own pool and API, and nothing else changes. The tenant config is cached in localStorage, so the lookup happens only when the tenant is chosen. The app never sends a tenant id to the API. The Lambda authorizer reads it from the token (step 04).
The deployed app’s config.json holds just one value: the control plane API URL.
Screens
Section titled “Screens”| Screen | API | Notes |
|---|---|---|
| Tenant name | GET /tenant-config/{name} (control plane) |
“Unknown tenant” on 404 |
| Sign in | Cognito (tenant pool) | Handles the first-login “choose a new password” challenge for emailed temporary passwords |
| Dashboard | GET /whoami, /products, /orders |
Shows the token’s claims next to what the authorizer derived, including the API key (usage plan) |
| Products | GET/POST /products, PUT/DELETE /products/{key} |
Add, edit inline, delete |
| Orders | GET/POST /orders, DELETE /orders/{key} |
Pick quantities per product. Totals are computed client-side. |
| Users | GET/POST /users, PUT .../enable, DELETE .../disable, DELETE /users/{name} |
Management controls only for TenantAdmins. The API enforces the rules anyway (step 07). |
Bug found and fixed in this step: URL-encoded keys
Section titled “Bug found and fixed in this step: URL-encoded keys”Product and order keys look like tenant-a-6:246d.... The app URL-encodes them (: → %3A), and API Gateway REST APIs pass path parameters through still encoded. The service then saw the shard tenant-a-6%3A246d..., the ownership check failed, and the user got 403 for their own product. tenant_data.split_key now URL-decodes the key first, and a test covers it (43 tests pass).
- client/app/src/main.tsx: config → tenant selection →
AuthProvider(re-mounted per tenant) → app. - client/app/src/tenant.ts: tenant lookup and
localStorage. - client/app/src/auth.tsx: Amplify auth against the tenant pool. Exposes the token’s tenant claims.
- client/app/src/api.ts:
fetchwrapper for the tenant API (friendly message on429usage plan limits). - client/app/src/pages/:
SelectTenant,Login,Dashboard,Products,Orders,Users. - client/cdk/lib/client-stack.ts: second
StaticSite(AppSiteUrl). - server/src/layers/tenant_data.py: the decode fix.
Deploy
Section titled “Deploy”./scripts/deploy.sh you@example.com # or ./scripts/deploy-client.sh for just the web appsOutputs: AdminSiteUrl and AppSiteUrl. Local dev: cd client/app && cp public/config.example.json public/config.json && npm run dev (port 5174, which the control plane CORS allows).
Verify
Section titled “Verify”On the test deployment, with the tenant acme onboarded (BASIC):
In the browser at https://d296tcdmqjm8ho.cloudfront.net:
nosuchtenantshowsUnknown tenant "nosuchtenant".acmelooks up the tenant config and shows “Sign in to acme”.
Signed-in calls as acme’s provisioned admin, sent with Origin: https://d296tcdmqjm8ho.cloudfront.net exactly as the app sends them:
| Call | Result |
|---|---|
CORS preflight for PUT /products/x, DELETE /orders/x, POST /users |
204, methods GET,POST,PUT,PATCH,DELETE,... |
POST /products, then PUT /products/<url-encoded key> |
200, 200 |
POST /orders with a line item |
{"orderName":"First","orderProducts":[{"price":60,"productId":"6e02...-5:40a0...","quantity":2}]} |
POST /users (invite wile), GET /users |
New user created, [tenant-admin-6e02... (TenantAdmin), wile (TenantUser)] |
disable / enable wile |
200 / 200 |
DELETE /orders/<url-encoded key> |
200 |
responses carry access-control-allow-origin: * |
yes |
I didn’t sign in through the browser, because the automation here doesn’t type passwords into deployed sites. Try it yourself: open the App site, enter acme, and sign in as the tenant admin, using the temporary password Cognito emailed or one you set with aws cognito-idp admin-set-user-password.
SBT finds the tenant config by name. If you offboard a tenant and onboard a new one with the same name, the old (inactive) record still exists and
GET /tenant-config/{name}may return either. Use unique tenant names, or filter inactive tenants in a production control plane.
Step 12 adds the tenant pipeline: when new code is uploaded, CodePipeline updates the pooled stack and every Platinum stack, in waves.