Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -152,6 +153,9 @@ class AuthenticatedIamApiClient(
previous: ProtectedAuthenticatedApiSession? = null,
): IamApiResult<ProtectedAuthenticatedApiSession> = 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")
}
Expand Down
20 changes: 19 additions & 1 deletion apps/web/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type WebAccessContext,
} from './navigation.ts';
import {
currentAuthSessionScopeV1,
currentWebAuthenticationStateV1,
initializeWebAuthenticationStateV1,
subscribeWebAuthenticationStateV1,
Expand Down Expand Up @@ -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 <Navigate replace to={`/${locale}/data`} />;
return (
<Navigate
replace
to={`/${locale}/${sessionScope === 'PLATFORM' ? 'platform-admin' : 'data'}`}
/>
);
if (
!publicRoute &&
authenticationState === 'signed-in' &&
sessionScope === 'PLATFORM' &&
!location.pathname.startsWith(`/${locale}/platform-admin`)
)
return <Navigate replace to={`/${locale}/platform-admin`} />;
if (!publicRoute && authenticationState === 'signed-out')
return (
<Navigate
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/features/auth/auth-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { AuthApiV1 } from './auth-api.ts';
import { createSignInRedirect } from './auth-redirect.ts';
import {
clearAuthSessionV1,
confirmPlatformAuthSessionV1,
currentAuthSessionScopeV1,
rememberAuthBootstrapV1,
type WebAuthenticationStateV1,
} from './auth-session.ts';
Expand All @@ -23,6 +25,7 @@ export interface RecoverSessionBeforeAppStartInputV1 {
readonly search?: string;
readonly hash?: string;
readonly replace: (pathname: string) => void;
readonly confirmPlatformAccess?: () => Promise<boolean>;
}

export interface StartWebApplicationInputV1 extends RecoverSessionBeforeAppStartInputV1 {
Expand All @@ -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';
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/features/auth/auth-route-pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,11 +31,20 @@ async function establishProductSession(
session: Parameters<typeof rememberAuthSessionV1>[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 };
Expand All @@ -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,
},
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/features/auth/auth-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 ||
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions apps/web/test/auth-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
35 changes: 35 additions & 0 deletions apps/web/test/auth-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions apps/web/test/auth-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/web/test/tenant-live-configuration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/web/test/workspace-auth-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions docs/plans/410-platform-owner-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/plans/426-lightsail-seeded-pilot-activation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading