Skip to content

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 SaaS app serves every tenant: tenant config lookup, sign-in, API calls

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.

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).

Terminal window
./scripts/deploy.sh you@example.com # or ./scripts/deploy-client.sh for just the web apps

Outputs: 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).

On the test deployment, with the tenant acme onboarded (BASIC):

In the browser at https://d296tcdmqjm8ho.cloudfront.net:

  • nosuchtenant shows Unknown tenant "nosuchtenant".
  • acme looks 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.