diff --git a/apps/android/app/src/main/java/com/databreeze/android/network/AuthenticatedIamApiClient.kt b/apps/android/app/src/main/java/com/databreeze/android/network/AuthenticatedIamApiClient.kt index 90a48ebc..a56d6f4f 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/network/AuthenticatedIamApiClient.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/network/AuthenticatedIamApiClient.kt @@ -1,6 +1,7 @@ package com.databreeze.android.network import com.databreeze.contracts.v4.IamAuthSession +import com.databreeze.contracts.v4.TenantSession import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -152,6 +153,9 @@ class AuthenticatedIamApiClient( previous: ProtectedAuthenticatedApiSession? = null, ): IamApiResult = runCatching { val value = mapper.readValue(body, IamAuthSession::class.java) + if (value !is TenantSession) { + return IamApiResult.Rejected("auth_response_invalid") + } if (value.schemaVersion != 4L || value.refreshToken.isNullOrBlank()) { return IamApiResult.Rejected("auth_response_invalid") } diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index f9b22a66..9f9179c9 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -22,6 +22,7 @@ import { type WebAccessContext, } from './navigation.ts'; import { + currentAuthSessionScopeV1, currentWebAuthenticationStateV1, initializeWebAuthenticationStateV1, subscribeWebAuthenticationStateV1, @@ -201,11 +202,28 @@ function AuthenticationGate({ publicRoute }: { readonly publicRoute: boolean }) currentWebAuthenticationStateV1, currentWebAuthenticationStateV1, ); + const sessionScope = useSyncExternalStore( + subscribeWebAuthenticationStateV1, + currentAuthSessionScopeV1, + currentAuthSessionScopeV1, + ); const { locale: routeLocale } = useParams(); const location = useLocation(); const locale = normalizeRouteLocale(routeLocale); if (publicRoute && authenticationState === 'signed-in') - return ; + return ( + + ); + if ( + !publicRoute && + authenticationState === 'signed-in' && + sessionScope === 'PLATFORM' && + !location.pathname.startsWith(`/${locale}/platform-admin`) + ) + return ; if (!publicRoute && authenticationState === 'signed-out') return ( void; + readonly confirmPlatformAccess?: () => Promise; } export interface StartWebApplicationInputV1 extends RecoverSessionBeforeAppStartInputV1 { @@ -47,6 +50,17 @@ export async function recoverSessionBeforeAppStartV1( recovered = false; } if (recovered) { + if (currentAuthSessionScopeV1() === 'PLATFORM') { + try { + if ((await input.confirmPlatformAccess?.()) === true && confirmPlatformAuthSessionV1()) { + const route = routeV1(input.pathname); + if (route.section !== 'platform-admin') input.replace(`/${route.locale}/platform-admin`); + return 'signed-in'; + } + } catch { + // Platform authority must be confirmed live before the console can mount. + } + } try { const bootstrap = await input.api.loadBootstrap(); if (bootstrap.accepted && rememberAuthBootstrapV1(bootstrap.value)) return 'signed-in'; diff --git a/apps/web/src/features/auth/auth-route-pages.tsx b/apps/web/src/features/auth/auth-route-pages.tsx index b8c2f21c..8f9040e2 100644 --- a/apps/web/src/features/auth/auth-route-pages.tsx +++ b/apps/web/src/features/auth/auth-route-pages.tsx @@ -6,6 +6,8 @@ import { createAuthApiV1 } from './auth-api.ts'; import { readAuthReturnTarget } from './auth-redirect.ts'; import { clearAuthSessionV1, + confirmPlatformAuthSessionV1, + currentAuthSessionScopeV1, rememberAuthBootstrapV1, rememberAuthSessionV1, } from './auth-session.ts'; @@ -29,11 +31,20 @@ async function establishProductSession( session: Parameters[0], ) { rememberAuthSessionV1(session); + if (currentAuthSessionScopeV1() === 'PLATFORM') { + const { createPlatformAdminApi } = await import('../platform-admin/platform-admin-api.ts'); + const platformAdmin = await createPlatformAdminApi().canAccess(); + if (platformAdmin && confirmPlatformAuthSessionV1()) + return { accepted: true as const, platformAdmin: true, platformOnly: true }; + clearAuthSessionV1(); + return { accepted: false as const, code: 'AUTH_FAILED' as const }; + } const bootstrap = await api.loadBootstrap(); if (bootstrap.accepted) { const { createPlatformAdminApi } = await import('../platform-admin/platform-admin-api.ts'); const platformAdmin = await createPlatformAdminApi().canAccess(); - if (rememberAuthBootstrapV1(bootstrap.value)) return { accepted: true as const, platformAdmin }; + if (rememberAuthBootstrapV1(bootstrap.value)) + return { accepted: true as const, platformAdmin, platformOnly: false }; } clearAuthSessionV1(); return { accepted: false as const, code: 'AUTH_FAILED' as const }; @@ -56,7 +67,10 @@ export function SignInRoutePage() { if (!established.accepted) return established; const returnTarget = readAuthReturnTarget(location.search); void navigate( - returnTarget ?? `/${locale}/${established.platformAdmin ? 'platform-admin' : 'data'}`, + established.platformOnly + ? `/${locale}/platform-admin` + : (returnTarget ?? + `/${locale}/${established.platformAdmin ? 'platform-admin' : 'data'}`), { replace: true, }, diff --git a/apps/web/src/features/auth/auth-session.ts b/apps/web/src/features/auth/auth-session.ts index 74b268a1..3b76bb2a 100644 --- a/apps/web/src/features/auth/auth-session.ts +++ b/apps/web/src/features/auth/auth-session.ts @@ -25,6 +25,21 @@ export function rememberAuthSessionV1(session: IamAuthSession): void { activeSession = Object.freeze(publicSession); } +export function currentAuthSessionScopeV1(): 'TENANT' | 'PLATFORM' | undefined { + if (activeSession === undefined) return undefined; + return activeSession.scopeType; +} + +/** IAM-026: establish an identity-only platform session after live authority confirmation. */ +export function confirmPlatformAuthSessionV1(): boolean { + if (currentAuthSessionScopeV1() !== 'PLATFORM') return false; + const wasSignedIn = authenticationState === 'signed-in'; + activeBootstrap = undefined; + initializeWebAuthenticationStateV1('signed-in'); + if (wasSignedIn) notifyAuthenticationListenersV1(); + return true; +} + function notifyAuthenticationListenersV1(): void { for (const listener of authenticationListeners) listener(); } @@ -57,6 +72,7 @@ export function rememberAuthBootstrapV1(bootstrap: IamBootstrapValue): boolean { const scope = bootstrap.session; if ( session === undefined || + session.scopeType !== 'TENANT' || bootstrap.user.id !== session.userId || scope.organizationId !== session.organizationId || scope.authorizationEpoch !== session.securityEpoch || diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9a5920e0..7cfc4057 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -16,6 +16,7 @@ import { ApplicationBoundary, createBrowserAppRouter } from './app/app.tsx'; import { createAuthApiV1 } from './features/auth/auth-api.ts'; import { startWebApplicationV1 } from './features/auth/auth-bootstrap.ts'; import { installSessionAwareFetchV1 } from './features/auth/auth-session.ts'; +import { createPlatformAdminApi } from './features/platform-admin/platform-admin-api.ts'; import './styles.css'; import './styles/dashboard-canvas.css'; import './styles/dashboard-agent.css'; @@ -41,6 +42,8 @@ await startWebApplicationV1({ pathname: globalThis.location.pathname, search: globalThis.location.search, hash: globalThis.location.hash, + confirmPlatformAccess: () => + createPlatformAdminApi({ baseUrl: apiBaseUrl, fetcher: browserFetch }).canAccess(), replace: (pathname) => globalThis.location.replace(pathname), mount: () => { createRoot(rootElement).render( diff --git a/apps/web/test/auth-api.test.ts b/apps/web/test/auth-api.test.ts index f90f72aa..40e48138 100644 --- a/apps/web/test/auth-api.test.ts +++ b/apps/web/test/auth-api.test.ts @@ -18,6 +18,7 @@ function jsonBody(init?: RequestInit): unknown { describe('generated-contract auth transport [IAM-022, IAM-023, WEB-004]', () => { const session = { schemaVersion: 4 as const, + scopeType: 'TENANT' as const, sessionId: '00000000-0000-4000-8000-000000000401', userId: '00000000-0000-4000-8000-000000000402', organizationId: '00000000-0000-4000-8000-000000000403', @@ -77,6 +78,7 @@ describe('generated-contract auth transport [IAM-022, IAM-023, WEB-004]', () => new Response( JSON.stringify({ schemaVersion: 4, + scopeType: 'TENANT', sessionId: '00000000-0000-4000-8000-000000000401', userId: '00000000-0000-4000-8000-000000000402', organizationId: '00000000-0000-4000-8000-000000000403', @@ -193,6 +195,7 @@ describe('generated-contract auth transport [IAM-022, IAM-023, WEB-004]', () => return new Response( JSON.stringify({ schemaVersion: 4, + scopeType: 'TENANT', sessionId: '00000000-0000-4000-8000-000000000401', userId: '00000000-0000-4000-8000-000000000402', organizationId: '00000000-0000-4000-8000-000000000403', diff --git a/apps/web/test/auth-bootstrap.test.ts b/apps/web/test/auth-bootstrap.test.ts index 458f49ef..62749919 100644 --- a/apps/web/test/auth-bootstrap.test.ts +++ b/apps/web/test/auth-bootstrap.test.ts @@ -18,6 +18,7 @@ afterEach(() => { describe('Web authentication bootstrap [IAM-023, WEB-002, WEB-004]', () => { const session = { schemaVersion: 4 as const, + scopeType: 'TENANT' as const, sessionId: '00000000-0000-4000-8000-000000000401', userId: '00000000-0000-4000-8000-000000000402', organizationId: '00000000-0000-4000-8000-000000000403', @@ -67,9 +68,22 @@ describe('Web authentication bootstrap [IAM-023, WEB-002, WEB-004]', () => { platform: { apiVersion: 'v1' as const }, }; + const platformSession = { + schemaVersion: 4 as const, + scopeType: 'PLATFORM' as const, + sessionId: '00000000-0000-4000-8000-000000000411', + userId: '00000000-0000-4000-8000-000000000412', + accessToken: 'p'.repeat(80), + accessExpiresAt: '2026-08-13T00:15:00.000Z', + securityEpoch: 1, + mfaRequired: false, + mfaReenrollmentRequired: false, + }; + it('installs the memory-only credential transport for shared API clients', async () => { rememberAuthSessionV1({ schemaVersion: 4, + scopeType: 'TENANT', sessionId: '00000000-0000-4000-8000-000000000401', userId: '00000000-0000-4000-8000-000000000402', organizationId: '00000000-0000-4000-8000-000000000403', @@ -119,6 +133,27 @@ describe('Web authentication bootstrap [IAM-023, WEB-002, WEB-004]', () => { expect(replace).not.toHaveBeenCalled(); }); + it('[IAM-026][WEB-025] recovers a server-confirmed platform-only session without tenant bootstrap', async () => { + const replace = vi.fn(); + const loadBootstrap = vi.fn(); + await expect( + recoverSessionBeforeAppStartV1({ + api: { + recoverWebSession: vi.fn(async () => { + rememberAuthSessionV1(platformSession); + return { accepted: true as const }; + }), + loadBootstrap, + }, + confirmPlatformAccess: vi.fn(async () => true), + pathname: '/vi-VN/platform-admin', + replace, + }), + ).resolves.toBe('signed-in'); + expect(loadBootstrap).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); + }); + it('fails closed to localized sign-in before a protected route can render', async () => { const replace = vi.fn(); await expect( diff --git a/apps/web/test/auth-session.test.ts b/apps/web/test/auth-session.test.ts index 7928bbe6..92e6a8ab 100644 --- a/apps/web/test/auth-session.test.ts +++ b/apps/web/test/auth-session.test.ts @@ -13,6 +13,7 @@ describe('memory-only Web session [WEB-004]', () => { it('retains only the current access credential and clears it explicitly', () => { rememberAuthSessionV1({ schemaVersion: 4, + scopeType: 'TENANT', sessionId: '00000000-0000-4000-8000-000000000401', userId: '00000000-0000-4000-8000-000000000402', organizationId: '00000000-0000-4000-8000-000000000403', @@ -33,6 +34,7 @@ describe('memory-only Web session [WEB-004]', () => { globalThis.document.cookie = `databreeze_csrf=${'c'.repeat(43)}; Path=/`; rememberAuthSessionV1({ schemaVersion: 4, + scopeType: 'TENANT', sessionId: '00000000-0000-4000-8000-000000000401', userId: '00000000-0000-4000-8000-000000000402', organizationId: '00000000-0000-4000-8000-000000000403', @@ -71,6 +73,7 @@ describe('memory-only Web session [WEB-004]', () => { it('does not authorize a same-origin path outside an explicitly configured API prefix', async () => { rememberAuthSessionV1({ schemaVersion: 4, + scopeType: 'TENANT', sessionId: '00000000-0000-4000-8000-000000000401', userId: '00000000-0000-4000-8000-000000000402', organizationId: '00000000-0000-4000-8000-000000000403', diff --git a/apps/web/test/tenant-live-configuration.test.ts b/apps/web/test/tenant-live-configuration.test.ts index 201889b1..10bf5994 100644 --- a/apps/web/test/tenant-live-configuration.test.ts +++ b/apps/web/test/tenant-live-configuration.test.ts @@ -23,6 +23,7 @@ describe('tenant live configuration [IAM-009][DDA-002][DDA-006]', () => { it('derives tenant scope from authenticated bootstrap while retaining an optional intake id', () => { const session = { schemaVersion: 4 as const, + scopeType: 'TENANT' as const, sessionId: '00000000-0000-4000-8000-000000000001', userId: '00000000-0000-4000-8000-000000000002', organizationId: '00000000-0000-4000-8000-000000000003', diff --git a/apps/web/test/workspace-auth-api.test.ts b/apps/web/test/workspace-auth-api.test.ts index 78778664..9daae84a 100644 --- a/apps/web/test/workspace-auth-api.test.ts +++ b/apps/web/test/workspace-auth-api.test.ts @@ -9,6 +9,7 @@ import { const currentSession = { schemaVersion: 4 as const, + scopeType: 'TENANT' as const, sessionId: '00000000-0000-4000-8000-000000000601', userId: '00000000-0000-4000-8000-000000000602', organizationId: '00000000-0000-4000-8000-000000000603', diff --git a/docs/plans/410-platform-owner-console.md b/docs/plans/410-platform-owner-console.md index b60e34c9..33abf124 100644 --- a/docs/plans/410-platform-owner-console.md +++ b/docs/plans/410-platform-owner-console.md @@ -18,7 +18,7 @@ Deliver a separate, read-only DataBreeze internal console where an explicitly as - IAM owns the new persisted `PlatformOperator` assignment and current-role lookup. `PLATFORM_OWNER` and `PLATFORM_SUPPORT` are not tenant memberships. - A new platform-administration composition consumes IAM-owned identity aggregates and BUA-owned commercial aggregates through public ports. No feature reads another feature's persistence. -- Every API request first resolves the current short-lived session, then checks the current platform assignment by actor ID. Client claims never authorize platform access. +- Every API request first resolves the current short-lived session, then checks the current platform assignment by actor ID. A platform-only session carries no organization/workspace scope and is accepted only by the platform actor boundary; tenant request-context resolution remains fail-closed. Client claims never authorize platform access. - This slice is read-only. Account mutation, impersonation, plan overrides, refunds, subscription changes, and provider administration are deferred and require recent MFA plus dedicated audited commands. - Responses are closed, generated contracts. They expose bounded identity/contact metadata necessary for support but no artifacts, dataset/source values, payment credentials, tax data, webhook payloads, or provider secrets. - Local seed data is synthetic, deterministic, idempotent, and clearly labeled. Production platform assignments are never created from repository seed defaults. @@ -70,7 +70,7 @@ Verify Prisma schema/migration, generated contracts, API tests/OpenAPI, Web unit ## Acceptance - A normal workspace Owner/Admin receives `403` and no aggregate body from the platform endpoint. -- An active seeded platform owner signs in normally, opens the separate console, and sees values computed from the database rather than the customer dashboard demo fixture. +- An active seeded platform owner with no tenant membership signs in normally, opens the separate console, and sees values computed from the database rather than the customer dashboard demo fixture; the same session is rejected by tenant-scoped endpoints. - Suspending the platform assignment removes access without modifying organization/workspace memberships. - The overview is bounded and content-minimized; no response contains source paths, artifact/dataset content, credentials, provider secrets, webhook payloads, or client-supplied counters. - Vietnamese is the default complete locale; English is complete; keyboard, focus, table semantics, non-color status, reduced motion, and narrow layouts are tested. diff --git a/docs/plans/426-lightsail-seeded-pilot-activation.md b/docs/plans/426-lightsail-seeded-pilot-activation.md index f860296e..e2ff6e3d 100644 --- a/docs/plans/426-lightsail-seeded-pilot-activation.md +++ b/docs/plans/426-lightsail-seeded-pilot-activation.md @@ -20,7 +20,7 @@ - Build and deploy immutable image digests; never deploy `latest`. - Run a verified PostgreSQL and MinIO backup before the first migration for every existing release. - Run migrations before the opt-in seed and run the seed before API/Web activation. -- `DATABREEZE_PILOT_SEED_ENABLED=false` remains the default. Enabling it requires a protected operator email and password on the host. +- `DATABREEZE_PILOT_SEED_ENABLED=false` remains the default. Enabling it requires a protected operator email and password on the host. Replaying the seed preserves an existing credential; credential replacement is a separate explicit one-shot rotation that consumes the protected host value, increments the user's security epoch, and revokes existing sessions. - The platform operator is created or assigned only from the protected host configuration. Repository defaults never grant production platform authority. - Synthetic users, subscriptions, invoices, payments, and feedback remain content-minimized and carry no customer source data, paths, OCR text, credentials, provider payloads, or webhook bodies. - OpenAI and PayOS remain fail-closed when their protected settings are absent or invalid. @@ -40,7 +40,7 @@ - [ ] **Step 1: Add a failing deployment behavior test** that runs the shell-contract harness and proves deployment invokes `backup.sh` before `api-migrate`, invokes `api-seed` only after migration when enabled, and never prints protected values. - [ ] **Step 2: Add failing Compose/workflow assertions** for `VITE_DATABREEZE_DEMO_MODE=false`, required OpenAI/PayOS/public-URL mappings, an `api-seed` one-shot service, disabled seed defaults, and no committed secret value. -- [ ] **Step 3: Add failing seed tests** using a fake Prisma boundary. The tests must prove disabled execution rejects, missing operator configuration rejects, a new explicit operator receives one Argon2id credential and one active `PLATFORM_OWNER` assignment, an existing credential is not rotated, replay preserves row counts, and no delete delegate is called. +- [ ] **Step 3: Add failing seed tests** using a fake Prisma boundary. The tests must prove disabled execution rejects, missing operator configuration rejects, a new explicit operator receives one Argon2id credential and one active `PLATFORM_OWNER` assignment, an existing credential is not rotated by seed replay, replay preserves row counts, and no delete delegate is called. Add a separate explicit credential-rotation test proving the protected password replaces the hash atomically, increments the security epoch, revokes active sessions, and never prints protected material. - [ ] **Step 4: Run `node --test tools/repo-cli/test/lightsail-deployment.test.mjs services/api/test/seed-local.test.mjs`** and confirm failures name the missing provider, backup, and pilot-seed behavior. ### Task 2: Implement the opt-in pilot seed diff --git a/docs/specs/foundation/identity-workspaces-permissions.md b/docs/specs/foundation/identity-workspaces-permissions.md index c2aae703..f0f2f3c5 100644 --- a/docs/specs/foundation/identity-workspaces-permissions.md +++ b/docs/specs/foundation/identity-workspaces-permissions.md @@ -56,7 +56,7 @@ Define the identity, tenant hierarchy, membership, session, device identity, and Roles are permission bundles, not authorization decisions. Every request also requires an active membership, matching organization/workspace/project scope, resource visibility, data-mode compatibility, and applicable policy conditions. -Internal platform operations use a separate persisted `PlatformOperator` assignment with the initial roles `PLATFORM_OWNER` and `PLATFORM_SUPPORT`. A platform assignment is not an organization, workspace, or project membership, never grants implicit access to tenant source content, and is resolved from current IAM state for every protected platform request. Platform operators still authenticate through normal short-lived sessions, require MFA for privileged or account-affecting actions, and all platform reads and mutations are attributable and auditable. Revoking or suspending the assignment removes platform access without changing the user's tenant memberships. +Internal platform operations use a separate persisted `PlatformOperator` assignment with the initial roles `PLATFORM_OWNER` and `PLATFORM_SUPPORT`. A platform assignment is not an organization, workspace, or project membership, never grants implicit access to tenant source content, and is resolved from current IAM state for every protected platform request. Platform operators still authenticate through normal short-lived sessions, but a platform-only session carries no tenant scope and must fail closed at every tenant-context boundary. Platform endpoints resolve only the authenticated actor from that session and then re-check the current platform assignment. Platform operators require MFA for privileged or account-affecting actions, and all platform reads and mutations are attributable and auditable. Revoking or suspending the assignment removes platform access without changing the user's tenant memberships. The normal customer UI exposes Owner, Editor, and Viewer access presets. Those presets map to the six canonical server roles and versioned permission constants. Preset mapping is explicit, versioned, and deny-by-default. Presets are presentation metadata; the six server roles remain the policy-enforcement vocabulary. diff --git a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v4/Models.kt b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v4/Models.kt index 4b8d385b..26f6feb8 100644 --- a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v4/Models.kt +++ b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v4/Models.kt @@ -54,6 +54,10 @@ public typealias ToolCallId = String public typealias UtcTimestamp = String +public sealed interface IamAuthSession { + public val scopeType: String +} + public sealed interface IamBootstrapResponse { public val outcome: String } @@ -588,20 +592,6 @@ public data class Group( public val total: Double? = null, ) -public data class IamAuthSession( - public val accessExpiresAt: UtcTimestamp, - public val accessToken: String, - public val mfaReenrollmentRequired: Boolean, - public val mfaRequired: Boolean, - public val organizationId: Identifier, - public val refreshToken: String? = null, - public val schemaVersion: Long, - public val securityEpoch: Long, - public val sessionId: Identifier, - public val userId: Identifier, - public val workspaceId: Identifier, -) - public data class IamBootstrapOrganization( public val id: Identifier, public val name: String, @@ -935,6 +925,20 @@ public data class PlatformAdminOverviewWindow( public val startsAt: UtcTimestamp, ) +public data class PlatformSession( + public val accessExpiresAt: UtcTimestamp, + public val accessToken: String, + public val mfaReenrollmentRequired: Boolean, + public val mfaRequired: Boolean, + public val refreshToken: String? = null, + public val schemaVersion: Long, + public val securityEpoch: Long, + public val sessionId: Identifier, + public val userId: Identifier, +) : IamAuthSession { + public override val scopeType: String = "PLATFORM" +} + public data class PreparedOutput( public val allowedMediaTypes: List, public val capabilityId: Identifier, @@ -1069,6 +1073,22 @@ public data class SourceOpenValue( public val sourceId: Identifier, ) +public data class TenantSession( + public val accessExpiresAt: UtcTimestamp, + public val accessToken: String, + public val mfaReenrollmentRequired: Boolean, + public val mfaRequired: Boolean, + public val organizationId: Identifier, + public val refreshToken: String? = null, + public val schemaVersion: Long, + public val securityEpoch: Long, + public val sessionId: Identifier, + public val userId: Identifier, + public val workspaceId: Identifier, +) : IamAuthSession { + public override val scopeType: String = "TENANT" +} + public data class Value( public val columns: List, public val datasetId: Identifier, diff --git a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v4/Validation.kt b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v4/Validation.kt index a6018144..f6812575 100644 --- a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v4/Validation.kt +++ b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v4/Validation.kt @@ -15,6 +15,18 @@ import com.networknt.schema.SchemaRegistry import com.networknt.schema.SpecificationVersion import java.util.Base64 +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "scopeType", + visible = false, +) +@JsonSubTypes( + JsonSubTypes.Type(value = TenantSession::class, name = "TENANT"), + JsonSubTypes.Type(value = PlatformSession::class, name = "PLATFORM"), +) +private interface IamAuthSessionMixin + @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, @@ -87,6 +99,7 @@ public data object RejectedV4Contract : ContractV4ParseResult { private val mapper: ObjectMapper = jacksonObjectMapper() .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addMixIn(IamAuthSession::class.java, IamAuthSessionMixin::class.java) .addMixIn(IamBootstrapResponse::class.java, IamBootstrapResponseMixin::class.java) .addMixIn(IamBootstrapScope::class.java, IamBootstrapScopeMixin::class.java) .addMixIn(IamBootstrapSession::class.java, IamBootstrapSessionMixin::class.java) @@ -117,7 +130,7 @@ private val schemaSources: Map = mapOf( "https://schemas.databreeze.dev/contracts/v4/dda-data-import-dashboard-preview" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3Y0L2RkYS1kYXRhLWltcG9ydC1kYXNoYm9hcmQtcHJldmlldyIsIiRjb21tZW50IjoiUGxhbiA0MTMgLyBEREEtMDUzOiBib3VuZGVkLCBzZXJ2ZXItb3duZWQgcHJldmlldyBkZXJpdmVkIGZyb20gb25lIGV4YWN0IGFwcHJvdmVkIGltcG9ydC4gVGhpcyBpcyBub3QgYSBjZXJ0aWZpZWQgc25hcHNob3Qgb3IgcHVibGljYXRpb24uIiwidGl0bGUiOiJEREEgRGF0YSBJbXBvcnQgRGFzaGJvYXJkIFByZXZpZXciLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY2hlbWFWZXJzaW9uIiwiYWNjZXB0ZWQiLCJ2YWx1ZSJdLCJwcm9wZXJ0aWVzIjp7InNjaGVtYVZlcnNpb24iOnsiY29uc3QiOjR9LCJhY2NlcHRlZCI6eyJjb25zdCI6dHJ1ZX0sInZhbHVlIjp7IiRyZWYiOiIjLyRkZWZzL3ZhbHVlIn19LCIkZGVmcyI6eyJ2YWx1ZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJpbXBvcnRJZCIsImRhdGFzZXRJZCIsImRhdGFzZXRWZXJzaW9uSWQiLCJkYXRhc2V0TmFtZSIsInNvdXJjZUNvdW50Iiwicm93Q291bnQiLCJ0cnVuY2F0ZWQiLCJzb3VyY2VIYXNoZXMiLCJjb2x1bW5zIiwic2FtcGxlUm93cyIsImdlbmVyYXRlZEF0Il0sInByb3BlcnRpZXMiOnsiaW1wb3J0SWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJkYXRhc2V0SWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJkYXRhc2V0VmVyc2lvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwiZGF0YXNldE5hbWUiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjoyMDB9LCJzb3VyY2VDb3VudCI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjEsIm1heGltdW0iOjh9LCJyb3dDb3VudCI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjEsIm1heGltdW0iOjIwMDAwfSwidHJ1bmNhdGVkIjp7InR5cGUiOiJib29sZWFuIn0sInNvdXJjZUhhc2hlcyI6eyJ0eXBlIjoiYXJyYXkiLCJtaW5JdGVtcyI6MSwibWF4SXRlbXMiOjgsInVuaXF1ZUl0ZW1zIjp0cnVlLCJpdGVtcyI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bMC05YS1mXXs2NH0kIn19LCJjb2x1bW5zIjp7InR5cGUiOiJhcnJheSIsIm1pbkl0ZW1zIjoxLCJtYXhJdGVtcyI6MjU2LCJpdGVtcyI6eyIkcmVmIjoiIy8kZGVmcy9jb2x1bW4ifX0sIm1lYXN1cmUiOnsiJHJlZiI6IiMvJGRlZnMvbWVhc3VyZSJ9LCJkaW1lbnNpb24iOnsiJHJlZiI6IiMvJGRlZnMvZGltZW5zaW9uIn0sInNhbXBsZVJvd3MiOnsidHlwZSI6ImFycmF5IiwibWF4SXRlbXMiOjI1LCJpdGVtcyI6eyIkcmVmIjoiIy8kZGVmcy9zYW1wbGVSb3cifX0sImdlbmVyYXRlZEF0Ijp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAifX19LCJjb2x1bW4iOnsidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsibmFtZSIsInR5cGUiLCJudWxsYWJsZSJdLCJwcm9wZXJ0aWVzIjp7Im5hbWUiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjoxMjh9LCJ0eXBlIjp7ImVudW0iOlsiVEVYVCIsIklOVEVHRVIiLCJERUNJTUFMIiwiQk9PTEVBTiIsIkRBVEUiXX0sIm51bGxhYmxlIjp7InR5cGUiOiJib29sZWFuIn19fSwibWVhc3VyZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJmaWVsZCIsInN1bSIsImF2ZXJhZ2UiLCJtaW5pbXVtIiwibWF4aW11bSJdLCJwcm9wZXJ0aWVzIjp7ImZpZWxkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjEsIm1heExlbmd0aCI6MTI4fSwic3VtIjp7InR5cGUiOiJudW1iZXIifSwiYXZlcmFnZSI6eyJ0eXBlIjoibnVtYmVyIn0sIm1pbmltdW0iOnsidHlwZSI6Im51bWJlciJ9LCJtYXhpbXVtIjp7InR5cGUiOiJudW1iZXIifX19LCJkaW1lbnNpb24iOnsidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiZmllbGQiLCJncm91cHMiXSwicHJvcGVydGllcyI6eyJmaWVsZCI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjEyOH0sImdyb3VwcyI6eyJ0eXBlIjoiYXJyYXkiLCJtaW5JdGVtcyI6MSwibWF4SXRlbXMiOjEyLCJpdGVtcyI6eyIkcmVmIjoiIy8kZGVmcy9ncm91cCJ9fX19LCJncm91cCI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJsYWJlbCIsImNvdW50Il0sInByb3BlcnRpZXMiOnsibGFiZWwiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjoxMjh9LCJjb3VudCI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjF9LCJ0b3RhbCI6eyJ0eXBlIjoibnVtYmVyIn19fSwic2FtcGxlUm93Ijp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbImNlbGxzIl0sInByb3BlcnRpZXMiOnsiY2VsbHMiOnsidHlwZSI6ImFycmF5IiwibWF4SXRlbXMiOjMyLCJpdGVtcyI6eyIkcmVmIjoiIy8kZGVmcy9zYW1wbGVDZWxsIn19fX0sInNhbXBsZUNlbGwiOnsidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiZmllbGQiLCJ2YWx1ZSIsImtpbmQiXSwicHJvcGVydGllcyI6eyJmaWVsZCI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjEyOH0sInZhbHVlIjp7InR5cGUiOiJzdHJpbmciLCJtYXhMZW5ndGgiOjgxOTJ9LCJraW5kIjp7ImVudW0iOlsiVEVYVCIsIk5VTUJFUiIsIkJPT0xFQU4iLCJFTVBUWSJdfX19fX0="), "https://schemas.databreeze.dev/contracts/v4/dda-notification-preferences-accepted" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3Y0L2RkYS1ub3RpZmljYXRpb24tcHJlZmVyZW5jZXMtYWNjZXB0ZWQiLCIkY29tbWVudCI6Ik5DTy0wMDYvTkNPLTAxOC9OQ08tMDI0OiBzZXJ2ZXItYXV0aG9yaXRhdGl2ZSBub3RpZmljYXRpb24gcHJlZmVyZW5jZSBzbmFwc2hvdC4iLCJ0aXRsZSI6IkREQSBOb3RpZmljYXRpb24gUHJlZmVyZW5jZXMgQWNjZXB0ZWQiLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY2hlbWFWZXJzaW9uIiwicmV2aXNpb24iLCJwcmVmZXJlbmNlcyJdLCJwcm9wZXJ0aWVzIjp7InNjaGVtYVZlcnNpb24iOnsiY29uc3QiOjR9LCJyZXZpc2lvbiI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjF9LCJwcmVmZXJlbmNlcyI6eyJ0eXBlIjoiYXJyYXkiLCJtaW5JdGVtcyI6MSwibWF4SXRlbXMiOjY0LCJpdGVtcyI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJjYXRlZ29yeSIsImNoYW5uZWwiLCJlbmFibGVkIiwibWluaW11bVVyZ2VuY3kiLCJkZWxpdmVyeU1vZGUiLCJxdWlldEhvdXJzIiwidGltZXpvbmUiLCJtYW5kYXRvcnkiXSwicHJvcGVydGllcyI6eyJjYXRlZ29yeSI6eyJlbnVtIjpbIlJFVklFV1MiLCJEQVRBIiwiREFTSEJPQVJEUyIsIlVTQUdFIiwiU0VDVVJJVFkiLCJCSUxMSU5HIiwiU1lTVEVNIl19LCJjaGFubmVsIjp7ImVudW0iOlsiSU5fQVBQIiwiRU1BSUwiLCJQVVNIIiwiREVTS1RPUCJdfSwiZW5hYmxlZCI6eyJ0eXBlIjoiYm9vbGVhbiJ9LCJtaW5pbXVtVXJnZW5jeSI6eyJlbnVtIjpbIkxPVyIsIk5PUk1BTCIsIkhJR0giLCJDUklUSUNBTCJdfSwiZGVsaXZlcnlNb2RlIjp7ImVudW0iOlsiSU1NRURJQVRFIiwiRElHRVNUIl19LCJxdWlldEhvdXJzIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbImVuYWJsZWQiLCJzdGFydCIsImVuZCJdLCJwcm9wZXJ0aWVzIjp7ImVuYWJsZWQiOnsidHlwZSI6ImJvb2xlYW4ifSwic3RhcnQiOnsidHlwZSI6InN0cmluZyIsInBhdHRlcm4iOiJeKD86WzAxXVswLTldfDJbMC0zXSk6WzAtNV1bMC05XSQifSwiZW5kIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXig/OlswMV1bMC05XXwyWzAtM10pOlswLTVdWzAtOV0kIn19fSwidGltZXpvbmUiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjo2NCwicGF0dGVybiI6Il5bQS1aYS16MC05XysuLzotXXsxLDY0fSQifSwibWFuZGF0b3J5Ijp7InR5cGUiOiJib29sZWFuIn19fX19fQ=="), "https://schemas.databreeze.dev/contracts/v4/dda-notification-preferences-command" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3Y0L2RkYS1ub3RpZmljYXRpb24tcHJlZmVyZW5jZXMtY29tbWFuZCIsIiRjb21tZW50IjoiTkNPLTAwNi9OQ08tMDE4L05DTy0wMjQ6IGF1dGhlbnRpY2F0ZWQgcmVjaXBpZW50LXNjb3BlZCBub3RpZmljYXRpb24gcHJlZmVyZW5jZXMuIFRlbmFudCwgcmVjaXBpZW50LCBwb2xpY3kgYXV0aG9yaXR5LCBtYW5kYXRvcnkgY2F0ZWdvcnkgc3RhdGUsIGFuZCByZXZpc2lvbiBhcmUgc2VydmVyLW93bmVkLiIsInRpdGxlIjoiRERBIE5vdGlmaWNhdGlvbiBQcmVmZXJlbmNlcyBDb21tYW5kIiwidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsic2NoZW1hVmVyc2lvbiIsImV4cGVjdGVkUmV2aXNpb24iLCJwcmVmZXJlbmNlcyJdLCJwcm9wZXJ0aWVzIjp7InNjaGVtYVZlcnNpb24iOnsiY29uc3QiOjR9LCJleHBlY3RlZFJldmlzaW9uIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MX0sInByZWZlcmVuY2VzIjp7InR5cGUiOiJhcnJheSIsIm1pbkl0ZW1zIjoxLCJtYXhJdGVtcyI6NjQsIml0ZW1zIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbImNhdGVnb3J5IiwiY2hhbm5lbCIsImVuYWJsZWQiLCJtaW5pbXVtVXJnZW5jeSIsImRlbGl2ZXJ5TW9kZSIsInF1aWV0SG91cnMiLCJ0aW1lem9uZSJdLCJwcm9wZXJ0aWVzIjp7ImNhdGVnb3J5Ijp7ImVudW0iOlsiUkVWSUVXUyIsIkRBVEEiLCJEQVNIQk9BUkRTIiwiVVNBR0UiLCJTRUNVUklUWSIsIkJJTExJTkciLCJTWVNURU0iXX0sImNoYW5uZWwiOnsiZW51bSI6WyJJTl9BUFAiLCJFTUFJTCIsIlBVU0giLCJERVNLVE9QIl19LCJlbmFibGVkIjp7InR5cGUiOiJib29sZWFuIn0sIm1pbmltdW1VcmdlbmN5Ijp7ImVudW0iOlsiTE9XIiwiTk9STUFMIiwiSElHSCIsIkNSSVRJQ0FMIl19LCJkZWxpdmVyeU1vZGUiOnsiZW51bSI6WyJJTU1FRElBVEUiLCJESUdFU1QiXX0sInF1aWV0SG91cnMiOnsidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiZW5hYmxlZCIsInN0YXJ0IiwiZW5kIl0sInByb3BlcnRpZXMiOnsiZW5hYmxlZCI6eyJ0eXBlIjoiYm9vbGVhbiJ9LCJzdGFydCI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il4oPzpbMDFdWzAtOV18MlswLTNdKTpbMC01XVswLTldJCJ9LCJlbmQiOnsidHlwZSI6InN0cmluZyIsInBhdHRlcm4iOiJeKD86WzAxXVswLTldfDJbMC0zXSk6WzAtNV1bMC05XSQifX19LCJ0aW1lem9uZSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjY0LCJwYXR0ZXJuIjoiXltBLVphLXowLTlfKy4vOi1dezEsNjR9JCJ9fX19fX0="), - "https://schemas.databreeze.dev/contracts/v4/iam-auth-session" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3Y0L2lhbS1hdXRoLXNlc3Npb24iLCIkY29tbWVudCI6IklBTS0wMDUvSUFNLTAwNi9JQU0tMDIzL1dFQi0wMDQ6IHB1YmxpYyBzZXNzaW9uIHJlc3BvbnNlLiBCcm93c2VyIHJlZnJlc2ggY3JlZGVudGlhbHMgcmVtYWluIEh0dHBPbmx5IGFuZCBhcmUgb21pdHRlZC4iLCJ0aXRsZSI6IklBTSBBdXRoIFNlc3Npb24iLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY2hlbWFWZXJzaW9uIiwic2Vzc2lvbklkIiwidXNlcklkIiwib3JnYW5pemF0aW9uSWQiLCJ3b3Jrc3BhY2VJZCIsImFjY2Vzc1Rva2VuIiwiYWNjZXNzRXhwaXJlc0F0Iiwic2VjdXJpdHlFcG9jaCIsIm1mYVJlcXVpcmVkIiwibWZhUmVlbnJvbGxtZW50UmVxdWlyZWQiXSwicHJvcGVydGllcyI6eyJzY2hlbWFWZXJzaW9uIjp7ImNvbnN0Ijo0fSwic2Vzc2lvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwidXNlcklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwib3JnYW5pemF0aW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJ3b3Jrc3BhY2VJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sImFjY2Vzc1Rva2VuIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjgwLCJtYXhMZW5ndGgiOjQwOTYsInBhdHRlcm4iOiJeW0EtWmEtejAtOS5ffi1dKyQifSwicmVmcmVzaFRva2VuIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjgwLCJtYXhMZW5ndGgiOjQwOTYsInBhdHRlcm4iOiJeW0EtWmEtejAtOS5ffi1dKyQifSwiYWNjZXNzRXhwaXJlc0F0Ijp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAifSwic2VjdXJpdHlFcG9jaCI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjF9LCJtZmFSZXF1aXJlZCI6eyJ0eXBlIjoiYm9vbGVhbiJ9LCJtZmFSZWVucm9sbG1lbnRSZXF1aXJlZCI6eyJ0eXBlIjoiYm9vbGVhbiJ9fX0="), + "https://schemas.databreeze.dev/contracts/v4/iam-auth-session" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3Y0L2lhbS1hdXRoLXNlc3Npb24iLCIkY29tbWVudCI6IklBTS0wMDUvSUFNLTAwNi9JQU0tMDIzL0lBTS0wMjYvV0VCLTAwNDogdGVuYW50LXNjb3BlZCBvciBwbGF0Zm9ybS1vbmx5IHB1YmxpYyBzZXNzaW9uIHJlc3BvbnNlLiBCcm93c2VyIHJlZnJlc2ggY3JlZGVudGlhbHMgcmVtYWluIEh0dHBPbmx5IGFuZCBhcmUgb21pdHRlZC4iLCJ0aXRsZSI6IklBTSBBdXRoIFNlc3Npb24iLCJvbmVPZiI6W3siJHJlZiI6IiMvJGRlZnMvdGVuYW50U2Vzc2lvbiJ9LHsiJHJlZiI6IiMvJGRlZnMvcGxhdGZvcm1TZXNzaW9uIn1dLCIkZGVmcyI6eyJ0ZW5hbnRTZXNzaW9uIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjaGVtYVZlcnNpb24iLCJzY29wZVR5cGUiLCJzZXNzaW9uSWQiLCJ1c2VySWQiLCJvcmdhbml6YXRpb25JZCIsIndvcmtzcGFjZUlkIiwiYWNjZXNzVG9rZW4iLCJhY2Nlc3NFeHBpcmVzQXQiLCJzZWN1cml0eUVwb2NoIiwibWZhUmVxdWlyZWQiLCJtZmFSZWVucm9sbG1lbnRSZXF1aXJlZCJdLCJwcm9wZXJ0aWVzIjp7InNjaGVtYVZlcnNpb24iOnsiY29uc3QiOjR9LCJzY29wZVR5cGUiOnsiY29uc3QiOiJURU5BTlQifSwic2Vzc2lvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwidXNlcklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwib3JnYW5pemF0aW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJ3b3Jrc3BhY2VJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sImFjY2Vzc1Rva2VuIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjgwLCJtYXhMZW5ndGgiOjQwOTYsInBhdHRlcm4iOiJeW0EtWmEtejAtOS5ffi1dKyQifSwicmVmcmVzaFRva2VuIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjgwLCJtYXhMZW5ndGgiOjQwOTYsInBhdHRlcm4iOiJeW0EtWmEtejAtOS5ffi1dKyQifSwiYWNjZXNzRXhwaXJlc0F0Ijp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAifSwic2VjdXJpdHlFcG9jaCI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjF9LCJtZmFSZXF1aXJlZCI6eyJ0eXBlIjoiYm9vbGVhbiJ9LCJtZmFSZWVucm9sbG1lbnRSZXF1aXJlZCI6eyJ0eXBlIjoiYm9vbGVhbiJ9fX0sInBsYXRmb3JtU2Vzc2lvbiI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY2hlbWFWZXJzaW9uIiwic2NvcGVUeXBlIiwic2Vzc2lvbklkIiwidXNlcklkIiwiYWNjZXNzVG9rZW4iLCJhY2Nlc3NFeHBpcmVzQXQiLCJzZWN1cml0eUVwb2NoIiwibWZhUmVxdWlyZWQiLCJtZmFSZWVucm9sbG1lbnRSZXF1aXJlZCJdLCJwcm9wZXJ0aWVzIjp7InNjaGVtYVZlcnNpb24iOnsiY29uc3QiOjR9LCJzY29wZVR5cGUiOnsiY29uc3QiOiJQTEFURk9STSJ9LCJzZXNzaW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJ1c2VySWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJhY2Nlc3NUb2tlbiI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjo4MCwibWF4TGVuZ3RoIjo0MDk2LCJwYXR0ZXJuIjoiXltBLVphLXowLTkuX34tXSskIn0sInJlZnJlc2hUb2tlbiI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjo4MCwibWF4TGVuZ3RoIjo0MDk2LCJwYXR0ZXJuIjoiXltBLVphLXowLTkuX34tXSskIn0sImFjY2Vzc0V4cGlyZXNBdCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS91dGMtdGltZXN0YW1wIn0sInNlY3VyaXR5RXBvY2giOnsidHlwZSI6ImludGVnZXIiLCJtaW5pbXVtIjoxfSwibWZhUmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifSwibWZhUmVlbnJvbGxtZW50UmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX19fX0="), "https://schemas.databreeze.dev/contracts/v4/iam-bootstrap-response" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3Y0L2lhbS1ib290c3RyYXAtcmVzcG9uc2UiLCIkY29tbWVudCI6IlBsYW4gNDA4IC8gSUFNLTAwMSAvIElBTS0wMDkgLyBXRUItMDAyIC8gV0VCLTAwMzogY29udGVudC1zYWZlIGF1dGhlbnRpY2F0ZWQgV2ViIGJvb3RzdHJhcCBkZXJpdmVkIGV4Y2x1c2l2ZWx5IGZyb20gc2VydmVyIGlkZW50aXR5IGFuZCBzY29wZS4iLCJ0aXRsZSI6IklBTSBCb290c3RyYXAgUmVzcG9uc2UiLCJvbmVPZiI6W3siJHJlZiI6IiMvJGRlZnMvaWFtQm9vdHN0cmFwUmVzcG9uc2VBY2NlcHRlZCJ9LHsiJHJlZiI6IiMvJGRlZnMvaWFtQm9vdHN0cmFwUmVzcG9uc2VSZWplY3RlZCJ9XSwiJGRlZnMiOnsiaWFtQm9vdHN0cmFwUmVzcG9uc2VBY2NlcHRlZCI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY2hlbWFWZXJzaW9uIiwib3V0Y29tZSIsInZhbHVlIl0sInByb3BlcnRpZXMiOnsic2NoZW1hVmVyc2lvbiI6eyJjb25zdCI6NH0sIm91dGNvbWUiOnsiY29uc3QiOiJBQ0NFUFRFRCJ9LCJ2YWx1ZSI6eyIkcmVmIjoiIy8kZGVmcy9pYW1Cb290c3RyYXBWYWx1ZSJ9fX0sImlhbUJvb3RzdHJhcFJlc3BvbnNlUmVqZWN0ZWQiOnsidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsic2NoZW1hVmVyc2lvbiIsIm91dGNvbWUiLCJjb2RlIl0sInByb3BlcnRpZXMiOnsic2NoZW1hVmVyc2lvbiI6eyJjb25zdCI6NH0sIm91dGNvbWUiOnsiY29uc3QiOiJSRUpFQ1RFRCJ9LCJjb2RlIjp7ImVudW0iOlsiSU5WQUxJRF9JREVOVElGSUVSIiwiTk9UX0ZPVU5EIiwiVU5BVkFJTEFCTEUiXX19fSwiaWFtQm9vdHN0cmFwVmFsdWUiOnsidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsidXNlciIsIm9yZ2FuaXphdGlvbnMiLCJyZWNlbnRTY29wZXMiLCJzZXNzaW9uIiwicGxhdGZvcm0iXSwicHJvcGVydGllcyI6eyJ1c2VyIjp7IiRyZWYiOiIjLyRkZWZzL2lhbUJvb3RzdHJhcFVzZXIifSwib3JnYW5pemF0aW9ucyI6eyJ0eXBlIjoiYXJyYXkiLCJtaW5JdGVtcyI6MSwibWF4SXRlbXMiOjY0LCJpdGVtcyI6eyIkcmVmIjoiIy8kZGVmcy9pYW1Cb290c3RyYXBPcmdhbml6YXRpb24ifX0sInJlY2VudFNjb3BlcyI6eyJ0eXBlIjoiYXJyYXkiLCJtaW5JdGVtcyI6MSwibWF4SXRlbXMiOjY0LCJpdGVtcyI6eyIkcmVmIjoiIy8kZGVmcy9pYW1Cb290c3RyYXBTY29wZSJ9fSwic2Vzc2lvbiI6eyIkcmVmIjoiIy8kZGVmcy9pYW1Cb290c3RyYXBTZXNzaW9uIn0sInBsYXRmb3JtIjp7IiRyZWYiOiIjLyRkZWZzL2lhbUJvb3RzdHJhcFBsYXRmb3JtIn19fSwiaWFtQm9vdHN0cmFwVXNlciI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJpZCIsImRpc3BsYXlOYW1lIiwibG9jYWxlIiwibWZhU3RhdGUiXSwicHJvcGVydGllcyI6eyJpZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sImVtYWlsIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjMsIm1heExlbmd0aCI6MjU0LCJwYXR0ZXJuIjoiXlteQFxcc10rQFteQFxcc10rXFwuW15AXFxzXSskIn0sImRpc3BsYXlOYW1lIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjEsIm1heExlbmd0aCI6MjAwfSwibG9jYWxlIjp7ImVudW0iOlsidmktVk4iLCJlbiJdfSwicHJvZmlsZVJldmlzaW9uIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MX0sIm1mYVN0YXRlIjp7ImVudW0iOlsiRU5BQkxFRCIsIk5PVF9DT05GSUdVUkVEIl19fX0sImlhbUJvb3RzdHJhcE9yZ2FuaXphdGlvbiI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJpZCIsIm5hbWUiLCJwZXJzb25hbCIsInN0YXR1cyIsIndvcmtzcGFjZXMiXSwicHJvcGVydGllcyI6eyJpZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sIm5hbWUiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjoyMDB9LCJwZXJzb25hbCI6eyJ0eXBlIjoiYm9vbGVhbiJ9LCJzdGF0dXMiOnsiZW51bSI6WyJBQ1RJVkUiLCJTVVNQRU5ERUQiLCJERUFDVElWQVRFRCJdfSwid29ya3NwYWNlcyI6eyJ0eXBlIjoiYXJyYXkiLCJtaW5JdGVtcyI6MSwibWF4SXRlbXMiOjI1NiwiaXRlbXMiOnsiJHJlZiI6IiMvJGRlZnMvaWFtQm9vdHN0cmFwV29ya3NwYWNlIn19fX0sImlhbUJvb3RzdHJhcFdvcmtzcGFjZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJpZCIsIm5hbWUiLCJzdGF0dXMiLCJwcm9qZWN0cyJdLCJwcm9wZXJ0aWVzIjp7ImlkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwibmFtZSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjIwMH0sInN0YXR1cyI6eyJlbnVtIjpbIkFDVElWRSIsIkFSQ0hJVkVEIl19LCJwcm9qZWN0cyI6eyJ0eXBlIjoiYXJyYXkiLCJtaW5JdGVtcyI6MSwibWF4SXRlbXMiOjI1NiwiaXRlbXMiOnsiJHJlZiI6IiMvJGRlZnMvaWFtQm9vdHN0cmFwUHJvamVjdCJ9fX19LCJpYW1Cb290c3RyYXBQcm9qZWN0Ijp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbImlkIiwibmFtZSIsImtpbmQiLCJzdGF0dXMiXSwicHJvcGVydGllcyI6eyJpZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sIm5hbWUiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjoyMDB9LCJraW5kIjp7ImVudW0iOlsiSU5URVJOQUwiLCJDTElFTlQiLCJMT0NBVElPTiIsIkVOR0FHRU1FTlQiXX0sInN0YXR1cyI6eyJlbnVtIjpbIkFDVElWRSIsIkFSQ0hJVkVEIl19fX0sImlhbUJvb3RzdHJhcFNjb3BlIjp7Im9uZU9mIjpbeyIkcmVmIjoiIy8kZGVmcy9pYW1Cb290c3RyYXBPcmdhbml6YXRpb25TY29wZSJ9LHsiJHJlZiI6IiMvJGRlZnMvaWFtQm9vdHN0cmFwV29ya3NwYWNlU2NvcGUifSx7IiRyZWYiOiIjLyRkZWZzL2lhbUJvb3RzdHJhcFByb2plY3RTY29wZSJ9XX0sImlhbUJvb3RzdHJhcE9yZ2FuaXphdGlvblNjb3BlIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjb3BlVHlwZSIsIm9yZ2FuaXphdGlvbklkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0Ijoib3JnYW5pemF0aW9uIn0sIm9yZ2FuaXphdGlvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifX19LCJpYW1Cb290c3RyYXBXb3Jrc3BhY2VTY29wZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZVR5cGUiLCJvcmdhbml6YXRpb25JZCIsIndvcmtzcGFjZUlkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0Ijoid29ya3NwYWNlIn0sIm9yZ2FuaXphdGlvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwid29ya3NwYWNlSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9fX0sImlhbUJvb3RzdHJhcFByb2plY3RTY29wZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZVR5cGUiLCJvcmdhbml6YXRpb25JZCIsIndvcmtzcGFjZUlkIiwicHJvamVjdElkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0IjoicHJvamVjdCJ9LCJvcmdhbml6YXRpb25JZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sIndvcmtzcGFjZUlkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwicHJvamVjdElkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifX19LCJpYW1Cb290c3RyYXBTZXNzaW9uIjp7Im9uZU9mIjpbeyIkcmVmIjoiIy8kZGVmcy9pYW1Cb290c3RyYXBPcmdhbml6YXRpb25TZXNzaW9uIn0seyIkcmVmIjoiIy8kZGVmcy9pYW1Cb290c3RyYXBXb3Jrc3BhY2VTZXNzaW9uIn0seyIkcmVmIjoiIy8kZGVmcy9pYW1Cb290c3RyYXBQcm9qZWN0U2Vzc2lvbiJ9XX0sImlhbUJvb3RzdHJhcE9yZ2FuaXphdGlvblNlc3Npb24iOnsidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsic2NvcGVUeXBlIiwib3JnYW5pemF0aW9uSWQiLCJhdXRob3JpemF0aW9uRXBvY2giXSwicHJvcGVydGllcyI6eyJzY29wZVR5cGUiOnsiY29uc3QiOiJvcmdhbml6YXRpb24ifSwib3JnYW5pemF0aW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJhdXRob3JpemF0aW9uRXBvY2giOnsidHlwZSI6ImludGVnZXIiLCJtaW5pbXVtIjoxfX19LCJpYW1Cb290c3RyYXBXb3Jrc3BhY2VTZXNzaW9uIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjb3BlVHlwZSIsIm9yZ2FuaXphdGlvbklkIiwid29ya3NwYWNlSWQiLCJhdXRob3JpemF0aW9uRXBvY2giXSwicHJvcGVydGllcyI6eyJzY29wZVR5cGUiOnsiY29uc3QiOiJ3b3Jrc3BhY2UifSwib3JnYW5pemF0aW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJ3b3Jrc3BhY2VJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sImF1dGhvcml6YXRpb25FcG9jaCI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjF9fX0sImlhbUJvb3RzdHJhcFByb2plY3RTZXNzaW9uIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjb3BlVHlwZSIsIm9yZ2FuaXphdGlvbklkIiwid29ya3NwYWNlSWQiLCJwcm9qZWN0SWQiLCJhdXRob3JpemF0aW9uRXBvY2giXSwicHJvcGVydGllcyI6eyJzY29wZVR5cGUiOnsiY29uc3QiOiJwcm9qZWN0In0sIm9yZ2FuaXphdGlvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwid29ya3NwYWNlSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJwcm9qZWN0SWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJhdXRob3JpemF0aW9uRXBvY2giOnsidHlwZSI6ImludGVnZXIiLCJtaW5pbXVtIjoxfX19LCJpYW1Cb290c3RyYXBQbGF0Zm9ybSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJhcGlWZXJzaW9uIl0sInByb3BlcnRpZXMiOnsiYXBpVmVyc2lvbiI6eyJjb25zdCI6InYxIn19fX19"), "https://schemas.databreeze.dev/contracts/v4/iam-email-verification-command" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3Y0L2lhbS1lbWFpbC12ZXJpZmljYXRpb24tY29tbWFuZCIsIiRjb21tZW50IjoiSUFNLTAyMi9JQU0tMDIzOiBib3VuZGVkIE9UUCBhY3RpdmF0aW9uIGNvbW1hbmQuIFRlbmFudCBhbmQgdXNlciBpZGVudGl0eSBhcmUgc2VydmVyLWRlcml2ZWQuIiwidGl0bGUiOiJJQU0gRW1haWwgVmVyaWZpY2F0aW9uIENvbW1hbmQiLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY2hlbWFWZXJzaW9uIiwiY2hhbGxlbmdlSWQiLCJjb2RlIiwiaWRlbXBvdGVuY3lLZXkiLCJjbGllbnRQbGF0Zm9ybSJdLCJwcm9wZXJ0aWVzIjp7InNjaGVtYVZlcnNpb24iOnsiY29uc3QiOjR9LCJjaGFsbGVuZ2VJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sImNvZGUiOnsidHlwZSI6InN0cmluZyIsInBhdHRlcm4iOiJeWzAtOV17Nn0kIn0sImlkZW1wb3RlbmN5S2V5Ijp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjgsIm1heExlbmd0aCI6MjAwLCJwYXR0ZXJuIjoiXltBLVphLXowLTldW0EtWmEtejAtOS5fOi1dezcsMTk5fSQifSwiY2xpZW50UGxhdGZvcm0iOnsidHlwZSI6InN0cmluZyIsImVudW0iOlsiYW5kcm9pZCIsImRlc2t0b3AiLCJ3ZWIiXX19fQ=="), "https://schemas.databreeze.dev/contracts/v4/iam-password-sign-in-command" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3Y0L2lhbS1wYXNzd29yZC1zaWduLWluLWNvbW1hbmQiLCIkY29tbWVudCI6IklBTS0wMDUvSUFNLTAyMzogYm91bmRlZCBlbWFpbC9wYXNzd29yZCBzaWduLWluIGNvbW1hbmQgd2l0aG91dCBjbGllbnQtc2VsZWN0ZWQgdGVuYW50IGF1dGhvcml0eS4iLCJ0aXRsZSI6IklBTSBQYXNzd29yZCBTaWduIEluIENvbW1hbmQiLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY2hlbWFWZXJzaW9uIiwiZW1haWwiLCJwYXNzd29yZCIsImNsaWVudFBsYXRmb3JtIl0sInByb3BlcnRpZXMiOnsic2NoZW1hVmVyc2lvbiI6eyJjb25zdCI6NH0sImVtYWlsIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjMsIm1heExlbmd0aCI6MjU0LCJwYXR0ZXJuIjoiXlteQFxcc10rQFteQFxcc10rXFwuW15AXFxzXSskIn0sInBhc3N3b3JkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjEyLCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bXlxcdTAwMDBcXHJcXG5dKyQifSwiY2xpZW50UGxhdGZvcm0iOnsidHlwZSI6InN0cmluZyIsImVudW0iOlsiYW5kcm9pZCIsImRlc2t0b3AiLCJ3ZWIiXX19fQ=="), @@ -219,7 +232,11 @@ private fun constructGeneratedModel(schemaId: String, payload: JsonNode): Any = "https://schemas.databreeze.dev/contracts/v4/dda-data-import-dashboard-preview" -> mapper.treeToValue(payload, DdaDataImportDashboardPreview::class.java) "https://schemas.databreeze.dev/contracts/v4/dda-notification-preferences-accepted" -> mapper.treeToValue(payload, DdaNotificationPreferencesAccepted::class.java) "https://schemas.databreeze.dev/contracts/v4/dda-notification-preferences-command" -> mapper.treeToValue(payload, DdaNotificationPreferencesCommand::class.java) - "https://schemas.databreeze.dev/contracts/v4/iam-auth-session" -> mapper.treeToValue(payload, IamAuthSession::class.java) + "https://schemas.databreeze.dev/contracts/v4/iam-auth-session" -> when (payload.required("scopeType").asText()) { + "TENANT" -> mapper.treeToValue(payload, TenantSession::class.java) + "PLATFORM" -> mapper.treeToValue(payload, PlatformSession::class.java) + else -> error("Unknown scopeType discriminator") + } "https://schemas.databreeze.dev/contracts/v4/iam-bootstrap-response" -> when (payload.required("outcome").asText()) { "ACCEPTED" -> mapper.treeToValue(payload, IamBootstrapResponseAccepted::class.java) "REJECTED" -> mapper.treeToValue(payload, IamBootstrapResponseRejected::class.java) diff --git a/packages/contracts/generated/python/databreeze_contracts/v4/__init__.py b/packages/contracts/generated/python/databreeze_contracts/v4/__init__.py index 178eeeed..d7f12a38 100644 --- a/packages/contracts/generated/python/databreeze_contracts/v4/__init__.py +++ b/packages/contracts/generated/python/databreeze_contracts/v4/__init__.py @@ -128,6 +128,7 @@ PlatformAdminOverviewOperator, PlatformAdminOverviewTotals, PlatformAdminOverviewWindow, + PlatformSession, PrepareIdempotencyKey, PrepareOpaqueToken, PrepareSafeName, @@ -153,6 +154,7 @@ SnapshotQuotasItem, SourceOpenResult, SourceOpenValue, + TenantSession, ToolCallId, ToolResult, UtcTimestamp, @@ -294,6 +296,7 @@ "PlatformAdminOverviewOperator", "PlatformAdminOverviewTotals", "PlatformAdminOverviewWindow", + "PlatformSession", "PrepareIdempotencyKey", "PrepareOpaqueToken", "PrepareSafeName", @@ -319,6 +322,7 @@ "SnapshotQuotasItem", "SourceOpenResult", "SourceOpenValue", + "TenantSession", "ToolCallId", "ToolResult", "UtcTimestamp", diff --git a/packages/contracts/generated/python/databreeze_contracts/v4/models.py b/packages/contracts/generated/python/databreeze_contracts/v4/models.py index b484291d..4ebc30a7 100644 --- a/packages/contracts/generated/python/databreeze_contracts/v4/models.py +++ b/packages/contracts/generated/python/databreeze_contracts/v4/models.py @@ -535,19 +535,6 @@ class Group(ClosedModel): label: Annotated[StrictStr, StringConstraints(min_length=1, max_length=128)] total: StrictFloat | None = None -class IamAuthSession(ClosedModel): - accessExpiresAt: UtcTimestamp - accessToken: Annotated[StrictStr, StringConstraints(min_length=80, max_length=4096, pattern=r"^[A-Za-z0-9._~-]+$")] - mfaReenrollmentRequired: StrictBool - mfaRequired: StrictBool - organizationId: Identifier - refreshToken: Annotated[StrictStr, StringConstraints(min_length=80, max_length=4096, pattern=r"^[A-Za-z0-9._~-]+$")] | None = None - schemaVersion: Literal[4] - securityEpoch: Annotated[int, Field(strict=True, ge=1)] - sessionId: Identifier - userId: Identifier - workspaceId: Identifier - class IamBootstrapOrganization(ClosedModel): id: Identifier name: Annotated[StrictStr, StringConstraints(min_length=1, max_length=200)] @@ -830,6 +817,18 @@ class PlatformAdminOverviewWindow(ClosedModel): endsAt: UtcTimestamp startsAt: UtcTimestamp +class PlatformSession(ClosedModel): + accessExpiresAt: UtcTimestamp + accessToken: Annotated[StrictStr, StringConstraints(min_length=80, max_length=4096, pattern=r"^[A-Za-z0-9._~-]+$")] + mfaReenrollmentRequired: StrictBool + mfaRequired: StrictBool + refreshToken: Annotated[StrictStr, StringConstraints(min_length=80, max_length=4096, pattern=r"^[A-Za-z0-9._~-]+$")] | None = None + schemaVersion: Literal[4] + scopeType: Literal["PLATFORM"] + securityEpoch: Annotated[int, Field(strict=True, ge=1)] + sessionId: Identifier + userId: Identifier + class PreparedOutput(ClosedModel): allowedMediaTypes: Annotated[list[PreparedMediaType], Field(min_length=1, max_length=16)] capabilityId: Identifier @@ -946,6 +945,20 @@ class SourceOpenValue(ClosedModel): kind: Annotated[StrictStr, StringConstraints(min_length=1, max_length=64)] sourceId: Identifier +class TenantSession(ClosedModel): + accessExpiresAt: UtcTimestamp + accessToken: Annotated[StrictStr, StringConstraints(min_length=80, max_length=4096, pattern=r"^[A-Za-z0-9._~-]+$")] + mfaReenrollmentRequired: StrictBool + mfaRequired: StrictBool + organizationId: Identifier + refreshToken: Annotated[StrictStr, StringConstraints(min_length=80, max_length=4096, pattern=r"^[A-Za-z0-9._~-]+$")] | None = None + schemaVersion: Literal[4] + scopeType: Literal["TENANT"] + securityEpoch: Annotated[int, Field(strict=True, ge=1)] + sessionId: Identifier + userId: Identifier + workspaceId: Identifier + class Value(ClosedModel): columns: Annotated[list[Column], Field(min_length=1, max_length=256)] datasetId: Identifier @@ -1014,6 +1027,8 @@ class WorkerResultBinding(ClosedModel): outputNames: Annotated[list[FinalizeSafeName], Field(min_length=1, max_length=32)] outputSchemaId: FinalizeSafeName +IamAuthSession: TypeAlias = Annotated[TenantSession | PlatformSession, Field(discriminator="scopeType")] + IamBootstrapResponse: TypeAlias = Annotated[IamBootstrapResponseAccepted | IamBootstrapResponseRejected, Field(discriminator="outcome")] IamBootstrapScope: TypeAlias = Annotated[IamBootstrapOrganizationScope | IamBootstrapWorkspaceScope | IamBootstrapProjectScope, Field(discriminator="scopeType")] @@ -1087,7 +1102,6 @@ class WorkerResultBinding(ClosedModel): Feedback.model_rebuild() Freshness.model_rebuild() Group.model_rebuild() -IamAuthSession.model_rebuild() IamBootstrapOrganization.model_rebuild() IamBootstrapOrganizationScope.model_rebuild() IamBootstrapOrganizationSession.model_rebuild() @@ -1131,6 +1145,7 @@ class WorkerResultBinding(ClosedModel): PlatformAdminOverviewOperator.model_rebuild() PlatformAdminOverviewTotals.model_rebuild() PlatformAdminOverviewWindow.model_rebuild() +PlatformSession.model_rebuild() PreparedOutput.model_rebuild() Provenance.model_rebuild() PublicMessage.model_rebuild() @@ -1148,6 +1163,7 @@ class WorkerResultBinding(ClosedModel): SnapshotQuotasItem.model_rebuild() SourceOpenResult.model_rebuild() SourceOpenValue.model_rebuild() +TenantSession.model_rebuild() Value.model_rebuild() WidgetResult.model_rebuild() WorkerDashboardResultProvenance.model_rebuild() diff --git a/packages/contracts/generated/typescript/v4/index.ts b/packages/contracts/generated/typescript/v4/index.ts index c16bb341..72ac68b4 100644 --- a/packages/contracts/generated/typescript/v4/index.ts +++ b/packages/contracts/generated/typescript/v4/index.ts @@ -535,19 +535,7 @@ export interface Group { export type GroupKey = string; -export interface IamAuthSession { - readonly accessExpiresAt: UtcTimestamp; - readonly accessToken: string; - readonly mfaReenrollmentRequired: boolean; - readonly mfaRequired: boolean; - readonly organizationId: Identifier; - readonly refreshToken?: string; - readonly schemaVersion: 4; - readonly securityEpoch: number; - readonly sessionId: Identifier; - readonly userId: Identifier; - readonly workspaceId: Identifier; -} +export type IamAuthSession = TenantSession | PlatformSession; export interface IamBootstrapOrganization { readonly id: Identifier; @@ -888,6 +876,19 @@ export interface PlatformAdminOverviewWindow { readonly startsAt: UtcTimestamp; } +export interface PlatformSession { + readonly accessExpiresAt: UtcTimestamp; + readonly accessToken: string; + readonly mfaReenrollmentRequired: boolean; + readonly mfaRequired: boolean; + readonly refreshToken?: string; + readonly schemaVersion: 4; + readonly scopeType: "PLATFORM"; + readonly securityEpoch: number; + readonly sessionId: Identifier; + readonly userId: Identifier; +} + export type PrepareIdempotencyKey = string; export type PrepareOpaqueToken = string; @@ -1037,6 +1038,21 @@ export interface SourceOpenValue { readonly sourceId: Identifier; } +export interface TenantSession { + readonly accessExpiresAt: UtcTimestamp; + readonly accessToken: string; + readonly mfaReenrollmentRequired: boolean; + readonly mfaRequired: boolean; + readonly organizationId: Identifier; + readonly refreshToken?: string; + readonly schemaVersion: 4; + readonly scopeType: "TENANT"; + readonly securityEpoch: number; + readonly sessionId: Identifier; + readonly userId: Identifier; + readonly workspaceId: Identifier; +} + export type ToolCallId = string; export type ToolResult = DatasetDescribeResult | DatasetSampleResult | AnalysisPlanResult | AnalysisExecuteResult | DashboardProposeResult | DashboardApplyConfirmedResult | DashboardExplainValueResult | EvidenceResolveResult | SourceOpenResult | EtlProposeCorrectionResult; diff --git a/packages/contracts/generated/typescript/v4/validation.mjs b/packages/contracts/generated/typescript/v4/validation.mjs index 7a40ba7c..8faf1594 100644 --- a/packages/contracts/generated/typescript/v4/validation.mjs +++ b/packages/contracts/generated/typescript/v4/validation.mjs @@ -22,7 +22,7 @@ const schemas = [ {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v4/dda-data-import-dashboard-preview","$comment":"Plan 413 / DDA-053: bounded, server-owned preview derived from one exact approved import. This is not a certified snapshot or publication.","title":"DDA Data Import Dashboard Preview","type":"object","additionalProperties":false,"required":["schemaVersion","accepted","value"],"properties":{"schemaVersion":{"const":4},"accepted":{"const":true},"value":{"$ref":"#/$defs/value"}},"$defs":{"value":{"type":"object","additionalProperties":false,"required":["importId","datasetId","datasetVersionId","datasetName","sourceCount","rowCount","truncated","sourceHashes","columns","sampleRows","generatedAt"],"properties":{"importId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"datasetId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"datasetVersionId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"datasetName":{"type":"string","minLength":1,"maxLength":200},"sourceCount":{"type":"integer","minimum":1,"maximum":8},"rowCount":{"type":"integer","minimum":1,"maximum":20000},"truncated":{"type":"boolean"},"sourceHashes":{"type":"array","minItems":1,"maxItems":8,"uniqueItems":true,"items":{"type":"string","pattern":"^[0-9a-f]{64}$"}},"columns":{"type":"array","minItems":1,"maxItems":256,"items":{"$ref":"#/$defs/column"}},"measure":{"$ref":"#/$defs/measure"},"dimension":{"$ref":"#/$defs/dimension"},"sampleRows":{"type":"array","maxItems":25,"items":{"$ref":"#/$defs/sampleRow"}},"generatedAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"}}},"column":{"type":"object","additionalProperties":false,"required":["name","type","nullable"],"properties":{"name":{"type":"string","minLength":1,"maxLength":128},"type":{"enum":["TEXT","INTEGER","DECIMAL","BOOLEAN","DATE"]},"nullable":{"type":"boolean"}}},"measure":{"type":"object","additionalProperties":false,"required":["field","sum","average","minimum","maximum"],"properties":{"field":{"type":"string","minLength":1,"maxLength":128},"sum":{"type":"number"},"average":{"type":"number"},"minimum":{"type":"number"},"maximum":{"type":"number"}}},"dimension":{"type":"object","additionalProperties":false,"required":["field","groups"],"properties":{"field":{"type":"string","minLength":1,"maxLength":128},"groups":{"type":"array","minItems":1,"maxItems":12,"items":{"$ref":"#/$defs/group"}}}},"group":{"type":"object","additionalProperties":false,"required":["label","count"],"properties":{"label":{"type":"string","minLength":1,"maxLength":128},"count":{"type":"integer","minimum":1},"total":{"type":"number"}}},"sampleRow":{"type":"object","additionalProperties":false,"required":["cells"],"properties":{"cells":{"type":"array","maxItems":32,"items":{"$ref":"#/$defs/sampleCell"}}}},"sampleCell":{"type":"object","additionalProperties":false,"required":["field","value","kind"],"properties":{"field":{"type":"string","minLength":1,"maxLength":128},"value":{"type":"string","maxLength":8192},"kind":{"enum":["TEXT","NUMBER","BOOLEAN","EMPTY"]}}}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v4/dda-notification-preferences-accepted","$comment":"NCO-006/NCO-018/NCO-024: server-authoritative notification preference snapshot.","title":"DDA Notification Preferences Accepted","type":"object","additionalProperties":false,"required":["schemaVersion","revision","preferences"],"properties":{"schemaVersion":{"const":4},"revision":{"type":"integer","minimum":1},"preferences":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["category","channel","enabled","minimumUrgency","deliveryMode","quietHours","timezone","mandatory"],"properties":{"category":{"enum":["REVIEWS","DATA","DASHBOARDS","USAGE","SECURITY","BILLING","SYSTEM"]},"channel":{"enum":["IN_APP","EMAIL","PUSH","DESKTOP"]},"enabled":{"type":"boolean"},"minimumUrgency":{"enum":["LOW","NORMAL","HIGH","CRITICAL"]},"deliveryMode":{"enum":["IMMEDIATE","DIGEST"]},"quietHours":{"type":"object","additionalProperties":false,"required":["enabled","start","end"],"properties":{"enabled":{"type":"boolean"},"start":{"type":"string","pattern":"^(?:[01][0-9]|2[0-3]):[0-5][0-9]$"},"end":{"type":"string","pattern":"^(?:[01][0-9]|2[0-3]):[0-5][0-9]$"}}},"timezone":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[A-Za-z0-9_+./:-]{1,64}$"},"mandatory":{"type":"boolean"}}}}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v4/dda-notification-preferences-command","$comment":"NCO-006/NCO-018/NCO-024: authenticated recipient-scoped notification preferences. Tenant, recipient, policy authority, mandatory category state, and revision are server-owned.","title":"DDA Notification Preferences Command","type":"object","additionalProperties":false,"required":["schemaVersion","expectedRevision","preferences"],"properties":{"schemaVersion":{"const":4},"expectedRevision":{"type":"integer","minimum":1},"preferences":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["category","channel","enabled","minimumUrgency","deliveryMode","quietHours","timezone"],"properties":{"category":{"enum":["REVIEWS","DATA","DASHBOARDS","USAGE","SECURITY","BILLING","SYSTEM"]},"channel":{"enum":["IN_APP","EMAIL","PUSH","DESKTOP"]},"enabled":{"type":"boolean"},"minimumUrgency":{"enum":["LOW","NORMAL","HIGH","CRITICAL"]},"deliveryMode":{"enum":["IMMEDIATE","DIGEST"]},"quietHours":{"type":"object","additionalProperties":false,"required":["enabled","start","end"],"properties":{"enabled":{"type":"boolean"},"start":{"type":"string","pattern":"^(?:[01][0-9]|2[0-3]):[0-5][0-9]$"},"end":{"type":"string","pattern":"^(?:[01][0-9]|2[0-3]):[0-5][0-9]$"}}},"timezone":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[A-Za-z0-9_+./:-]{1,64}$"}}}}}}, - {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v4/iam-auth-session","$comment":"IAM-005/IAM-006/IAM-023/WEB-004: public session response. Browser refresh credentials remain HttpOnly and are omitted.","title":"IAM Auth Session","type":"object","additionalProperties":false,"required":["schemaVersion","sessionId","userId","organizationId","workspaceId","accessToken","accessExpiresAt","securityEpoch","mfaRequired","mfaReenrollmentRequired"],"properties":{"schemaVersion":{"const":4},"sessionId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"userId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"accessToken":{"type":"string","minLength":80,"maxLength":4096,"pattern":"^[A-Za-z0-9._~-]+$"},"refreshToken":{"type":"string","minLength":80,"maxLength":4096,"pattern":"^[A-Za-z0-9._~-]+$"},"accessExpiresAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"securityEpoch":{"type":"integer","minimum":1},"mfaRequired":{"type":"boolean"},"mfaReenrollmentRequired":{"type":"boolean"}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v4/iam-auth-session","$comment":"IAM-005/IAM-006/IAM-023/IAM-026/WEB-004: tenant-scoped or platform-only public session response. Browser refresh credentials remain HttpOnly and are omitted.","title":"IAM Auth Session","oneOf":[{"$ref":"#/$defs/tenantSession"},{"$ref":"#/$defs/platformSession"}],"$defs":{"tenantSession":{"type":"object","additionalProperties":false,"required":["schemaVersion","scopeType","sessionId","userId","organizationId","workspaceId","accessToken","accessExpiresAt","securityEpoch","mfaRequired","mfaReenrollmentRequired"],"properties":{"schemaVersion":{"const":4},"scopeType":{"const":"TENANT"},"sessionId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"userId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"accessToken":{"type":"string","minLength":80,"maxLength":4096,"pattern":"^[A-Za-z0-9._~-]+$"},"refreshToken":{"type":"string","minLength":80,"maxLength":4096,"pattern":"^[A-Za-z0-9._~-]+$"},"accessExpiresAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"securityEpoch":{"type":"integer","minimum":1},"mfaRequired":{"type":"boolean"},"mfaReenrollmentRequired":{"type":"boolean"}}},"platformSession":{"type":"object","additionalProperties":false,"required":["schemaVersion","scopeType","sessionId","userId","accessToken","accessExpiresAt","securityEpoch","mfaRequired","mfaReenrollmentRequired"],"properties":{"schemaVersion":{"const":4},"scopeType":{"const":"PLATFORM"},"sessionId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"userId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"accessToken":{"type":"string","minLength":80,"maxLength":4096,"pattern":"^[A-Za-z0-9._~-]+$"},"refreshToken":{"type":"string","minLength":80,"maxLength":4096,"pattern":"^[A-Za-z0-9._~-]+$"},"accessExpiresAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"securityEpoch":{"type":"integer","minimum":1},"mfaRequired":{"type":"boolean"},"mfaReenrollmentRequired":{"type":"boolean"}}}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v4/iam-bootstrap-response","$comment":"Plan 408 / IAM-001 / IAM-009 / WEB-002 / WEB-003: content-safe authenticated Web bootstrap derived exclusively from server identity and scope.","title":"IAM Bootstrap Response","oneOf":[{"$ref":"#/$defs/iamBootstrapResponseAccepted"},{"$ref":"#/$defs/iamBootstrapResponseRejected"}],"$defs":{"iamBootstrapResponseAccepted":{"type":"object","additionalProperties":false,"required":["schemaVersion","outcome","value"],"properties":{"schemaVersion":{"const":4},"outcome":{"const":"ACCEPTED"},"value":{"$ref":"#/$defs/iamBootstrapValue"}}},"iamBootstrapResponseRejected":{"type":"object","additionalProperties":false,"required":["schemaVersion","outcome","code"],"properties":{"schemaVersion":{"const":4},"outcome":{"const":"REJECTED"},"code":{"enum":["INVALID_IDENTIFIER","NOT_FOUND","UNAVAILABLE"]}}},"iamBootstrapValue":{"type":"object","additionalProperties":false,"required":["user","organizations","recentScopes","session","platform"],"properties":{"user":{"$ref":"#/$defs/iamBootstrapUser"},"organizations":{"type":"array","minItems":1,"maxItems":64,"items":{"$ref":"#/$defs/iamBootstrapOrganization"}},"recentScopes":{"type":"array","minItems":1,"maxItems":64,"items":{"$ref":"#/$defs/iamBootstrapScope"}},"session":{"$ref":"#/$defs/iamBootstrapSession"},"platform":{"$ref":"#/$defs/iamBootstrapPlatform"}}},"iamBootstrapUser":{"type":"object","additionalProperties":false,"required":["id","displayName","locale","mfaState"],"properties":{"id":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"email":{"type":"string","minLength":3,"maxLength":254,"pattern":"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"},"displayName":{"type":"string","minLength":1,"maxLength":200},"locale":{"enum":["vi-VN","en"]},"profileRevision":{"type":"integer","minimum":1},"mfaState":{"enum":["ENABLED","NOT_CONFIGURED"]}}},"iamBootstrapOrganization":{"type":"object","additionalProperties":false,"required":["id","name","personal","status","workspaces"],"properties":{"id":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"name":{"type":"string","minLength":1,"maxLength":200},"personal":{"type":"boolean"},"status":{"enum":["ACTIVE","SUSPENDED","DEACTIVATED"]},"workspaces":{"type":"array","minItems":1,"maxItems":256,"items":{"$ref":"#/$defs/iamBootstrapWorkspace"}}}},"iamBootstrapWorkspace":{"type":"object","additionalProperties":false,"required":["id","name","status","projects"],"properties":{"id":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"name":{"type":"string","minLength":1,"maxLength":200},"status":{"enum":["ACTIVE","ARCHIVED"]},"projects":{"type":"array","minItems":1,"maxItems":256,"items":{"$ref":"#/$defs/iamBootstrapProject"}}}},"iamBootstrapProject":{"type":"object","additionalProperties":false,"required":["id","name","kind","status"],"properties":{"id":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"name":{"type":"string","minLength":1,"maxLength":200},"kind":{"enum":["INTERNAL","CLIENT","LOCATION","ENGAGEMENT"]},"status":{"enum":["ACTIVE","ARCHIVED"]}}},"iamBootstrapScope":{"oneOf":[{"$ref":"#/$defs/iamBootstrapOrganizationScope"},{"$ref":"#/$defs/iamBootstrapWorkspaceScope"},{"$ref":"#/$defs/iamBootstrapProjectScope"}]},"iamBootstrapOrganizationScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId"],"properties":{"scopeType":{"const":"organization"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}},"iamBootstrapWorkspaceScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","workspaceId"],"properties":{"scopeType":{"const":"workspace"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}},"iamBootstrapProjectScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","workspaceId","projectId"],"properties":{"scopeType":{"const":"project"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"projectId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}},"iamBootstrapSession":{"oneOf":[{"$ref":"#/$defs/iamBootstrapOrganizationSession"},{"$ref":"#/$defs/iamBootstrapWorkspaceSession"},{"$ref":"#/$defs/iamBootstrapProjectSession"}]},"iamBootstrapOrganizationSession":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","authorizationEpoch"],"properties":{"scopeType":{"const":"organization"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"authorizationEpoch":{"type":"integer","minimum":1}}},"iamBootstrapWorkspaceSession":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","workspaceId","authorizationEpoch"],"properties":{"scopeType":{"const":"workspace"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"authorizationEpoch":{"type":"integer","minimum":1}}},"iamBootstrapProjectSession":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","workspaceId","projectId","authorizationEpoch"],"properties":{"scopeType":{"const":"project"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"projectId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"authorizationEpoch":{"type":"integer","minimum":1}}},"iamBootstrapPlatform":{"type":"object","additionalProperties":false,"required":["apiVersion"],"properties":{"apiVersion":{"const":"v1"}}}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v4/iam-email-verification-command","$comment":"IAM-022/IAM-023: bounded OTP activation command. Tenant and user identity are server-derived.","title":"IAM Email Verification Command","type":"object","additionalProperties":false,"required":["schemaVersion","challengeId","code","idempotencyKey","clientPlatform"],"properties":{"schemaVersion":{"const":4},"challengeId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"code":{"type":"string","pattern":"^[0-9]{6}$"},"idempotencyKey":{"type":"string","minLength":8,"maxLength":200,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{7,199}$"},"clientPlatform":{"type":"string","enum":["android","desktop","web"]}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v4/iam-password-sign-in-command","$comment":"IAM-005/IAM-023: bounded email/password sign-in command without client-selected tenant authority.","title":"IAM Password Sign In Command","type":"object","additionalProperties":false,"required":["schemaVersion","email","password","clientPlatform"],"properties":{"schemaVersion":{"const":4},"email":{"type":"string","minLength":3,"maxLength":254,"pattern":"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"},"password":{"type":"string","minLength":12,"maxLength":128,"pattern":"^[^\\u0000\\r\\n]+$"},"clientPlatform":{"type":"string","enum":["android","desktop","web"]}}}, diff --git a/packages/contracts/schemas/v4/iam-auth-session.schema.json b/packages/contracts/schemas/v4/iam-auth-session.schema.json index 78723ebc..377c2dee 100644 --- a/packages/contracts/schemas/v4/iam-auth-session.schema.json +++ b/packages/contracts/schemas/v4/iam-auth-session.schema.json @@ -1,22 +1,48 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://schemas.databreeze.dev/contracts/v4/iam-auth-session", - "$comment": "IAM-005/IAM-006/IAM-023/WEB-004: public session response. Browser refresh credentials remain HttpOnly and are omitted.", + "$comment": "IAM-005/IAM-006/IAM-023/IAM-026/WEB-004: tenant-scoped or platform-only public session response. Browser refresh credentials remain HttpOnly and are omitted.", "title": "IAM Auth Session", - "type": "object", - "additionalProperties": false, - "required": ["schemaVersion", "sessionId", "userId", "organizationId", "workspaceId", "accessToken", "accessExpiresAt", "securityEpoch", "mfaRequired", "mfaReenrollmentRequired"], - "properties": { - "schemaVersion": { "const": 4 }, - "sessionId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, - "userId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, - "organizationId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, - "workspaceId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, - "accessToken": { "type": "string", "minLength": 80, "maxLength": 4096, "pattern": "^[A-Za-z0-9._~-]+$" }, - "refreshToken": { "type": "string", "minLength": 80, "maxLength": 4096, "pattern": "^[A-Za-z0-9._~-]+$" }, - "accessExpiresAt": { "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" }, - "securityEpoch": { "type": "integer", "minimum": 1 }, - "mfaRequired": { "type": "boolean" }, - "mfaReenrollmentRequired": { "type": "boolean" } + "oneOf": [ + { "$ref": "#/$defs/tenantSession" }, + { "$ref": "#/$defs/platformSession" } + ], + "$defs": { + "tenantSession": { + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "scopeType", "sessionId", "userId", "organizationId", "workspaceId", "accessToken", "accessExpiresAt", "securityEpoch", "mfaRequired", "mfaReenrollmentRequired"], + "properties": { + "schemaVersion": { "const": 4 }, + "scopeType": { "const": "TENANT" }, + "sessionId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "userId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "organizationId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "workspaceId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "accessToken": { "type": "string", "minLength": 80, "maxLength": 4096, "pattern": "^[A-Za-z0-9._~-]+$" }, + "refreshToken": { "type": "string", "minLength": 80, "maxLength": 4096, "pattern": "^[A-Za-z0-9._~-]+$" }, + "accessExpiresAt": { "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" }, + "securityEpoch": { "type": "integer", "minimum": 1 }, + "mfaRequired": { "type": "boolean" }, + "mfaReenrollmentRequired": { "type": "boolean" } + } + }, + "platformSession": { + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "scopeType", "sessionId", "userId", "accessToken", "accessExpiresAt", "securityEpoch", "mfaRequired", "mfaReenrollmentRequired"], + "properties": { + "schemaVersion": { "const": 4 }, + "scopeType": { "const": "PLATFORM" }, + "sessionId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "userId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "accessToken": { "type": "string", "minLength": 80, "maxLength": 4096, "pattern": "^[A-Za-z0-9._~-]+$" }, + "refreshToken": { "type": "string", "minLength": 80, "maxLength": 4096, "pattern": "^[A-Za-z0-9._~-]+$" }, + "accessExpiresAt": { "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" }, + "securityEpoch": { "type": "integer", "minimum": 1 }, + "mfaRequired": { "type": "boolean" }, + "mfaReenrollmentRequired": { "type": "boolean" } + } + } } } diff --git a/packages/contracts/test/schemas.test.mjs b/packages/contracts/test/schemas.test.mjs index 82e362d9..349df5a8 100644 --- a/packages/contracts/test/schemas.test.mjs +++ b/packages/contracts/test/schemas.test.mjs @@ -26,6 +26,7 @@ const ids = { conversationListAccepted: `${schemaBaseV4}/dda-conversation-list-accepted`, conversationLoadAccepted: `${schemaBaseV4}/dda-conversation-load-accepted`, conversationSummary: `${schemaBaseV4}/dda-conversation-summary`, + iamAuthSession: `${schemaBaseV4}/iam-auth-session`, iamBootstrapResponse: `${schemaBaseV4}/iam-bootstrap-response`, iamScopeSwitchCommand: `${schemaBaseV4}/iam-scope-switch-command`, iamWorkspaceCreateAccepted: `${schemaBaseV4}/iam-workspace-create-accepted`, @@ -71,6 +72,25 @@ function loadContracts() { return { ajv, manifest, schemas }; } +test('[IAM-005][IAM-026] auth sessions distinguish tenant and platform-only scope', () => { + const validate = validatorFor(ids.iamAuthSession); + const base = { + schemaVersion: 4, + sessionId: actorId, + userId: actorId, + accessToken: 'a'.repeat(80), + accessExpiresAt: '2026-08-21T12:15:00.000Z', + securityEpoch: 2, + mfaRequired: false, + mfaReenrollmentRequired: false, + }; + + assert.equal(validate({ ...base, scopeType: 'TENANT', organizationId, workspaceId }), true); + assert.equal(validate({ ...base, scopeType: 'PLATFORM' }), true); + assert.equal(validate({ ...base, scopeType: 'TENANT', organizationId }), false); + assert.equal(validate({ ...base, scopeType: 'PLATFORM', organizationId, workspaceId }), false); +}); + function validatorFor(id) { const { ajv } = loadContracts(); const validate = ajv.getSchema(id); diff --git a/packages/domain/src/identity/v1.ts b/packages/domain/src/identity/v1.ts index ab34196a..63456753 100644 --- a/packages/domain/src/identity/v1.ts +++ b/packages/domain/src/identity/v1.ts @@ -95,8 +95,9 @@ export interface SessionRecordV1 { readonly schemaVersion: typeof IDENTITY_SCHEMA_VERSION_V1; readonly sessionId: StableIdentifierV1; readonly userId: StableIdentifierV1; - readonly organizationId: StableIdentifierV1; - readonly workspaceId: StableIdentifierV1; + readonly principalKind: 'TENANT' | 'PLATFORM'; + readonly organizationId?: StableIdentifierV1; + readonly workspaceId?: StableIdentifierV1; readonly familyId: StableIdentifierV1; readonly issuedAt: StrictUtcTimestampV1; readonly accessExpiresAt: StrictUtcTimestampV1; @@ -540,28 +541,36 @@ export function checkOwnerRemovalV1( export function createSessionRecordV1(input: { readonly sessionId: unknown; readonly userId: unknown; - readonly organizationId: unknown; - readonly workspaceId: unknown; + readonly principalKind?: unknown; + readonly organizationId?: unknown; + readonly workspaceId?: unknown; readonly familyId: unknown; readonly issuedAt: unknown; readonly accessExpiresAt: unknown; readonly inactivityExpiresAt: unknown; readonly absoluteExpiresAt: unknown; }): IdentityResultV1 { - const ids = [ - stableId(input.sessionId), - stableId(input.userId), - stableId(input.organizationId), - stableId(input.workspaceId), - stableId(input.familyId), - ]; + const sessionId = stableId(input.sessionId); + const userId = stableId(input.userId); + const familyId = stableId(input.familyId); + const principalKind = input.principalKind ?? 'TENANT'; + const organizationId = + input.organizationId === undefined ? undefined : stableId(input.organizationId); + const workspaceId = input.workspaceId === undefined ? undefined : stableId(input.workspaceId); const times = [ timestamp(input.issuedAt), timestamp(input.accessExpiresAt), timestamp(input.inactivityExpiresAt), timestamp(input.absoluteExpiresAt), ]; - if (ids.some((value) => !value)) return rejected('INVALID_IDENTIFIER'); + if (!sessionId || !userId || !familyId) return rejected('INVALID_IDENTIFIER'); + if (principalKind !== 'TENANT' && principalKind !== 'PLATFORM') return rejected('INVALID_KIND'); + if ( + (principalKind === 'TENANT' && (!organizationId || !workspaceId)) || + (principalKind === 'PLATFORM' && + (input.organizationId !== undefined || input.workspaceId !== undefined)) + ) + return rejected('INVALID_SCOPE'); if (times.some((value) => !value)) return rejected('INVALID_TIMESTAMP'); const issuedAt = times[0] as StrictUtcTimestampV1; const accessExpiresAt = times[1] as StrictUtcTimestampV1; @@ -578,11 +587,12 @@ export function createSessionRecordV1(input: { return accepted( Object.freeze({ schemaVersion: 1, - sessionId: ids[0] as StableIdentifierV1, - userId: ids[1] as StableIdentifierV1, - organizationId: ids[2] as StableIdentifierV1, - workspaceId: ids[3] as StableIdentifierV1, - familyId: ids[4] as StableIdentifierV1, + sessionId, + userId, + principalKind, + ...(organizationId === undefined ? {} : { organizationId }), + ...(workspaceId === undefined ? {} : { workspaceId }), + familyId, issuedAt, accessExpiresAt, inactivityExpiresAt, diff --git a/packages/test-fixtures/contracts/v4/payloads/iam-auth-session/valid.json b/packages/test-fixtures/contracts/v4/payloads/iam-auth-session/valid.json index d584d9da..0e2b853a 100644 --- a/packages/test-fixtures/contracts/v4/payloads/iam-auth-session/valid.json +++ b/packages/test-fixtures/contracts/v4/payloads/iam-auth-session/valid.json @@ -1,5 +1,6 @@ { "schemaVersion": 4, + "scopeType": "TENANT", "sessionId": "00000000-0000-4000-8000-000000000401", "userId": "00000000-0000-4000-8000-000000000402", "organizationId": "00000000-0000-4000-8000-000000000403", diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 8301d839..75c92a65 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -19340,6 +19340,7 @@ "type": "object", "properties": { "schemaVersion": { "type": "number", "enum": [4] }, + "scopeType": { "type": "string", "enum": ["TENANT", "PLATFORM"] }, "sessionId": { "type": "string", "format": "uuid" }, "userId": { "type": "string", "format": "uuid" }, "organizationId": { "type": "string", "format": "uuid" }, @@ -19353,10 +19354,9 @@ }, "required": [ "schemaVersion", + "scopeType", "sessionId", "userId", - "organizationId", - "workspaceId", "accessToken", "accessExpiresAt", "securityEpoch", diff --git a/services/api/prisma/migrations/20260821010000_iam_platform_only_sessions/migration.sql b/services/api/prisma/migrations/20260821010000_iam_platform_only_sessions/migration.sql new file mode 100644 index 00000000..da0c1183 --- /dev/null +++ b/services/api/prisma/migrations/20260821010000_iam_platform_only_sessions/migration.sql @@ -0,0 +1,12 @@ +ALTER TABLE "iam"."sessions" + ADD COLUMN "principal_kind" VARCHAR(16) NOT NULL DEFAULT 'TENANT', + ALTER COLUMN "organization_id" DROP NOT NULL, + ALTER COLUMN "workspace_id" DROP NOT NULL; + +ALTER TABLE "iam"."sessions" + ADD CONSTRAINT "sessions_principal_scope_check" + CHECK ( + ("principal_kind" = 'TENANT' AND "organization_id" IS NOT NULL AND "workspace_id" IS NOT NULL) + OR + ("principal_kind" = 'PLATFORM' AND "organization_id" IS NULL AND "workspace_id" IS NULL) + ); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 570a3f47..7bf27524 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -198,8 +198,9 @@ model RecoveryCompensationFailureRecord { model SessionRecord { id String @id @db.Uuid userId String @map("user_id") @db.Uuid - organizationId String @map("organization_id") @db.Uuid - workspaceId String @map("workspace_id") @db.Uuid + principalKind String @default("TENANT") @map("principal_kind") @db.VarChar(16) + organizationId String? @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid familyId String @map("family_id") @db.Uuid issuedAt DateTime @map("issued_at") @db.Timestamptz(6) accessExpiresAt DateTime @map("access_expires_at") @db.Timestamptz(6) diff --git a/services/api/scripts/rotate-pilot-operator-password.mjs b/services/api/scripts/rotate-pilot-operator-password.mjs new file mode 100644 index 00000000..962c05b1 --- /dev/null +++ b/services/api/scripts/rotate-pilot-operator-password.mjs @@ -0,0 +1,136 @@ +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { PrismaPg } from '@prisma/adapter-pg'; +import { argon2id, hash } from 'argon2'; + +import { loadPrismaClient } from './seed-local.mjs'; + +const PASSWORD_HASH_OPTIONS = Object.freeze({ + type: argon2id, + memoryCost: 64 * 1024, + timeCost: 3, + parallelism: 1, + hashLength: 32, + saltLength: 16, +}); + +function requiredText(value, code, maximumLength) { + const normalized = typeof value === 'string' ? value.trim() : ''; + if (normalized.length === 0 || normalized.length > maximumLength) throw new Error(code); + return normalized; +} + +export function parsePilotOperatorRotationConfiguration(environment = process.env) { + if (environment.DATABREEZE_PILOT_OPERATOR_ROTATION_ENABLED?.trim().toLowerCase() !== 'true') { + throw new Error('PILOT_OPERATOR_ROTATION_DISABLED'); + } + const operatorEmail = requiredText( + environment.DATABREEZE_PILOT_OPERATOR_EMAIL, + 'PILOT_OPERATOR_EMAIL_REQUIRED', + 254, + ).toLowerCase(); + const operatorPassword = requiredText( + environment.DATABREEZE_PILOT_OPERATOR_PASSWORD, + 'PILOT_OPERATOR_PASSWORD_REQUIRED', + 512, + ); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(operatorEmail) || operatorPassword.length < 12) { + throw new Error('PILOT_OPERATOR_ROTATION_CONFIGURATION_INVALID'); + } + return Object.freeze({ operatorEmail, operatorPassword }); +} + +/** IAM-005/IAM-026: explicit credential rotation, security-epoch bump and revocation are atomic. */ +export async function rotatePilotOperatorPassword( + database, + configuration, + { + hashPassword = (password) => hash(password, PASSWORD_HASH_OPTIONS), + now = () => new Date(), + } = {}, +) { + const encodedHash = await hashPassword(configuration.operatorPassword); + const timestamp = now(); + return database.$transaction(async (transaction) => { + const user = await transaction.userIdentity.findUnique({ + where: { email: configuration.operatorEmail }, + }); + if (user === null || user.status !== 'ACTIVE') throw new Error('PILOT_OPERATOR_NOT_FOUND'); + const [assignment, credential, activeSessions] = await Promise.all([ + transaction.platformOperatorRecord.findUnique({ where: { userId: user.id } }), + transaction.passwordCredential.findUnique({ where: { userId: user.id } }), + transaction.sessionRecord.findMany({ where: { userId: user.id, status: 'ACTIVE' } }), + ]); + if ( + assignment?.status !== 'ACTIVE' || + assignment.role !== 'PLATFORM_OWNER' || + credential === null + ) { + throw new Error('PILOT_OPERATOR_ROTATION_FORBIDDEN'); + } + + await transaction.passwordCredential.update({ + where: { userId: user.id }, + data: { encodedHash, rotatedAt: timestamp }, + }); + const updatedUser = await transaction.userIdentity.update({ + where: { id: user.id }, + data: { securityEpoch: { increment: 1 } }, + }); + const sessionIds = activeSessions.map((session) => session.id); + if (sessionIds.length > 0) { + await transaction.refreshTokenRecord.updateMany({ + where: { sessionId: { in: sessionIds }, status: 'ACTIVE' }, + data: { status: 'REVOKED' }, + }); + await transaction.accessTokenRecord.updateMany({ + where: { sessionId: { in: sessionIds }, status: 'ACTIVE' }, + data: { status: 'REVOKED', revokedAt: timestamp }, + }); + await transaction.sessionRecord.updateMany({ + where: { id: { in: sessionIds }, status: 'ACTIVE' }, + data: { status: 'REVOKED', revokedAt: timestamp }, + }); + } + return Object.freeze({ + userId: user.id, + securityEpoch: updatedUser.securityEpoch, + revokedSessions: sessionIds.length, + }); + }); +} + +export async function runPilotOperatorRotation({ + environment = process.env, + log = console.log, +} = {}) { + const configuration = parsePilotOperatorRotationConfiguration(environment); + const connectionString = requiredText( + environment.DATABASE_URL, + 'PILOT_OPERATOR_DATABASE_URL_REQUIRED', + 4096, + ); + const PrismaClient = await loadPrismaClient(); + const database = new PrismaClient({ adapter: new PrismaPg({ connectionString }) }); + await database.$connect(); + try { + const result = await rotatePilotOperatorPassword(database, configuration); + log( + `Platform operator credential rotated; security epoch ${result.securityEpoch}; revoked sessions ${result.revokedSessions}.`, + ); + return result; + } finally { + await database.$disconnect(); + } +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + runPilotOperatorRotation().catch((error) => { + console.error(error instanceof Error ? error.message : 'PILOT_OPERATOR_ROTATION_FAILED'); + process.exitCode = 1; + }); +} diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index c4a6ff9e..3fd155c8 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -53,6 +53,7 @@ import { SessionRequestTenantContextAdapter, UnavailableWorkspaceAuthorizationEpochResolverAdapter, } from './platform/http/session-tenant-context.adapter.js'; +import { SessionRequestActorAdapter } from './platform/http/session-request-actor.adapter.js'; import { IamHierarchyDashboardProjectResolverAdapter } from './platform/http/iam-hierarchy-dashboard-project-resolver.adapter.js'; import { PrismaSessionLifecycleAdapter } from './features/iam/adapter/prisma-session-lifecycle.adapter.js'; import { PrismaIamRepositoryAdapter } from './features/iam/adapter/prisma-iam-repository.adapter.js'; @@ -322,6 +323,13 @@ export class AppModule { }, ) : undefined); + const requestAuthenticatedActor = + options.requestAuthenticatedActor ?? + (typeof sessions?.findSessionByAccessToken === 'function' + ? new SessionRequestActorAdapter({ + findSessionByAccessToken: sessions.findSessionByAccessToken.bind(sessions), + }) + : undefined); const platformOperatorAuthority = options.platformOperatorAuthority ?? (options.iamDatabase === undefined @@ -683,6 +691,7 @@ export class AppModule { ...(auditRepository === undefined ? {} : { auditRepository }), ...(auditLedgerService === undefined ? {} : { auditLedgerService }), ...(requestTenantContext === undefined ? {} : { requestTenantContext }), + ...(requestAuthenticatedActor === undefined ? {} : { requestAuthenticatedActor }), ...(platformOperatorAuthority === undefined ? {} : { platformOperatorAuthority }), ...(platformIdentityAnalytics === undefined ? {} : { platformIdentityAnalytics }), ...(platformBillingAnalytics === undefined ? {} : { platformBillingAnalytics }), diff --git a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts index a8226e3c..818a3800 100644 --- a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts @@ -12,10 +12,12 @@ import { type StrictUtcTimestampV1, } from '@databreeze/domain/tenant-scope/v1'; -import type { - AuthenticationSessionV1, - AuthenticatedPrincipalV1, - SessionIssuerPortV1, +import { + isPlatformPrincipalV1, + type AuthenticationSessionV1, + type AuthenticatedPrincipalV1, + type SessionPrincipalV1, + type SessionIssuerPortV1, } from '../application/authentication.port.js'; import type { SessionLifecyclePortV1, @@ -26,7 +28,7 @@ import { sessionPolicyForPlatformV1 } from '../application/session-policy.v1.js' interface SessionEntryV1 { record: SessionRecordV1; - readonly principal: AuthenticatedPrincipalV1; + readonly principal: SessionPrincipalV1; activeTokenId: StableIdentifierV1; familyStatus: 'ACTIVE' | 'REVOKED'; } @@ -78,7 +80,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { } public issue( - principal: AuthenticatedPrincipalV1, + principal: SessionPrincipalV1, clientPlatform: 'android' | 'desktop' | 'web', ): Promise { const policy = sessionPolicyForPlatformV1(clientPlatform); @@ -92,8 +94,10 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { const created = createSessionRecordV1({ sessionId: sessionIdentifier, userId: principal.userId, - organizationId: principal.organizationId, - workspaceId: principal.workspaceId, + principalKind: isPlatformPrincipalV1(principal) ? 'PLATFORM' : 'TENANT', + ...(isPlatformPrincipalV1(principal) + ? {} + : { organizationId: principal.organizationId, workspaceId: principal.workspaceId }), familyId: familyIdentifier, issuedAt: now.toISOString(), accessExpiresAt: addSeconds(now, policy.accessTokenSeconds), @@ -224,6 +228,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { if ( !current || current.familyStatus !== 'ACTIVE' || + isPlatformPrincipalV1(current.principal) || current.principal.userId !== principal.userId || current.principal.organizationId !== principal.organizationId ) @@ -250,9 +255,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { return revoked; } - public async findPrincipal( - sessionIdInput: unknown, - ): Promise { + public async findPrincipal(sessionIdInput: unknown): Promise { await Promise.resolve(); if (typeof sessionIdInput !== 'string') return undefined; const session = this.sessions.get(sessionIdInput); @@ -262,7 +265,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { public async findPrincipalByAccessToken( accessTokenInput: unknown, - ): Promise { + ): Promise { await Promise.resolve(); if (typeof accessTokenInput !== 'string' || accessTokenInput.length < 80) return undefined; const sessionId = this.accessTokens.get(digestToken(accessTokenInput)); @@ -277,9 +280,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { public async findSessionByAccessToken( accessTokenInput: unknown, - ): Promise< - { readonly sessionId: string; readonly principal: AuthenticatedPrincipalV1 } | undefined - > { + ): Promise<{ readonly sessionId: string; readonly principal: SessionPrincipalV1 } | undefined> { if (typeof accessTokenInput !== 'string' || accessTokenInput.length < 80) return undefined; const sessionId = this.accessTokens.get(digestToken(accessTokenInput)); if (sessionId === undefined) return undefined; diff --git a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts index 2039de90..fbefd519 100644 --- a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -2,8 +2,8 @@ import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import type { - AuthenticatedPrincipalV1, CredentialLookupPortV1, + SessionPrincipalV1, } from '../application/authentication.port.js'; export interface UserIdentityDatabaseRowV1 { @@ -46,6 +46,12 @@ export interface MfaFactorDatabaseRowV1 { readonly id: string; } +export interface PlatformOperatorDatabaseRowV1 { + readonly userId: string; + readonly role: string; + readonly status: string; +} + interface UniqueDelegateV1 { findUnique(input: { readonly where: Readonly> }): Promise; } @@ -71,6 +77,7 @@ export interface CredentialLookupDatabaseClientV1 { readonly workspaceIdentity: WorkspaceLookupDelegateV1; readonly organizationIdentity: UniqueDelegateV1; readonly mfaFactor: ListDelegateV1; + readonly platformOperatorRecord?: UniqueDelegateV1; } interface ActiveMembershipV1 { @@ -110,7 +117,7 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { public async findCredential(emailInput: string): Promise< | { - readonly principal: AuthenticatedPrincipalV1; + readonly principal: SessionPrincipalV1; readonly credential: { readonly algorithm: 'argon2id'; readonly encodedHash: string }; } | undefined @@ -123,12 +130,15 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { if (!userId || !Number.isSafeInteger(user.securityEpoch) || user.securityEpoch < 1) return undefined; - const [credential, memberships] = await Promise.all([ + const [credential, memberships, platformOperator, factors] = await Promise.all([ this.client.passwordCredential.findUnique({ where: { userId } }), this.client.membershipIdentity.findMany({ where: { principalId: userId, status: 'ACTIVE' }, orderBy: { createdAt: 'asc' }, }), + this.client.platformOperatorRecord?.findUnique({ where: { userId } }) ?? + Promise.resolve(null), + this.client.mfaFactor.findMany({ where: { userId, status: 'ACTIVE' } }), ]); if ( !credential || @@ -140,6 +150,27 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { ) return undefined; + if ( + platformOperator?.userId === userId && + platformOperator.status === 'ACTIVE' && + (platformOperator.role === 'PLATFORM_OWNER' || platformOperator.role === 'PLATFORM_SUPPORT') + ) { + if (typeof user.mfaReenrollmentRequired !== 'boolean') return undefined; + return Object.freeze({ + principal: Object.freeze({ + scopeType: 'PLATFORM' as const, + userId, + securityEpoch: user.securityEpoch, + mfaRequired: factors.length > 0, + mfaReenrollmentRequired: user.mfaReenrollmentRequired, + }), + credential: Object.freeze({ + algorithm: 'argon2id' as const, + encodedHash: credential.encodedHash, + }), + }); + } + const selected = memberships .map((membership) => activeMembership(membership, userId)) .filter((membership): membership is ActiveMembershipV1 => membership !== undefined) @@ -151,10 +182,9 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { .at(0); if (!selected) return undefined; - const [organization, factors] = await Promise.all([ - this.client.organizationIdentity.findUnique({ where: { id: selected.organizationId } }), - this.client.mfaFactor.findMany({ where: { userId, status: 'ACTIVE' } }), - ]); + const organization = await this.client.organizationIdentity.findUnique({ + where: { id: selected.organizationId }, + }); let workspaceId = selected.workspaceId; if (!workspaceId) { if (!this.client.workspaceIdentity.findMany) return undefined; diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts index 005eed6a..0a4ef125 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -12,10 +12,12 @@ import { type StrictUtcTimestampV1, } from '@databreeze/domain/tenant-scope/v1'; -import type { - AuthenticationSessionV1, - AuthenticatedPrincipalV1, - SessionIssuerPortV1, +import { + isPlatformPrincipalV1, + type AuthenticationSessionV1, + type AuthenticatedPrincipalV1, + type SessionPrincipalV1, + type SessionIssuerPortV1, } from '../application/authentication.port.js'; import type { SessionLifecyclePortV1, @@ -28,8 +30,9 @@ import { sessionPolicyForPlatformV1 } from '../application/session-policy.v1.js' export interface SessionRecordDatabaseRowV1 { readonly id: string; readonly userId: string; - readonly organizationId: string; - readonly workspaceId: string; + readonly principalKind: string; + readonly organizationId: string | null; + readonly workspaceId: string | null; readonly familyId: string; readonly issuedAt: Date; readonly accessExpiresAt: Date; @@ -92,6 +95,12 @@ export interface SessionMfaFactorDatabaseRowV1 { readonly id: string; } +export interface SessionPlatformOperatorDatabaseRowV1 { + readonly userId: string; + readonly role: string; + readonly status: string; +} + interface SessionDelegateV1 { create(input: { readonly data: SessionRecordDatabaseRowV1 }): Promise; findUnique(input: { @@ -149,6 +158,7 @@ export interface SessionLifecycleDatabaseClientV1 { readonly workspaceIdentity: UniqueDelegateV1; readonly organizationIdentity: UniqueDelegateV1; readonly mfaFactor: ListDelegateV1; + readonly platformOperatorRecord?: UniqueDelegateV1; $transaction( work: (transaction: SessionLifecycleDatabaseClientV1) => Promise, ): Promise; @@ -187,8 +197,6 @@ function tokenFor(tokenId: string): string { function sessionFromRow(row: SessionRecordDatabaseRowV1): SessionRecordV1 { const sessionId = stableIdentifier(row.id); const userId = stableIdentifier(row.userId); - const organizationId = stableIdentifier(row.organizationId); - const workspaceId = stableIdentifier(row.workspaceId); const familyId = stableIdentifier(row.familyId); const issuedAt = timestamp(row.issuedAt); const accessExpiresAt = timestamp(row.accessExpiresAt); @@ -218,12 +226,24 @@ function sessionFromRow(row: SessionRecordDatabaseRowV1): SessionRecordV1 { ) { throw new Error('IAM_PERSISTED_SESSION_INVALID'); } + if (row.principalKind !== 'TENANT' && row.principalKind !== 'PLATFORM') + throw new Error('IAM_PERSISTED_SESSION_INVALID'); + const principalKind: SessionRecordV1['principalKind'] = row.principalKind; + const organizationId = + row.organizationId === null ? undefined : stableIdentifier(row.organizationId); + const workspaceId = row.workspaceId === null ? undefined : stableIdentifier(row.workspaceId); + if ( + (principalKind === 'TENANT' && (!organizationId || !workspaceId)) || + (principalKind === 'PLATFORM' && (organizationId !== undefined || workspaceId !== undefined)) + ) + throw new Error('IAM_PERSISTED_SESSION_INVALID'); const created = { schemaVersion: 1 as const, sessionId, userId, - organizationId, - workspaceId, + principalKind, + ...(organizationId === undefined ? {} : { organizationId }), + ...(workspaceId === undefined ? {} : { workspaceId }), familyId, issuedAt, accessExpiresAt, @@ -321,7 +341,7 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { } public async issue( - principal: AuthenticatedPrincipalV1, + principal: SessionPrincipalV1, clientPlatform: 'android' | 'desktop' | 'web', ): Promise { const policy = sessionPolicyForPlatformV1(clientPlatform); @@ -332,8 +352,10 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { const created = createSessionRecordV1({ sessionId, userId: principal.userId, - organizationId: principal.organizationId, - workspaceId: principal.workspaceId, + principalKind: isPlatformPrincipalV1(principal) ? 'PLATFORM' : 'TENANT', + ...(isPlatformPrincipalV1(principal) + ? {} + : { organizationId: principal.organizationId, workspaceId: principal.workspaceId }), familyId, issuedAt: now.toISOString(), accessExpiresAt: addSeconds(now, policy.accessTokenSeconds), @@ -350,8 +372,9 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { data: { id: record.sessionId, userId: record.userId, - organizationId: record.organizationId, - workspaceId: record.workspaceId, + principalKind: record.principalKind, + organizationId: record.organizationId ?? null, + workspaceId: record.workspaceId ?? null, familyId: record.familyId, issuedAt: new Date(record.issuedAt), accessExpiresAt: new Date(record.accessExpiresAt), @@ -583,8 +606,9 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { data: { id: record.sessionId, userId: record.userId, - organizationId: record.organizationId, - workspaceId: record.workspaceId, + principalKind: record.principalKind, + organizationId: record.organizationId ?? null, + workspaceId: record.workspaceId ?? null, familyId: record.familyId, issuedAt: new Date(record.issuedAt), accessExpiresAt: new Date(record.accessExpiresAt), @@ -651,7 +675,7 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { public async findPrincipalByAccessToken( accessTokenInput: unknown, - ): Promise { + ): Promise { if (typeof accessTokenInput !== 'string' || accessTokenInput.length < 80) return undefined; const row = await this.client.accessTokenRecord.findUnique({ where: { tokenDigest: digestToken(accessTokenInput) }, @@ -663,9 +687,7 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { public async findSessionByAccessToken( accessTokenInput: unknown, - ): Promise< - { readonly sessionId: string; readonly principal: AuthenticatedPrincipalV1 } | undefined - > { + ): Promise<{ readonly sessionId: string; readonly principal: SessionPrincipalV1 } | undefined> { if (typeof accessTokenInput !== 'string' || accessTokenInput.length < 80) return undefined; const row = await this.client.accessTokenRecord.findUnique({ where: { tokenDigest: digestToken(accessTokenInput) }, @@ -678,9 +700,7 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { : Object.freeze({ sessionId: row.sessionId, principal }); } - public async findPrincipal( - sessionIdInput: unknown, - ): Promise { + public async findPrincipal(sessionIdInput: unknown): Promise { if (typeof sessionIdInput !== 'string') return undefined; const parsed = parseStableIdentifierV1(sessionIdInput); if (!parsed.accepted) return undefined; @@ -698,7 +718,34 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { return undefined; const user = await this.client.userIdentity.findUnique({ where: { id: session.userId } }); if (!user || user.status !== 'ACTIVE' || user.id !== session.userId) return undefined; - if (!Number.isSafeInteger(user.securityEpoch) || user.securityEpoch < 1) return undefined; + if ( + !Number.isSafeInteger(user.securityEpoch) || + user.securityEpoch < 1 || + typeof user.mfaReenrollmentRequired !== 'boolean' + ) + return undefined; + const factors = await this.client.mfaFactor.findMany({ + where: { userId: session.userId, status: 'ACTIVE' }, + }); + if (session.principalKind === 'PLATFORM') { + const operator = await this.client.platformOperatorRecord?.findUnique({ + where: { userId: session.userId }, + }); + if ( + operator?.userId !== session.userId || + operator.status !== 'ACTIVE' || + (operator.role !== 'PLATFORM_OWNER' && operator.role !== 'PLATFORM_SUPPORT') + ) + return undefined; + return Object.freeze({ + scopeType: 'PLATFORM' as const, + userId: session.userId, + securityEpoch: user.securityEpoch, + mfaRequired: factors.length > 0, + mfaReenrollmentRequired: user.mfaReenrollmentRequired, + }); + } + if (session.organizationId === undefined || session.workspaceId === undefined) return undefined; const memberships = await this.client.membershipIdentity.findMany({ where: { principalId: session.userId, @@ -718,10 +765,9 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { const organizationId = parseStableIdentifierV1(session.organizationId); const workspaceId = parseStableIdentifierV1(session.workspaceId); if (!organizationId.accepted || !workspaceId.accepted) return undefined; - const [organization, workspace, factors] = await Promise.all([ + const [organization, workspace] = await Promise.all([ this.client.organizationIdentity.findUnique({ where: { id: organizationId.value } }), this.client.workspaceIdentity.findUnique({ where: { id: workspaceId.value } }), - this.client.mfaFactor.findMany({ where: { userId: session.userId, status: 'ACTIVE' } }), ]); if ( !organization || diff --git a/services/api/src/features/iam/api/auth-session.dto.ts b/services/api/src/features/iam/api/auth-session.dto.ts index de8eefc2..7ba7ab72 100644 --- a/services/api/src/features/iam/api/auth-session.dto.ts +++ b/services/api/src/features/iam/api/auth-session.dto.ts @@ -16,6 +16,10 @@ export class AuthSessionDto { @Equals(4) schemaVersion!: 4; + @ApiProperty({ enum: ['TENANT', 'PLATFORM'] }) + @IsString() + scopeType!: 'TENANT' | 'PLATFORM'; + @ApiProperty({ format: 'uuid' }) @IsUUID() sessionId!: string; @@ -24,13 +28,15 @@ export class AuthSessionDto { @IsUUID() userId!: string; - @ApiProperty({ format: 'uuid' }) + @ApiProperty({ format: 'uuid', required: false }) + @IsOptional() @IsUUID() - organizationId!: string; + organizationId?: string; - @ApiProperty({ format: 'uuid' }) + @ApiProperty({ format: 'uuid', required: false }) + @IsOptional() @IsUUID() - workspaceId!: string; + workspaceId?: string; @ApiProperty({ minLength: 1, maxLength: 4096 }) @IsString() diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 3a52a1df..01456145 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -27,6 +27,7 @@ import { import { AUTHENTICATION_USE_CASE, + isPlatformPrincipalV1, type AuthenticationUseCaseV1, } from '../application/authentication.port.js'; import { AuthenticationProblemError } from '../application/authentication-problem.error.js'; @@ -58,6 +59,10 @@ import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; +import { + REQUEST_AUTHENTICATED_ACTOR, + type RequestAuthenticatedActorPortV1, +} from '../../../platform/http/request-authenticated-actor.port.js'; import type { FastifyReply, FastifyRequest } from 'fastify'; @ApiTags('auth') @@ -72,6 +77,9 @@ export class AuthenticationController { @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext?: RequestTenantContextPortV1, @Optional() + @Inject(REQUEST_AUTHENTICATED_ACTOR) + private readonly requestAuthenticatedActor?: RequestAuthenticatedActorPortV1, + @Optional() @Inject(IAM_SCOPE_SWITCH_SERVICE) private readonly scopeSwitch?: IamScopeSwitchService, ) {} @@ -124,10 +132,15 @@ export class AuthenticationController { } return { schemaVersion: 4, + scopeType: isPlatformPrincipalV1(result.value.principal) ? 'PLATFORM' : 'TENANT', sessionId: result.value.session.sessionId, userId: result.value.principal.userId, - organizationId: result.value.principal.organizationId, - workspaceId: result.value.principal.workspaceId, + ...(isPlatformPrincipalV1(result.value.principal) + ? {} + : { + organizationId: result.value.principal.organizationId, + workspaceId: result.value.principal.workspaceId, + }), accessToken: result.value.session.accessToken, ...(input.clientPlatform === 'web' ? {} @@ -199,10 +212,12 @@ export class AuthenticationController { } return { schemaVersion: 4, + scopeType: isPlatformPrincipalV1(principal) ? 'PLATFORM' : 'TENANT', sessionId: result.value.sessionId, userId: principal.userId, - organizationId: principal.organizationId, - workspaceId: principal.workspaceId, + ...(isPlatformPrincipalV1(principal) + ? {} + : { organizationId: principal.organizationId, workspaceId: principal.workspaceId }), accessToken: result.value.accessToken, accessExpiresAt: result.value.accessExpiresAt, ...(input.clientPlatform === 'web' ? {} : { refreshToken: result.value.refreshToken }), @@ -261,6 +276,7 @@ export class AuthenticationController { ]); return { schemaVersion: 4, + scopeType: 'TENANT', sessionId: session.sessionId, userId: context.actorId, organizationId: context.tenantScope.organizationId, @@ -286,18 +302,11 @@ export class AuthenticationController { @Res({ passthrough: true }) reply: FastifyReply, ): Promise { if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); - if (this.requestContext === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); - const context = await this.requestContext.resolve(request); + if (this.requestAuthenticatedActor === undefined) + throw new SessionProblemError('SESSION_UNAVAILABLE'); + const actor = await this.requestAuthenticatedActor.resolve(request); try { - const principal = await this.sessions.findPrincipal(input.sessionId); - if ( - !principal || - principal.userId !== context.actorId || - principal.organizationId !== context.tenantScope.organizationId || - (context.tenantScope.scopeType !== 'organization' && - principal.workspaceId !== context.tenantScope.workspaceId) - ) - throw new SessionProblemError('SESSION_INVALID'); + if (input.sessionId !== actor.sessionId) throw new SessionProblemError('SESSION_INVALID'); await this.sessions.revoke(input.sessionId); } catch (error) { if (error instanceof SessionProblemError) throw error; diff --git a/services/api/src/features/iam/api/email-verification.controller.ts b/services/api/src/features/iam/api/email-verification.controller.ts index bbfb0861..a05f6f71 100644 --- a/services/api/src/features/iam/api/email-verification.controller.ts +++ b/services/api/src/features/iam/api/email-verification.controller.ts @@ -71,6 +71,7 @@ export class EmailVerificationController { } return { schemaVersion: 4, + scopeType: 'TENANT', sessionId: result.value.session.sessionId, userId: result.value.principal.userId, organizationId: result.value.principal.organizationId, diff --git a/services/api/src/features/iam/application/authentication.port.ts b/services/api/src/features/iam/application/authentication.port.ts index 2498569c..5f73def4 100644 --- a/services/api/src/features/iam/application/authentication.port.ts +++ b/services/api/src/features/iam/application/authentication.port.ts @@ -5,6 +5,7 @@ export const AUTHENTICATION_USE_CASE = Symbol('AUTHENTICATION_USE_CASE'); export const CREDENTIAL_LOOKUP_PORT = Symbol('CREDENTIAL_LOOKUP_PORT'); export interface AuthenticatedPrincipalV1 { + readonly scopeType?: 'TENANT'; readonly userId: string; readonly organizationId: string; readonly workspaceId: string; @@ -14,10 +15,26 @@ export interface AuthenticatedPrincipalV1 { readonly mfaReenrollmentRequired: boolean; } +export interface PlatformAuthenticatedPrincipalV1 { + readonly scopeType: 'PLATFORM'; + readonly userId: string; + readonly securityEpoch: number; + readonly mfaRequired: boolean; + readonly mfaReenrollmentRequired: boolean; +} + +export type SessionPrincipalV1 = AuthenticatedPrincipalV1 | PlatformAuthenticatedPrincipalV1; + +export function isPlatformPrincipalV1( + principal: SessionPrincipalV1, +): principal is PlatformAuthenticatedPrincipalV1 { + return principal.scopeType === 'PLATFORM'; +} + export interface CredentialLookupPortV1 { findCredential(email: string): Promise< | { - readonly principal: AuthenticatedPrincipalV1; + readonly principal: SessionPrincipalV1; readonly credential: { readonly algorithm: 'argon2id'; readonly encodedHash: string }; } | undefined @@ -26,7 +43,7 @@ export interface CredentialLookupPortV1 { export interface SessionIssuerPortV1 { issue( - principal: AuthenticatedPrincipalV1, + principal: SessionPrincipalV1, clientPlatform: 'android' | 'desktop' | 'web', ): Promise<{ readonly sessionId: string; @@ -54,7 +71,7 @@ export interface AuthenticationSessionV1 { } export interface AuthenticationValueV1 { - readonly principal: AuthenticatedPrincipalV1; + readonly principal: SessionPrincipalV1; readonly session: AuthenticationSessionV1; } diff --git a/services/api/src/features/iam/application/session-lifecycle.port.ts b/services/api/src/features/iam/application/session-lifecycle.port.ts index 73e92f40..963375a6 100644 --- a/services/api/src/features/iam/application/session-lifecycle.port.ts +++ b/services/api/src/features/iam/application/session-lifecycle.port.ts @@ -1,6 +1,7 @@ import type { AuthenticationSessionV1, AuthenticatedPrincipalV1, + SessionPrincipalV1, SessionIssuerPortV1, } from './authentication.port.js'; @@ -41,16 +42,14 @@ export interface SessionLifecyclePortV1 extends SessionIssuerPortV1 { revoke(sessionId: unknown): Promise; /** Revokes every active refresh family for the user. Returns revoked session count. */ revokeAllForUser?(userId: unknown): Promise; - findPrincipal(sessionId: unknown): Promise; + findPrincipal(sessionId: unknown): Promise; findSessionByAccessToken?( accessToken: unknown, - ): Promise< - { readonly sessionId: string; readonly principal: AuthenticatedPrincipalV1 } | undefined - >; + ): Promise<{ readonly sessionId: string; readonly principal: SessionPrincipalV1 } | undefined>; /** Optional until a host enables authenticated request-context resolution. */ - findPrincipalByAccessToken?(accessToken: unknown): Promise; + findPrincipalByAccessToken?(accessToken: unknown): Promise; } export interface SessionAccessTokenLookupPortV1 { - findPrincipalByAccessToken(accessToken: unknown): Promise; + findPrincipalByAccessToken(accessToken: unknown): Promise; } diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index a80fade1..18f91d03 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -252,6 +252,11 @@ import { type RequestTenantContextPortV1, UnavailableRequestTenantContextAdapter, } from '../../platform/http/request-tenant-context.port.js'; +import { + REQUEST_AUTHENTICATED_ACTOR, + type RequestAuthenticatedActorPortV1, + UnavailableRequestAuthenticatedActorAdapter, +} from '../../platform/http/request-authenticated-actor.port.js'; export interface IamModuleOptions { readonly authentication?: AuthenticationUseCaseV1; @@ -349,6 +354,7 @@ export interface IamModuleOptions { readonly serviceAccountClock?: ServiceAccountClockV1; readonly serviceAccountIdGenerator?: ServiceAccountIdGeneratorV1; readonly requestTenantContext?: RequestTenantContextPortV1; + readonly requestAuthenticatedActor?: RequestAuthenticatedActorPortV1; } /** Compare already-normalized recovery-code digests without data-dependent byte comparisons. */ @@ -938,6 +944,11 @@ export class IamModule { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), }, + { + provide: REQUEST_AUTHENTICATED_ACTOR, + useValue: + options.requestAuthenticatedActor ?? new UnavailableRequestAuthenticatedActorAdapter(), + }, ], exports, }; diff --git a/services/api/src/features/platform-admin/api/platform-admin.controller.ts b/services/api/src/features/platform-admin/api/platform-admin.controller.ts index cbbba225..874da575 100644 --- a/services/api/src/features/platform-admin/api/platform-admin.controller.ts +++ b/services/api/src/features/platform-admin/api/platform-admin.controller.ts @@ -14,9 +14,9 @@ import { type PlatformAdminService, } from '../application/platform-admin.service.js'; import { - REQUEST_TENANT_CONTEXT, - type RequestTenantContextPortV1, -} from '../../../platform/http/request-tenant-context.port.js'; + REQUEST_AUTHENTICATED_ACTOR, + type RequestAuthenticatedActorPortV1, +} from '../../../platform/http/request-authenticated-actor.port.js'; const ALLOWED_WINDOWS = new Set([30, 90, 180, 365]); @@ -33,7 +33,8 @@ function windowDays(input: string | undefined): number { export class PlatformAdminController { public constructor( @Inject(PLATFORM_ADMIN_SERVICE) private readonly service: PlatformAdminService, - @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + @Inject(REQUEST_AUTHENTICATED_ACTOR) + private readonly requestActor: RequestAuthenticatedActorPortV1, ) {} @Get('overview') @@ -79,8 +80,8 @@ export class PlatformAdminController { description: 'Authoritative platform analytics are unavailable.', }) public async overview(@Req() request: unknown, @Query('days') daysInput?: string) { - const context = await this.requestContext.resolve(request); - return this.service.overview(context.actorId, windowDays(daysInput)); + const actor = await this.requestActor.resolve(request); + return this.service.overview(actor.actorId, windowDays(daysInput)); } @Get('feedbacks') @@ -105,7 +106,7 @@ export class PlatformAdminController { description: 'Authoritative landing feedback reads are unavailable.', }) public async feedbacks(@Req() request: unknown) { - const context = await this.requestContext.resolve(request); - return this.service.feedbacks(context.actorId, 200); + const actor = await this.requestActor.resolve(request); + return this.service.feedbacks(actor.actorId, 200); } } diff --git a/services/api/src/features/platform-admin/platform-admin.module.ts b/services/api/src/features/platform-admin/platform-admin.module.ts index 7a4a20a4..6dd3fc0f 100644 --- a/services/api/src/features/platform-admin/platform-admin.module.ts +++ b/services/api/src/features/platform-admin/platform-admin.module.ts @@ -7,11 +7,12 @@ import type { } from '../iam/application/platform-administration.port.js'; import type { PlatformBillingAnalyticsPortV1 } from '../bua/application/platform-billing-analytics.port.js'; import type { LandingFeedbackListPortV1 } from '../lfb/application/landing-feedback-intake.port.js'; +import { type RequestTenantContextPortV1 } from '../../platform/http/request-tenant-context.port.js'; import { - REQUEST_TENANT_CONTEXT, - type RequestTenantContextPortV1, - UnavailableRequestTenantContextAdapter, -} from '../../platform/http/request-tenant-context.port.js'; + REQUEST_AUTHENTICATED_ACTOR, + type RequestAuthenticatedActorPortV1, + UnavailableRequestAuthenticatedActorAdapter, +} from '../../platform/http/request-authenticated-actor.port.js'; import { PLATFORM_ADMIN_SERVICE, PlatformAdminService, @@ -54,6 +55,7 @@ export interface PlatformAdminModuleOptions { readonly platformFeedbacks?: LandingFeedbackListPortV1; readonly platformAdminClock?: () => Date; readonly requestTenantContext?: RequestTenantContextPortV1; + readonly requestAuthenticatedActor?: RequestAuthenticatedActorPortV1; } @Module({}) @@ -66,14 +68,31 @@ export class PlatformAdminModule { feedbacks: options.platformFeedbacks ?? new UnavailableLandingFeedbackList(), ...(options.platformAdminClock === undefined ? {} : { now: options.platformAdminClock }), }); + const requestActor = + options.requestAuthenticatedActor ?? + (options.requestTenantContext === undefined + ? new UnavailableRequestAuthenticatedActorAdapter() + : { + resolve: async (request: unknown) => { + const context = await options.requestTenantContext!.resolve(request); + return Object.freeze({ + sessionId: context.sessionId ?? 'tenant-session', + actorId: context.actorId, + scopeType: 'TENANT' as const, + securityEpoch: context.authorizationEpoch, + mfaRequired: context.mfaRequired ?? false, + mfaReenrollmentRequired: context.mfaReenrollmentRequired, + }); + }, + }); return { module: PlatformAdminModule, controllers: [PlatformAdminController], providers: [ { provide: PLATFORM_ADMIN_SERVICE, useValue: service }, { - provide: REQUEST_TENANT_CONTEXT, - useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + provide: REQUEST_AUTHENTICATED_ACTOR, + useValue: requestActor, }, ], }; diff --git a/services/api/src/platform/http/request-authenticated-actor.port.ts b/services/api/src/platform/http/request-authenticated-actor.port.ts new file mode 100644 index 00000000..25a5fcca --- /dev/null +++ b/services/api/src/platform/http/request-authenticated-actor.port.ts @@ -0,0 +1,25 @@ +import { RequestTenantContextProblemError } from './request-tenant-context.port.js'; + +export const REQUEST_AUTHENTICATED_ACTOR = Symbol('REQUEST_AUTHENTICATED_ACTOR'); + +export interface RequestAuthenticatedActorV1 { + readonly sessionId: string; + readonly actorId: string; + readonly scopeType: 'TENANT' | 'PLATFORM'; + readonly securityEpoch: number; + readonly mfaRequired: boolean; + readonly mfaReenrollmentRequired: boolean; +} + +export interface RequestAuthenticatedActorPortV1 { + resolve(request: unknown): Promise; +} + +export class UnavailableRequestAuthenticatedActorAdapter + implements RequestAuthenticatedActorPortV1 +{ + public async resolve(): Promise { + await Promise.resolve(); + throw new RequestTenantContextProblemError('AUTHENTICATION_UNAVAILABLE'); + } +} diff --git a/services/api/src/platform/http/session-request-actor.adapter.ts b/services/api/src/platform/http/session-request-actor.adapter.ts new file mode 100644 index 00000000..7eecdbcf --- /dev/null +++ b/services/api/src/platform/http/session-request-actor.adapter.ts @@ -0,0 +1,72 @@ +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + isPlatformPrincipalV1, + type SessionPrincipalV1, +} from '../../features/iam/application/authentication.port.js'; +import { RequestTenantContextProblemError } from './request-tenant-context.port.js'; +import type { + RequestAuthenticatedActorPortV1, + RequestAuthenticatedActorV1, +} from './request-authenticated-actor.port.js'; + +type HeaderValueV1 = string | readonly string[] | undefined; + +interface RequestLikeV1 { + readonly headers?: Readonly>; +} + +export interface SessionActorLookupV1 { + findSessionByAccessToken( + accessToken: unknown, + ): Promise<{ readonly sessionId: string; readonly principal: SessionPrincipalV1 } | undefined>; +} + +function bearerToken(input: unknown): string | undefined { + if (typeof input !== 'object' || input === null) return undefined; + const headers = (input as RequestLikeV1).headers; + if (headers === undefined || typeof headers !== 'object') return undefined; + const values = Object.entries(headers) + .filter(([name]) => name.toLowerCase() === 'authorization') + .map(([, value]) => value) + .filter((value): value is string | readonly string[] => value !== undefined); + if (values.length !== 1 || typeof values[0] !== 'string') return undefined; + return /^Bearer ([A-Za-z0-9._~-]{20,4096})$/u.exec(values[0])?.[1]; +} + +/** IAM-026: resolve identity without manufacturing organization or workspace scope. */ +export class SessionRequestActorAdapter implements RequestAuthenticatedActorPortV1 { + public constructor(private readonly sessions: SessionActorLookupV1) {} + + public async resolve(request: unknown): Promise { + const token = bearerToken(request); + if (token === undefined) throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + let binding: Awaited>; + try { + binding = await this.sessions.findSessionByAccessToken(token); + } catch { + throw new RequestTenantContextProblemError('AUTHENTICATION_UNAVAILABLE'); + } + const sessionId = parseStableIdentifierV1(binding?.sessionId); + const actorId = parseStableIdentifierV1(binding?.principal.userId); + if ( + binding === undefined || + !sessionId.accepted || + !actorId.accepted || + !Number.isSafeInteger(binding.principal.securityEpoch) || + binding.principal.securityEpoch < 1 || + typeof binding.principal.mfaRequired !== 'boolean' || + typeof binding.principal.mfaReenrollmentRequired !== 'boolean' + ) { + throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + } + return Object.freeze({ + sessionId: sessionId.value, + actorId: actorId.value, + scopeType: isPlatformPrincipalV1(binding.principal) ? 'PLATFORM' : 'TENANT', + securityEpoch: binding.principal.securityEpoch, + mfaRequired: binding.principal.mfaRequired, + mfaReenrollmentRequired: binding.principal.mfaReenrollmentRequired, + }); + } +} diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index 9edcef67..ee87adff 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -1,6 +1,9 @@ import { randomUUID } from 'node:crypto'; -import { type AuthenticatedPrincipalV1 } from '../../features/iam/application/authentication.port.js'; +import { + isPlatformPrincipalV1, + type SessionPrincipalV1, +} from '../../features/iam/application/authentication.port.js'; import type { WorkspaceAuthorizationEpochResolverPortV1 } from '../../features/iam/application/agent-grant-repository.port.js'; import { createIamTenantContextV1 } from '../../features/iam/application/tenant-context.js'; import { @@ -21,12 +24,10 @@ interface RequestLikeV1 { } export interface SessionPrincipalLookupV1 { - findPrincipalByAccessToken(accessToken: unknown): Promise; + findPrincipalByAccessToken(accessToken: unknown): Promise; findSessionByAccessToken?( accessToken: unknown, - ): Promise< - { readonly sessionId: string; readonly principal: AuthenticatedPrincipalV1 } | undefined - >; + ): Promise<{ readonly sessionId: string; readonly principal: SessionPrincipalV1 } | undefined>; } export class UnavailableWorkspaceAuthorizationEpochResolverAdapter @@ -108,7 +109,7 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP if (input === undefined || token === undefined) { throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); } - let principal: AuthenticatedPrincipalV1 | undefined; + let principal: SessionPrincipalV1 | undefined; let sessionId: string | undefined; try { if (this.sessions.findSessionByAccessToken !== undefined) { @@ -123,6 +124,8 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP } if (principal === undefined) throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + if (isPlatformPrincipalV1(principal)) + throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); if (typeof principal.mfaReenrollmentRequired !== 'boolean') throw new RequestTenantContextProblemError('CONTEXT_INVALID'); const context = createIamTenantContextV1({ diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index a2a131e2..4e6df5a8 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -140,6 +140,36 @@ void test('[IAM-001, IAM-002] lookup does not authenticate users without an acti assert.equal(await adapter.findCredential('user@example.com'), undefined); }); +void test('[IAM-026] active platform operators authenticate without receiving tenant scope', async () => { + const client = database({ + membershipIdentity: { findMany: async () => [] }, + }) as CredentialLookupDatabaseClientV1 & { + readonly platformOperatorRecord: { + findUnique(input: { readonly where: { readonly userId: string } }): Promise<{ + readonly userId: string; + readonly role: string; + readonly status: string; + } | null>; + }; + }; + Object.assign(client, { + platformOperatorRecord: { + findUnique: async () => ({ userId, role: 'PLATFORM_OWNER', status: 'ACTIVE' }), + }, + }); + const adapter = new PrismaCredentialLookupAdapter(client); + + const result = await adapter.findCredential('user@example.com'); + + assert.deepEqual(result?.principal, { + scopeType: 'PLATFORM', + userId, + securityEpoch: 3, + mfaRequired: true, + mfaReenrollmentRequired: false, + }); +}); + void test('[IAM-001, IAM-009] an organization owner resolves the canonical active workspace', async () => { const adapter = new PrismaCredentialLookupAdapter( database({ @@ -160,8 +190,18 @@ void test('[IAM-001, IAM-009] an organization owner resolves the canonical activ ); const result = await adapter.findCredential('user@example.com'); - assert.equal(result?.principal.organizationId, organizationId); - assert.equal(result?.principal.workspaceId, workspaceId); + assert.equal( + result !== undefined && 'organizationId' in result.principal + ? result.principal.organizationId + : undefined, + organizationId, + ); + assert.equal( + result !== undefined && 'workspaceId' in result.principal + ? result.principal.workspaceId + : undefined, + workspaceId, + ); }); void test('[IAM-002] workspace membership selection is deterministic', async () => { @@ -194,5 +234,10 @@ void test('[IAM-002] workspace membership selection is deterministic', async () ); const result = await adapter.findCredential('user@example.com'); - assert.equal(result?.principal.workspaceId, workspaceId); + assert.equal( + result !== undefined && 'workspaceId' in result.principal + ? result.principal.workspaceId + : undefined, + workspaceId, + ); }); diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index 0a0a6e99..6cff9c0d 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -129,7 +129,12 @@ function createDatabase(): { }, }, userIdentity: { - findUnique: async () => ({ id: userId, status: 'ACTIVE', securityEpoch: 4 }), + findUnique: async () => ({ + id: userId, + status: 'ACTIVE', + securityEpoch: 4, + mfaReenrollmentRequired: false, + }), }, membershipIdentity: { findMany: async ({ where }: { readonly where: Readonly> }) => { @@ -316,10 +321,50 @@ void test('[IAM-005, IAM-019] persisted sessions retain the exact sign-in tenant assert.equal(sessions.get(session.sessionId)?.organizationId, organizationId); assert.equal(sessions.get(session.sessionId)?.workspaceId, workspaceId); - assert.equal((await adapter.findPrincipal(session.sessionId))?.workspaceId, workspaceId); + const resolved = await adapter.findPrincipal(session.sessionId); + assert.equal( + resolved !== undefined && 'workspaceId' in resolved ? resolved.workspaceId : undefined, + workspaceId, + ); assert.deepEqual(membershipQueries.at(-1), { principalId: userId, organizationId, status: 'ACTIVE', }); }); + +void test('[IAM-005][IAM-026] platform-only sessions persist and refresh without tenant scope', async () => { + const { client, sessions } = createDatabase(); + const platformClient = client as SessionLifecycleDatabaseClientV1 & { + readonly platformOperatorRecord: { + findUnique(input: { readonly where: { readonly userId: string } }): Promise<{ + readonly userId: string; + readonly role: string; + readonly status: string; + } | null>; + }; + }; + Object.assign(platformClient, { + platformOperatorRecord: { + findUnique: async () => ({ userId, role: 'PLATFORM_OWNER', status: 'ACTIVE' }), + }, + }); + const adapter = new PrismaSessionLifecycleAdapter(platformClient, { + clock: () => new Date('2026-01-01T00:00:00.000Z'), + }); + const platformPrincipal = { + scopeType: 'PLATFORM', + userId, + securityEpoch: 4, + mfaRequired: true, + mfaReenrollmentRequired: false, + } as unknown as Parameters[0]; + + const issued = await adapter.issue(platformPrincipal, 'web'); + const row = sessions.get(issued.sessionId); + assert.equal(row?.principalKind, 'PLATFORM'); + assert.equal(row?.organizationId, null); + assert.equal(row?.workspaceId, null); + assert.equal((await adapter.findPrincipal(issued.sessionId))?.scopeType, 'PLATFORM'); + assert.equal((await adapter.refresh(issued.refreshToken, 'web')).accepted, true); +}); diff --git a/services/api/test/features/iam/scope-switch-service.test.ts b/services/api/test/features/iam/scope-switch-service.test.ts index 96529407..3dbda261 100644 --- a/services/api/test/features/iam/scope-switch-service.test.ts +++ b/services/api/test/features/iam/scope-switch-service.test.ts @@ -148,7 +148,10 @@ void test('[IAM-028] switches only to an active workspace covered by the actor m assert.equal(result.accepted, true); if (!result.accepted) return; const switched = await sessions.findPrincipal(result.value.session.sessionId); - assert.equal(switched?.workspaceId, ids.targetWorkspace); + assert.equal( + switched !== undefined && 'workspaceId' in switched ? switched.workspaceId : undefined, + ids.targetWorkspace, + ); assert.equal(await sessions.findPrincipal(current.sessionId), undefined); }); diff --git a/services/api/test/features/platform-admin/platform-admin.controller.test.ts b/services/api/test/features/platform-admin/platform-admin.controller.test.ts index 79a7b77f..874bd5e2 100644 --- a/services/api/test/features/platform-admin/platform-admin.controller.test.ts +++ b/services/api/test/features/platform-admin/platform-admin.controller.test.ts @@ -77,6 +77,32 @@ void test('[IAM-026][BUA-024] protected overview returns the closed v4 response' } }); +void test('[IAM-026] platform overview accepts a platform-only authenticated actor', async () => { + const { app } = await createApiApplication({ + requestAuthenticatedActor: { + resolve: async () => ({ + sessionId: '00000000-0000-4000-8000-000000000011', + actorId, + scopeType: 'PLATFORM' as const, + securityEpoch: 1, + mfaRequired: false, + mfaReenrollmentRequired: false, + }), + }, + platformOperatorAuthority: { + resolve: async () => ({ role: 'PLATFORM_OWNER' as const, revision: 1 }), + }, + platformIdentityAnalytics: identities, + platformBillingAnalytics: billing, + } as Parameters[0]); + try { + const response = await app.inject({ method: 'GET', url: '/v1/platform-admin/overview' }); + assert.equal(response.statusCode, 200); + } finally { + await app.close(); + } +}); + void test('[IAM-026] tenant Owner/Admin receives 403 from platform administration', async () => { const { app } = await createApiApplication({ requestTenantContext: { resolve: async () => context() }, diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 3039d879..f3f1b337 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -328,6 +328,7 @@ void test('sign-in returns a session DTO and maps authentication failures withou assert.equal(response.statusCode, 200); assert.deepEqual(response.json(), { schemaVersion: 4, + scopeType: 'TENANT', sessionId: '00000000-0000-4000-8000-000000000010', userId: '00000000-0000-4000-8000-000000000001', organizationId: '00000000-0000-4000-8000-000000000002', @@ -388,6 +389,51 @@ void test('sign-in returns a session DTO and maps authentication failures withou }); }); +void test('[IAM-026] platform sign-in returns identity-only scope with no tenant identifiers', async () => { + await withApp( + { + authentication: { + signIn: () => + Promise.resolve({ + accepted: true as const, + value: { + principal: { + scopeType: 'PLATFORM' as const, + userId: '00000000-0000-4000-8000-000000000001', + securityEpoch: 3, + mfaRequired: false, + mfaReenrollmentRequired: false, + }, + session: { + sessionId: '00000000-0000-4000-8000-000000000012', + accessToken: 'platform-access-token', + refreshToken: 'platform-refresh-token', + accessExpiresAt: '2026-01-01T00:15:00.000Z', + }, + }, + }), + }, + }, + async (app) => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-in', + payload: { + schemaVersion: 4, + email: 'operator@example.com', + password: 'correct horse battery staple', + clientPlatform: 'web', + }, + }); + assert.equal(response.statusCode, 200); + const body = response.json>(); + assert.equal(body['scopeType'], 'PLATFORM'); + assert.equal('organizationId' in body, false); + assert.equal('workspaceId' in body, false); + }, + ); +}); + void test('[IAM-023] refresh returns the identity-bound v4 session, rotates Web cookies, and preserves native delivery', async () => { const refreshed = { sessionId: '00000000-0000-4000-8000-000000000020', @@ -431,6 +477,7 @@ void test('[IAM-023] refresh returns the identity-bound v4 session, rotates Web assert.equal(web.statusCode, 200); assert.deepEqual(web.json(), { schemaVersion: 4, + scopeType: 'TENANT', sessionId: refreshed.sessionId, userId: principal.userId, organizationId: principal.organizationId, @@ -559,7 +606,26 @@ void test('sign-out revokes idempotently and clears browser credentials', async : signOutPrincipal, ), findPrincipalByAccessToken: (token) => - Promise.resolve(token === 'sign-out-access-token' ? signOutPrincipal : undefined), + Promise.resolve(typeof token === 'string' ? signOutPrincipal : undefined), + findSessionByAccessToken: (token) => + Promise.resolve( + token === 'sign-out-current-web-token' + ? { + sessionId: '00000000-0000-4000-8000-000000000010', + principal: signOutPrincipal, + } + : token === 'sign-out-current-native-token' + ? { + sessionId: '00000000-0000-4000-8000-000000000011', + principal: signOutPrincipal, + } + : token === 'sign-out-access-token' + ? { + sessionId: '00000000-0000-4000-8000-000000000010', + principal: signOutPrincipal, + } + : undefined, + ), }, }, async (app) => { @@ -570,7 +636,7 @@ void test('sign-out revokes idempotently and clears browser credentials', async cookie: `databreeze_refresh=current-refresh-token; databreeze_csrf=${csrfToken}`, 'x-csrf-token': csrfToken, origin: 'http://localhost:3000', - authorization: 'Bearer sign-out-access-token', + authorization: 'Bearer sign-out-current-web-token', 'idempotency-key': 'sign-out-web-001', }, payload: { @@ -592,7 +658,7 @@ void test('sign-out revokes idempotently and clears browser credentials', async method: 'POST', url: '/v1/auth/sign-out', headers: { - authorization: 'Bearer sign-out-access-token', + authorization: 'Bearer sign-out-current-native-token', 'idempotency-key': 'sign-out-native-001', }, payload: { @@ -632,6 +698,15 @@ void test('sign-out revokes idempotently and clears browser credentials', async findPrincipal: () => Promise.resolve(signOutPrincipal), findPrincipalByAccessToken: (token) => Promise.resolve(token === 'sign-out-access-token' ? signOutPrincipal : undefined), + findSessionByAccessToken: (token) => + Promise.resolve( + token === 'sign-out-access-token' + ? { + sessionId: '00000000-0000-4000-8000-000000000011', + principal: signOutPrincipal, + } + : undefined, + ), }, }, async (app) => { @@ -653,7 +728,7 @@ void test('sign-out revokes idempotently and clears browser credentials', async await withApp( { - requestTenantContext: { + requestAuthenticatedActor: { resolve: () => Promise.reject(new RequestTenantContextProblemError('AUTHENTICATION_FAILED')), }, diff --git a/services/api/test/platform/http/session-request-actor.test.ts b/services/api/test/platform/http/session-request-actor.test.ts new file mode 100644 index 00000000..610a2d2a --- /dev/null +++ b/services/api/test/platform/http/session-request-actor.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { SessionRequestActorAdapter } from '../../../src/platform/http/session-request-actor.adapter.js'; +import { RequestTenantContextProblemError } from '../../../src/platform/http/request-tenant-context.port.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; + +void test('[IAM-026] resolves a platform-only actor without manufacturing tenant scope', async () => { + const adapter = new SessionRequestActorAdapter({ + findSessionByAccessToken: async () => ({ + sessionId: '00000000-0000-4000-8000-000000000002', + principal: { + scopeType: 'PLATFORM', + userId, + securityEpoch: 3, + mfaRequired: false, + mfaReenrollmentRequired: false, + }, + }), + }); + + assert.deepEqual( + await adapter.resolve({ + method: 'GET', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }), + { + sessionId: '00000000-0000-4000-8000-000000000002', + actorId: userId, + scopeType: 'PLATFORM', + securityEpoch: 3, + mfaRequired: false, + mfaReenrollmentRequired: false, + }, + ); +}); + +void test('[IAM-005] rejects malformed persisted session identity at the actor boundary', async () => { + const adapter = new SessionRequestActorAdapter({ + findSessionByAccessToken: async () => ({ + sessionId: 'not-a-session-id', + principal: { + scopeType: 'PLATFORM', + userId, + securityEpoch: 3, + mfaRequired: false, + mfaReenrollmentRequired: false, + }, + }), + }); + + await assert.rejects( + adapter.resolve({ headers: { authorization: 'Bearer opaque-access-token-123456789' } }), + (error) => + error instanceof RequestTenantContextProblemError && error.code === 'AUTHENTICATION_FAILED', + ); +}); diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index 376a36a7..2d5ab95c 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -296,3 +296,37 @@ void test('[IAM-024] resolves the live workspace authorization epoch on every se const second = await adapter.resolve(request); assert.equal(second.workspaceAuthorizationEpoch, 12); }); + +void test('[IAM-026] platform-only sessions cannot cross the tenant request-context boundary', async () => { + let workspaceEpochReads = 0; + const adapter = new SessionRequestTenantContextAdapter( + { + findPrincipalByAccessToken: () => + Promise.resolve({ + scopeType: 'PLATFORM', + userId: principal.userId, + securityEpoch: principal.securityEpoch, + mfaRequired: false, + mfaReenrollmentRequired: false, + } as never), + }, + { + resolveWorkspaceAuthorizationEpoch: () => { + workspaceEpochReads += 1; + return Promise.resolve(1); + }, + }, + ); + + await assert.rejects( + adapter.resolve({ + method: 'GET', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }), + (error: unknown) => { + assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_FAILED'); + return true; + }, + ); + assert.equal(workspaceEpochReads, 0); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 3b28c904..ae9c274c 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -205,6 +205,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260818020000_iam_profile_mutations', '20260818030000_dda_notification_preferences', '20260819010000_jra_execution_workload_envelopes', + '20260821010000_iam_platform_only_sessions', 'migration_lock.toml', ]); const migration = await readFile( diff --git a/services/api/test/seed-local.test.mjs b/services/api/test/seed-local.test.mjs index 5ca7d45d..04d17ae3 100644 --- a/services/api/test/seed-local.test.mjs +++ b/services/api/test/seed-local.test.mjs @@ -18,6 +18,10 @@ import { LOCAL_FEEDBACK_EMAILS } from '../scripts/local-feedback-seed.mjs'; const apiDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const seedScriptPath = path.join(apiDirectory, 'scripts', 'seed-local.mjs'); const pilotSeedModulePath = new URL('../scripts/seed-pilot.mjs', import.meta.url).href; +const pilotRotationModulePath = new URL( + '../scripts/rotate-pilot-operator-password.mjs', + import.meta.url, +).href; async function loadPilotSeedModule() { const module = await import(pilotSeedModulePath).catch(() => undefined); @@ -25,6 +29,12 @@ async function loadPilotSeedModule() { return module; } +async function loadPilotRotationModule() { + const module = await import(pilotRotationModulePath).catch(() => undefined); + assert.ok(module, 'pilot operator rotation module must exist'); + return module; +} + const expectedFeedbackIdentities = Object.freeze([ ['Lê Thanh Hải', 'lethanhhai177@gmail.com'], ['Duy Đỗ', 'doychanne11802@gmail.com'], @@ -472,6 +482,58 @@ test('[IAM-026] pilot operator replay preserves an existing credential and activ assert.equal(assignments.get(first.userId).revokedAt, null); }); +test('[IAM-005][IAM-026] explicit operator rotation replaces the credential and revokes sessions', async () => { + const { rotatePilotOperatorPassword } = await loadPilotRotationModule(); + const sessions = [ + { id: 'session-1', familyId: 'family-1', status: 'ACTIVE', revokedAt: null }, + { id: 'session-2', familyId: 'family-2', status: 'REVOKED', revokedAt: new Date(0) }, + ]; + const credential = { userId: 'user-1', encodedHash: 'old-hash', rotatedAt: null }; + const user = { id: 'user-1', email: 'owner@example.com', status: 'ACTIVE', securityEpoch: 4 }; + const transaction = { + userIdentity: { + findUnique: async () => user, + update: async ({ data }) => { + user.securityEpoch += data.securityEpoch.increment; + return user; + }, + }, + platformOperatorRecord: { + findUnique: async () => ({ userId: user.id, role: 'PLATFORM_OWNER', status: 'ACTIVE' }), + }, + passwordCredential: { + findUnique: async () => credential, + update: async ({ data }) => Object.assign(credential, data), + }, + sessionRecord: { + findMany: async () => sessions.filter((row) => row.status === 'ACTIVE'), + updateMany: async ({ data }) => { + for (const row of sessions) { + if (row.status === 'ACTIVE') Object.assign(row, data); + } + return { count: 1 }; + }, + }, + refreshTokenRecord: { updateMany: async () => ({ count: 1 }) }, + accessTokenRecord: { updateMany: async () => ({ count: 1 }) }, + }; + const database = { $transaction: async (operation) => operation(transaction) }; + + const result = await rotatePilotOperatorPassword( + database, + { operatorEmail: user.email, operatorPassword: 'same-protected-password' }, + { + hashPassword: async () => 'new-argon2id-hash', + now: () => new Date('2026-08-21T00:00:00.000Z'), + }, + ); + + assert.equal(credential.encodedHash, 'new-argon2id-hash'); + assert.equal(user.securityEpoch, 5); + assert.equal(sessions[0].status, 'REVOKED'); + assert.deepEqual(result, { userId: user.id, securityEpoch: 5, revokedSessions: 1 }); +}); + test('[DDA-055] seeded conversation binds each dataset to its active version', () => { const { conversationRows } = buildConversationAndNotifications();