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.
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:
Azure AD supports both. Cognito User Pools support both. Pick one.
jwt.io, inspect claimsLocked: 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.
Two patterns for getting Azure AD users into Cognito.
| Pattern | How it works | Pros | Cons |
|---|---|---|---|
| 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.
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.
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.
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:
AdminAddUserToGroup on Cognito.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.
Locked: today's Cognito-native accounts become "system admins" outside SSO; SSO users get MFA via Azure. Two parallel auth tracks with different responsibilities:
| Track | Who's in it | Sign-in | MFA | What 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. |
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 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).
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.
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."
New PayCargo employees who get pointed at the Forge:
mtang) reviews + approves.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.
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.
Two options. Trade-off: convenience vs scope.
| Option | What 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.
Today's environments: ai-sandbox (account 959228203854). Production-equivalent doesn't exist yet but the doctrine should anticipate it.
| Environment | Cognito User Pool | Azure app registration |
|---|---|---|
| ai-sandbox | us-east-1_fJLnhjqnl | PayCargo Forge (ai-sandbox) |
| production (future) | new pool, separate | PayCargo 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.
| Layer | Change |
|---|---|
| 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. |
The seven decisions, with the locked answer and the doctrinal consequence.
Aligned with Azure admin preference. Cognito User Pool IdP type = SAML. Azure provides a metadata XML URL the Terraform consumes.
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/.
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.
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.
Confirmed. ai-sandbox today; production gets its own app registration when it exists.
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).
PayCargo Forge (ai-sandbox).https://<cognito-pool-id>.auth.us-east-1.amazoncognito.com/oauth2/idpresponse (Cognito gives us this URL after PR 2 declares the IdP)https://<cognito-pool-id>.auth.us-east-1.amazoncognito.com/logoutopenid, profile, email (standard OIDC)User.Read (Microsoft Graph) for basic profile readsGroupMember.Read.All (Microsoft Graph) — needed if we want group claims to include all the user's groups/forge/sso/azure-client-secret.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.
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.
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.
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.
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).
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.
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.
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.