PayCargo · AI-Sandbox · Step 6.p

6.pSSO layer scoping: Azure AD via Cognito federation

PayCargo employees sign in with their existing Azure AD identity via SAML federation. Cognito stays as the AWS-facing identity layer. Federated users land with NO default permissions and request access in-platform via a Request Roles cartridge. System admins (Cognito-native, today's accounts) approve.
Block: 6.p (Console doctrine; companion to 4.5.b three-layer defense and 6.n OAuth) · State: Decisions locked. Doctrine entries land in Doctrine.md alongside this. · Surface: Cognito SAML IdP, Hosted UI, in-platform group management, Request Roles cartridge
Locked
Seven decisions confirmed. SAML federation. JIT provisioning with zero default permissions. Groups assigned in-platform, not synced from Azure. Existing Cognito accounts become "system admins" outside SSO. Doctrine entries land in Doctrine.md sections 2.7, 2.8, 8.8 in this same PR. Build plan in section 13 ready to execute.

Sections

  1. Why SSO now
  2. Architecture: federation, not replacement
  3. Federation protocol: OIDC vs SAML
  4. User provisioning: JIT vs SCIM
  5. Group sync
  6. MFA model
  7. Existing user migration + break-glass
  8. Sign-out flow
  9. Per-environment topology
  10. What changes in the codebase
  11. Decisions to confirm
  12. Azure-side prerequisites
  13. Ship plan

1. Why SSO now

Three problems with the current Cognito-only auth that SSO fixes:

The goal: a PayCargo employee opens the Console, clicks Sign In, gets bounced through Azure AD, comes back signed in. No Cognito password. Group membership flows from Azure.

2. Architecture: federation, not replacement

Cognito stays. Cognito federates to Azure AD upstream. Everything downstream of Cognito (cartridge JWT verify, group checks, audit logs) is unchanged.

Why not replace Cognito with Azure tokens directly? Three reasons:

  1. Every cartridge already verifies Cognito JWTs. Changing the token issuer means changing every cartridge handler. Federation lets us add Azure as the source of truth for identity without touching cartridge code.
  2. Local accounts for break-glass. When Azure has a bad day, we need at least one operator who can still sign in. Cognito-only accounts handle this; an Azure-only world cannot.
  3. Cartridge-by-cartridge migration. Federation supports a hybrid period where some users are Cognito-only and some are Azure-federated. A clean cutover would require all users to migrate at once.

The flow

User Console (CloudFront + S3 SPA) │ │ │ click "Sign in" │ │ ─────────────────────────│ │ │ redirect to Cognito Hosted UI │ │ (https://<pool-id>.auth.us-east-1.amazoncognito.com) │ │ │ user sees Hosted UI: Cognito Hosted UI │ ┌──────────────────────────┐ │ │ │ Sign in with Azure │ ← only button (or also) │ │ │ Username + password (fallback for break-glass admins) │ │ └──────────────────────────┘ │ │ │ │ click "Sign in with Azure" │ │ ───────────────────────────────────────────────────────────│ │ │ redirect to Azure AD │ │ /oauth2/v2.0/authorize │ │ │ Azure AD page (familiar PayCargo SSO experience) │ ├ enter Azure password (if not already cached in browser) │ └ Azure MFA (if not already satisfied) │ │ Azure redirects back to Cognito with auth code │ ─────────────────────────────────────────────────────────────────────────────────────► Cognito │ │ │ │ Cognito exchanges code for Azure │ │ ID token, extracts claims, │ │ creates Cognito user (JIT) on first │ │ login, maps attributes + groups │ │ │ Cognito redirects back to Console with Cognito tokens │ ◄─────────────────────────────────────────────────────────────────────────────────────│ │ │ │ │ Console stores Cognito JWT │ │ Cartridges read it as today │ │ │ use the Forge as today (JWT-verified by every cartridge)

3. Federation protocol: OIDC vs SAML

Azure AD supports both. Cognito User Pools support both. Pick one.

OIDC (recommended)

  • JSON-based, modern, web-friendly
  • Cognito treats it like an OAuth 2.0 provider (we already understand OAuth from Step 6.n)
  • App registration in Azure is simpler (no SAML metadata XML)
  • Debugging is easier: read tokens at jwt.io, inspect claims
  • Same shape as our existing OAuth provider abstraction

SAML 2.0

  • Traditional enterprise SSO; Azure has decade-old support
  • Some IT teams prefer the XML metadata exchange model
  • Federation Metadata XML can be uploaded directly to Cognito
  • More legacy, harder to debug, less aligned with our existing OAuth pattern

Locked: SAML 2.0. Aligned with PayCargo Azure admin policy. The Cognito User Pool IdP type will be SAML; the Azure side provides a SAML metadata XML URL that we wire into the Terraform.

4. User provisioning: JIT vs SCIM

Two patterns for getting Azure AD users into Cognito.

PatternHow it worksProsCons
JIT (just-in-time)
(recommended)
First sign-in creates the Cognito user automatically from Azure claims. No proactive sync. Zero ops infra. Cognito user appears the moment the person needs to log in. Aligns with how users actually discover the Forge. Cognito doesn't know about deactivations until the user tries to log in (and fails because Azure rejects them).
SCIM Azure AD pushes user create/update/delete events to a Cognito SCIM endpoint. Deactivations propagate immediately. Group changes propagate immediately. Better audit story. Requires SCIM endpoint Lambda. Requires Azure admin to enable SCIM provisioning for the enterprise app. More moving parts.

Locked: JIT, with zero default permissions. First Azure sign-in creates the Cognito user. The new user lands in the Console with NO Cognito groups, which means they see only the Request Roles cartridge (because every other cartridge gates on a group membership the user doesn't have yet). They use the Request Roles cartridge to ask a system admin for access to specific cartridges or groups.

The Request Roles cartridge (new piece of work)

A platform cartridge, similar in shape to obs. Audience: every authenticated Forge user (no group gating). Surface:

Lives at cartridges/global/request-roles/. Loaded by every user. Storage: DDB table for request records. The Console launcher shows it as the only tile a brand-new SSO user sees on first sign-in.

Deactivation latency caveat

With JIT alone, a deactivated Azure user keeps their Cognito session until the Cognito access token expires (1 hour). To force-revoke faster, we'd need to invalidate the Cognito session (admin signoutAll). v1 accepts the 1-hour window; v2 wires SCIM to push deactivation events.

5. Groups are assigned in-platform, not synced from Azure

Locked: in-platform assignment. The SAML assertion from Azure does NOT include group claims. Cognito groups are managed entirely within the Forge: by the system admin via the obs Admin tab, or via the Request Roles approval flow.

Why this matters:

The flow

  1. User signs in via Azure SAML for the first time. JIT creates the Cognito user with NO groups.
  2. User sees only the Request Roles cartridge (because every other cartridge gates on a group they don't have).
  3. User submits a request for access to specific cartridges or groups.
  4. System admin receives an SES notification + sees the request in the Request Roles admin queue.
  5. System admin approves; the cartridge calls AdminAddUserToGroup on Cognito.
  6. User signs in next time and sees their granted cartridges.

For day-to-day changes (revoke a group, add a group), the system admin uses the obs Admin tab user-and-group surface that already exists. Request Roles is the new-user onramp; the Admin tab is the ongoing management surface.

6. MFA model + system admins

Locked: today's Cognito-native accounts become "system admins" outside SSO; SSO users get MFA via Azure. Two parallel auth tracks with different responsibilities:

TrackWho's in itSign-inMFAWhat they do
System admins (Cognito-native) Today's Cognito accounts (currently mtang); future platform operators Cognito-native username + password Cognito TOTP (mandatory) Platform administration. Approve Request Roles. Break-glass when Azure or federation is broken. Quarterly tested.
SSO users (Azure-federated) All other PayCargo employees who use the Forge Azure SAML SSO Azure conditional access (mandatory on the Forge app registration) Use cartridges they've been granted via Request Roles. Cannot sign in via Cognito-native; cannot bypass Azure.

Why two tracks

System admins keep Cognito-native sign-in for the same reason every payments platform keeps an out-of-band admin path: federation can fail. Azure outage, expired SAML signing certificate, misconfigured assertion, IdP-side incident. When any of these happen, the platform operator still needs to log in and fix the federation. The system admin track is the bypass that lets that happen.

System admins are not "break-glass only." They are the platform's administrators, used routinely. Approving Request Roles requests, debugging cartridges in production, running incident response — these are admin work, done from the system admin track. Break-glass is one of the things this track supports, not its only purpose.

Azure-side MFA enforcement

Azure conditional access policy MUST require MFA on the Forge enterprise application. We do not trust "Azure MFA exists somewhere on the user"; we require it on this specific app registration. Without that policy, federated users could in principle sign in with just a password, which collapses the trust assumption.

Azure admin confirms the conditional access policy during the prereq step (section 12).

7. Migration: existing accounts become system admins

Locked: today's Cognito-native accounts become system admins. No migration to Azure for these accounts.

Today's known Cognito users (currently mtang plus any others invited so far) are reclassified as system admins. They stay Cognito-native. They keep TOTP MFA. They are in the admins Cognito group. They do not need an Azure-federated equivalent because their entire purpose is to be reachable when Azure isn't.

What changes for them

Nothing changes for the existing accounts in terms of sign-in. They keep their username, password, TOTP. The doctrinal reframing is the change: they were "the only way to use the Forge"; now they are "the system admin track that exists outside SSO."

What new users see

New PayCargo employees who get pointed at the Forge:

  1. Open the Console URL. See the Hosted UI with the "Sign in with Azure" button.
  2. Click it. Bounced through Azure SAML. Cognito JIT-creates their user.
  3. Land in the Console launcher. Only the Request Roles cartridge is visible.
  4. Submit a request for the cartridges they need.
  5. A system admin (today: mtang) reviews + approves.
  6. Next sign-in, the user sees their granted cartridges.

No new user goes through Cognito-native sign-in. The system admin track is closed to new accounts unless the platform team explicitly creates them.

Quarterly verification

System admin accounts are tested quarterly: sign in via Cognito-native, confirm the admins group is still attached, confirm the obs Admin tab works. This catches password-rotation drift, TOTP token loss, and silent IAM changes before they become an incident.

8. Sign-out flow

Two options. Trade-off: convenience vs scope.

OptionWhat happens on logout
Local Cognito logout
(recommended for v1)
User clicks logout in the Console. Cognito session ends. Azure AD session is unaffected; the user is still signed into Outlook, Teams, etc.
Single sign-out (SLO) Cognito logout triggers an Azure logout endpoint call. User is signed out of everything Azure-connected. Surprises the user if they didn't expect it.

Locked: local Cognito logout for v1. Forge logout shouldn't be allowed to also log a user out of email and Teams. SLO is a v2 iteration if security policy requires it.

9. Per-environment topology

Today's environments: ai-sandbox (account 959228203854). Production-equivalent doesn't exist yet but the doctrine should anticipate it.

EnvironmentCognito User PoolAzure app registration
ai-sandboxus-east-1_fJLnhjqnlPayCargo Forge (ai-sandbox)
production (future)new pool, separatePayCargo Forge (production)

Locked: same Azure tenant, separate app registrations per environment. The Azure tenant is one (PayCargo's tenant). Each Forge environment has its own Cognito User Pool, and each pool federates to its own Azure app registration. This lets policy diverge per environment (e.g., production might require stricter Azure conditional access) without coupling the environments.

10. What changes in the codebase

LayerChange
Substrate Terraform Console layer. console/sso.tf declares the Cognito SAML identity provider (aws_cognito_identity_provider) attached to the obs-owned Cognito user pool. console/auth.tf updated to conditionally include the SAML IdP in supported_identity_providers. Outputs sso_acs_url and sso_entity_id for the Azure admin handoff. (Group sync Lambda removed per Step 6.p decision 3: groups are assigned in-platform, not synced from Azure.)
obs cartridge auth Cognito User Pool client gets Azure added to supported_identity_providers. Hosted UI gets a custom "Sign in with Azure" branded button. callback_urls already include the obs / console domains; no change.
console cartridge auth Same Cognito User Pool client change. Console Hosted UI lands users on the Sign in with Azure flow by default.
Cartridge handler code No change. Cartridges read Cognito JWT, verify it, check cognito:groups. The token shape is the same whether the user signed in via Cognito-native or Azure-federated.
Doctrine New entries in Doctrine.md section 2 (Console): trust Azure MFA for federated users; static group mapping for federated identities. Section 7 (Operational): break-glass account ceremony.
SDK skill Updated to note that jwtPayload['cognito:groups'] may include groups assigned by federation. Cartridges don't care about origin.

11. Decisions (locked)

The seven decisions, with the locked answer and the doctrinal consequence.

Locked 1

Federation protocol: SAML 2.0

Aligned with Azure admin preference. Cognito User Pool IdP type = SAML. Azure provides a metadata XML URL the Terraform consumes.

Locked 2

JIT with zero default permissions

First Azure sign-in creates the Cognito user with NO groups. User sees only the Request Roles cartridge. Permissions are explicitly granted via the in-platform request flow. New foundation cartridge: cartridges/global/request-roles/.

Locked 3

Groups assigned in-platform, not synced from Azure

SAML assertion does not carry group claims. Cognito groups are managed via the obs Admin tab and the Request Roles approval flow. No pre-token-generation Lambda; no SSM mapping table. Forge access is a Forge concern.

Locked 4-6

System admins outside SSO

Today's Cognito-native accounts (currently mtang) are reclassified as system admins. They keep Cognito-native sign-in with TOTP MFA. They approve Request Roles. They are not break-glass-only; they are the platform's administrative track, used routinely, that also covers break-glass when federation fails. SSO users get MFA via Azure conditional access. Sign-out is local Cognito only.

Locked 7

Same Azure tenant, separate app registrations per environment

Confirmed. ai-sandbox today; production gets its own app registration when it exists.

12. Azure-side prerequisites

What a PayCargo Azure AD admin needs to do before PR 2 lands. Same shape as the Salesforce Connected App prereq for OAuth (Step 6.n).

  1. Create an Enterprise Application in Azure AD under the PayCargo tenant. Name: PayCargo Forge (ai-sandbox).
  2. Configure as OIDC (assuming Decision 1 = OIDC):
    • Sign-in URLs / redirect URIs: https://<cognito-pool-id>.auth.us-east-1.amazoncognito.com/oauth2/idpresponse (Cognito gives us this URL after PR 2 declares the IdP)
    • Logout URL: https://<cognito-pool-id>.auth.us-east-1.amazoncognito.com/logout
    • Supported account types: PayCargo tenant only (single tenant)
  3. API permissions — grant the app:
    • openid, profile, email (standard OIDC)
    • User.Read (Microsoft Graph) for basic profile reads
    • GroupMember.Read.All (Microsoft Graph) — needed if we want group claims to include all the user's groups
  4. Configure group claims in the token configuration. Options: emit Azure AD group object IDs (we map them in the SSM mapping table) or emit display names (more human-readable but easier to clash).
  5. Generate client secret and hand it to the Forge operator (same way Salesforce Consumer Secret was handed over). Stored in AWS Secrets Manager at /forge/sso/azure-client-secret.
  6. Apply conditional access policy requiring MFA on this app. Without this, Decision 4 (trust Azure MFA) is invalid.
  7. Assign users or groups who should be able to sign into the Forge. PayCargo's standard practice should apply (group-based assignment, not individual users).

One-time setup. After it's done, every change happens on our side via Terraform. Adding a user to the Forge means adding them to the Azure group already assigned to the app.

13. Ship plan (with locked decisions)

Six PRs. Reshape from the original five-PR plan because: SAML replaces OIDC (no functional change to the ship plan), the group sync Lambda is gone (no PR for it), and the Request Roles cartridge is a new piece of work.

  1. PR 1 (this PR): scoping doc with locked decisions + new doctrine entries. Step 6.p with all "Proposed default" lines flipped to "Locked." New doctrine entries land in Doctrine.md + Doctrine.html: 2.7 (SAML federation with zero default permissions), 2.8 (groups assigned in-platform), 8.8 (system admins outside SSO). Index status flips from pending to done. No code yet.
  2. PR 2: console/sso.tf at the Console layer. SAML IdP declared with count = var.sso_enabled ? 1 : 0; feature-flagged off. After apply, the operator reads the ACS URL + Entity ID from terraform output sso_acs_url and sso_entity_id and hands them to the Azure admin (section 12 prereqs). Console's Cognito User Pool client updated to conditionally include the SAML IdP in supported_identity_providers. SSO not active until PR 3 sets sso_enabled = true and sso_metadata_url.
  3. PR 3: Azure app registration + SAML metadata wired. Manual Azure admin step (the prereq in section 12). Operator hands off the ACS URL + Entity ID from PR 2 apply. Azure admin provides back the SAML metadata XML URL + the signing certificate. Terraform updates the SAML IdP with the real metadata URL. After apply, "Sign in with Azure" works end-to-end. First test user signs in.
  4. PR 4: cartridges/global/request-roles/ cartridge (new piece of work). User-side: form to request access. System admin side: queue + approve/deny + group assignment via AdminAddUserToGroup. DDB table for request records. SES notifications. Console launcher loads this for every authenticated user (no group gating).
  5. PR 5: obs Admin tab updates for system admin workflow. The user-and-group surface in obs Admin gains a "system admins" annotation column so an operator can see at a glance which accounts are Cognito-native (system admin track) vs Azure-federated (SSO track). Decommission noise from "manual invite" flows.
  6. PR 6: system admin runbook + quarterly verification. Runbook in the obs Documentation tab. Covers: how a system admin signs in, where credentials live, how the quarterly verification is run, who has access. Calendar entry for the verification ceremony.

PR 2 can ship before Azure admin work is done. PR 3 is blocked on Azure admin work. PR 4 can be scoped + designed in parallel with PR 2/3.

What unblocks what

PR 1 is this PR. After it merges, PR 2 ships and gets us the ACS URL + Entity ID. Hand those to the Azure admin along with section 12. PR 3 ships once they've configured the SAML enterprise app and handed back the metadata URL. PR 4 (Request Roles cartridge) can ship before, during, or after PR 3 because it doesn't depend on the federation itself; it gates on the in-platform group assignment surface being ready. PR 5 and 6 finish.

Order-sensitive: Request Roles cartridge timing

If Azure SSO ships in PR 3 before the Request Roles cartridge ships in PR 4, the first Azure-federated user will sign in with no groups and see an empty Console launcher. That's confusing. Two options: (a) hold PR 3 deployment until PR 4 is also ready, or (b) ship PR 3 with a temporary "no cartridges available, contact mtang for access" landing page in the launcher. Operator picks at PR 3 time.

References