From 8242481fff2ff8d343e2692cc6de737bcdc8f9a4 Mon Sep 17 00:00:00 2001 From: "hyeonho.jung" Date: Sat, 31 Oct 2026 12:46:12 +0900 Subject: [PATCH 1/3] fix: recover employee organization enrollment for existing sessions --- docs/platformclaw/employee-auth.md | 43 + .../platformclaw/organization-architecture.md | 100 +- .../tsconfig.json | 13 + .../src/browser-announcements-http.test.ts | 9 +- .../src/browser-auth-runtime.test.ts | 6 +- .../src/browser-auth-runtime.ts | 31 +- .../src/browser-auth-service.test.ts | 2 + .../src/browser-auth-service.ts | 20 +- .../src/browser-organization-http.ts | 12 +- .../src/directory-organization-access.test.ts | 154 +++- .../directory-organization-backfill.test.ts | 859 ++++++++++++++++++ .../src/directory-organization-http.test.ts | 90 ++ .../src/directory-organization.test.ts | 283 +++++- .../src/directory-organization.ts | 2 +- .../src/employee-directory-snapshot.test.ts | 361 ++++++++ .../src/employee-directory.ts | 34 + .../src/employee-organization-backfill.ts | 195 ++++ .../src/employee-sso.test.ts | 2 + .../src/knowledge-vault-access.ts | 5 + .../src/memory-store.ts | 3 + .../src/organization-policy.ts | 53 +- .../src/sqlite-fishing.test.ts | 5 +- ...ite-schema-directory-organizations.test.ts | 129 ++- .../src/sqlite-schema-employee-directory.ts | 29 + .../src/sqlite-schema-managed-scopes.ts | 91 ++ .../src/sqlite-schema.test.ts | 4 +- .../src/sqlite-schema.ts | 155 ++-- .../src/sqlite-store-auth.ts | 4 +- .../src/sqlite-store-core.ts | 12 +- .../sqlite-store-directory-organization.ts | 101 +- .../src/sqlite-store-employee-directory.ts | 250 +++++ .../src/sqlite-store-exec-credentials.test.ts | 5 +- .../src/sqlite-store-organization-access.ts | 9 +- .../sqlite-store-organization-join-read.ts | 15 +- .../src/sqlite-store-organization.ts | 1 + .../src/sqlite-store-types.ts | 27 + .../src/web-ingress-runtime.ts | 23 +- .../platformclaw/control-ui-adapter.test.ts | 93 ++ ui/src/platformclaw/control-ui-adapter.ts | 11 +- ui/src/platformclaw/locales/en-guide.ts | 24 + ui/src/platformclaw/locales/ko.ts | 23 + ui/src/platformclaw/organization-api.ts | 5 + .../organization-overview-view.ts | 32 +- ui/src/platformclaw/organization-page.test.ts | 202 +++- ui/tsconfig.json | 15 + 45 files changed, 3328 insertions(+), 214 deletions(-) create mode 100644 packages/platformclaw-coding-agent-contract/tsconfig.json create mode 100644 packages/platformclaw-control-plane/src/directory-organization-backfill.test.ts create mode 100644 packages/platformclaw-control-plane/src/employee-directory-snapshot.test.ts create mode 100644 packages/platformclaw-control-plane/src/employee-directory.ts create mode 100644 packages/platformclaw-control-plane/src/employee-organization-backfill.ts create mode 100644 packages/platformclaw-control-plane/src/sqlite-schema-employee-directory.ts create mode 100644 packages/platformclaw-control-plane/src/sqlite-schema-managed-scopes.ts create mode 100644 packages/platformclaw-control-plane/src/sqlite-store-employee-directory.ts create mode 100644 ui/tsconfig.json diff --git a/docs/platformclaw/employee-auth.md b/docs/platformclaw/employee-auth.md index e20e33614e2..042f11e85d8 100644 --- a/docs/platformclaw/employee-auth.md +++ b/docs/platformclaw/employee-auth.md @@ -84,6 +84,49 @@ for contract compatibility but never establish authority in PlatformClaw 2.0. If ADSSO deployment configuration is absent, the button returns to the login page with an explicit unavailable message instead of failing silently. +## Organization facts and existing sessions + +After a browser session succeeds, LDAP and verified SSO record the current +organization decision against the canonical user and authentication time. +Eligible version-1 AICM claims save only their bounded organization fields and +source timestamp in SQLite. Passwords, bearer tokens, signed handoffs, and +unrelated directory metadata are excluded. The source fact commits before the +organization mutation, so a failed mutation can be retried without losing its +authenticated input. + +Startup scans existing authenticated users in stable pages of 100, preparing +four local recovery tasks at a time. It does not delay every login until the +scan finishes. The first Organization context request awaits that user's task +and returns current membership plus a `directorySync` outcome. Repeated context +requests reuse the recorded outcome; they do not poll another service. Shutdown +stops recovery before closing SQLite. + +A saved eligible fact can therefore repair organization membership while the +same browser cookie remains signed in. A newer test-account, mapping, null, +missing, malformed, or unsupported-version authentication decision prevents +replay of an older eligible fact. Those cases retain the original no-change +contract. New authentication or a restart retries failed recovery. + +Recovery requires an eligible decision matching the user's current `lastLoginAt` +authentication stamp. This stamp increases on every principal upsert, including +equal-clock logins and linked providers; it is not an exact wall-clock time. +Session creation, expiration, and organization source timestamps keep their +existing clock semantics. A missing current decision never authorizes replay +of an older fact, including while provisioning is pending or a decision write +has failed. + +Cookies issued before organization facts were stored contain no organization +claim. When no trusted fact exists, the user remains signed in and Organization +reports `requires-authenticated-org` without creating membership. A subsequent +successful authentication carrying a valid claim supplies the missing fact; +when ADSSO is configured, Organization offers an explicit company sign-in action +that returns to Organization settings. Company SSO may prompt for authentication; +the app never starts this round-trip automatically. +Administrators can also use the existing manual membership flow. Knox employee +directory results do not establish PlatformClaw organization authority. This +runtime has no verified legacy-user organization lookup API and does not infer +one from the login proxy URL, department labels, or legacy Part strings. + ## Profile provisioning Successful authentication produces two different objects: diff --git a/docs/platformclaw/organization-architecture.md b/docs/platformclaw/organization-architecture.md index 5f2c2ac70d3..c773fe58eac 100644 --- a/docs/platformclaw/organization-architecture.md +++ b/docs/platformclaw/organization-architecture.md @@ -20,7 +20,7 @@ Global └── User ``` -This page is the canonical contract. Schema v3, Team scopes, join requests, +This page is the canonical contract. Schema v4, Team scopes, join requests, upward access inheritance, and the shared organization and authorization services are implemented. The authenticated browser API described below is also implemented. Skill Hub and Memory now consume this shared authorization @@ -39,7 +39,8 @@ role, not a Global membership. - every Team belongs directly to Global; - every Group has exactly one parent Team; -- every Part has exactly one parent Group; and +- every Part has exactly one parent Group, except a trusted directory Part + whose parent information is not yet available; and - a user may be a direct member of any Team, Group, or Part without being a direct member of a lower scope. @@ -69,13 +70,25 @@ Different external keys may have equal display names; Settings shows their full path and external key. Existing manually created scopes are not adopted by matching their names, and manual-only name uniqueness remains enforced. -The hierarchy stays Team > Group > Part. Known existing parents complete -partial claims. A missing Group preserves the existing Part parent. If a new -scope's required parent cannot be resolved, the entire synchronization is -deferred without creating partial scopes or changing membership. A later -successful sign-in can complete it. Source timestamps are checked per user -and per shared scope; older or conflicting same-timestamp changes do not -overwrite newer names, parentage or assignments. +The complete hierarchy stays Team > Group > Part. A missing Group preserves +the existing Part parent. A new trusted Part is created and joined even when +its parents are unknown; no placeholder Team or Group is invented. A supplied +Group without a known Team waits for its real Team while the Part remains +parentless. A newer explicit move to such a Group detaches the Part from its +old lineage immediately. The Part records the desired external parent key, so +another user's cached claim cannot replace that known pending assignment. +An existing Group retains its Team when the Team claim is unknown. + +Source timestamps are checked per user and shared scope. Older or conflicting +same-timestamp changes do not overwrite newer names, parentage or assignments. +At the same source timestamp, an unchanged scope name permits refinement of +the parent that was unknown at that revision, including a Group's unknown +Team. An older physical parent may remain in place until that refinement; +retaining it does not turn an unknown claim into a newly confirmed parent. +A pending Part's recorded desired Group may also be physically attached. +This does not permit same-timestamp renames or replacement of an explicit +parent at that revision. A +parentless Part grants no ancestor access until a real parent is attached. Only a direct Part membership is added. Group and Team access uses the existing ancestor policy. Moving employees removes only their ldap-login membership @@ -93,17 +106,37 @@ automatically restored. Directory parent changes update effective ancestor and leader authority for every affected member; consumer authorization continues to use current scope IDs and lineage. -Organization changes, membership movement and successful-sync timestamps commit -atomically after browser-session success. Failures roll back organization -changes, log a redacted diagnostic, and leave sign-in successful. Retry occurs -on the next successful sign-in; there is no background queue or bulk backfill. - -This feature retains control schema v3. Nullable identity/provenance columns, -a user sync-state table and manual-only name indexes preserve existing IDs, -roles and hierarchy for older readers. Older applications do not enforce the -new automatic-management protections or preserve directory provenance when -performing writes; downgrading loses those feature guarantees. No legacy state -files or parallel organization authority are introduced. +Successful authentication records its organization eligibility decision and +validated organization facts in SQLite before attempting membership changes. +Organization changes, membership movement and successful-sync timestamps +commit atomically. A synchronization failure rolls back organization changes, +logs a redacted diagnostic, and leaves sign-in successful; the verified source +fact remains available for recovery. + +Startup scans existing active enterprise users in bounded pages and replays +their saved verified facts. The authenticated Organization context also +prepares the current user's organization before projecting membership. +Recovery rechecks the latest authentication revision inside the write +transaction. A newer test-account, unknown, mapping or unsupported claim is a +recorded no-change decision and cannot be bypassed by an older saved snapshot. +No reauthentication is needed when usable verified facts were saved. Legacy +sessions without those facts receive an explicit `requires-authenticated-org` +status and remain unchanged; there is no authoritative external organization +lookup integration. Legacy department strings and Knox transport data cannot +substitute for authenticated `org` claims. Settings explains pending, failed +and unavailable-fact outcomes while preserving the manual membership-request +route. + +When company SSO is configured, the unavailable-fact status offers the existing +company sign-in route with a return to Organization settings. It never starts +navigation automatically and may require company authentication. Without +configured SSO, the status directs the user to an administrator instead of +inventing a recovery endpoint. + +The approved control schema v4 permits only externally identified +ldap-login Parts to have no parent. Manual Parts and all Groups still require +their normal parents. See the migration contract below. No legacy state files +or parallel organization authority are introduced. ### Direct and effective membership @@ -185,6 +218,10 @@ is available under **Settings > Organization**. Skipping or dismissing it does not block the application, and the dismissal is scoped to the current browser session and actor. +Automatic membership recovery must first produce a usable outcome. Pending, +failed and unavailable verified-fact states suppress the automatic join +prompt; the manual Settings request route remains available. + A user may: - search the active organization tree; @@ -337,7 +374,26 @@ bounded reason where required. Audit and notification payloads exclude personal memory content, package content, credentials, tokens, internal URLs, and host paths. -## Implemented schema v3 migration +## Implemented schema v4 migration + +The explicitly approved v3-to-v4 migration rebuilds `managed_scopes` to allow +only an externally identified ldap-login Part to have a null parent. It adds +the canonical desired external parent key and initializes it from each +existing managed scope's real parent. Scope IDs, source timestamps, membership +provenance, manual roles, primary scopes and dependent foreign keys are +preserved. Additive authentication snapshots, eligibility decisions and +recovery outcomes stay in the same control database. + +Before replacing the table, the migration checkpoints the WAL and verifies a +SQLite-consistent `.pre-v4-*.sqlite` backup. The synchronous migration +transaction rebuilds the table, indexes and parent-kind triggers, checks all +foreign keys and database integrity, and stamps version 4 only after validation +succeeds. Existing lazy feature tables remain intact. A failure rolls back to +the authoritative v3 state and can be retried. After a successful commit, +rollback requires restoring the verified pre-v4 database together with the +matching v3 application; older applications must not open the v4 database. + +### Historical schema v3 migration The organization model uses the approved SQLite schema version change from v2 to v3. This is not an additive lazy-table change: schema v2 constrains @@ -390,7 +446,7 @@ The later Wiki Hub startup cleanup intentionally deletes that retired knowledge corpus; the following migration history does not preserve it in current runtime. Existing pending Group-to-Global requests remain visible and may receive one administrator decision under their immutable original contract; new ordinary-user requests -after cutover follow Group-to-Team-to-Global. Runtime uses only the v3 shape +after cutover follow Group-to-Team-to-Global. That migration uses only the v3 shape after migration: there is no dual write, fallback reader, or silent compatibility alias. Rollback requires restoring the pre-migration application and database backup together. diff --git a/packages/platformclaw-coding-agent-contract/tsconfig.json b/packages/platformclaw-coding-agent-contract/tsconfig.json new file mode 100644 index 00000000000..0416e997957 --- /dev/null +++ b/packages/platformclaw-coding-agent-contract/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "../.." + }, + "include": [ + "**/*", + "../../src/**/*.d.ts", + "../../ui/**/*.d.ts", + "../../packages/**/*.d.ts", + "../../extensions/**/*.d.ts" + ] +} diff --git a/packages/platformclaw-control-plane/src/browser-announcements-http.test.ts b/packages/platformclaw-control-plane/src/browser-announcements-http.test.ts index 4a5a25feeb7..6ac3a0aad8c 100644 --- a/packages/platformclaw-control-plane/src/browser-announcements-http.test.ts +++ b/packages/platformclaw-control-plane/src/browser-announcements-http.test.ts @@ -19,6 +19,7 @@ import { BrowserAnnouncementService } from "./browser-announcements-http.js"; import { BrowserAuthService } from "./browser-auth-service.js"; import { BrowserGatewayProxy } from "./browser-gateway-proxy.js"; import { ControlPlaneAuthorizationError } from "./contracts.js"; +import { PLATFORMCLAW_CONTROL_SCHEMA_VERSION } from "./sqlite-schema.js"; import { SqliteControlPlaneStore } from "./sqlite-store.js"; import { PlatformClawWebIngressServer } from "./web-ingress-server.js"; import { createFrameQueue, FakeGateway, isRecord } from "./web-ingress-test-harness.js"; @@ -296,7 +297,9 @@ describe("web announcements through the authenticated BFF", () => { base = (await start(store, auth)).base; expect(await history()).toEqual(page); const db = new DatabaseSync(databasePath); - expect(db.prepare("PRAGMA user_version").get()?.user_version).toBe(3); + expect(db.prepare("PRAGMA user_version").get()?.user_version).toBe( + PLATFORMCLAW_CONTROL_SCHEMA_VERSION, + ); db.close(); }); @@ -634,7 +637,9 @@ describe("web announcements through the authenticated BFF", () => { .toSorted(), ).toEqual(["announcement.cancel", "announcement.publish", "announcement.update"]); const db = new DatabaseSync(databasePath); - expect(db.prepare("PRAGMA user_version").get()?.user_version).toBe(3); + expect(db.prepare("PRAGMA user_version").get()?.user_version).toBe( + PLATFORMCLAW_CONTROL_SCHEMA_VERSION, + ); db.close(); }); diff --git a/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts b/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts index ce6e9e4bd27..f71005b6c73 100644 --- a/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts +++ b/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts @@ -3,11 +3,11 @@ import { createEmployeeBrowserAuthRuntime } from "./browser-auth-runtime.js"; import { EMPLOYEE_AUTH_LOGIN_URL_ENV } from "./employee-auth-client.js"; describe("createEmployeeBrowserAuthRuntime", () => { - const runtimes: Array<{ close(): void }> = []; + const runtimes: Array<{ close(): Promise }> = []; - afterEach(() => { + afterEach(async () => { for (const runtime of runtimes.splice(0)) { - runtime.close(); + await runtime.close(); } }); diff --git a/packages/platformclaw-control-plane/src/browser-auth-runtime.ts b/packages/platformclaw-control-plane/src/browser-auth-runtime.ts index 20568d7ee62..5ea13458465 100644 --- a/packages/platformclaw-control-plane/src/browser-auth-runtime.ts +++ b/packages/platformclaw-control-plane/src/browser-auth-runtime.ts @@ -1,10 +1,15 @@ import { BrowserAuthService, type PersonalAgentProvisioner } from "./browser-auth-service.js"; -import type { MainSessionKeyBuilder } from "./contracts.js"; +import type { MainSessionKeyBuilder, PlatformUser } from "./contracts.js"; import { HttpEmployeeAuthenticator, loadEmployeeAuthClientConfig, type EmployeeAuthClientConfig, } from "./employee-auth-client.js"; +import type { EmployeeDirectoryBackfillOutcome } from "./employee-directory.js"; +import { + EmployeeOrganizationBackfill, + type EmployeeOrganizationBackfillSummary, +} from "./employee-organization-backfill.js"; import { ExecCredentialCipher } from "./exec-credential-crypto.js"; import { McpCredentialCipher } from "./mcp-credential-crypto.js"; import { McpCredentialVault } from "./mcp-credential-vault.js"; @@ -35,7 +40,9 @@ export type EmployeeBrowserAuthRuntime = { credentialVault?: SshCredentialVault; mcpCredentialVault?: McpCredentialVault; execCredentialCipher?: ExecCredentialCipher; - close(): void; + prepareOrganizationBackfill(): Promise; + prepareUserOrganization(user: PlatformUser): Promise; + close(): Promise; }; /** Build the production LDAP-phase browser-auth runtime around the persistent store. */ @@ -59,7 +66,15 @@ export function createEmployeeBrowserAuthRuntime( store, authenticator, provisioner: options.provisioner, - syncOrganization: (params) => store.syncDirectoryOrganization(params), + syncOrganization: async (params) => { + const snapshot = await store.recordEmployeeDirectoryAuthentication({ + ...params, + observedAt: params.syncedAt, + }); + if (snapshot) { + await store.syncDirectoryOrganization({ ...params, ...snapshot }); + } + }, ...(options.now ? { now: options.now } : {}), ...(options.tokenFactory ? { tokenFactory: options.tokenFactory } : {}), ...(options.onLogoutAgent ? { onLogoutAgent: options.onLogoutAgent } : {}), @@ -71,17 +86,25 @@ export function createEmployeeBrowserAuthRuntime( ? new McpCredentialVault(store, options.mcpCredentialCipher) : undefined; let closed = false; + const backfill = new EmployeeOrganizationBackfill({ store, now: options.now }); return { store, service, ...(credentialVault ? { credentialVault } : {}), ...(mcpCredentialVault ? { mcpCredentialVault } : {}), ...(options.execCredentialCipher ? { execCredentialCipher: options.execCredentialCipher } : {}), - close() { + prepareOrganizationBackfill() { + return backfill.start(); + }, + prepareUserOrganization(user) { + return backfill.prepareUser(user); + }, + async close() { if (closed) { return; } closed = true; + await backfill.stop(); store.close(); }, }; diff --git a/packages/platformclaw-control-plane/src/browser-auth-service.test.ts b/packages/platformclaw-control-plane/src/browser-auth-service.test.ts index 94a600c5d64..6a970aba5b5 100644 --- a/packages/platformclaw-control-plane/src/browser-auth-service.test.ts +++ b/packages/platformclaw-control-plane/src/browser-auth-service.test.ts @@ -155,6 +155,7 @@ describe("BrowserAuthService", () => { org: undefined, isTestAccount: false, syncedAt: 2_000, + expectedLastLoginAt: 2_000, }); }); @@ -180,6 +181,7 @@ describe("BrowserAuthService", () => { org, isTestAccount: false, syncedAt: 1_000, + expectedLastLoginAt: 1_000, }); }); diff --git a/packages/platformclaw-control-plane/src/browser-auth-service.ts b/packages/platformclaw-control-plane/src/browser-auth-service.ts index 1c055600506..fc40d86ebb6 100644 --- a/packages/platformclaw-control-plane/src/browser-auth-service.ts +++ b/packages/platformclaw-control-plane/src/browser-auth-service.ts @@ -80,6 +80,7 @@ export class BrowserAuthService { org: unknown; isTestAccount: boolean; syncedAt: number; + expectedLastLoginAt: number; }) => Promise; }, ) {} @@ -155,7 +156,12 @@ export class BrowserAuthService { currentSession?.status === "active" && currentSession.user.id === upserted.user.id ) { - await this.syncDirectoryOrganization(auth, upserted.user.id, sessionAt); + await this.syncDirectoryOrganization( + auth, + upserted.user.id, + sessionAt, + upserted.user.lastLoginAt, + ); return { status: "authenticated", token: currentSessionToken, @@ -182,7 +188,12 @@ export class BrowserAuthService { if (currentSession?.status === "active") { await this.options.store.revokeBrowserSession(currentSession.session.id, sessionAt); } - await this.syncDirectoryOrganization(auth, upserted.user.id, sessionAt); + await this.syncDirectoryOrganization( + auth, + upserted.user.id, + sessionAt, + upserted.user.lastLoginAt, + ); return { status: "authenticated", token, @@ -198,14 +209,19 @@ export class BrowserAuthService { auth: EmployeeAuthenticationSuccess, userId: string, syncedAt: number, + expectedLastLoginAt: number | undefined, ): Promise { try { + if (expectedLastLoginAt === undefined) { + throw new Error("principal store did not return an authentication stamp"); + } // Sessions must succeed first; directory failures leave login and old access intact. await this.options.syncOrganization?.({ userId, org: auth.org, isTestAccount: auth.isTestAccount === true, syncedAt, + expectedLastLoginAt, }); } catch { console.warn("directory_organization_sync_failed", { userId }); diff --git a/packages/platformclaw-control-plane/src/browser-organization-http.ts b/packages/platformclaw-control-plane/src/browser-organization-http.ts index b13b7341717..8fc9c506743 100644 --- a/packages/platformclaw-control-plane/src/browser-organization-http.ts +++ b/packages/platformclaw-control-plane/src/browser-organization-http.ts @@ -16,6 +16,10 @@ import { type ManagedScopeKind, type ManagedScopeRole, } from "./contracts.js"; +import { + projectEmployeeDirectoryStatus, + type EmployeeDirectoryBackfillOutcome, +} from "./employee-directory.js"; import { OrganizationService } from "./organization-service.js"; export const PLATFORMCLAW_ORGANIZATION_PATH = "/platformclaw/api/organization"; @@ -196,6 +200,8 @@ export class BrowserOrganizationService { private readonly options: { authService: BrowserAuthService; organization: OrganizationService; + prepareDirectoryOrganization?: (userId: string) => Promise; + ssoRefreshUrl?: string; now?: () => number; }, ) {} @@ -206,6 +212,7 @@ export class BrowserOrganizationService { } async context(actor: { id: string; globalRole: "member" | "admin"; displayName?: string }) { + const directoryOutcome = await this.options.prepareDirectoryOrganization?.(actor.id); const snapshot = await this.options.organization.getContext(actor.id, 20); return { actor: { @@ -242,8 +249,11 @@ export class BrowserOrganizationService { canReviewJoinRequests: snapshot.canReviewJoinRequests, canManageOrganization: snapshot.canManageOrganization, canViewOrganizationAudit: snapshot.canViewOrganizationAudit, - joinPromptEligible: + ...projectEmployeeDirectoryStatus( + directoryOutcome, actor.globalRole !== "admin" && snapshot.isUnaffiliated && !snapshot.hasPendingJoinRequest, + this.options.ssoRefreshUrl, + ), }; } diff --git a/packages/platformclaw-control-plane/src/directory-organization-access.test.ts b/packages/platformclaw-control-plane/src/directory-organization-access.test.ts index 336a04f8c3e..dc3f564dddc 100644 --- a/packages/platformclaw-control-plane/src/directory-organization-access.test.ts +++ b/packages/platformclaw-control-plane/src/directory-organization-access.test.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { afterEach, describe, expect, it } from "vitest"; import { SqliteControlPlaneStore } from "./sqlite-store.js"; @@ -32,8 +33,8 @@ function synchronize( userId: string, overrides: { part?: { id: string; name: string }; - group?: { id: string; name: string }; - team?: string; + group?: { id: string; name: string } | null; + team?: string | null; sourceOffset?: number; } = {}, ) { @@ -43,8 +44,8 @@ function synchronize( version: 1, source: "aicm", part: overrides.part ?? { id: "runtime", name: "Runtime" }, - group: overrides.group ?? { id: "platform", name: "Platform" }, - team: overrides.team ?? "Engineering", + group: overrides.group === undefined ? { id: "platform", name: "Platform" } : overrides.group, + team: overrides.team === undefined ? "Engineering" : overrides.team, updatedAt: new Date(sourceAt + (overrides.sourceOffset ?? 0)).toISOString(), }, isTestAccount: false, @@ -61,8 +62,9 @@ async function externalScope(store: SqliteControlPlaneStore, key: string) { async function fixture() { const directory = realpathSync(mkdtempSync(join(tmpdir(), "platformclaw-directory-access-"))); directories.push(directory); + const databasePath = join(directory, "control.sqlite"); const store = new SqliteControlPlaneStore({ - databasePath: join(directory, "control.sqlite"), + databasePath, initialAdminAccountIds: ["admin"], buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, }); @@ -73,10 +75,150 @@ async function fixture() { const part = await externalScope(store, "aicm-part:runtime"); const group = await externalScope(store, "group:platform"); const team = await externalScope(store, "team:Engineering"); - return { store, admin, member, part, group, team }; + return { store, databasePath, admin, member, part, group, team }; } describe("directory synchronization access lifecycle", () => { + it("keeps a parentless Part usable for direct sharing and review before attaching its real ancestors", async () => { + const { store, admin, member, part: siblingPart, group, team } = await fixture(); + const orphan = await createUser(store, "orphan"); + const applicant = await createUser(store, "applicant"); + await synchronize(store, orphan.id, { + part: { id: "unmapped", name: "Unmapped" }, + group: null, + team: null, + }); + const part = await externalScope(store, "aicm-part:unmapped"); + await store.setManagedScopeMembership({ + actorUserId: admin.id, + userId: orphan.id, + scopeId: part.id, + role: "leader", + reason: "Part leadership remains local until its parent is known", + changedAt: sourceAt + 101_000, + }); + await store.submitOrganizationJoinRequest({ + userId: applicant.id, + scopeId: part.id, + requestId: "join-unmapped", + reason: "Work with this Part", + submittedAt: sourceAt + 102_000, + }); + expect( + await store.listReviewableOrganizationJoinRequests({ actorUserId: orphan.id }), + ).toMatchObject([{ id: "join-unmapped" }]); + expect( + await store.listReviewableOrganizationJoinRequests({ actorUserId: admin.id }), + ).toMatchObject([{ id: "join-unmapped" }]); + expect(await store.listReviewableOrganizationJoinRequests({ actorUserId: member.id })).toEqual( + [], + ); + const vault = store.vaults.createVault({ userId: admin.id, name: "Unmapped Part Wiki" }); + store.vaults.setOrganizationGrant({ + userId: admin.id, + vaultId: vault.id, + scopeId: part.id, + role: "reader", + }); + expect(store.vaults.snapshot({ userId: orphan.id, vaultId: vault.id }).selected).toBeDefined(); + expect(() => store.vaults.snapshot({ userId: member.id, vaultId: vault.id })).toThrow( + "unavailable", + ); + const operationId = randomUUID(); + const publication = await store.skillHubCatalog.reservePublication({ + operationId, + actorUserId: admin.id, + slug: "unmapped-part-skill", + version: "1.0.0", + fingerprint: operationId, + sharing: { mode: "SELECTED", userIds: [], scopeIds: [part.id] }, + now: sourceAt + 103_000, + }); + const skill = await store.skillHubCatalog.completePublication({ + operationId: publication.receipt.operationId, + registryId: 101, + now: sourceAt + 104_000, + }); + expect(await store.skillHubCatalog.canAccess({ skillId: skill.id, userId: orphan.id })).toBe( + true, + ); + expect(await store.skillHubCatalog.canAccess({ skillId: skill.id, userId: member.id })).toBe( + false, + ); + expect( + (await store.listEffectiveManagedScopeAccess(orphan.id)).map((entry) => entry.scope.id), + ).toEqual([part.id]); + + await synchronize(store, orphan.id, { + part: { id: "unmapped", name: "Mapped" }, + sourceOffset: 1_000, + }); + + expect(await externalScope(store, "aicm-part:unmapped")).toMatchObject({ + id: part.id, + parentScopeId: group.id, + }); + expect( + (await store.listEffectiveManagedScopeAccess(orphan.id)) + .map((entry) => entry.scope.id) + .toSorted(), + ).toEqual([part.id, group.id, team.id].toSorted()); + expect((await store.resolveManagedScopeAuthorization(orphan.id, siblingPart.id)).canRead).toBe( + false, + ); + expect((await store.resolveManagedScopeAuthorization(member.id, part.id)).canRead).toBe(false); + expect(store.vaults.snapshot({ userId: orphan.id, vaultId: vault.id }).selected).toBeDefined(); + expect(await store.skillHubCatalog.canAccess({ skillId: skill.id, userId: member.id })).toBe( + false, + ); + }); + + it("never revives an archived parentless Part or grants its direct shared content", async () => { + const { store, databasePath, admin } = await fixture(); + const orphan = await createUser(store, "orphan"); + await synchronize(store, orphan.id, { + part: { id: "unmapped", name: "Unmapped" }, + group: null, + team: null, + }); + const part = await externalScope(store, "aicm-part:unmapped"); + const vault = store.vaults.createVault({ userId: admin.id, name: "Archived Part Wiki" }); + store.vaults.setOrganizationGrant({ + userId: admin.id, + vaultId: vault.id, + scopeId: part.id, + role: "reader", + }); + const database = new DatabaseSync(databasePath); + try { + database.prepare("UPDATE managed_scopes SET status = 'archived' WHERE id = ?").run(part.id); + } finally { + database.close(); + } + + await expect( + synchronize(store, orphan.id, { + part: { id: "unmapped", name: "Unmapped" }, + group: null, + team: null, + sourceOffset: 1_000, + }), + ).resolves.toEqual({ status: "skipped", reason: "inactive-organization" }); + expect((await store.resolveManagedScopeAuthorization(orphan.id, part.id)).canRead).toBe(false); + expect(await store.listEffectiveManagedScopeAccess(orphan.id)).toEqual([]); + expect( + await store.searchOrganizationScopesForUser({ + userId: orphan.id, + query: "Unmapped", + limit: 10, + }), + ).toEqual([]); + expect(() => store.vaults.snapshot({ userId: orphan.id, vaultId: vault.id })).toThrow( + "unavailable", + ); + expect(await store.getManagedScope(part.id)).toMatchObject({ status: "archived" }); + }); + it.each([ { manual: false, connected: true }, { manual: true, connected: true }, diff --git a/packages/platformclaw-control-plane/src/directory-organization-backfill.test.ts b/packages/platformclaw-control-plane/src/directory-organization-backfill.test.ts new file mode 100644 index 00000000000..7defd060566 --- /dev/null +++ b/packages/platformclaw-control-plane/src/directory-organization-backfill.test.ts @@ -0,0 +1,859 @@ +import { createHmac } from "node:crypto"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it, vi } from "vitest"; +import { hashBrowserSessionToken } from "./browser-auth-service.js"; +import type { GatewayAdminRpc } from "./gateway-admin-rpc-client.js"; +import { GatewayPersonalAgentProvisioner } from "./personal-agent-provisioner.js"; +import { + createPlatformClawWebIngressRuntime, + type PlatformClawWebIngressRuntime, +} from "./web-ingress-runtime.js"; + +const NOW = Date.parse("2026-10-30T01:00:00Z"); +const ORIGIN = "http://127.0.0.1:3000"; +const SSO_SECRET = "verified-directory-recovery-test-secret"; +const SSO_REFRESH_URL = + "/employee/auth/adsso?returnTo=%2Fplatformclaw%2Fapp%2Fsettings%2Forganization"; +const SOURCE_ORGANIZATION = { + version: 1, + source: "aicm", + updatedAt: "2026-10-30T00:00:00Z", + team: "Engineering", + group: { id: "platform", name: "Platform" }, + part: { id: "agents", name: "Agents" }, +}; + +async function fixture(ssoConfigured = true) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "platformclaw-directory-backfill-"))); + const controlUiRoot = join(directory, "ui"); + mkdirSync(join(controlUiRoot, "assets"), { recursive: true }); + writeFileSync(join(controlUiRoot, "platformclaw-login.html"), "Login"); + writeFileSync( + join(controlUiRoot, "index.html"), + 'ControlApp', + ); + writeFileSync(join(controlUiRoot, "manifest.webmanifest"), "{}"); + writeFileSync(join(controlUiRoot, "assets", "platformclaw-pixel-test.svg"), ""); + writeFileSync( + join(controlUiRoot, "sw.js"), + [ + 'if (url.pathname.startsWith("/api/") || false) {}', + 'data = { title: "OpenClaw", body: event.data.text() };', + 'const title = data.title || "OpenClaw";', + 'const options = { icon: "./apple-touch-icon.png", badge: "./favicon-32.png", };', + ].join("\n"), + ); + + let loginOrg: unknown = SOURCE_ORGANIZATION; + let isTestAccount = false; + let now = NOW; + let nextProfileSeed: { entered(): void; wait: Promise } | undefined; + const authRequests: string[] = []; + const server = createServer((request, response) => { + authRequests.push(`${request.method} ${request.url}`); + request.resume(); + response.setHeader("Content-Type", "application/json"); + if (request.url === "/login" && request.method === "POST") { + response.end( + JSON.stringify({ + authenticated: true, + employeeId: "employee-1", + accountId: "person.one", + org: loginOrg, + isTestAccount, + }), + ); + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: "not found" })); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const employeeOrigin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const rpc: GatewayAdminRpc = { + async call(method: string, raw: unknown): Promise { + const params = raw as { agentId: string; workspace: string }; + if (method === "platformclaw.agent.configStatus") { + return { ok: true, configured: true, matches: true, ...params } as T; + } + if (method === "platformclaw.profile.status") { + return { ok: true, status: "matched", ...params } as T; + } + if (method === "platformclaw.profile.seed") { + const gate = nextProfileSeed; + nextProfileSeed = undefined; + if (gate) { + gate.entered(); + await gate.wait; + } + return { ok: true, created: true, ...params } as T; + } + throw new Error(`unexpected Gateway boundary call: ${method}`); + }, + }; + const createRuntime = () => { + const provisioner = new GatewayPersonalAgentProvisioner({ + rpc, + workspaceRoot: join(directory, "workspaces"), + }); + return createPlatformClawWebIngressRuntime({ + databasePath: join(directory, "control.sqlite"), + controlUiRoot, + initialAdminAccountIds: ["admin.user"], + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + resolveAgentIdFromSessionKey: (key) => /^agent:([^:]+):/.exec(key)?.[1] ?? null, + provisioner, + restartRecoveryProbe: provisioner, + publicOrigin: ORIGIN, + adminRpc: rpc, + ...(ssoConfigured + ? { + employeeSso: { loginUrl: "https://auth.example.test/adsso", handoffSecret: SSO_SECRET }, + } + : {}), + employeeAuth: { + employeeAuthConfig: { + loginUrl: `${employeeOrigin}/login`, + bearerToken: "test-directory-service-token", + }, + now: () => now, + }, + gatewayClient: { + client: { url: "ws://127.0.0.1:18789", token: "test-gateway-token" }, + createClient: () => ({ + start() {}, + stop() {}, + async request() { + return {}; + }, + }), + }, + }); + }; + return { + createRuntime, + databasePath: join(directory, "control.sqlite"), + authRequests, + setLogin(org: unknown, testAccount = false) { + loginOrg = org; + isTestAccount = testAccount; + }, + setNow(value: number) { + now = value; + }, + deferNextProfileSeed() { + let entered = () => {}; + let release = () => {}; + const started = new Promise((resolve) => { + entered = resolve; + }); + const wait = new Promise((resolve) => { + release = resolve; + }); + nextProfileSeed = { entered, wait }; + return { started, release }; + }, + async close() { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + rmSync(directory, { recursive: true, force: true }); + }, + }; +} + +async function listen(runtime: PlatformClawWebIngressRuntime) { + await runtime.listen({ host: "127.0.0.1", port: 0 }); + return `http://127.0.0.1:${(runtime.server.address() as AddressInfo).port}`; +} + +async function assertExistingCookie(baseUrl: string, cookie: string, accountId = "person.one") { + const session = await fetch(`${baseUrl}/platformclaw/api/auth/session`, { + headers: { Cookie: cookie }, + }); + expect(await session.json()).toMatchObject({ + authenticated: true, + user: { accountId }, + }); + const response = await fetch(`${baseUrl}/platformclaw/api/organization/context`, { + headers: { Cookie: cookie }, + }); + expect(response.status).toBe(200); + const context = await response.json(); + expect(context).toMatchObject({ + isUnaffiliated: false, + directorySync: { status: "synced" }, + joinPromptEligible: false, + directMemberships: [{ managedBy: "ldap-login", role: "member" }], + directScopeLineages: [ + { + lineage: [ + { kind: "team", name: "Engineering" }, + { kind: "group", name: "Platform" }, + { kind: "part", name: "Agents" }, + ], + }, + ], + }); + expect(context.directMemberships).toHaveLength(1); +} + +describe("existing-session directory organization backfill", () => { + it.each([true, false])( + "keeps a legacy cookie usable with an explicit recovery action only when SSO is configured: %s", + async (ssoConfigured) => { + const setup = await fixture(ssoConfigured); + let runtime: PlatformClawWebIngressRuntime | undefined; + try { + runtime = setup.createRuntime(); + // A pre-upgrade cookie contains identity, not a trusted organization claim. + const { user } = await runtime.auth.store.upsertPrincipal( + { + provider: "ldap", + subject: "person.one", + accountId: "person.one", + employeeId: "employee-1", + }, + NOW - 1_000, + ); + const { binding } = await runtime.auth.store.reservePersonalAgent(user.id, NOW - 900); + await runtime.auth.store.transitionAgent({ + bindingId: binding.id, + state: "active", + changedAt: NOW - 800, + }); + await runtime.auth.store.createBrowserSession({ + userId: user.id, + tokenHash: hashBrowserSessionToken("pre-upgrade-cookie"), + createdAt: NOW - 700, + }); + expect(await runtime.auth.store.listManagedScopes()).toEqual([]); + await runtime.close(); + runtime = setup.createRuntime(); + await runtime.prepare(); + expect(runtime.server.address()).toBeNull(); + const baseUrl = await listen(runtime); + const cookie = "platformclaw_session=pre-upgrade-cookie"; + const session = await fetch(`${baseUrl}/platformclaw/api/auth/session`, { + headers: { Cookie: cookie }, + }); + expect(await session.json()).toMatchObject({ authenticated: true }); + for (let count = 0; count < 2; count += 1) { + const response = await fetch(`${baseUrl}/platformclaw/api/organization/context`, { + headers: { Cookie: cookie }, + }); + const context = await response.json(); + expect(context).toMatchObject({ + isUnaffiliated: true, + joinPromptEligible: false, + directorySync: { status: "skipped", reason: "requires-authenticated-org" }, + }); + expect(context.directorySync.refreshUrl).toBe( + ssoConfigured ? SSO_REFRESH_URL : undefined, + ); + } + expect(setup.authRequests).toEqual([]); + expect(await runtime.auth.store.listManagedScopes()).toEqual([]); + } finally { + await runtime?.close(); + await setup.close(); + } + }, + ); + + it.each([ + { name: "restart", parentless: false, refresh: false }, + { name: "same-revision unknown Team", parentless: false, refresh: true }, + { name: "same-revision unknown Group", parentless: true, refresh: true }, + ])("recovers the selected authenticated fact after sync failure: $name", async (claim) => { + const setup = await fixture(); + const initialOrg = claim.parentless + ? { ...SOURCE_ORGANIZATION, team: null } + : SOURCE_ORGANIZATION; + setup.setLogin(initialOrg); + let runtime: PlatformClawWebIngressRuntime | undefined; + try { + runtime = setup.createRuntime(); + const failedSync = vi + .spyOn(runtime.auth.store, "syncDirectoryOrganization") + .mockRejectedValueOnce(new Error("injected organization write failure")); + const baseUrl = await listen(runtime); + const login = (cookie?: string) => + fetch(`${baseUrl}/platformclaw/api/auth/login`, { + method: "POST", + headers: { + Origin: ORIGIN, + "Content-Type": "application/json", + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify({ identifier: "person.one", password: "test-password" }), + }); + const first = await login(); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ authenticated: true }); + expect(failedSync).toHaveBeenCalledOnce(); + const cookie = first.headers.get("set-cookie")!.split(";", 1)[0]!; + expect(await runtime.auth.store.listManagedScopes()).toEqual([]); + failedSync.mockRestore(); + if (claim.refresh) { + setup.setLogin({ + ...SOURCE_ORGANIZATION, + ...(claim.parentless ? { group: null } : { team: null }), + }); + const refreshed = await login(cookie); + expect(refreshed.status).toBe(200); + expect(refreshed.headers.get("set-cookie")!.split(";", 1)[0]).toBe(cookie); + const user = (await runtime.auth.store.getUserByAccountId("person.one"))!; + expect(await runtime.auth.store.getEmployeeDirectorySnapshot(user.id)).toEqual({ + org: { ...initialOrg, updatedAt: "2026-10-30T00:00:00.000Z" }, + isTestAccount: false, + }); + // Inspect the graph before context recovery: login must apply the selected + // fact rather than the weaker incoming claim or an inferred Group-Team edge. + const scopes = await runtime.auth.store.listManagedScopes(); + expect(scopes).toHaveLength(claim.parentless ? 1 : 3); + const part = scopes.find((scope) => scope.kind === "part")!; + expect(part.parentScopeId).toBe( + claim.parentless ? undefined : scopes.find((scope) => scope.kind === "group")!.id, + ); + const database = new DatabaseSync(setup.databasePath); + try { + expect( + database + .prepare("SELECT directory_parent_key FROM managed_scopes WHERE id=?") + .get(part.id), + ).toMatchObject({ directory_parent_key: "group:platform" }); + } finally { + database.close(); + } + } + await runtime.close(); + runtime = setup.createRuntime(); + const restarted = await listen(runtime); + if (claim.parentless) { + const response = await fetch(`${restarted}/platformclaw/api/organization/context`, { + headers: { Cookie: cookie }, + }); + expect(await response.json()).toMatchObject({ + directorySync: { status: "synced" }, + directScopeLineages: [{ lineage: [{ kind: "part", name: "Agents" }] }], + }); + } else { + await assertExistingCookie(restarted, cookie); + await assertExistingCookie(restarted, cookie); + } + expect(await runtime.auth.store.listManagedScopes()).toHaveLength(claim.parentless ? 1 : 3); + expect(setup.authRequests).toEqual( + claim.refresh ? ["POST /login", "POST /login"] : ["POST /login"], + ); + } finally { + await runtime?.close(); + await setup.close(); + } + }); + + it("attaches newly known parents through actual login at the same source revision", async () => { + const setup = await fixture(); + setup.setLogin({ ...SOURCE_ORGANIZATION, team: null, group: null }); + let runtime: PlatformClawWebIngressRuntime | undefined; + try { + runtime = setup.createRuntime(); + const baseUrl = await listen(runtime); + const login = async (cookie?: string) => + fetch(`${baseUrl}/platformclaw/api/auth/login`, { + method: "POST", + headers: { + Origin: ORIGIN, + "Content-Type": "application/json", + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify({ identifier: "person.one", password: "test-password" }), + }); + const first = await login(); + expect(first.status).toBe(200); + const cookie = first.headers.get("set-cookie")!.split(";", 1)[0]!; + expect(await runtime.auth.store.listManagedScopes()).toMatchObject([ + { kind: "part", name: "Agents" }, + ]); + setup.setLogin(SOURCE_ORGANIZATION); + expect((await login(cookie)).status).toBe(200); + await assertExistingCookie(baseUrl, cookie); + await assertExistingCookie(baseUrl, cookie); + expect(await runtime.auth.store.listManagedScopes()).toHaveLength(3); + expect(setup.authRequests).toEqual(["POST /login", "POST /login"]); + } finally { + await runtime?.close(); + await setup.close(); + } + }); + + it("replays a complementary parent claim without retaining the old ancestor or inventing a new edge", async () => { + const setup = await fixture(); + let runtime: PlatformClawWebIngressRuntime | undefined; + try { + runtime = setup.createRuntime(); + const baseUrl = await listen(runtime); + const login = (cookie?: string) => + fetch(`${baseUrl}/platformclaw/api/auth/login`, { + method: "POST", + headers: { + Origin: ORIGIN, + "Content-Type": "application/json", + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify({ identifier: "person.one", password: "test-password" }), + }); + const first = await login(); + expect(first.status).toBe(200); + const cookie = first.headers.get("set-cookie")!.split(";", 1)[0]!; + const store = runtime.auth.store; + const user = (await store.getUserByAccountId("person.one"))!; + const scopes = await store.listManagedScopes(); + const oldGroup = scopes.find((scope) => scope.externalKey === "group:platform")!; + const part = scopes.find((scope) => scope.externalKey === "aicm-part:agents")!; + const { user: admin } = await store.upsertPrincipal( + { + provider: "ldap", + subject: "admin.user", + accountId: "admin.user", + employeeId: "admin.user", + }, + NOW, + ); + const manualTeam = await store.createManagedScope({ + actorUserId: admin.id, + kind: "team", + name: "Manual responsibility", + createdAt: NOW, + }); + await store.setManagedScopeMembership({ + actorUserId: admin.id, + userId: user.id, + scopeId: manualTeam.id, + role: "leader", + reason: "Explicit responsibility independent of directory ancestry", + changedAt: NOW, + }); + const updatedAt = "2026-10-30T00:00:01Z"; + setup.setLogin({ ...SOURCE_ORGANIZATION, group: null, updatedAt }); + const unknownGroupLogin = await login(cookie); + expect(unknownGroupLogin.status).toBe(200); + expect(unknownGroupLogin.headers.get("set-cookie")!.split(";", 1)[0]).toBe(cookie); + expect((await store.getManagedScope(part.id))?.parentScopeId).toBe(oldGroup.id); + + const pendingOrganization = { + ...SOURCE_ORGANIZATION, + team: null, + group: { id: "security", name: "Security" }, + updatedAt, + }; + setup.setLogin(pendingOrganization); + const failedSync = vi + .spyOn(store, "syncDirectoryOrganization") + .mockRejectedValueOnce(new Error("injected complementary-claim write failure")); + const complementaryLogin = await login(cookie); + expect(complementaryLogin.status).toBe(200); + expect(complementaryLogin.headers.get("set-cookie")!.split(";", 1)[0]).toBe(cookie); + expect(await store.getEmployeeDirectorySnapshot(user.id)).toEqual({ + org: { ...pendingOrganization, updatedAt: "2026-10-30T00:00:01.000Z" }, + isTestAccount: false, + }); + expect(failedSync).toHaveBeenCalledOnce(); + failedSync.mockRestore(); + await runtime.close(); + runtime = setup.createRuntime(); + const restarted = await listen(runtime); + const context = await fetch(`${restarted}/platformclaw/api/organization/context`, { + headers: { Cookie: cookie }, + }); + expect(context.status).toBe(200); + expect(await context.json()).toMatchObject({ directorySync: { status: "synced" } }); + const recoveredStore = runtime.auth.store; + expect((await recoveredStore.getManagedScope(part.id))?.parentScopeId).toBeUndefined(); + expect( + (await recoveredStore.listEffectiveManagedScopeAccess(user.id)).map( + (entry) => entry.scope.id, + ), + ).not.toContain(oldGroup.id); + expect(await recoveredStore.listUserManagedScopeMemberships(user.id)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ scopeId: part.id, role: "member", managedBy: "ldap-login" }), + expect.objectContaining({ scopeId: manualTeam.id, role: "leader" }), + ]), + ); + const database = new DatabaseSync(setup.databasePath); + try { + expect( + database + .prepare("SELECT parent_scope_id,directory_parent_key FROM managed_scopes WHERE id=?") + .get(part.id), + ).toMatchObject({ parent_scope_id: null, directory_parent_key: "group:security" }); + } finally { + database.close(); + } + expect( + (await recoveredStore.listManagedScopes()).some( + (scope) => scope.externalKey === "group:security", + ), + ).toBe(false); + expect(setup.authRequests).toEqual(["POST /login", "POST /login", "POST /login"]); + setup.setLogin({ + ...pendingOrganization, + group: { id: "conflicting", name: "Conflicting group" }, + }); + const conflictingLogin = await fetch(`${restarted}/platformclaw/api/auth/login`, { + method: "POST", + headers: { Origin: ORIGIN, "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify({ identifier: "person.one", password: "test-password" }), + }); + expect(conflictingLogin.status).toBe(200); + expect(await recoveredStore.getEmployeeDirectorySnapshot(user.id)).toEqual({ + org: { ...pendingOrganization, updatedAt: "2026-10-30T00:00:01.000Z" }, + isTestAccount: false, + }); + expect((await recoveredStore.getManagedScope(part.id))?.parentScopeId).toBeUndefined(); + const rejectedContext = await fetch(`${restarted}/platformclaw/api/organization/context`, { + headers: { Cookie: cookie }, + }); + expect(await rejectedContext.json()).toMatchObject({ + directorySync: { status: "skipped", reason: "conflicting-organization" }, + }); + const session = await fetch(`${restarted}/platformclaw/api/auth/session`, { + headers: { Cookie: cookie }, + }); + expect(await session.json()).toMatchObject({ + authenticated: true, + user: { accountId: "person.one" }, + }); + } finally { + await runtime?.close(); + await setup.close(); + } + }); + + it.each([ + { org: SOURCE_ORGANIZATION, isTestAccount: true, reason: "test-account" }, + { org: null, isTestAccount: false, reason: "no-organization" }, + { + org: { ...SOURCE_ORGANIZATION, source: "mapping" }, + isTestAccount: false, + reason: "untrusted-source", + }, + { + org: { ...SOURCE_ORGANIZATION, version: 2 }, + isTestAccount: false, + reason: "unsupported-version", + }, + ])( + "preserves the completed authentication exclusion across context reads and restart: $reason", + async (claim) => { + const setup = await fixture(); + setup.setLogin(claim.org, claim.isTestAccount); + let runtime: PlatformClawWebIngressRuntime | undefined; + try { + runtime = setup.createRuntime(); + const baseUrl = await listen(runtime); + const login = await fetch(`${baseUrl}/platformclaw/api/auth/login`, { + method: "POST", + headers: { Origin: ORIGIN, "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: "person.one", password: "test-password" }), + }); + expect(login.status).toBe(200); + const cookie = login.headers.get("set-cookie")!.split(";", 1)[0]!; + for (const restart of [false, true]) { + if (restart) { + await runtime.close(); + runtime = setup.createRuntime(); + } + const currentUrl = restart ? await listen(runtime) : baseUrl; + const response = await fetch(`${currentUrl}/platformclaw/api/organization/context`, { + headers: { Cookie: cookie }, + }); + const context = await response.json(); + expect(context).toMatchObject({ + directorySync: { status: "skipped", reason: claim.reason }, + directMemberships: [], + }); + expect(context.directorySync).not.toHaveProperty("refreshUrl"); + expect(await runtime.auth.store.listManagedScopes()).toEqual([]); + } + expect(setup.authRequests).toEqual(["POST /login"]); + } finally { + await runtime?.close(); + await setup.close(); + } + }, + ); + + it.each([ + { name: "test-account", org: SOURCE_ORGANIZATION, testAccount: true, reason: "test-account" }, + { + name: "stale-source", + org: { ...SOURCE_ORGANIZATION, updatedAt: "2026-10-29T23:59:59Z" }, + testAccount: false, + reason: "stale-organization", + }, + { + name: "conflicting-source", + org: { ...SOURCE_ORGANIZATION, group: { id: "other", name: "Other group" } }, + testAccount: false, + reason: "conflicting-organization", + }, + ])("does not replay a prior accepted fact after a newer $name login", async (claim) => { + const setup = await fixture(); + let runtime: PlatformClawWebIngressRuntime | undefined; + try { + runtime = setup.createRuntime(); + const baseUrl = await listen(runtime); + const failure = vi + .spyOn(runtime.auth.store, "syncDirectoryOrganization") + .mockRejectedValueOnce(new Error("injected sync failure")); + const login = async (cookie?: string) => + fetch(`${baseUrl}/platformclaw/api/auth/login`, { + method: "POST", + headers: { + Origin: ORIGIN, + "Content-Type": "application/json", + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify({ identifier: "person.one", password: "test-password" }), + }); + const first = await login(); + expect(first.status).toBe(200); + const cookie = first.headers.get("set-cookie")!.split(";", 1)[0]!; + failure.mockRestore(); + setup.setLogin(claim.org, claim.testAccount); + expect((await login(cookie)).status).toBe(200); + for (const restart of [false, true]) { + if (restart) { + await runtime.close(); + runtime = setup.createRuntime(); + } + const currentUrl = restart ? await listen(runtime) : baseUrl; + const response = await fetch(`${currentUrl}/platformclaw/api/organization/context`, { + headers: { Cookie: cookie }, + }); + expect(await response.json()).toMatchObject({ + directorySync: { status: "skipped", reason: claim.reason }, + directMemberships: [], + }); + expect(await runtime.auth.store.listManagedScopes()).toEqual([]); + } + } finally { + await runtime?.close(); + await setup.close(); + } + }); + + it.each([ + { org: SOURCE_ORGANIZATION, isTestAccount: true, name: "test account", clockDelta: 1 }, + { org: null, isTestAccount: false, name: "missing organization", clockDelta: 1 }, + { + org: SOURCE_ORGANIZATION, + isTestAccount: true, + name: "equal-clock test account", + clockDelta: 0, + }, + { org: null, isTestAccount: false, name: "equal-clock missing organization", clockDelta: 0 }, + { + org: { ...SOURCE_ORGANIZATION, updatedAt: "2026-10-30T00:00:01Z" }, + isTestAccount: false, + name: "eligible organization", + clockDelta: 0, + }, + ])( + "does not replay an older fact when the newer $name decision cannot be recorded", + async (claim) => { + const setup = await fixture(); + let runtime: PlatformClawWebIngressRuntime | undefined; + try { + runtime = setup.createRuntime(); + const baseUrl = await listen(runtime); + const syncFailure = vi + .spyOn(runtime.auth.store, "syncDirectoryOrganization") + .mockRejectedValueOnce(new Error("injected organization write failure")); + const login = async (cookie?: string) => + fetch(`${baseUrl}/platformclaw/api/auth/login`, { + method: "POST", + headers: { + Origin: ORIGIN, + "Content-Type": "application/json", + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify({ identifier: "person.one", password: "test-password" }), + }); + const first = await login(); + expect(first.status).toBe(200); + const cookie = first.headers.get("set-cookie")!.split(";", 1)[0]!; + const user = await runtime.auth.store.getUserByAccountId("person.one"); + expect(await runtime.auth.store.getEmployeeDirectorySnapshot(user!.id)).not.toBeNull(); + expect(await runtime.auth.store.listManagedScopes()).toEqual([]); + syncFailure.mockRestore(); + + setup.setNow(NOW + claim.clockDelta); + setup.setLogin(claim.org, claim.isTestAccount); + const database = new DatabaseSync(setup.databasePath); + try { + database.exec(`CREATE TRIGGER fail_directory_auth_decision + BEFORE UPDATE ON employee_directory_auth_decisions + BEGIN SELECT RAISE(ABORT, 'injected authentication decision write failure'); END`); + expect((await login(cookie)).status).toBe(200); + expect(await runtime.auth.store.getEmployeeDirectorySnapshot(user!.id)).toEqual({ + org: { ...SOURCE_ORGANIZATION, updatedAt: "2026-10-30T00:00:00.000Z" }, + isTestAccount: false, + }); + const current = (await runtime.auth.store.getUserById(user!.id))!; + expect(current.lastLoginAt).toBeGreaterThan(user!.lastLoginAt!); + expect( + await runtime.auth.store.getEmployeeDirectoryAuthDecision( + user!.id, + current.lastLoginAt!, + ), + ).toBeNull(); + expect( + await runtime.auth.store.getEmployeeDirectoryAuthDecision(user!.id, user!.lastLoginAt!), + ).toBe("eligible"); + } finally { + database.exec("DROP TRIGGER IF EXISTS fail_directory_auth_decision"); + database.close(); + } + + for (const restart of [false, true]) { + if (restart) { + await runtime.close(); + runtime = setup.createRuntime(); + } + const currentUrl = restart ? await listen(runtime) : baseUrl; + const response = await fetch(`${currentUrl}/platformclaw/api/organization/context`, { + headers: { Cookie: cookie }, + }); + expect(await response.json()).toMatchObject({ + directorySync: { status: "skipped", reason: "requires-authenticated-org" }, + directMemberships: [], + }); + expect(await runtime.auth.store.listManagedScopes()).toEqual([]); + } + expect(setup.authRequests).toEqual(["POST /login", "POST /login"]); + } finally { + await runtime?.close(); + await setup.close(); + } + }, + ); + + it("does not replay an older fact while an equal-clock test-account login is provisioning", async () => { + const setup = await fixture(); + let runtime: PlatformClawWebIngressRuntime | undefined; + try { + runtime = setup.createRuntime(); + const baseUrl = await listen(runtime); + const syncFailure = vi + .spyOn(runtime.auth.store, "syncDirectoryOrganization") + .mockRejectedValueOnce(new Error("injected organization write failure")); + const login = async (cookie?: string) => + fetch(`${baseUrl}/platformclaw/api/auth/login`, { + method: "POST", + headers: { + Origin: ORIGIN, + "Content-Type": "application/json", + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify({ identifier: "person.one", password: "test-password" }), + }); + const first = await login(); + expect(first.status).toBe(200); + const cookie = first.headers.get("set-cookie")!.split(";", 1)[0]!; + syncFailure.mockRestore(); + setup.setLogin(SOURCE_ORGANIZATION, true); + const gate = setup.deferNextProfileSeed(); + const pendingLogin = login(cookie); + let context: unknown; + try { + await gate.started; + const response = await fetch(`${baseUrl}/platformclaw/api/organization/context`, { + headers: { Cookie: cookie }, + }); + context = await response.json(); + } finally { + gate.release(); + expect((await pendingLogin).status).toBe(200); + } + expect(context).toMatchObject({ + directorySync: { status: "skipped", reason: "requires-authenticated-org" }, + directMemberships: [], + }); + const completed = await fetch(`${baseUrl}/platformclaw/api/organization/context`, { + headers: { Cookie: cookie }, + }); + expect(await completed.json()).toMatchObject({ + directorySync: { status: "skipped", reason: "test-account" }, + directMemberships: [], + }); + expect(await runtime.auth.store.listManagedScopes()).toEqual([]); + } finally { + await runtime?.close(); + await setup.close(); + } + }); + + it("records verified signed-SSO facts and retains the same cookie through restart", async () => { + const setup = await fixture(); + let runtime: PlatformClawWebIngressRuntime | undefined; + try { + runtime = setup.createRuntime(); + const baseUrl = await listen(runtime); + const signed = (secret = SSO_SECRET, expired = false) => { + const issuedAt = NOW / 1_000 - (expired ? 61 : 0); + const payload = Buffer.from( + JSON.stringify({ + contractVersion: 1, + kind: "sso", + issuer: "platformclaw-auth", + audience: "platformclaw", + authMethod: "saml", + employeeId: "person.sso", + agentId: "legacy-agent", + sessionKey: "agent:legacy-agent:main", + iat: issuedAt, + exp: issuedAt + 60, + org: SOURCE_ORGANIZATION, + }), + ); + return `${payload.toString("base64url")}.${createHmac("sha256", secret).update(payload).digest("base64url")}`; + }; + for (const rejected of [signed("incorrect-signing-secret"), signed(SSO_SECRET, true)]) { + const response = await fetch( + `${baseUrl}/employee/auth/sso-callback?token=${encodeURIComponent(rejected)}`, + { redirect: "manual" }, + ); + expect(response.status).toBe(401); + expect(await runtime.auth.store.getUserByAccountId("person.sso")).toBeNull(); + expect(await runtime.auth.store.listManagedScopes()).toEqual([]); + } + const response = await fetch( + `${baseUrl}/employee/auth/sso-callback?token=${encodeURIComponent(signed())}`, + { redirect: "manual" }, + ); + expect(response.status).toBe(302); + const cookie = response.headers + .getSetCookie() + .find((value) => value.startsWith("platformclaw_session="))! + .split(";", 1)[0]!; + await assertExistingCookie(baseUrl, cookie, "person.sso"); + await runtime.close(); + runtime = setup.createRuntime(); + await assertExistingCookie(await listen(runtime), cookie, "person.sso"); + expect(setup.authRequests).toEqual([]); + } finally { + await runtime?.close(); + await setup.close(); + } + }); +}); diff --git a/packages/platformclaw-control-plane/src/directory-organization-http.test.ts b/packages/platformclaw-control-plane/src/directory-organization-http.test.ts index 4d5098850a7..8c35381f4c8 100644 --- a/packages/platformclaw-control-plane/src/directory-organization-http.test.ts +++ b/packages/platformclaw-control-plane/src/directory-organization-http.test.ts @@ -10,6 +10,96 @@ import { afterEach(cleanupOrganizationHttpFixtures); describe("PlatformClaw directory organization browser API", () => { + it("projects the actual one-Part lineage before a directory parent mapping is available", async () => { + const fixture = createFixture(); + try { + const admin = (await fixture.store.upsertPrincipal(principal("admin"), 1)).user; + const member = (await fixture.store.upsertPrincipal(principal("member"), 2)).user; + fixture.users.set("admin-token", admin); + fixture.users.set("member-token", member); + const result = await fixture.store.syncDirectoryOrganization({ + userId: member.id, + org: { + version: 1, + source: "aicm", + team: null, + group: null, + part: { id: "unmapped", name: "Runtime" }, + updatedAt: "2026-10-20T01:00:00Z", + }, + isTestAccount: false, + syncedAt: 10, + }); + expect(result.status).toBe("synced"); + const scopes = await call(fixture.service, { + path: `${PLATFORMCLAW_ORGANIZATION_PATH}/scopes?q=Runtime&limit=10`, + token: "admin-token", + }); + expect(scopes).toMatchObject({ + status: 200, + body: { + items: [ + { + kind: "part", + managedBy: "ldap-login", + externalKey: "aicm-part:unmapped", + lineage: [{ kind: "part", name: "Runtime" }], + capabilities: { canManageMembers: true, canManageStructure: false }, + }, + ], + }, + }); + expect( + await call(fixture.service, { + path: `${PLATFORMCLAW_ORGANIZATION_PATH}/context`, + token: "member-token", + }), + ).toMatchObject({ + status: 200, + body: { + isUnaffiliated: false, + directScopeLineages: [{ lineage: [{ kind: "part", name: "Runtime" }] }], + }, + }); + expect( + ( + await call(fixture.service, { + path: `${PLATFORMCLAW_ORGANIZATION_PATH}/context`, + token: "admin-token", + }) + ).body, + ).toMatchObject({ isUnaffiliated: true }); + await fixture.store.syncDirectoryOrganization({ + userId: admin.id, + org: { + version: 1, + source: "aicm", + team: null, + group: null, + part: { id: "unmapped", name: "Runtime" }, + updatedAt: "2026-10-20T01:00:00Z", + }, + isTestAccount: false, + syncedAt: 11, + }); + expect( + ( + await call(fixture.service, { + path: `${PLATFORMCLAW_ORGANIZATION_PATH}/context`, + token: "admin-token", + }) + ).body, + ).toMatchObject({ + actor: { isAdministrator: true }, + isUnaffiliated: false, + effectiveScopes: [{ source: "administrator" }], + directScopeLineages: [{ lineage: [{ kind: "part", name: "Runtime" }] }], + }); + } finally { + fixture.store.close(); + } + }); + it("projects directory identity and provenance while rejecting automatic scope edits", async () => { const fixture = createFixture(); try { diff --git a/packages/platformclaw-control-plane/src/directory-organization.test.ts b/packages/platformclaw-control-plane/src/directory-organization.test.ts index fbaed9741e2..60b67872adb 100644 --- a/packages/platformclaw-control-plane/src/directory-organization.test.ts +++ b/packages/platformclaw-control-plane/src/directory-organization.test.ts @@ -228,18 +228,262 @@ describe("directory organization synchronization", () => { ]); }); - it.each([{ group: null }, { team: null }])( - "defers incomplete new hierarchy without partial organization writes: %j", - async (missing) => { + it.each([null, "Engineering"])( + "creates and joins a parentless directory Part, then attaches the same Part when its parents arrive (initial Team: %s)", + async (initialTeam) => { const { store, member } = await fixture(); - await expect(sync(store, member.id, org(missing))).resolves.toMatchObject({ - status: "skipped", + await expect( + sync(store, member.id, org({ group: null, team: initialTeam })), + ).resolves.toMatchObject({ + status: "synced", }); - expect(await state(store, member.id)).toEqual({ scopes: [], memberships: [] }); + const part = await scope(store, "aicm-part:runtime"); + expect(part).toMatchObject({ kind: "part", managedBy: "ldap-login" }); + expect(part.parentScopeId).toBeUndefined(); + expect(await store.listManagedScopes()).toHaveLength(1); + expect(await store.listUserManagedScopeMemberships(member.id)).toMatchObject([ + { scopeId: part.id, managedBy: "ldap-login" }, + ]); + expect( + (await store.listEffectiveManagedScopeAccess(member.id)).map((entry) => entry.scope.id), + ).toEqual([part.id]); + expect( + await store.searchOrganizationScopesForUser({ + userId: member.id, + query: "Runtime", + limit: 10, + }), + ).toMatchObject([ + { scope: { id: part.id }, lineage: [{ id: part.id }], requestState: "member" }, + ]); + await expect(sync(store, member.id)).resolves.toMatchObject({ status: "synced" }); + + const group = await scope(store, "group:platform"); + const team = await scope(store, "team:Engineering"); + expect(await scope(store, "aicm-part:runtime")).toMatchObject({ + id: part.id, + parentScopeId: group.id, + }); + expect( + (await store.listEffectiveManagedScopeAccess(member.id)) + .map((entry) => entry.scope.id) + .toSorted(), + ).toEqual([part.id, group.id, team.id].toSorted()); + expect(await store.listUserManagedScopeMemberships(member.id)).toHaveLength(1); }, ); + it.each([false, true])( + "refines a latest unknown parent without promoting the retained parent to the latest source revision (renamed Part: %s)", + async (renamed) => { + const { store, member, databasePath } = await fixture(); + const colleague = await user(store, "colleague"); + await sync(store, member.id); + await sync(store, colleague.id); + const original = await scope(store, "aicm-part:runtime"); + const current = org({ + updatedAt: new Date(sourceAt + 1_000).toISOString(), + part: { id: "runtime", name: renamed ? "Runtime Revised" : "Runtime" }, + }); + await expect( + sync(store, member.id, { ...current, group: null, team: null }), + ).resolves.toMatchObject({ status: "synced" }); + expect(await scope(store, "aicm-part:runtime")).toMatchObject({ + id: original.id, + name: current.part!.name, + parentScopeId: original.parentScopeId, + }); + closeStore(store); + const reopened = openStore(databasePath); + const observed = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + observed + .prepare( + "SELECT directory_updated_at,directory_parent_key,parent_scope_id FROM managed_scopes WHERE id=?", + ) + .get(original.id), + ).toEqual({ + directory_updated_at: sourceAt + 1_000, + directory_parent_key: null, + parent_scope_id: original.parentScopeId, + }); + expect( + observed + .prepare( + "SELECT event_type FROM control_audit_events WHERE target_id=? AND event_type='scope.moved'", + ) + .all(original.id), + ).toEqual([]); + } finally { + observed.close(); + } + const reopenedPart = await scope(reopened, "aicm-part:runtime"); + if (renamed) { + expect(reopenedPart.updatedAt).toBeGreaterThan(original.updatedAt); + } else { + expect(reopenedPart.updatedAt).toBe(original.updatedAt); + } + const moved = { ...current, group: { id: "services", name: "Services" }, team: "Technology" }; + await expect(sync(reopened, colleague.id, moved)).resolves.toMatchObject({ + status: "synced", + }); + const group = await scope(reopened, "group:services"); + expect(await scope(reopened, "aicm-part:runtime")).toMatchObject({ + id: original.id, + name: current.part!.name, + parentScopeId: group.id, + }); + for (const target of [member, colleague]) { + const ids = (await reopened.listEffectiveManagedScopeAccess(target.id)).map( + (entry) => entry.scope.id, + ); + expect(ids).toContain(group.id); + expect(ids).not.toContain(original.parentScopeId); + } + await expect( + sync(reopened, member.id, { ...current, group: null, team: null }), + ).resolves.toMatchObject({ status: "synced" }); + await expect( + sync(reopened, member.id, { ...moved, group: { id: "conflict", name: "Conflict" } }), + ).resolves.toEqual({ status: "skipped", reason: "conflicting-organization" }); + await expect(sync(reopened, member.id, org())).resolves.toEqual({ + status: "skipped", + reason: "stale-organization", + }); + expect((await scope(reopened, "aicm-part:runtime")).parentScopeId).toBe(group.id); + }, + ); + + it("refines a latest unknown Team while preserving the Group's same-revision name and rejecting a second known Team", async () => { + const { store, member } = await fixture(); + await sync(store, member.id); + const original = await scope(store, "group:platform"); + const current = org({ + updatedAt: new Date(sourceAt + 1_000).toISOString(), + group: { id: "platform", name: "Platform Revised" }, + }); + await expect(sync(store, member.id, { ...current, team: null })).resolves.toMatchObject({ + status: "synced", + }); + expect(await scope(store, "group:platform")).toMatchObject({ + id: original.id, + name: "Platform Revised", + parentScopeId: original.parentScopeId, + }); + await expect(sync(store, member.id, { ...current, team: "Technology" })).resolves.toMatchObject( + { status: "synced" }, + ); + const team = await scope(store, "team:Technology"); + expect(await scope(store, "group:platform")).toMatchObject({ + id: original.id, + name: "Platform Revised", + parentScopeId: team.id, + }); + await expect(sync(store, member.id, { ...current, team: null })).resolves.toMatchObject({ + status: "synced", + }); + await expect(sync(store, member.id, { ...current, team: "Conflict" })).resolves.toEqual({ + status: "skipped", + reason: "conflicting-organization", + }); + await expect( + sync(store, member.id, { + ...current, + group: { id: "platform", name: "Platform" }, + team: "Technology", + }), + ).resolves.toEqual({ status: "skipped", reason: "conflicting-organization" }); + }); + + it("joins a Part without inventing its Group's unknown Team and preserves the pending external parent across users", async () => { + const { store, member } = await fixture(); + const colleague = await user(store, "colleague"); + await expect(sync(store, member.id, org({ team: null }))).resolves.toMatchObject({ + status: "synced", + }); + const part = await scope(store, "aicm-part:runtime"); + expect(part.parentScopeId).toBeUndefined(); + expect(await store.listManagedScopes()).toHaveLength(1); + await expect( + sync(store, colleague.id, org({ team: null, group: { id: "conflict", name: "Conflict" } })), + ).resolves.toEqual({ + status: "skipped", + reason: "conflicting-organization", + }); + await expect( + sync(store, colleague.id, org({ updatedAt: new Date(sourceAt - 1_000).toISOString() })), + ).resolves.toEqual({ + status: "skipped", + reason: "stale-organization", + }); + expect(await store.listManagedScopes()).toHaveLength(1); + expect(await store.listUserManagedScopeMemberships(colleague.id)).toEqual([]); + await expect(sync(store, member.id)).resolves.toMatchObject({ status: "synced" }); + const group = await scope(store, "group:platform"); + expect(await scope(store, "aicm-part:runtime")).toMatchObject({ + id: part.id, + parentScopeId: group.id, + }); + }); + + it("detaches a newer known move whose Team is unavailable, then resolves only that same pending Group at the same revision", async () => { + const { store, member } = await fixture(); + const colleague = await user(store, "colleague"); + await sync(store, member.id); + await sync(store, colleague.id); + const part = await scope(store, "aicm-part:runtime"); + const moved = org({ + updatedAt: new Date(sourceAt + 1_000).toISOString(), + team: null, + group: { id: "services", name: "Services" }, + }); + await expect(sync(store, member.id, moved)).resolves.toMatchObject({ status: "synced" }); + for (const target of [member, colleague]) { + expect( + (await store.listEffectiveManagedScopeAccess(target.id)).map((entry) => entry.scope.id), + ).toEqual([part.id]); + } + await expect( + sync(store, colleague.id, { ...moved, group: { id: "other", name: "Other" } }), + ).resolves.toEqual({ status: "skipped", reason: "conflicting-organization" }); + await expect( + sync(store, colleague.id, { ...moved, part: { id: "runtime", name: "Renamed" } }), + ).resolves.toEqual({ status: "skipped", reason: "conflicting-organization" }); + await expect(sync(store, colleague.id, { ...moved, group: null })).resolves.toMatchObject({ + status: "synced", + }); + expect((await scope(store, "aicm-part:runtime")).parentScopeId).toBeUndefined(); + await expect( + sync(store, colleague.id, { + ...moved, + group: { id: "after-unknown", name: "After Unknown" }, + }), + ).resolves.toEqual({ status: "skipped", reason: "conflicting-organization" }); + await expect(sync(store, member.id, { ...moved, team: "Technology" })).resolves.toMatchObject({ + status: "synced", + }); + const group = await scope(store, "group:services"); + expect(await scope(store, "aicm-part:runtime")).toMatchObject({ + id: part.id, + parentScopeId: group.id, + }); + const before = await store.listManagedScopes(); + await expect(sync(store, colleague.id, { ...moved, team: "Another Team" })).resolves.toEqual({ + status: "skipped", + reason: "conflicting-organization", + }); + await expect( + sync(store, colleague.id, { + ...moved, + team: "Technology", + group: { id: "services", name: "Changed" }, + }), + ).resolves.toEqual({ status: "skipped", reason: "conflicting-organization" }); + expect(await store.listManagedScopes()).toEqual(before); + }); + it.each([ undefined, null, @@ -324,6 +568,31 @@ describe("directory organization synchronization", () => { expect(await state(store, member.id)).toEqual(moved); }); + it("rejects directory lookup snapshots taken before a newer authentication", async () => { + const { store, member } = await fixture(); + const snapshot = await store.getUserById(member.id); + await store.upsertPrincipal( + { + provider: "ldap", + subject: member.accountId, + accountId: member.accountId, + employeeId: member.employeeId, + }, + syncedAt + 1_000, + ); + + await expect( + store.syncDirectoryOrganization({ + userId: member.id, + org: org(), + isTestAccount: false, + syncedAt: syncedAt + 2_000, + expectedLastLoginAt: snapshot!.lastLoginAt, + }), + ).resolves.toEqual({ status: "skipped", reason: "stale-authentication" }); + expect(await state(store, member.id)).toEqual({ scopes: [], memberships: [] }); + }); + it.each(["before", "after"])( "preserves manual leader authority granted %s automatic membership", async (when) => { @@ -561,7 +830,7 @@ describe("directory organization synchronization", () => { expect(await state(store, member.id)).toEqual(before); } } finally { - runtime.close(); + await runtime.close(); await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); diff --git a/packages/platformclaw-control-plane/src/directory-organization.ts b/packages/platformclaw-control-plane/src/directory-organization.ts index 51e8c227234..8aca9470253 100644 --- a/packages/platformclaw-control-plane/src/directory-organization.ts +++ b/packages/platformclaw-control-plane/src/directory-organization.ts @@ -18,9 +18,9 @@ export type DirectoryOrganizationSyncResult = | "untrusted-source" | "no-part" | "invalid-organization" - | "missing-parent" | "inactive-organization" | "inactive-user" + | "stale-authentication" | "stale-organization" | "conflicting-organization"; }; diff --git a/packages/platformclaw-control-plane/src/employee-directory-snapshot.test.ts b/packages/platformclaw-control-plane/src/employee-directory-snapshot.test.ts new file mode 100644 index 00000000000..e76ebc258a3 --- /dev/null +++ b/packages/platformclaw-control-plane/src/employee-directory-snapshot.test.ts @@ -0,0 +1,361 @@ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { backfillEmployeeOrganizations } from "./employee-organization-backfill.js"; +import { InMemoryControlPlaneStore } from "./memory-store.js"; +import { SqliteControlPlaneStore } from "./sqlite-store.js"; + +const directories: string[] = []; +const stores: SqliteControlPlaneStore[] = []; +const SOURCE_TIME = Date.parse("2026-10-30T00:00:00Z"); +const organization = (offset = 0, name = "Agents") => ({ + version: 1, + source: "aicm", + team: "Engineering", + group: { id: "platform", name: "Platform" }, + part: { id: "agents", name }, + updatedAt: new Date(SOURCE_TIME + offset).toISOString(), +}); + +function createStore() { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "platformclaw-directory-snapshot-"))); + directories.push(directory); + const store = new SqliteControlPlaneStore({ + databasePath: join(directory, "control.sqlite"), + initialAdminAccountIds: ["admin"], + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + }); + stores.push(store); + return store; +} + +async function createUser(store: SqliteControlPlaneStore, name = "one", time = 1_000) { + const principal = { provider: "ldap" as const, subject: name, accountId: name, employeeId: name }; + return { principal, user: (await store.upsertPrincipal(principal, time)).user }; +} + +afterEach(() => { + for (const store of stores.splice(0)) { + store.close(); + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("authenticated employee directory snapshots", () => { + it.each(["sqlite", "memory"] as const)( + "gives equal-clock authentications distinct stamps while preserving provider stale checks: %s", + async (kind) => { + const store = + kind === "sqlite" + ? createStore() + : new InMemoryControlPlaneStore({ + initialAdminAccountIds: ["admin"], + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + }); + const principal = { + provider: "ldap" as const, + subject: "one", + accountId: "one", + employeeId: "one", + }; + const first = await store.upsertPrincipal(principal, 1_000); + const repeated = await store.upsertPrincipal(principal, 1_000); + const linked = await store.upsertPrincipal( + { ...principal, provider: "saml", subject: "saml:one" }, + 1_000, + ); + expect([first.user.lastLoginAt, repeated.user.lastLoginAt, linked.user.lastLoginAt]).toEqual([ + 1_000, 1_001, 1_002, + ]); + expect(linked.identity.lastAuthenticatedAt).toBe(1_000); + await expect(store.upsertPrincipal(principal, 999)).rejects.toMatchObject({ + code: "stale_authentication", + }); + await expect( + store.upsertPrincipal({ ...principal, employeeId: "changed" }, 1_000), + ).rejects.toMatchObject({ code: "stale_authentication" }); + expect((await store.getUserById(first.user.id))?.lastLoginAt).toBe(1_002); + const created = await store.createBrowserSession({ + userId: first.user.id, + tokenHash: "monotonic-authentication-session", + createdAt: 1_000, + }); + expect(created).toMatchObject({ status: "created", session: { createdAt: 1_000 } }); + }, + ); + + it("lets a health listener respond before reading the next recovery page", async () => { + const users = Array.from({ length: 200 }, (_, index) => ({ + id: String(index), + lastLoginAt: 1_000, + })); + let pagesRead = 0; + const store = { + async listDirectoryOrganizationBackfillUsers(params: { + afterUserId?: string; + limit: number; + }) { + pagesRead += 1; + const offset = params.afterUserId === undefined ? 0 : Number(params.afterUserId) + 1; + return users.slice(offset, offset + params.limit); + }, + async getEmployeeDirectorySnapshot() { + return null; + }, + async getEmployeeDirectoryAuthDecision() { + return null; + }, + async getEmployeeDirectoryBackfillAttempt() { + return null; + }, + async recordEmployeeDirectoryBackfillAttempt() { + return true; + }, + async syncDirectoryOrganization() { + return { status: "synced" as const, scopeId: "unused" }; + }, + }; + let scan: ReturnType | undefined; + const server = createServer((_request, response) => { + scan = backfillEmployeeOrganizations({ store }); + // A check-phase response must not wait for every immediately resolved DB task. + setImmediate(() => { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ pagesRead })); + }); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + try { + const response = await fetch( + `http://127.0.0.1:${(server.address() as AddressInfo).port}/health`, + ); + expect(await response.json()).toEqual({ pagesRead: 1 }); + expect(await scan).toMatchObject({ found: 200, skipped: 200, failed: 0, limited: false }); + } finally { + await scan; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + + it("stores only canonical eligible fields and rejects older or conflicting source data", async () => { + const store = createStore(); + const { user } = await createUser(store); + const save = (org: unknown) => + store.recordEmployeeDirectoryAuthentication({ + userId: user.id, + org, + isTestAccount: false, + observedAt: 2_000, + expectedLastLoginAt: 1_000, + }); + expect( + await save({ ...organization(1_000), unrelated: "discarded directory metadata" }), + ).toEqual({ org: organization(1_000), isTestAccount: false }); + expect(await store.getEmployeeDirectorySnapshot(user.id)).toEqual({ + org: organization(1_000), + isTestAccount: false, + }); + expect(await save(organization(0, "Old name"))).toBeNull(); + expect(await store.getEmployeeDirectoryAuthDecision(user.id, 1_000)).toBe("stale-organization"); + expect(await save(organization(1_000, "Conflicting name"))).toBeNull(); + expect(await store.getEmployeeDirectoryAuthDecision(user.id, 1_000)).toBe( + "conflicting-organization", + ); + expect(await store.getEmployeeDirectorySnapshot(user.id)).toEqual({ + org: organization(1_000), + isTestAccount: false, + }); + }); + + it("retains same-revision parent refinements without accepting known-parent moves or renames", async () => { + const store = createStore(); + const { user } = await createUser(store); + const save = (org: unknown) => + store.recordEmployeeDirectoryAuthentication({ + userId: user.id, + org, + isTestAccount: false, + observedAt: 2_000, + expectedLastLoginAt: 1_000, + }); + expect(await save({ ...organization(), team: null, group: null })).toEqual({ + org: { ...organization(), team: null, group: null }, + isTestAccount: false, + }); + expect(await save({ ...organization(), team: null })).toEqual({ + org: { ...organization(), team: null }, + isTestAccount: false, + }); + expect(await save(organization())).toEqual({ org: organization(), isTestAccount: false }); + for (const conflict of [ + { ...organization(), part: { id: "other", name: "Agents" } }, + organization(0, "Renamed part"), + { ...organization(), group: { id: "other", name: "Platform" } }, + { ...organization(), group: { id: "platform", name: "Renamed group" } }, + { ...organization(), team: "Other team" }, + ]) { + expect(await save(conflict)).toBeNull(); + } + for (const weaker of [ + { ...organization(), group: null }, + { ...organization(), team: null }, + ]) { + expect(await save(weaker)).toEqual({ org: organization(), isTestAccount: false }); + } + expect(await store.getEmployeeDirectorySnapshot(user.id)).toEqual({ + org: organization(), + isTestAccount: false, + }); + }); + + it.each([false, true])( + "keeps the closest known parent without inventing a Team edge in either claim order: group first %s", + async (groupFirst) => { + const store = createStore(); + const { user, principal } = await createUser(store); + const knownGroup = { ...organization(), team: null }; + const standaloneTeam = { ...organization(), group: null }; + const claims = groupFirst ? [knownGroup, standaloneTeam] : [standaloneTeam, knownGroup]; + for (const [index, org] of claims.entries()) { + const authentication = await store.upsertPrincipal(principal, 1_000 + index); + expect( + await store.recordEmployeeDirectoryAuthentication({ + userId: user.id, + org, + isTestAccount: false, + expectedLastLoginAt: authentication.user.lastLoginAt!, + observedAt: 2_000 + index, + }), + ).toEqual({ org: index === 0 ? claims[0] : knownGroup, isTestAccount: false }); + } + expect(await store.getEmployeeDirectorySnapshot(user.id)).toEqual({ + org: knownGroup, + isTestAccount: false, + }); + expect(await store.getEmployeeDirectoryAuthDecision(user.id, 1_002)).toBe("eligible"); + }, + ); + + it.each([ + undefined, + null, + { ...organization(), part: null }, + { ...organization(), source: "mapping" }, + { ...organization(), version: 2 }, + ])("does not replace a known fact with unknown or ineligible data: %j", async (org) => { + const store = createStore(); + const { user } = await createUser(store); + await store.recordEmployeeDirectoryAuthentication({ + userId: user.id, + org: organization(), + isTestAccount: false, + observedAt: 2_000, + expectedLastLoginAt: 1_000, + }); + expect( + await store.recordEmployeeDirectoryAuthentication({ + userId: user.id, + org, + isTestAccount: false, + observedAt: 3_000, + expectedLastLoginAt: 1_000, + }), + ).toBeNull(); + expect( + await store.recordEmployeeDirectoryAuthentication({ + userId: user.id, + org: organization(1_000), + isTestAccount: true, + observedAt: 3_000, + expectedLastLoginAt: 1_000, + }), + ).toBeNull(); + expect(await store.getEmployeeDirectorySnapshot(user.id)).toEqual({ + org: organization(), + isTestAccount: false, + }); + }); + + it("lists only previously authenticated users without duplicate identities or sessions", async () => { + const store = createStore(); + const first = await createUser(store, "one"); + const second = await createUser(store, "two"); + await createUser(store, "never-completed-login"); + await store.upsertPrincipal( + { ...first.principal, provider: "saml", subject: "saml:one" }, + 1_001, + ); + for (const [index, user] of [first.user, first.user, second.user].entries()) { + await store.createBrowserSession({ + userId: user.id, + tokenHash: `snapshot-session-${index}`, + createdAt: 2_000, + }); + } + const page = await store.listDirectoryOrganizationBackfillUsers({ limit: 1 }); + const next = await store.listDirectoryOrganizationBackfillUsers({ + limit: 1, + afterUserId: page[0]!.id, + }); + expect(new Set([...page, ...next].map((entry) => entry.id))).toEqual( + new Set([first.user.id, second.user.id]), + ); + expect( + await store.listDirectoryOrganizationBackfillUsers({ limit: 1, afterUserId: next[0]!.id }), + ).toEqual([]); + }); + + it("does not replay a captured user version after a newer login commits", async () => { + const store = createStore(); + const { user, principal } = await createUser(store); + await store.createBrowserSession({ + userId: user.id, + tokenHash: "existing-cookie", + createdAt: 1_001, + }); + await store.recordEmployeeDirectoryAuthentication({ + userId: user.id, + org: organization(), + isTestAccount: false, + observedAt: 1_000, + expectedLastLoginAt: 1_000, + }); + // The scan captures the old user row synchronously before its first await. + const backfill = backfillEmployeeOrganizations({ + store, + now: () => 3_000, + }); + await store.upsertPrincipal(principal, 2_000); + await store.recordEmployeeDirectoryAuthentication({ + userId: user.id, + org: organization(1_000, "New source"), + isTestAccount: false, + observedAt: 2_000, + expectedLastLoginAt: 2_000, + }); + await store.syncDirectoryOrganization({ + userId: user.id, + org: organization(1_000, "New source"), + isTestAccount: false, + syncedAt: 2_000, + expectedLastLoginAt: 2_000, + }); + expect(await backfill).toMatchObject({ found: 1, skipped: 1, failed: 0 }); + expect((await store.listManagedScopes()).find((scope) => scope.kind === "part")).toMatchObject({ + name: "New source", + }); + expect(await store.getEmployeeDirectorySnapshot(user.id)).toEqual({ + org: organization(1_000, "New source"), + isTestAccount: false, + }); + }); +}); diff --git a/packages/platformclaw-control-plane/src/employee-directory.ts b/packages/platformclaw-control-plane/src/employee-directory.ts new file mode 100644 index 00000000000..fd334413d0c --- /dev/null +++ b/packages/platformclaw-control-plane/src/employee-directory.ts @@ -0,0 +1,34 @@ +import type { PlatformUser } from "./contracts.js"; + +export type EmployeeDirectoryUser = Pick; + +export type EmployeeDirectoryOrganizationSnapshot = { + org: unknown; + isTestAccount: boolean; +}; + +export type EmployeeDirectoryBackfillOutcome = { + status: "pending" | "synced" | "skipped" | "failed"; + reason?: string; +}; + +export function projectEmployeeDirectoryStatus( + outcome: EmployeeDirectoryBackfillOutcome | undefined, + joinPromptEligible: boolean, + ssoRefreshUrl?: string, +) { + const refreshUrl = + outcome?.status === "skipped" && outcome.reason === "requires-authenticated-org" + ? ssoRefreshUrl + : undefined; + return { + ...(outcome ? { directorySync: { ...outcome, ...(refreshUrl ? { refreshUrl } : {}) } } : {}), + joinPromptEligible: + joinPromptEligible && + !( + outcome?.status === "pending" || + outcome?.status === "failed" || + outcome?.reason === "requires-authenticated-org" + ), + }; +} diff --git a/packages/platformclaw-control-plane/src/employee-organization-backfill.ts b/packages/platformclaw-control-plane/src/employee-organization-backfill.ts new file mode 100644 index 00000000000..a8666a0a77d --- /dev/null +++ b/packages/platformclaw-control-plane/src/employee-organization-backfill.ts @@ -0,0 +1,195 @@ +import { randomUUID } from "node:crypto"; +import { setImmediate } from "node:timers/promises"; +import type { + EmployeeDirectoryBackfillOutcome, + EmployeeDirectoryUser, +} from "./employee-directory.js"; +import type { SqliteControlPlaneStore } from "./sqlite-store.js"; + +const PAGE_SIZE = 100; +const CONCURRENCY = 4; +const MAX_PREPARED_TASKS = 64; + +export type EmployeeOrganizationBackfillSummary = { + found: number; + synced: number; + skipped: number; + failed: number; + limited: boolean; +}; + +type BackfillStore = Pick< + SqliteControlPlaneStore, + | "listDirectoryOrganizationBackfillUsers" + | "getEmployeeDirectorySnapshot" + | "syncDirectoryOrganization" + | "getEmployeeDirectoryAuthDecision" + | "getEmployeeDirectoryBackfillAttempt" + | "recordEmployeeDirectoryBackfillAttempt" +>; + +type Options = { store: BackfillStore; signal?: AbortSignal; now?: () => number }; + +/** Replays recorded authentication facts; never discovers organization from another service. */ +export class EmployeeOrganizationBackfill { + private readonly generation = randomUUID(); + private readonly abort = new AbortController(); + private readonly signal: AbortSignal; + private readonly tasks = new Map>(); + private scan: Promise | undefined; + + constructor(private readonly options: Options) { + this.signal = options.signal + ? AbortSignal.any([options.signal, this.abort.signal]) + : this.abort.signal; + } + + start(): Promise { + this.scan ??= this.scanUsers(); + return this.scan; + } + + prepareUser(user: EmployeeDirectoryUser): Promise { + if (this.signal.aborted) { + return Promise.resolve({ status: "pending", reason: "backfill-stopped" }); + } + const existing = this.tasks.get(user.id); + if (existing) { + return existing; + } + if (this.tasks.size >= MAX_PREPARED_TASKS) { + return Promise.race(this.tasks.values()).then(() => this.prepareUser(user)); + } + const task = this.prepare(user); + this.tasks.set(user.id, task); + const cleanup = () => { + if (this.tasks.get(user.id) === task) { + this.tasks.delete(user.id); + } + }; + void task.then(cleanup, cleanup); + return task; + } + + async stop(): Promise { + this.abort.abort(); + await Promise.allSettled([...this.tasks.values(), ...(this.scan ? [this.scan] : [])]); + } + + private async record(user: EmployeeDirectoryUser, outcome: EmployeeDirectoryBackfillOutcome) { + if (this.signal.aborted || user.lastLoginAt === undefined) { + return false; + } + return this.options.store.recordEmployeeDirectoryBackfillAttempt({ + userId: user.id, + generation: this.generation, + lastLoginAt: user.lastLoginAt, + outcome, + attemptedAt: (this.options.now ?? Date.now)(), + }); + } + + private async prepare(user: EmployeeDirectoryUser): Promise { + try { + if (user.lastLoginAt === undefined) { + return { status: "skipped", reason: "no-authentication" }; + } + const attempt = await this.options.store.getEmployeeDirectoryBackfillAttempt({ + userId: user.id, + generation: this.generation, + lastLoginAt: user.lastLoginAt, + }); + if (attempt && attempt.status !== "pending") { + return attempt; + } + this.signal.throwIfAborted(); + if (!(await this.record(user, { status: "pending" }))) { + return { status: "skipped", reason: "stale-authentication" }; + } + const decision = await this.options.store.getEmployeeDirectoryAuthDecision( + user.id, + user.lastLoginAt, + ); + // Only a positive decision from this authentication can authorize replay. + // Missing decisions include pending provisioning and failed decision writes. + let outcome: EmployeeDirectoryBackfillOutcome; + if (decision !== "eligible") { + outcome = { status: "skipped", reason: decision ?? "requires-authenticated-org" }; + } else { + const snapshot = await this.options.store.getEmployeeDirectorySnapshot(user.id); + this.signal.throwIfAborted(); + if (!snapshot) { + outcome = { status: "skipped", reason: "requires-authenticated-org" }; + } else { + const result = await this.options.store.syncDirectoryOrganization({ + userId: user.id, + ...snapshot, + syncedAt: (this.options.now ?? Date.now)(), + expectedLastLoginAt: user.lastLoginAt, + }); + outcome = + result.status === "synced" + ? { status: "synced" } + : { status: "skipped", reason: result.reason }; + } + } + await this.record(user, outcome); + return outcome; + } catch { + const outcome = { status: "failed", reason: "directory-unavailable" } as const; + if (!this.signal.aborted) { + await this.record(user, outcome).catch(() => false); + console.warn("directory_organization_backfill_failed", { userId: user.id }); + } + return outcome; + } + } + + private async scanUsers(): Promise { + const summary = { found: 0, synced: 0, skipped: 0, failed: 0, limited: false }; + let afterUserId: string | undefined; + try { + while (!this.signal.aborted) { + const users = await this.options.store.listDirectoryOrganizationBackfillUsers({ + afterUserId, + limit: PAGE_SIZE, + }); + if (!users.length) { + break; + } + summary.found += users.length; + afterUserId = users.at(-1)!.id; + for (let offset = 0; offset < users.length && !this.signal.aborted; offset += CONCURRENCY) { + const outcomes = await Promise.all( + users.slice(offset, offset + CONCURRENCY).map((user) => this.prepareUser(user)), + ); + for (const outcome of outcomes) { + if (outcome.status !== "pending") { + summary[outcome.status] += 1; + } + } + } + // SQLite tasks resolve in microtasks; yield so recovery cannot starve listener I/O. + await setImmediate(undefined, { signal: this.signal }); + } + } catch { + if (!this.signal.aborted) { + summary.failed += 1; + console.warn("directory_organization_backfill_unavailable"); + } + } + summary.limited = this.signal.aborted; + return summary; + } +} + +export async function backfillEmployeeOrganizations( + options: Options, +): Promise { + const owner = new EmployeeOrganizationBackfill(options); + try { + return await owner.start(); + } finally { + await owner.stop(); + } +} diff --git a/packages/platformclaw-control-plane/src/employee-sso.test.ts b/packages/platformclaw-control-plane/src/employee-sso.test.ts index 6d04a5b5f95..5635cafa860 100644 --- a/packages/platformclaw-control-plane/src/employee-sso.test.ts +++ b/packages/platformclaw-control-plane/src/employee-sso.test.ts @@ -97,6 +97,7 @@ describe("EmployeeSsoService", () => { org, isTestAccount: false, syncedAt: nowMs, + expectedLastLoginAt: nowMs, }); }); @@ -134,6 +135,7 @@ describe("EmployeeSsoService", () => { org: "invalid", isTestAccount: true, syncedAt: nowMs, + expectedLastLoginAt: nowMs, }); }); diff --git a/packages/platformclaw-control-plane/src/knowledge-vault-access.ts b/packages/platformclaw-control-plane/src/knowledge-vault-access.ts index ce2dbabc284..2d048795f85 100644 --- a/packages/platformclaw-control-plane/src/knowledge-vault-access.ts +++ b/packages/platformclaw-control-plane/src/knowledge-vault-access.ts @@ -19,6 +19,11 @@ export function activeWikiOrganizations(db: DatabaseSync) { if (scope.kind === "team") { return scope.parent_scope_id === null; } + // Canonical directory Parts may exist before their Group mapping arrives. + // Their direct grants remain valid without inventing ancestor access. + if (scope.kind === "part" && scope.parent_scope_id === null) { + return true; + } const parent = scopes.get(scope.parent_scope_id ?? ""); if (parent?.status !== "active") { return false; diff --git a/packages/platformclaw-control-plane/src/memory-store.ts b/packages/platformclaw-control-plane/src/memory-store.ts index ac019272bb9..91ef3855bec 100644 --- a/packages/platformclaw-control-plane/src/memory-store.ts +++ b/packages/platformclaw-control-plane/src/memory-store.ts @@ -622,6 +622,7 @@ export class InMemoryControlPlaneStore groups: normalizeGroups(principal.groups), createdAt, updatedAt: createdAt, + lastLoginAt: createdAt, ...this.profileFields(principal), }; this.users.set(user.id, user); @@ -636,6 +637,8 @@ export class InMemoryControlPlaneStore updatedAt: number, ) { user.groups = normalizeGroups(principal.groups); + // Match SQLite's distinct authentication stamps even when the clock has not advanced. + user.lastLoginAt = Math.max(updatedAt, (user.lastLoginAt ?? updatedAt - 1) + 1); user.updatedAt = updatedAt; const profile = this.profileFields(principal); for (const field of ["displayName", "email", "department"] as const) { diff --git a/packages/platformclaw-control-plane/src/organization-policy.ts b/packages/platformclaw-control-plane/src/organization-policy.ts index 0f6f0009a59..bb21f63b9f1 100644 --- a/packages/platformclaw-control-plane/src/organization-policy.ts +++ b/packages/platformclaw-control-plane/src/organization-policy.ts @@ -151,33 +151,41 @@ export function resolveOrganizationAuthorization(params: { return prepareOrganizationAuthorizationContext(params).authorize(params.targetScope); } -export function resolveEffectiveOrganizationAccess(params: { +export function resolveOrganizationAccess(params: { user: PlatformUser | null; scopes: readonly ManagedScope[]; memberships: readonly ManagedScopeMembership[]; -}): EffectiveManagedScopeAccess[] { +}): { + effectiveAccess: EffectiveManagedScopeAccess[]; + activeMemberships: ManagedScopeMembership[]; +} { if (!params.user || params.user.status !== "active") { - return []; + return { effectiveAccess: [], activeMemberships: [] }; } const scopeById = new Map(params.scopes.map((scope) => [scope.id, scope])); + // Administrator access is not a membership source. Preserve direct facts + // separately so an enrolled administrator is not reported as unaffiliated. + const direct = params.memberships.flatMap((membership) => { + const scope = scopeById.get(membership.scopeId); + const lineage = scope ? lineageFor(scope, scopeById) : []; + return lineage.length > 0 && lineage.every((entry) => entry.status === "active") + ? [{ membership, lineage }] + : []; + }); + const activeMemberships = direct.map(({ membership }) => membership); if (params.user.globalRole === "admin") { - return params.scopes - .filter((scope) => { - const lineage = lineageFor(scope, scopeById); - return lineage.length > 0 && lineage.every((entry) => entry.status === "active"); - }) - .map((scope) => ({ scope, source: "administrator" as const })); + return { + activeMemberships, + effectiveAccess: params.scopes + .filter((scope) => { + const lineage = lineageFor(scope, scopeById); + return lineage.length > 0 && lineage.every((entry) => entry.status === "active"); + }) + .map((scope) => ({ scope, source: "administrator" as const })), + }; } const access = new Map(); - for (const membership of params.memberships) { - const directScope = scopeById.get(membership.scopeId); - if (!directScope) { - continue; - } - const lineage = lineageFor(directScope, scopeById); - if (lineage.length === 0 || lineage.some((scope) => scope.status !== "active")) { - continue; - } + for (const { membership, lineage } of direct) { lineage.forEach((scope, index) => { const candidate: EffectiveManagedScopeAccess = index === 0 @@ -188,7 +196,10 @@ export function resolveEffectiveOrganizationAccess(params: { } }); } - return [...access.values()].toSorted((left, right) => - left.scope.id.localeCompare(right.scope.id), - ); + return { + activeMemberships, + effectiveAccess: [...access.values()].toSorted((left, right) => + left.scope.id.localeCompare(right.scope.id), + ), + }; } diff --git a/packages/platformclaw-control-plane/src/sqlite-fishing.test.ts b/packages/platformclaw-control-plane/src/sqlite-fishing.test.ts index 75582706a72..285bcff8700 100644 --- a/packages/platformclaw-control-plane/src/sqlite-fishing.test.ts +++ b/packages/platformclaw-control-plane/src/sqlite-fishing.test.ts @@ -7,6 +7,7 @@ import { } from "./fishing-economy.js"; import { FISHING_FINISH_WINDOW_MS, replayFishingFight } from "./fishing-simulation.js"; import { START, createFishingFixtures } from "./sqlite-fishing.test-harness.js"; +import { PLATFORMCLAW_CONTROL_SCHEMA_VERSION } from "./sqlite-schema.js"; const { setup, @@ -311,7 +312,9 @@ describe("direct fishing records and shared gold", () => { .prepare("UPDATE fishing_receipts SET receipt_json = '{}' WHERE user_id = ?") .run(alice.id), ).toThrow(/immutable/); - expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 3 }); + expect(db.prepare("PRAGMA user_version").get()).toEqual({ + user_version: PLATFORMCLAW_CONTROL_SCHEMA_VERSION, + }); }); }); diff --git a/packages/platformclaw-control-plane/src/sqlite-schema-directory-organizations.test.ts b/packages/platformclaw-control-plane/src/sqlite-schema-directory-organizations.test.ts index 4fafb5d4a92..d5e86c4865a 100644 --- a/packages/platformclaw-control-plane/src/sqlite-schema-directory-organizations.test.ts +++ b/packages/platformclaw-control-plane/src/sqlite-schema-directory-organizations.test.ts @@ -1,10 +1,13 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureDirectoryOrganizationSchema } from "./sqlite-schema-directory-organizations.js"; -import { initializeControlPlaneSchema } from "./sqlite-schema.js"; +import { + initializeControlPlaneSchema, + PLATFORMCLAW_CONTROL_SCHEMA_VERSION, +} from "./sqlite-schema.js"; const databases: DatabaseSync[] = []; const directories: string[] = []; @@ -34,7 +37,9 @@ function legacyDatabase(path?: string) { normalized_name TEXT NOT NULL, parent_scope_id TEXT REFERENCES managed_scopes(id), system_kind TEXT, system_provenance TEXT, status TEXT NOT NULL, created_by_user_id TEXT NOT NULL REFERENCES platform_users(id), - created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + CHECK ((kind = 'team' AND parent_scope_id IS NULL) OR + (kind IN ('group', 'part') AND parent_scope_id IS NOT NULL)) ) STRICT; CREATE UNIQUE INDEX managed_scopes_team_name ON managed_scopes(normalized_name) WHERE kind = 'team'; @@ -85,12 +90,15 @@ describe("directory organization schema", () => { initializeControlPlaneSchema(db); ensureDirectoryOrganizationSchema(db); - expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 3 }); + expect(db.prepare("PRAGMA user_version").get()).toEqual({ + user_version: PLATFORMCLAW_CONTROL_SCHEMA_VERSION, + }); expect(db.prepare("SELECT * FROM managed_scopes WHERE id = 'team'").get()).toEqual({ ...scope, external_key: null, managed_by: null, directory_updated_at: null, + directory_parent_key: null, }); expect(db.prepare("SELECT * FROM managed_scope_memberships").get()).toEqual({ ...membership, @@ -164,7 +172,9 @@ describe("directory organization schema", () => { managed_by: "ldap-login", manual_role: "leader", }); - expect(reopened.prepare("PRAGMA user_version").get()).toEqual({ user_version: 3 }); + expect(reopened.prepare("PRAGMA user_version").get()).toEqual({ + user_version: PLATFORMCLAW_CONTROL_SCHEMA_VERSION, + }); }); it("declares fresh schema canonically and participates in an existing transaction", () => { @@ -175,6 +185,113 @@ describe("directory organization schema", () => { ensureDirectoryOrganizationSchema(db); expect(db.isTransaction).toBe(true); db.exec("ROLLBACK"); + expect(db.prepare("PRAGMA user_version").get()).toEqual({ + user_version: PLATFORMCLAW_CONTROL_SCHEMA_VERSION, + }); + }); + + it("backs up v3 and permits only externally managed Parts to await their parents", () => { + const directory = mkdtempSync(join(tmpdir(), "platformclaw-directory-v4-")); + directories.push(directory); + const databasePath = join(directory, "control.sqlite"); + const db = legacyDatabase(databasePath); + ensureDirectoryOrganizationSchema(db); + insertScope(db, "directory-team", "Directory Team", "team:Directory Team"); + insertScope( + db, + "directory-group", + "Directory Group", + "group:platform", + "group", + "directory-team", + ); + insertScope( + db, + "directory-part", + "Directory Part", + "aicm-part:existing", + "part", + "directory-group", + ); + expect(() => insertScope(db, "part", "Part", "aicm-part:part", "part")).toThrow(); + + initializeControlPlaneSchema(db, databasePath); + + expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 4 }); + expect( + db + .prepare("SELECT directory_parent_key FROM managed_scopes WHERE id = 'directory-part'") + .get(), + ).toEqual({ directory_parent_key: "group:platform" }); + insertScope(db, "part", "Part", "aicm-part:part", "part"); + expect(() => insertScope(db, "manual-part", "Manual Part", null, "part")).toThrow(); + expect(() => + insertScope(db, "missing-part-id", "Invalid Part", "aicm-part:", "part"), + ).toThrow(); + expect(() => insertScope(db, "group", "Group", "group:group", "group")).toThrow(); + expect(() => + db.exec("UPDATE managed_scopes SET managed_by = NULL WHERE id = 'part'"), + ).toThrow(); + expect(() => + db.exec("UPDATE managed_scopes SET external_key = NULL WHERE id = 'part'"), + ).toThrow(); + expect(() => + db.exec("UPDATE managed_scopes SET parent_scope_id = 'team' WHERE id = 'part'"), + ).toThrow(); + expect(db.prepare("SELECT role FROM managed_scope_memberships").all()).toEqual([ + { role: "leader" }, + ]); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + const backups = readdirSync(directory).filter((name) => name.includes(".pre-v4-")); + expect(backups).toHaveLength(1); + const backup = openDatabase(join(directory, backups[0]!)); + expect(backup.prepare("PRAGMA user_version").get()).toEqual({ user_version: 3 }); + expect(backup.prepare("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" }); + expect(backup.prepare("SELECT id FROM managed_scopes ORDER BY id").all()).toEqual([ + { id: "directory-group" }, + { id: "directory-part" }, + { id: "directory-team" }, + { id: "team" }, + ]); + }); + + it("rolls back a failed v4 table replacement and retries without losing provenance or references", () => { + const db = legacyDatabase(); + ensureDirectoryOrganizationSchema(db); + db.exec(`UPDATE managed_scope_memberships SET managed_by = 'ldap-login', manual_role = 'leader'; + INSERT INTO directory_organization_sync VALUES ('user', 'team', 100)`); + const scopes = db.prepare("SELECT * FROM managed_scopes").all(); + const memberships = db.prepare("SELECT * FROM managed_scope_memberships").all(); + const run = db.exec.bind(db); + const fault = vi.spyOn(db, "exec").mockImplementation((sql) => { + if (sql.includes("ALTER TABLE managed_scopes_v4 RENAME TO managed_scopes")) { + throw new Error("injected v4 replacement failure"); + } + run(sql); + }); + try { + expect(() => initializeControlPlaneSchema(db)).toThrow("injected v4 replacement failure"); + } finally { + fault.mockRestore(); + } expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 3 }); + expect(db.prepare("PRAGMA foreign_keys").get()).toEqual({ foreign_keys: 1 }); + expect(db.prepare("SELECT * FROM managed_scopes").all()).toEqual(scopes); + expect(db.prepare("SELECT * FROM managed_scope_memberships").all()).toEqual(memberships); + expect( + db.prepare("SELECT name FROM sqlite_schema WHERE name = 'managed_scopes_v4'").get(), + ).toBeUndefined(); + + initializeControlPlaneSchema(db); + + expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 4 }); + expect(db.prepare("SELECT * FROM managed_scopes").all()).toEqual([ + { ...scopes[0], directory_parent_key: null }, + ]); + expect(db.prepare("SELECT * FROM managed_scope_memberships").all()).toEqual(memberships); + expect(db.prepare("SELECT * FROM directory_organization_sync").all()).toEqual([ + { user_id: "user", scope_id: "team", source_updated_at: 100 }, + ]); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); }); }); diff --git a/packages/platformclaw-control-plane/src/sqlite-schema-employee-directory.ts b/packages/platformclaw-control-plane/src/sqlite-schema-employee-directory.ts new file mode 100644 index 00000000000..13e7e61132f --- /dev/null +++ b/packages/platformclaw-control-plane/src/sqlite-schema-employee-directory.ts @@ -0,0 +1,29 @@ +import type { DatabaseSync } from "node:sqlite"; + +export const EMPLOYEE_DIRECTORY_SNAPSHOT_SCHEMA = ` +CREATE TABLE IF NOT EXISTS employee_directory_snapshots ( + user_id TEXT PRIMARY KEY REFERENCES platform_users(id) ON DELETE CASCADE, + org_json TEXT NOT NULL, + source_updated_at INTEGER NOT NULL, + observed_at INTEGER NOT NULL +) STRICT; +CREATE TABLE IF NOT EXISTS employee_directory_backfill_attempts ( + user_id TEXT PRIMARY KEY REFERENCES platform_users(id) ON DELETE CASCADE, + generation TEXT NOT NULL, + last_login_at INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'synced', 'skipped', 'failed')), + reason TEXT, + attempted_at INTEGER NOT NULL +) STRICT; +CREATE TABLE IF NOT EXISTS employee_directory_auth_decisions ( + user_id TEXT PRIMARY KEY REFERENCES platform_users(id) ON DELETE CASCADE, + authenticated_at INTEGER NOT NULL, + decision TEXT NOT NULL, + observed_at INTEGER NOT NULL +) STRICT; +`; + +/** Additive authenticated directory facts; older runtimes safely ignore this table. */ +export function ensureEmployeeDirectorySnapshotSchema(db: DatabaseSync): void { + db.exec(EMPLOYEE_DIRECTORY_SNAPSHOT_SCHEMA); +} diff --git a/packages/platformclaw-control-plane/src/sqlite-schema-managed-scopes.ts b/packages/platformclaw-control-plane/src/sqlite-schema-managed-scopes.ts new file mode 100644 index 00000000000..e16d4e99ae6 --- /dev/null +++ b/packages/platformclaw-control-plane/src/sqlite-schema-managed-scopes.ts @@ -0,0 +1,91 @@ +import type { DatabaseSync } from "node:sqlite"; +import { ensureDirectoryOrganizationSchema } from "./sqlite-schema-directory-organizations.js"; + +export const MANAGED_SCOPE_TABLE_SCHEMA = ` +CREATE TABLE managed_scopes ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('team', 'group', 'part')), + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + external_key TEXT, + managed_by TEXT CHECK (managed_by IN ('ldap-login')), + directory_updated_at INTEGER, + directory_parent_key TEXT, + parent_scope_id TEXT REFERENCES managed_scopes(id), + system_kind TEXT CHECK (system_kind IN ('unassigned-team')), + system_provenance TEXT CHECK (system_provenance IN ('migration-v2-v3')), + status TEXT NOT NULL CHECK (status IN ('active', 'archived')), + created_by_user_id TEXT NOT NULL REFERENCES platform_users(id), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK ( + (kind = 'team' AND parent_scope_id IS NULL) OR + (kind = 'group' AND parent_scope_id IS NOT NULL) OR + (kind = 'part' AND ( + parent_scope_id IS NOT NULL OR ( + managed_by IS 'ldap-login' AND external_key IS NOT NULL AND + external_key GLOB 'aicm-part:?*' + ) + )) + ), + CHECK ( + (system_kind IS NULL AND system_provenance IS NULL) OR + (kind = 'team' AND system_kind = 'unassigned-team' AND system_provenance = 'migration-v2-v3') + ) +) STRICT; +`; + +export const MANAGED_SCOPE_RELATION_SCHEMA = ` +CREATE UNIQUE INDEX managed_scopes_team_name + ON managed_scopes(normalized_name) WHERE kind = 'team' AND external_key IS NULL; +CREATE UNIQUE INDEX managed_scopes_child_name + ON managed_scopes(parent_scope_id, kind, normalized_name) + WHERE kind IN ('group', 'part') AND external_key IS NULL; +CREATE UNIQUE INDEX managed_scopes_system_kind + ON managed_scopes(system_kind) WHERE system_kind IS NOT NULL; +CREATE UNIQUE INDEX managed_scopes_external_key + ON managed_scopes(external_key) WHERE external_key IS NOT NULL; +CREATE TRIGGER managed_scopes_parent_kind_insert +BEFORE INSERT ON managed_scopes WHEN + (NEW.kind = 'team' AND NEW.parent_scope_id IS NOT NULL) OR + (NEW.kind = 'group' AND NOT EXISTS ( + SELECT 1 FROM managed_scopes WHERE id = NEW.parent_scope_id AND kind = 'team' + )) OR + (NEW.kind = 'part' AND NEW.parent_scope_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM managed_scopes WHERE id = NEW.parent_scope_id AND kind = 'group' + )) +BEGIN SELECT RAISE(ABORT, 'managed scope parent kind is invalid'); END; +CREATE TRIGGER managed_scopes_parent_kind_update +BEFORE UPDATE OF kind, parent_scope_id ON managed_scopes WHEN + (NEW.kind = 'team' AND NEW.parent_scope_id IS NOT NULL) OR + (NEW.kind = 'group' AND NOT EXISTS ( + SELECT 1 FROM managed_scopes WHERE id = NEW.parent_scope_id AND kind = 'team' + )) OR + (NEW.kind = 'part' AND NEW.parent_scope_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM managed_scopes WHERE id = NEW.parent_scope_id AND kind = 'group' + )) +BEGIN SELECT RAISE(ABORT, 'managed scope parent kind is invalid'); END; +`; + +/** Runs inside the version owner's backed-up, foreign-key-disabled migration transaction. */ +export function migrateDirectoryPartsToV4(db: DatabaseSync): void { + ensureDirectoryOrganizationSchema(db); + db.exec(MANAGED_SCOPE_TABLE_SCHEMA.replaceAll("managed_scopes", "managed_scopes_v4")); + db.exec(` + INSERT INTO managed_scopes_v4 ( + id, kind, name, normalized_name, external_key, managed_by, directory_updated_at, directory_parent_key, + parent_scope_id, system_kind, system_provenance, status, + created_by_user_id, created_at, updated_at + ) SELECT + scope.id, scope.kind, scope.name, scope.normalized_name, scope.external_key, scope.managed_by, scope.directory_updated_at, + CASE WHEN scope.managed_by = 'ldap-login' THEN + (SELECT parent.external_key FROM managed_scopes AS parent WHERE parent.id = scope.parent_scope_id) + ELSE NULL END, + scope.parent_scope_id, scope.system_kind, scope.system_provenance, scope.status, + scope.created_by_user_id, scope.created_at, scope.updated_at + FROM managed_scopes AS scope; + DROP TABLE managed_scopes; + ALTER TABLE managed_scopes_v4 RENAME TO managed_scopes; + `); + db.exec(MANAGED_SCOPE_RELATION_SCHEMA); +} diff --git a/packages/platformclaw-control-plane/src/sqlite-schema.test.ts b/packages/platformclaw-control-plane/src/sqlite-schema.test.ts index 768f86ddd4a..5ef93c6fb23 100644 --- a/packages/platformclaw-control-plane/src/sqlite-schema.test.ts +++ b/packages/platformclaw-control-plane/src/sqlite-schema.test.ts @@ -163,7 +163,9 @@ describe("PlatformClaw control schema migrations", () => { initializeControlPlaneSchema(secondOpener, databasePath); secondOpener.close(); - expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 3 }); + expect(db.prepare("PRAGMA user_version").get()).toEqual({ + user_version: PLATFORMCLAW_CONTROL_SCHEMA_VERSION, + }); expect( db .prepare( diff --git a/packages/platformclaw-control-plane/src/sqlite-schema.ts b/packages/platformclaw-control-plane/src/sqlite-schema.ts index 41a8f3e3e0f..b60fdca65d7 100644 --- a/packages/platformclaw-control-plane/src/sqlite-schema.ts +++ b/packages/platformclaw-control-plane/src/sqlite-schema.ts @@ -3,12 +3,18 @@ import { randomUUID } from "node:crypto"; import { chmodSync, unlinkSync } from "node:fs"; import type { DatabaseSync } from "node:sqlite"; import { DIRECTORY_ORGANIZATION_SCHEMA } from "./sqlite-schema-directory-organizations.js"; +import { EMPLOYEE_DIRECTORY_SNAPSHOT_SCHEMA } from "./sqlite-schema-employee-directory.js"; import { ORGANIZATION_MEMORY_SCHEMA, ORGANIZATION_MEMORY_LIFECYCLE_SCHEMA, SKILL_HUB_STATE_SCHEMA, SKILL_HUB_NOTIFICATION_SCHEMA, } from "./sqlite-schema-feature-state.js"; +import { + MANAGED_SCOPE_RELATION_SCHEMA, + MANAGED_SCOPE_TABLE_SCHEMA, + migrateDirectoryPartsToV4, +} from "./sqlite-schema-managed-scopes.js"; import { SCHEMA_V2 } from "./sqlite-schema-v2.js"; export { ensureMcpCredentialSchema, ensureMcpUserSettingsSchema } from "./sqlite-schema-mcp.js"; export { @@ -16,6 +22,7 @@ export { ensureExecCredentialSnapshotSchema, } from "./sqlite-schema-exec-credentials.js"; export { ensureDirectoryOrganizationSchema } from "./sqlite-schema-directory-organizations.js"; +export { ensureEmployeeDirectorySnapshotSchema } from "./sqlite-schema-employee-directory.js"; export { ORGANIZATION_KNOWLEDGE_SCHEMA, ensureOrganizationKnowledgeSchema, @@ -29,7 +36,7 @@ export { ensureKnowledgeVaultSchema, } from "./sqlite-schema-knowledge-vault.js"; -export const PLATFORMCLAW_CONTROL_SCHEMA_VERSION = 3; +export const PLATFORMCLAW_CONTROL_SCHEMA_VERSION = 4; // Additive feature state: older builds can continue using schema v3 unchanged. export function ensureWebAnnouncementSchema(db: DatabaseSync): void { @@ -120,57 +127,8 @@ CREATE TABLE browser_sessions ( ) STRICT; CREATE INDEX browser_sessions_user ON browser_sessions(user_id); -CREATE TABLE managed_scopes ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL CHECK (kind IN ('team', 'group', 'part')), - name TEXT NOT NULL, - normalized_name TEXT NOT NULL, - external_key TEXT, - managed_by TEXT CHECK (managed_by IN ('ldap-login')), - directory_updated_at INTEGER, - parent_scope_id TEXT REFERENCES managed_scopes(id), - system_kind TEXT CHECK (system_kind IN ('unassigned-team')), - system_provenance TEXT CHECK (system_provenance IN ('migration-v2-v3')), - status TEXT NOT NULL CHECK (status IN ('active', 'archived')), - created_by_user_id TEXT NOT NULL REFERENCES platform_users(id), - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - CHECK ( - (kind = 'team' AND parent_scope_id IS NULL) OR - (kind IN ('group', 'part') AND parent_scope_id IS NOT NULL) - ), - CHECK ( - (system_kind IS NULL AND system_provenance IS NULL) OR - (kind = 'team' AND system_kind = 'unassigned-team' AND system_provenance = 'migration-v2-v3') - ) -) STRICT; -CREATE UNIQUE INDEX managed_scopes_team_name - ON managed_scopes(normalized_name) WHERE kind = 'team' AND external_key IS NULL; -CREATE UNIQUE INDEX managed_scopes_child_name - ON managed_scopes(parent_scope_id, kind, normalized_name) - WHERE kind IN ('group', 'part') AND external_key IS NULL; -CREATE UNIQUE INDEX managed_scopes_system_kind - ON managed_scopes(system_kind) WHERE system_kind IS NOT NULL; -CREATE TRIGGER managed_scopes_parent_kind_insert -BEFORE INSERT ON managed_scopes WHEN - (NEW.kind = 'team' AND NEW.parent_scope_id IS NOT NULL) OR - (NEW.kind = 'group' AND NOT EXISTS ( - SELECT 1 FROM managed_scopes WHERE id = NEW.parent_scope_id AND kind = 'team' - )) OR - (NEW.kind = 'part' AND NOT EXISTS ( - SELECT 1 FROM managed_scopes WHERE id = NEW.parent_scope_id AND kind = 'group' - )) -BEGIN SELECT RAISE(ABORT, 'managed scope parent kind is invalid'); END; -CREATE TRIGGER managed_scopes_parent_kind_update -BEFORE UPDATE OF kind, parent_scope_id ON managed_scopes WHEN - (NEW.kind = 'team' AND NEW.parent_scope_id IS NOT NULL) OR - (NEW.kind = 'group' AND NOT EXISTS ( - SELECT 1 FROM managed_scopes WHERE id = NEW.parent_scope_id AND kind = 'team' - )) OR - (NEW.kind = 'part' AND NOT EXISTS ( - SELECT 1 FROM managed_scopes WHERE id = NEW.parent_scope_id AND kind = 'group' - )) -BEGIN SELECT RAISE(ABORT, 'managed scope parent kind is invalid'); END; +${MANAGED_SCOPE_TABLE_SCHEMA} +${MANAGED_SCOPE_RELATION_SCHEMA} CREATE TABLE managed_scope_memberships ( scope_id TEXT NOT NULL REFERENCES managed_scopes(id) ON DELETE CASCADE, @@ -185,6 +143,7 @@ CREATE TABLE managed_scope_memberships ( CREATE INDEX managed_scope_memberships_user ON managed_scope_memberships(user_id); ${DIRECTORY_ORGANIZATION_SCHEMA} +${EMPLOYEE_DIRECTORY_SNAPSHOT_SCHEMA} CREATE TABLE managed_scope_primary_memberships ( user_id TEXT PRIMARY KEY REFERENCES platform_users(id) ON DELETE CASCADE, @@ -308,7 +267,7 @@ function createMigrationBackup( throw new Error("PlatformClaw control database WAL checkpoint remained busy"); } assertDatabaseIntegrity(db); - const backupPath = `${databasePath}.pre-v3-${Date.now()}-${randomUUID()}.sqlite`; + const backupPath = `${databasePath}.pre-v${expectedVersion + 1}-${Date.now()}-${randomUUID()}.sqlite`; db.exec(`VACUUM INTO '${backupPath.replaceAll("'", "''")}'`); if (process.platform !== "win32") { chmodSync(backupPath, 0o600); @@ -327,8 +286,10 @@ function createMigrationBackup( } ).user_version; if ( - version.user_version === PLATFORMCLAW_CONTROL_SCHEMA_VERSION && - liveVersion === PLATFORMCLAW_CONTROL_SCHEMA_VERSION + version.user_version > expectedVersion && + version.user_version <= PLATFORMCLAW_CONTROL_SCHEMA_VERSION && + liveVersion >= version.user_version && + liveVersion <= PLATFORMCLAW_CONTROL_SCHEMA_VERSION ) { alreadyMigrated = true; } else { @@ -341,12 +302,12 @@ function createMigrationBackup( db.exec("DETACH DATABASE migration_backup"); } if (alreadyMigrated) { - // Another opener already committed v3. Redundant snapshot cleanup must not + // Another opener already advanced this migration. Redundant snapshot cleanup must not // turn a valid upgraded database into a startup failure on Windows AV races. try { unlinkSync(backupPath); } catch { - // Best-effort cleanup; the verified database is already canonical v3. + // Best-effort cleanup; the verified database already passed this migration. } return false; } @@ -591,6 +552,44 @@ function migrateV2ToV3(db: DatabaseSync): void { `); } +function migrateControlPlaneVersion( + db: DatabaseSync, + databasePath: string | undefined, + expectedVersion: number, + migrate: () => void, +): void { + if (!createMigrationBackup(db, databasePath, expectedVersion)) { + return; + } + db.exec("PRAGMA foreign_keys = OFF"); + try { + db.exec("BEGIN IMMEDIATE"); + const version = (db.prepare("PRAGMA user_version").get() as { user_version: number }) + .user_version; + if (version > expectedVersion && version <= PLATFORMCLAW_CONTROL_SCHEMA_VERSION) { + db.exec("COMMIT"); + return; + } + if (version !== expectedVersion) { + throw new Error(`unsupported PlatformClaw control schema version: ${version}`); + } + migrate(); + if (db.prepare("PRAGMA foreign_key_check").all().length > 0) { + throw new Error(`schema v${expectedVersion + 1} migration produced invalid foreign keys`); + } + assertDatabaseIntegrity(db); + db.exec(`PRAGMA user_version = ${expectedVersion + 1}`); + db.exec("COMMIT"); + } catch (error) { + if (db.isTransaction) { + db.exec("ROLLBACK"); + } + throw error; + } finally { + db.exec("PRAGMA foreign_keys = ON"); + } +} + export function initializeControlPlaneSchema(db: DatabaseSync, databasePath?: string): void { db.exec("PRAGMA foreign_keys = ON"); db.exec("PRAGMA busy_timeout = 5000"); @@ -641,40 +640,18 @@ export function initializeControlPlaneSchema(db: DatabaseSync, databasePath?: st } } version = (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version; - if (version === PLATFORMCLAW_CONTROL_SCHEMA_VERSION) { - return; - } - if (version !== 2) { - throw new Error(`unsupported PlatformClaw control schema version: ${version}`); + if (version === 2) { + migrateControlPlaneVersion(db, databasePath, 2, () => migrateV2ToV3(db)); } - const backupCreated = createMigrationBackup(db, databasePath, 2); version = (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version; - if (!backupCreated && version === PLATFORMCLAW_CONTROL_SCHEMA_VERSION) { - return; + if (version === 3) { + migrateControlPlaneVersion(db, databasePath, 3, () => { + migrateDirectoryPartsToV4(db); + db.exec(EMPLOYEE_DIRECTORY_SNAPSHOT_SCHEMA); + }); } - db.exec("PRAGMA foreign_keys = OFF"); - db.exec("BEGIN IMMEDIATE"); - try { - version = (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version; - if (version === PLATFORMCLAW_CONTROL_SCHEMA_VERSION) { - db.exec("COMMIT"); - return; - } - if (version !== 2) { - throw new Error(`unsupported PlatformClaw control schema version: ${version}`); - } - migrateV2ToV3(db); - const foreignKeyFailures = db.prepare("PRAGMA foreign_key_check").all(); - if (foreignKeyFailures.length > 0) { - throw new Error("schema v3 migration produced invalid foreign keys"); - } - assertDatabaseIntegrity(db); - db.exec(`PRAGMA user_version = ${PLATFORMCLAW_CONTROL_SCHEMA_VERSION}`); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } finally { - db.exec("PRAGMA foreign_keys = ON"); + version = (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version; + if (version !== PLATFORMCLAW_CONTROL_SCHEMA_VERSION) { + throw new Error(`unsupported PlatformClaw control schema version: ${version}`); } } diff --git a/packages/platformclaw-control-plane/src/sqlite-store-auth.ts b/packages/platformclaw-control-plane/src/sqlite-store-auth.ts index 6470c840b2a..012c778ffb5 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store-auth.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store-auth.ts @@ -27,11 +27,11 @@ import { rowToBinding, rowToIdentity, rowToSession, - SqliteControlPlaneStoreCore, } from "./sqlite-store-core.js"; +import { SqliteEmployeeDirectoryStore } from "./sqlite-store-employee-directory.js"; import type { AgentBindingRow, BrowserSessionRow } from "./sqlite-store-types.js"; -export abstract class SqliteControlPlaneAuthStore extends SqliteControlPlaneStoreCore { +export abstract class SqliteControlPlaneAuthStore extends SqliteEmployeeDirectoryStore { async upsertPrincipal( principal: EnterprisePrincipal, authenticatedAt: number, diff --git a/packages/platformclaw-control-plane/src/sqlite-store-core.ts b/packages/platformclaw-control-plane/src/sqlite-store-core.ts index 2ec643a93a5..1c3abea08a8 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store-core.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store-core.ts @@ -25,7 +25,7 @@ import { defaultControlPlaneIdFactory } from "./ids.js"; import { createSyncKysely, executeSync, takeFirstSync } from "./kysely-sync.js"; import { isDelegatedOrganizationLeader, - resolveEffectiveOrganizationAccess, + resolveOrganizationAccess, resolveOrganizationAuthorization, } from "./organization-policy.js"; import { retireOrganizationKnowledge } from "./sqlite-retire-organization-knowledge.js"; @@ -332,6 +332,10 @@ export abstract class SqliteControlPlaneStoreCore { principal: EnterprisePrincipal, updatedAt: number, ): void { + const previous = this.requireUserRow(userId).last_login_at; + // Same-clock authentications need distinct stamps so old eligible facts cannot + // authorize a newer authentication whose decision has not been recorded. + const lastLoginAt = previous === null ? updatedAt : Math.max(updatedAt, previous + 1); executeSync( this.db, this.query @@ -342,7 +346,7 @@ export abstract class SqliteControlPlaneStoreCore { email: optional(principal.email), department: optional(principal.department), updated_at: updatedAt, - last_login_at: updatedAt, + last_login_at: lastLoginAt, }) .where("id", "=", userId), ); @@ -514,7 +518,7 @@ export abstract class SqliteControlPlaneStoreCore { }); } - protected resolveEffectiveOrganizationAccessSnapshot(userId: string) { + protected resolveOrganizationAccessSnapshot(userId: string) { const user = this.selectUserById(userId); const scopes = executeSync( this.db, @@ -529,7 +533,7 @@ export abstract class SqliteControlPlaneStoreCore { .where("user_id", "=", user.id), ).rows.map(rowToMembership) : []; - return resolveEffectiveOrganizationAccess({ + return resolveOrganizationAccess({ user: user ? this.rowToUser(user) : null, scopes, memberships, diff --git a/packages/platformclaw-control-plane/src/sqlite-store-directory-organization.ts b/packages/platformclaw-control-plane/src/sqlite-store-directory-organization.ts index e915acdae72..d5e676d553b 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store-directory-organization.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store-directory-organization.ts @@ -14,17 +14,17 @@ type ScopePlan = { key: string; name: string; parentId: string | null; + parentKey: string | null; existing?: ManagedScopeRow; }; -type SkippedSync = Extract; - export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlaneOrganizationMutationStore { async syncDirectoryOrganization(params: { userId: string; org: unknown; isTestAccount: boolean; syncedAt: number; + expectedLastLoginAt?: number; }): Promise { if (params.isTestAccount) { return { status: "skipped", reason: "test-account" }; @@ -34,7 +34,12 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan parsed.status === "skipped" ? parsed : this.commitOrganizationMutation(() => - this.applyDirectoryOrganization(params.userId, parsed.organization, params.syncedAt), + this.applyDirectoryOrganization( + params.userId, + parsed.organization, + params.syncedAt, + params.expectedLastLoginAt, + ), ); if ( result.status === "skipped" && @@ -63,6 +68,7 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan key: string, name: string, parentId: string | null, + parentKey: string | null, existing = this.selectDirectoryScope(key), ): ScopePlan { return { @@ -71,35 +77,50 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan key, name, parentId, + parentKey, existing, }; } - private planDirectoryOrganization(org: DirectoryOrganization): ScopePlan[] | SkippedSync { + private planDirectoryOrganization(org: DirectoryOrganization): ScopePlan[] { const partKey = `aicm-part:${org.part.id}`; const part = this.selectDirectoryScope(partKey); + // Claim certainty belongs to this source revision; physical ancestry may + // retain an older parent. Same-revision unknowns cannot erase an explicit key. + const unknownParentKey = (scope: ManagedScopeRow | undefined) => + org.sourceUpdatedAt > (scope?.directory_updated_at ?? Number.NEGATIVE_INFINITY) + ? null + : (scope?.directory_parent_key ?? null); + const planPart = (parentId: string | null, parentKey: string | null) => + this.directoryScopePlan("part", partKey, org.part.name, parentId, parentKey, part); if (!org.group) { - // Unknown does not detach a known part or guess a group from department text. - if (!part) { - return { status: "skipped", reason: "missing-parent" }; - } - return [this.directoryScopePlan("part", partKey, org.part.name, part.parent_scope_id, part)]; + // Unknown preserves an existing parent; a new directory Part has no invented ancestors. + return [planPart(part?.parent_scope_id ?? null, unknownParentKey(part))]; } const groupKey = `group:${org.group.id}`; const group = this.selectDirectoryScope(groupKey); if (!org.team && !group) { - return { status: "skipped", reason: "missing-parent" }; + // A known new Group without its Team cannot be linked yet. Record its key + // on the real Part so cached claims cannot replace the pending assignment. + return [planPart(null, groupKey)]; } const plans: ScopePlan[] = []; let teamId = group?.parent_scope_id ?? null; if (org.team) { - const team = this.directoryScopePlan("team", `team:${org.team}`, org.team, null); + const team = this.directoryScopePlan("team", `team:${org.team}`, org.team, null, null); plans.push(team); teamId = team.id; } - const groupPlan = this.directoryScopePlan("group", groupKey, org.group.name, teamId, group); + const groupPlan = this.directoryScopePlan( + "group", + groupKey, + org.group.name, + teamId, + org.team ? `team:${org.team}` : unknownParentKey(group), + group, + ); plans.push(groupPlan); - plans.push(this.directoryScopePlan("part", partKey, org.part.name, groupPlan.id, part)); + plans.push(planPart(groupPlan.id, groupKey)); return plans; } @@ -107,10 +128,17 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan userId: string, org: DirectoryOrganization, syncedAt: number, + expectedLastLoginAt?: number, ): DirectoryOrganizationSyncResult { - if (this.requireUserRow(userId).status !== "active") { + const user = this.requireUserRow(userId); + if (user.status !== "active") { return { status: "skipped", reason: "inactive-user" }; } + // Recovery reads saved facts before BEGIN. A newer authentication owns the + // user's current directory decision and must not be overwritten by that read. + if (expectedLastLoginAt !== undefined && user.last_login_at !== expectedLastLoginAt) { + return { status: "skipped", reason: "stale-authentication" }; + } const previous = takeFirstSync( this.db, this.query @@ -122,9 +150,6 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan return { status: "skipped", reason: "stale-organization" }; } const plans = this.planDirectoryOrganization(org); - if (!Array.isArray(plans)) { - return plans; - } const partPlan = plans.at(-1)!; if ( previous && @@ -143,11 +168,25 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan if (this.scopeLineageRows(current).some((scope) => scope.status !== "active")) { return { status: "skipped", reason: "inactive-organization" }; } - const changed = current.name !== plan.name || current.parent_scope_id !== plan.parentId; + const changed = + current.name !== plan.name || + current.parent_scope_id !== plan.parentId || + current.directory_parent_key !== plan.parentKey; + // Equal-source names and explicit parents stay immutable. Unknown parent facts + // may refine while an older physical parent is retained; pending Parts may + // also materialize their already recorded external Group. + const refinesUnknownParent = + current.kind !== "team" && + current.name === plan.name && + (current.directory_parent_key === null || + (current.kind === "part" && + current.parent_scope_id === null && + current.directory_parent_key === plan.parentKey)); if ( changed && current.directory_updated_at !== null && - org.sourceUpdatedAt <= current.directory_updated_at + (org.sourceUpdatedAt < current.directory_updated_at || + (org.sourceUpdatedAt === current.directory_updated_at && !refinesUnknownParent)) ) { return { status: "skipped", @@ -273,8 +312,9 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan syncedAt: number, ) { const current = plan.existing; - const nameOrParentChanged = + const scopeChanged = !current || current.name !== plan.name || current.parent_scope_id !== plan.parentId; + const claimChanged = current && current.directory_parent_key !== plan.parentKey; if (!current) { executeSync( this.db, @@ -287,6 +327,7 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan external_key: plan.key, managed_by: "ldap-login", directory_updated_at: sourceUpdatedAt, + directory_parent_key: plan.parentKey, status: "active", created_by_user_id: userId, system_kind: null, @@ -295,7 +336,11 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan updated_at: syncedAt, }), ); - } else if (nameOrParentChanged || sourceUpdatedAt > (current.directory_updated_at ?? 0)) { + } else if ( + scopeChanged || + claimChanged || + sourceUpdatedAt > (current.directory_updated_at ?? 0) + ) { executeSync( this.db, this.query @@ -304,18 +349,19 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan name: plan.name, normalized_name: normalizeScopeName(plan.name), parent_scope_id: plan.parentId, + directory_parent_key: plan.parentKey, directory_updated_at: Math.max( sourceUpdatedAt, current.directory_updated_at ?? sourceUpdatedAt, ), - updated_at: nameOrParentChanged + updated_at: scopeChanged ? Math.max(syncedAt, current.updated_at + 1) : current.updated_at, }) .where("id", "=", plan.id), ); } - if (nameOrParentChanged) { + if (scopeChanged) { const action = !current ? "scope.created" : current.parent_scope_id !== plan.parentId @@ -323,11 +369,16 @@ export abstract class SqliteDirectoryOrganizationStore extends SqliteControlPlan : "scope.renamed"; const change = action === "scope.moved" - ? { priorScopeId: current?.parent_scope_id, resultScopeId: plan.parentId } + ? { + priorScopeId: current?.parent_scope_id, + resultScopeId: plan.parentId, + priorParentKey: current?.directory_parent_key, + resultParentKey: plan.parentKey, + } : { ...(current ? { beforeName: current.name } : {}), resultName: plan.name }; this.directoryAudit(action, plan.id, userId, syncedAt, change); } - return nameOrParentChanged; + return scopeChanged; } private completeDirectoryJoinRequests(userId: string, scopeId: string, at: number): void { diff --git a/packages/platformclaw-control-plane/src/sqlite-store-employee-directory.ts b/packages/platformclaw-control-plane/src/sqlite-store-employee-directory.ts new file mode 100644 index 00000000000..46cbe968e41 --- /dev/null +++ b/packages/platformclaw-control-plane/src/sqlite-store-employee-directory.ts @@ -0,0 +1,250 @@ +import { ControlPlaneStateError } from "./contracts.js"; +import { parseDirectoryOrganization } from "./directory-organization.js"; +import type { + EmployeeDirectoryBackfillOutcome, + EmployeeDirectoryOrganizationSnapshot, + EmployeeDirectoryUser, +} from "./employee-directory.js"; +import { executeSync, runImmediateTransaction, takeFirstSync } from "./kysely-sync.js"; +import { ensureEmployeeDirectorySnapshotSchema } from "./sqlite-schema-employee-directory.js"; +import { SqliteControlPlaneStoreCore } from "./sqlite-store-core.js"; + +export abstract class SqliteEmployeeDirectoryStore extends SqliteControlPlaneStoreCore { + private employeeDirectorySchemaReady = false; + + private ensureEmployeeDirectorySnapshots(): void { + if (!this.employeeDirectorySchemaReady) { + ensureEmployeeDirectorySnapshotSchema(this.db); + this.employeeDirectorySchemaReady = true; + } + } + + async recordEmployeeDirectoryAuthentication(params: { + userId: string; + org: unknown; + isTestAccount: boolean; + expectedLastLoginAt: number; + observedAt: number; + }): Promise { + const parsed = parseDirectoryOrganization(params.org); + this.ensureEmployeeDirectorySnapshots(); + return runImmediateTransaction(this.db, () => { + const user = this.requireUserRow(params.userId); + if (user.status !== "active" || user.last_login_at !== params.expectedLastLoginAt) { + return null; + } + let decision = params.isTestAccount + ? "test-account" + : parsed.status === "eligible" + ? "eligible" + : parsed.reason; + let snapshot: EmployeeDirectoryOrganizationSnapshot | null = null; + if (!params.isTestAccount && parsed.status === "eligible") { + const organization = parsed.organization; + let org: unknown = { + version: 1, + source: "aicm", + team: organization.team, + group: organization.group, + part: organization.part, + updatedAt: new Date(organization.sourceUpdatedAt).toISOString(), + }; + let orgJson = JSON.stringify(org); + const previous = takeFirstSync( + this.db, + this.query + .selectFrom("employee_directory_snapshots") + .selectAll() + .where("user_id", "=", user.id), + ); + if (previous && previous.source_updated_at > organization.sourceUpdatedAt) { + decision = "stale-organization"; + } else if ( + previous && + previous.source_updated_at === organization.sourceUpdatedAt && + previous.org_json !== orgJson + ) { + const previousOrg: unknown = JSON.parse(previous.org_json); + const parsedPrevious = parseDirectoryOrganization(previousOrg); + const prior = parsedPrevious.status === "eligible" ? parsedPrevious.organization : null; + const comparableTeams = prior && (prior.group === null) === (organization.group === null); + const conflictingGroup = + prior?.group && + organization.group && + (prior.group.id !== organization.group.id || + prior.group.name !== organization.group.name); + const conflictingTeam = + comparableTeams && + prior.team !== null && + organization.team !== null && + prior.team !== organization.team; + if ( + !prior || + prior.part.id !== organization.part.id || + prior.part.name !== organization.part.name || + conflictingGroup || + conflictingTeam + ) { + decision = "conflicting-organization"; + } else if ( + (prior.group !== null && organization.group === null) || + (comparableTeams && organization.team === null) + ) { + // Unknowns retain the stronger fact. A newly known Group instead uses + // its incoming nullable Team intact; never borrow a standalone Team. + org = previousOrg; + orgJson = previous.org_json; + } + } + if (decision === "eligible") { + const values = { + org_json: orgJson, + source_updated_at: organization.sourceUpdatedAt, + observed_at: Math.max(params.observedAt, previous?.observed_at ?? 0), + }; + executeSync( + this.db, + this.query + .insertInto("employee_directory_snapshots") + .values({ user_id: user.id, ...values }) + .onConflict((conflict) => conflict.column("user_id").doUpdateSet(values)), + ); + snapshot = { org, isTestAccount: false }; + } + } + // Only an accepted fact may authorize this authentication's replay. Commit + // both together, independently of graph sync, so failures stay recoverable. + const values = { + authenticated_at: params.expectedLastLoginAt, + decision, + observed_at: params.observedAt, + }; + executeSync( + this.db, + this.query + .insertInto("employee_directory_auth_decisions") + .values({ user_id: user.id, ...values }) + .onConflict((conflict) => conflict.column("user_id").doUpdateSet(values)), + ); + executeSync( + this.db, + this.query + .deleteFrom("employee_directory_backfill_attempts") + .where("user_id", "=", user.id), + ); + return snapshot; + }); + } + + async getEmployeeDirectoryAuthDecision( + userId: string, + authenticatedAt: number, + ): Promise { + this.ensureEmployeeDirectorySnapshots(); + return ( + takeFirstSync( + this.db, + this.query + .selectFrom("employee_directory_auth_decisions") + .select("decision") + .where("user_id", "=", userId) + .where("authenticated_at", "=", authenticatedAt), + )?.decision ?? null + ); + } + + async getEmployeeDirectoryBackfillAttempt(params: { + userId: string; + generation: string; + lastLoginAt: number; + }): Promise { + this.ensureEmployeeDirectorySnapshots(); + const row = takeFirstSync( + this.db, + this.query + .selectFrom("employee_directory_backfill_attempts") + .select(["status", "reason"]) + .where("user_id", "=", params.userId) + .where("generation", "=", params.generation) + .where("last_login_at", "=", params.lastLoginAt), + ); + return row ? { status: row.status, ...(row.reason ? { reason: row.reason } : {}) } : null; + } + + async recordEmployeeDirectoryBackfillAttempt(params: { + userId: string; + generation: string; + lastLoginAt: number; + outcome: EmployeeDirectoryBackfillOutcome; + attemptedAt: number; + }): Promise { + this.ensureEmployeeDirectorySnapshots(); + return runImmediateTransaction(this.db, () => { + const user = this.requireUserRow(params.userId); + if (user.status !== "active" || user.last_login_at !== params.lastLoginAt) { + return false; + } + const values = { + generation: params.generation, + last_login_at: params.lastLoginAt, + status: params.outcome.status, + reason: params.outcome.reason ?? null, + attempted_at: params.attemptedAt, + }; + executeSync( + this.db, + this.query + .insertInto("employee_directory_backfill_attempts") + .values({ user_id: user.id, ...values }) + .onConflict((conflict) => conflict.column("user_id").doUpdateSet(values)), + ); + return true; + }); + } + + async listDirectoryOrganizationBackfillUsers(params: { + afterUserId?: string; + limit: number; + }): Promise { + const limit = Math.max(1, Math.min(Math.trunc(params.limit), 200)); + let query = this.query + .selectFrom("platform_users") + .innerJoin("enterprise_identities", "enterprise_identities.user_id", "platform_users.id") + .innerJoin("browser_sessions", "browser_sessions.user_id", "platform_users.id") + .select(["platform_users.id", "platform_users.last_login_at"]) + .distinct() + .where("platform_users.status", "=", "active") + .where("platform_users.last_login_at", "is not", null) + .where("enterprise_identities.provider", "in", ["ldap", "saml"]) + .orderBy("platform_users.id") + .limit(limit); + if (params.afterUserId) { + query = query.where("platform_users.id", ">", params.afterUserId); + } + return executeSync(this.db, query).rows.map((row) => ({ + id: row.id, + lastLoginAt: row.last_login_at!, + })); + } + + async getEmployeeDirectorySnapshot( + userId: string, + ): Promise { + this.ensureEmployeeDirectorySnapshots(); + const row = takeFirstSync( + this.db, + this.query + .selectFrom("employee_directory_snapshots") + .select("org_json") + .where("user_id", "=", userId), + ); + if (!row) { + return null; + } + const org = JSON.parse(row.org_json) as unknown; + if (parseDirectoryOrganization(org).status !== "eligible") { + throw new ControlPlaneStateError("saved employee directory snapshot is invalid"); + } + return { org, isTestAccount: false }; + } +} diff --git a/packages/platformclaw-control-plane/src/sqlite-store-exec-credentials.test.ts b/packages/platformclaw-control-plane/src/sqlite-store-exec-credentials.test.ts index 51c388a520d..d7d823be58a 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store-exec-credentials.test.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store-exec-credentials.test.ts @@ -3,6 +3,7 @@ import { DatabaseSync } from "node:sqlite"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ControlPlaneConflictError } from "./contracts.js"; import { createExecCredentialTestContext } from "./exec-credential.test-support.js"; +import { PLATFORMCLAW_CONTROL_SCHEMA_VERSION } from "./sqlite-schema.js"; import { SqliteControlPlaneStore } from "./sqlite-store.js"; const contexts: Awaited>[] = []; @@ -193,7 +194,9 @@ describe("SqliteControlPlaneExecCredentialStore", () => { const readded = await c.preconditions(c.member.user, "API_TOKEN"); expect(readded.expectedDefinitionToken).not.toBe(before.expectedDefinitionToken); expect(readded.expectedCredentialToken).toBeNull(); - expect(db.prepare("PRAGMA user_version").get()).toMatchObject({ user_version: 3 }); + expect(db.prepare("PRAGMA user_version").get()).toMatchObject({ + user_version: PLATFORMCLAW_CONTROL_SCHEMA_VERSION, + }); } finally { db.close(); } diff --git a/packages/platformclaw-control-plane/src/sqlite-store-organization-access.ts b/packages/platformclaw-control-plane/src/sqlite-store-organization-access.ts index ec9f80edade..ce21adb6431 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store-organization-access.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store-organization-access.ts @@ -78,6 +78,10 @@ export abstract class SqliteControlPlaneOrganizationAccessStore extends SqliteCo expression("grandparent.status", "=", "active"), expression("grandparent.parent_scope_id", "is", null), ]), + expression.and([ + expression("scope.kind", "=", "part"), + expression("scope.parent_scope_id", "is", null), + ]), ]), ); const rawNeedle = params.query.trim(); @@ -331,8 +335,9 @@ export abstract class SqliteControlPlaneOrganizationAccessStore extends SqliteCo } async listEffectiveManagedScopeAccess(userId: string) { - return runReadTransaction(this.db, () => - this.resolveEffectiveOrganizationAccessSnapshot(userId), + return runReadTransaction( + this.db, + () => this.resolveOrganizationAccessSnapshot(userId).effectiveAccess, ); } diff --git a/packages/platformclaw-control-plane/src/sqlite-store-organization-join-read.ts b/packages/platformclaw-control-plane/src/sqlite-store-organization-join-read.ts index f4adcccc5fd..e37e9c002c9 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store-organization-join-read.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store-organization-join-read.ts @@ -139,8 +139,9 @@ export abstract class SqliteControlPlaneOrganizationJoinReadStore extends Sqlite lineage: this.scopeLineageRows(scopeRow).map(rowToScope).toReversed(), }; }); - const effectiveAccess = this.resolveEffectiveOrganizationAccessSnapshot(params.userId); - const activeDirectAccess = effectiveAccess.filter((access) => access.source === "direct"); + const { effectiveAccess, activeMemberships } = this.resolveOrganizationAccessSnapshot( + params.userId, + ); const hasPendingJoinRequest = Boolean( takeFirstSync( this.db, @@ -168,14 +169,14 @@ export abstract class SqliteControlPlaneOrganizationJoinReadStore extends Sqlite ? this.scopeLineageRows(primary).map(rowToScope).toReversed() : [], joinRequestDetails, - isUnaffiliated: activeDirectAccess.length === 0, + isUnaffiliated: activeMemberships.length === 0, hasPendingJoinRequest, canReviewJoinRequests: actor.globalRole === "admin" || - activeDirectAccess.some((access) => access.directRole === "leader"), + activeMemberships.some((membership) => membership.role === "leader"), canManageOrganization: actor.globalRole === "admin" || - activeDirectAccess.some((access) => access.directRole === "leader"), + activeMemberships.some((membership) => membership.role === "leader"), canViewOrganizationAudit: actor.globalRole === "admin", }; }); @@ -326,6 +327,10 @@ export abstract class SqliteControlPlaneOrganizationJoinReadStore extends Sqlite expression("grandparent.kind", "=", "team"), expression("grandparent.status", "=", "active"), ]), + expression.and([ + expression("scope.kind", "=", "part"), + expression("scope.parent_scope_id", "is", null), + ]), ]), ); } diff --git a/packages/platformclaw-control-plane/src/sqlite-store-organization.ts b/packages/platformclaw-control-plane/src/sqlite-store-organization.ts index a0adef9e86a..74e398e2752 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store-organization.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store-organization.ts @@ -67,6 +67,7 @@ export abstract class SqliteControlPlaneOrganizationStore extends SqliteDirector external_key: null, managed_by: null, directory_updated_at: null, + directory_parent_key: null, }; this.assertScopeNameAvailable(row); executeSync(this.db, this.query.insertInto("managed_scopes").values(row)); diff --git a/packages/platformclaw-control-plane/src/sqlite-store-types.ts b/packages/platformclaw-control-plane/src/sqlite-store-types.ts index 9df0914173a..9155547c1f4 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store-types.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store-types.ts @@ -30,6 +30,29 @@ export type EnterpriseIdentityRow = { last_authenticated_at: number; }; +export type EmployeeDirectorySnapshotRow = { + user_id: string; + org_json: string; + source_updated_at: number; + observed_at: number; +}; + +type EmployeeDirectoryBackfillAttemptRow = { + user_id: string; + generation: string; + last_login_at: number; + status: "pending" | "synced" | "skipped" | "failed"; + reason: string | null; + attempted_at: number; +}; + +type EmployeeDirectoryAuthDecisionRow = { + user_id: string; + authenticated_at: number; + decision: string; + observed_at: number; +}; + type DirectoryGroupRow = { user_id: string; group_name: string }; export type AgentBindingRow = { id: string; @@ -68,6 +91,7 @@ export type ManagedScopeRow = { external_key: string | null; managed_by: "ldap-login" | null; directory_updated_at: number | null; + directory_parent_key: string | null; }; type ManagedScopePrimaryMembershipRow = { user_id: string; @@ -381,6 +405,9 @@ type BaseballIdempotencyRow = { }; export type ControlPlaneDatabase = { + employee_directory_snapshots: EmployeeDirectorySnapshotRow; + employee_directory_backfill_attempts: EmployeeDirectoryBackfillAttemptRow; + employee_directory_auth_decisions: EmployeeDirectoryAuthDecisionRow; directory_organization_sync: { user_id: string; scope_id: string; diff --git a/packages/platformclaw-control-plane/src/web-ingress-runtime.ts b/packages/platformclaw-control-plane/src/web-ingress-runtime.ts index fe61002fd99..696ffd5b794 100644 --- a/packages/platformclaw-control-plane/src/web-ingress-runtime.ts +++ b/packages/platformclaw-control-plane/src/web-ingress-runtime.ts @@ -21,7 +21,11 @@ import { VmAdministrationService } from "./browser-vm-admin-http.js"; import { JiraVocService, type JiraVocConfig } from "./browser-voc-http.js"; import type { MainSessionKeyBuilder } from "./contracts.js"; import { DashboardService } from "./dashboard-service.js"; -import { EmployeeSsoService, type EmployeeSsoConfig } from "./employee-sso.js"; +import { + EmployeeSsoService, + PLATFORMCLAW_ADSSO_PATH, + type EmployeeSsoConfig, +} from "./employee-sso.js"; import { ExecCredentialService } from "./exec-credential-service.js"; import type { VmCredentialStateChange } from "./execution-contracts.js"; import { @@ -274,6 +278,17 @@ export function createPlatformClawWebIngressRuntime( const organization = new BrowserOrganizationService({ authService: auth.service, organization: organizationService, + prepareDirectoryOrganization: async (userId) => { + const user = await auth.store.getUserById(userId); + return user + ? auth.prepareUserOrganization(user) + : { status: "skipped", reason: "inactive-user" }; + }, + ...(employeeSsoService + ? { + ssoRefreshUrl: `${PLATFORMCLAW_ADSSO_PATH}?returnTo=${encodeURIComponent("/platformclaw/app/settings/organization")}`, + } + : {}), ...(options.employeeAuth?.now ? { now: options.employeeAuth.now } : {}), }); const vocService = options.jiraVoc @@ -410,6 +425,10 @@ export function createPlatformClawWebIngressRuntime( let preparing: Promise | undefined; const prepare = (): Promise => { preparing ??= restartReconciler.reconcile().then(async (summary) => { + if (closed) { + return summary; + } + void auth.prepareOrganizationBackfill(); await skillHub?.processGovernanceQueue(); if (closed) { return summary; @@ -479,7 +498,7 @@ export function createPlatformClawWebIngressRuntime( await credentialBroker?.close(); } finally { skillHub?.close(); - auth.close(); + await auth.close(); } } } diff --git a/ui/src/platformclaw/control-ui-adapter.test.ts b/ui/src/platformclaw/control-ui-adapter.test.ts index d28c5bba23d..7bf0655c7c7 100644 --- a/ui/src/platformclaw/control-ui-adapter.test.ts +++ b/ui/src/platformclaw/control-ui-adapter.test.ts @@ -170,6 +170,99 @@ describe("PlatformClawControlUiAdapter", () => { ); }); + it.each([ + { + name: "missing department", + profile: { displayName: "Person One" }, + displayName: "Person One", + department: "", + }, + { + name: "missing display name", + profile: { department: "Platform" }, + displayName: "person.one", + department: "Platform", + }, + { name: "missing both labels", profile: {}, displayName: "person.one", department: "" }, + { + name: "blank optional labels", + profile: { displayName: " \t ", department: " \t " }, + displayName: "person.one", + department: "", + }, + ])( + "boots the authenticated organization shell with optional profile metadata: $name", + async ({ profile, displayName, department }) => { + installDescriptor(); + const navigate = vi.fn(); + const adapter = createPlatformClawControlUiAdapter({ + location: { + href: "https://platformclaw.example/platformclaw/app/settings/organization", + origin: "https://platformclaw.example", + protocol: "https:", + }, + fetchImpl: async () => + jsonResponse({ + authenticated: true, + user: { accountId: "person.one", globalRole: "member", ...profile }, + agent: { agentId: "person_one", state: "active" }, + }), + navigate, + })!; + const identity = await adapter.loadSession(); + expect(identity).toEqual({ + accountId: "person.one", + agentId: "person_one", + globalRole: "member", + displayName, + department, + }); + const options = adapter.applicationOptions(identity!, vi.fn()); + expect(options.routeOverrides?.organization).toBeDefined(); + expect(options.shellSession).toMatchObject({ + primaryLabel: displayName, + secondaryLabel: department || "person.one", + }); + expect(navigate).not.toHaveBeenCalled(); + adapter.dispose(); + }, + ); + + it.each([ + { user: { accountId: undefined } }, + { user: { accountId: " " } }, + { agent: { agentId: undefined } }, + { user: { globalRole: "unknown" } }, + { user: { displayName: 42 } }, + { user: { displayName: null } }, + { user: { department: 42 } }, + { user: { department: null } }, + ])( + "rejects malformed required identity and present wrong-type optional labels: %j", + async (invalid) => { + installDescriptor(); + const navigate = vi.fn(); + const adapter = createPlatformClawControlUiAdapter({ + fetchImpl: async () => + jsonResponse({ + authenticated: true, + user: { + accountId: "person.one", + globalRole: "member", + displayName: "Person One", + department: "Platform", + ...invalid.user, + }, + agent: { agentId: "person_one", state: "active", ...invalid.agent }, + }), + navigate, + })!; + await expect(adapter.loadSession()).rejects.toThrow("session service is unavailable"); + expect(navigate).not.toHaveBeenCalled(); + adapter.dispose(); + }, + ); + it.each([1006, 1008])("rechecks session authority after close %i", async (code) => { installDescriptor(); const navigate = vi.fn(); diff --git a/ui/src/platformclaw/control-ui-adapter.ts b/ui/src/platformclaw/control-ui-adapter.ts index fc01748202c..700e09e0f9c 100644 --- a/ui/src/platformclaw/control-ui-adapter.ts +++ b/ui/src/platformclaw/control-ui-adapter.ts @@ -59,9 +59,8 @@ function parseSessionPayload(value: unknown): PlatformClawSessionPayload | null !accountId.trim() || typeof agentId !== "string" || !agentId.trim() || - typeof displayName !== "string" || - !displayName.trim() || - typeof department !== "string" || + (displayName !== undefined && typeof displayName !== "string") || + (department !== undefined && typeof department !== "string") || (globalRole !== "member" && globalRole !== "admin") ) { return null; @@ -71,8 +70,10 @@ function parseSessionPayload(value: unknown): PlatformClawSessionPayload | null user: { accountId: accountId.trim(), agentId: agentId.trim(), - displayName: displayName.trim(), - department: department.trim(), + // LDAP/SSO labels are optional. Missing directory metadata must not block + // an authenticated session or change its account, agent, or role authority. + displayName: displayName?.trim() || accountId.trim(), + department: department?.trim() ?? "", globalRole, }, }; diff --git a/ui/src/platformclaw/locales/en-guide.ts b/ui/src/platformclaw/locales/en-guide.ts index 11222016972..f763f4dcb25 100644 --- a/ui/src/platformclaw/locales/en-guide.ts +++ b/ui/src/platformclaw/locales/en-guide.ts @@ -271,6 +271,30 @@ export const translations: Readonly> = { "platformClaw.organization.my.title": "My organization", "platformClaw.organization.my.description": "Your direct memberships, inherited access, and primary scope.", + "platformClaw.organization.sync.title": "Automatic organization membership", + "platformClaw.organization.sync.pending": + "Your saved organization information is being checked. Refresh this page to see the result.", + "platformClaw.organization.sync.synced": + "Your verified organization information has been applied.", + "platformClaw.organization.sync.requiresAuthentication": + "No verified organization information was saved for this session. Automatic membership needs fresh verified organization information. Ask an administrator to confirm your membership or continue to company sign-in. Company sign-in may ask you to authenticate again.", + "platformClaw.organization.sync.refreshAuthentication": "Continue to company sign-in", + "platformClaw.organization.sync.failed": + "Automatic organization synchronization failed. Contact an administrator to check the result. You can still request membership manually.", + "platformClaw.organization.sync.testAccount": + "Automatic organization membership is disabled for test accounts.", + "platformClaw.organization.sync.missingOrganization": + "Sign-in succeeded without organization information. Existing memberships were left unchanged. Ask the sign-in service administrator to check the organization claim.", + "platformClaw.organization.sync.untrustedSource": + "The sign-in organization source is not eligible for automatic membership. Existing memberships were left unchanged. Ask the sign-in service administrator to check the organization source.", + "platformClaw.organization.sync.unsupportedVersion": + "The sign-in organization format version is not supported. Existing memberships were left unchanged. Ask an administrator to check the sign-in integration version.", + "platformClaw.organization.sync.staleOrganization": + "The sign-in organization information is older than the saved verified information. This sign-in did not change membership. Ask the sign-in service administrator to check the organization update time.", + "platformClaw.organization.sync.conflictingOrganization": + "The sign-in organization information conflicts with saved verified information at the same update time. This sign-in did not change membership. Ask the sign-in service administrator to check the organization assignment.", + "platformClaw.organization.sync.skipped": + "Authentication did not supply an eligible organization assignment. You can still request membership manually.", "platformClaw.organization.my.unaffiliated": "No organization membership", "platformClaw.organization.my.unaffiliatedDescription": "You can continue using PlatformClaw without joining a scope.", diff --git a/ui/src/platformclaw/locales/ko.ts b/ui/src/platformclaw/locales/ko.ts index 7bd01e0e35c..b8b03d03ea3 100644 --- a/ui/src/platformclaw/locales/ko.ts +++ b/ui/src/platformclaw/locales/ko.ts @@ -274,6 +274,29 @@ export const translations: Readonly> = { "platformClaw.organization.my.title": "내 조직", "platformClaw.organization.my.description": "직접 소속, 상위 범위 접근 권한, 기본 범위를 확인합니다.", + "platformClaw.organization.sync.title": "조직 자동 가입", + "platformClaw.organization.sync.pending": + "저장된 조직 정보를 확인하고 있습니다. 페이지를 새로 고치면 결과를 확인할 수 있습니다.", + "platformClaw.organization.sync.synced": "인증된 조직 정보를 소속에 반영했습니다.", + "platformClaw.organization.sync.requiresAuthentication": + "이 세션에는 인증된 조직 정보가 저장되어 있지 않습니다. 자동 가입에는 새로 인증된 조직 정보가 필요합니다. 관리자에게 소속 확인을 요청하거나 회사 로그인으로 이동해 주세요. 회사 로그인에서 다시 인증을 요청할 수 있습니다.", + "platformClaw.organization.sync.refreshAuthentication": "회사 로그인으로 이동", + "platformClaw.organization.sync.failed": + "조직 자동 반영에 실패했습니다. 관리자에게 결과 확인을 요청해 주세요. 직접 가입 요청도 가능합니다.", + "platformClaw.organization.sync.testAccount": + "테스트 계정에는 조직 자동 가입을 적용하지 않습니다.", + "platformClaw.organization.sync.missingOrganization": + "로그인은 완료됐지만 인증 응답에 조직 정보가 없습니다. 기존 소속은 유지했습니다. 로그인 서비스 관리자에게 조직 정보 전달을 확인해 주세요.", + "platformClaw.organization.sync.untrustedSource": + "인증 응답의 조직 출처는 자동 가입에 사용할 수 없습니다. 기존 소속은 유지했습니다. 로그인 서비스 관리자에게 조직 정보 출처를 확인해 주세요.", + "platformClaw.organization.sync.unsupportedVersion": + "인증 응답의 조직 정보 버전을 지원하지 않습니다. 기존 소속은 유지했습니다. 관리자에게 로그인 연동 버전 확인을 요청해 주세요.", + "platformClaw.organization.sync.staleOrganization": + "인증 응답의 조직 정보가 저장된 인증 정보보다 오래되었습니다. 이번 로그인에서는 소속을 변경하지 않았습니다. 로그인 서비스 관리자에게 조직 정보 갱신 시각 확인을 요청해 주세요.", + "platformClaw.organization.sync.conflictingOrganization": + "같은 갱신 시각의 인증 응답과 저장된 조직 정보가 서로 다릅니다. 이번 로그인에서는 소속을 변경하지 않았습니다. 로그인 서비스 관리자에게 조직 배정 확인을 요청해 주세요.", + "platformClaw.organization.sync.skipped": + "인증 결과에 자동 가입에 사용할 조직 정보가 없습니다. 직접 가입 요청이 가능합니다.", "platformClaw.organization.my.unaffiliated": "소속 조직 없음", "platformClaw.organization.my.unaffiliatedDescription": "조직에 가입하지 않아도 PlatformClaw를 계속 사용할 수 있습니다.", diff --git a/ui/src/platformclaw/organization-api.ts b/ui/src/platformclaw/organization-api.ts index 41defab706e..1393b8e71a0 100644 --- a/ui/src/platformclaw/organization-api.ts +++ b/ui/src/platformclaw/organization-api.ts @@ -52,6 +52,11 @@ export type OrganizationContext = { canManageOrganization: boolean; canViewOrganizationAudit: boolean; joinPromptEligible: boolean; + directorySync?: { + status: "pending" | "synced" | "skipped" | "failed"; + reason?: string; + refreshUrl?: string; + }; }; type OrganizationMember = { diff --git a/ui/src/platformclaw/organization-overview-view.ts b/ui/src/platformclaw/organization-overview-view.ts index 874efd4c798..435e04f50f6 100644 --- a/ui/src/platformclaw/organization-overview-view.ts +++ b/ui/src/platformclaw/organization-overview-view.ts @@ -7,6 +7,16 @@ import { renderOrganizationManagedBadge, } from "./organization-scope-label.ts"; +const skippedSyncMessages: Readonly> = { + "requires-authenticated-org": "requiresAuthentication", + "test-account": "testAccount", + "no-organization": "missingOrganization", + "untrusted-source": "untrustedSource", + "unsupported-version": "unsupportedVersion", + "stale-organization": "staleOrganization", + "conflicting-organization": "conflictingOrganization", +}; + function contextLineageLabel(scopeId: string, context: OrganizationContext): string | undefined { const explicit = context.directScopeLineages.find((entry) => entry.scopeId === scopeId)?.lineage; if (explicit?.length) { @@ -59,12 +69,32 @@ export function renderOrganizationOverview(options: { ? [context.primaryScope] : []), ]; + const sync = context.directorySync; + const syncMessage = sync + ? sync.status === "skipped" + ? (skippedSyncMessages[sync.reason ?? ""] ?? "skipped") + : sync.status + : undefined; const myOrganization = renderSettingsSection( { title: t("platformClaw.organization.my.title"), description: t("platformClaw.organization.my.description"), }, - html`${directScopes.length === 0 + html`${syncMessage + ? renderSettingsRow({ + title: t("platformClaw.organization.sync.title"), + description: html`${t(`platformClaw.organization.sync.${syncMessage}`)}`, + control: + sync?.reason === "requires-authenticated-org" && sync.refreshUrl + ? html`${t("platformClaw.organization.sync.refreshAuthentication")}` + : nothing, + }) + : nothing} + ${directScopes.length === 0 && !sync ? renderSettingsRow({ title: t("platformClaw.organization.my.unaffiliated"), description: t("platformClaw.organization.my.unaffiliatedDescription"), diff --git a/ui/src/platformclaw/organization-page.test.ts b/ui/src/platformclaw/organization-page.test.ts index 2ddb2967a7f..d47a6ef02a6 100644 --- a/ui/src/platformclaw/organization-page.test.ts +++ b/ui/src/platformclaw/organization-page.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../i18n/index.ts"; -import type { OrganizationScopeResult } from "./organization-api.ts"; +import type { OrganizationContext, OrganizationScopeResult } from "./organization-api.ts"; import "./organization-page.ts"; type OrganizationElement = HTMLElement & { @@ -50,6 +50,7 @@ function fixtureFetch(options?: { conflictRefresh?: Promise; refreshFailsAfterMutation?: boolean; automatic?: boolean; + directorySync?: OrganizationContext["directorySync"]; }) { let mutationCommitted = false; let membershipConflictObserved = false; @@ -139,6 +140,7 @@ function fixtureFetch(options?: { canReviewJoinRequests: options?.manager === true || options?.administrator === true, canManageOrganization: options?.manager === true || options?.administrator === true, canViewOrganizationAudit: options?.administrator === true, + directorySync: options?.directorySync, }); } if (url.includes("/scopes?")) { @@ -231,6 +233,204 @@ describe("PlatformClaw Organization settings", () => { expect(element.textContent).not.toContain("Create a scope"); }); + it.each([ + [{ status: "pending" }, "Your saved organization information is being checked"], + [ + { status: "skipped", reason: "requires-authenticated-org" }, + "No verified organization information was saved", + ], + [ + { status: "failed", reason: "database-unavailable" }, + "Automatic organization synchronization failed", + ], + [ + { status: "skipped", reason: "no-organization" }, + "Sign-in succeeded without organization information", + ], + [ + { status: "skipped", reason: "untrusted-source" }, + "The sign-in organization source is not eligible", + ], + [ + { status: "skipped", reason: "unsupported-version" }, + "The sign-in organization format version is not supported", + ], + [ + { status: "skipped", reason: "test-account" }, + "Automatic organization membership is disabled for test accounts", + ], + [ + { status: "skipped", reason: "stale-organization" }, + "The sign-in organization information is older than the saved verified information", + ], + [ + { status: "skipped", reason: "conflicting-organization" }, + "The sign-in organization information conflicts with saved verified information", + ], + ] as const)( + "shows the recorded automatic membership outcome without claiming an absent membership: %j", + async (directorySync, message) => { + const element = await mount(fixtureFetch({ empty: true, directorySync })); + expect(element.textContent).toContain(message); + expect(element.textContent).not.toContain("No organization membership"); + if ("reason" in directorySync) { + expect(element.textContent).not.toContain(directorySync.reason); + } + expect(element.querySelector("#platformclaw-organization-tab-requests")).not.toBeNull(); + expect(element.querySelector('a[href^="/employee/auth/adsso"]')).toBeNull(); + }, + ); + + it("keeps existing membership visible when automatic synchronization fails", async () => { + const element = await mount(fixtureFetch({ directorySync: { status: "failed" } })); + expect(element.textContent).toContain("Platform / Runtime"); + expect(element.textContent).toContain("Automatic organization synchronization failed"); + }); + + it.each([ + ["requires-authenticated-org", "인증된 조직 정보가 저장되어 있지 않습니다"], + ["no-organization", "인증 응답에 조직 정보가 없습니다"], + ["untrusted-source", "조직 출처는 자동 가입에 사용할 수 없습니다"], + ["unsupported-version", "조직 정보 버전을 지원하지 않습니다"], + ["test-account", "테스트 계정에는 조직 자동 가입을 적용하지 않습니다"], + ["stale-organization", "조직 정보가 저장된 인증 정보보다 오래되었습니다"], + ["conflicting-organization", "인증 응답과 저장된 조직 정보가 서로 다릅니다"], + ])( + "explains the recorded automatic organization outcome in Korean: %s", + async (reason, message) => { + await i18n.setLocale("ko"); + const element = await mount( + fixtureFetch({ + empty: true, + directorySync: { status: "skipped", reason }, + }), + ); + expect(element.textContent).toContain(message); + expect(element.textContent).not.toContain("소속 조직 없음"); + }, + ); + + it.each([ + ["en", "Continue to company sign-in", "Company sign-in may ask you to authenticate again"], + ["ko", "회사 로그인으로 이동", "회사 로그인에서 다시 인증을 요청할 수 있습니다"], + ] as const)( + "offers the configured company sign-in route for unavailable legacy facts: %s", + async (locale, label, prompt) => { + await i18n.setLocale(locale); + const refreshUrl = + "/employee/auth/adsso?returnTo=%2Fplatformclaw%2Fapp%2Fsettings%2Forganization"; + const element = await mount( + fixtureFetch({ + empty: true, + directorySync: { status: "skipped", reason: "requires-authenticated-org", refreshUrl }, + }), + ); + const action = element.querySelector('a[href^="/employee/auth/adsso"]'); + expect(action?.getAttribute("href")).toBe(refreshUrl); + expect(action?.textContent).toContain(label); + expect(element.textContent).toContain(prompt); + expect(element.querySelector("#platformclaw-organization-tab-requests")).not.toBeNull(); + }, + ); + + it("keeps recorded fresh exclusions separate from the legacy company sign-in action", async () => { + const element = await mount( + fixtureFetch({ + empty: true, + directorySync: { + status: "skipped", + reason: "unsupported-version", + refreshUrl: "/employee/auth/adsso", + }, + }), + ); + expect(element.querySelector('a[href^="/employee/auth/adsso"]')).toBeNull(); + expect(element.textContent).toContain("format version is not supported"); + }); + + it("shows a directory Part before its parents are known and keeps its automatic structure read-only", async () => { + const part = { + id: "part-unmapped", + kind: "part" as const, + name: "Runtime", + status: "active" as const, + managedBy: "ldap-login" as const, + externalKey: "aicm-part:runtime", + }; + const capabilities = { + canManageMembers: true, + canManageLeaders: true, + canManageStructure: false, + }; + const fetchImpl = vi.fn(async (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/context")) { + return json({ + actor: { id: "user-1", displayName: "Person", isAdministrator: true }, + directMemberships: [{ scopeId: part.id, role: "member", managedBy: "ldap-login" }], + directScopeLineages: [{ scopeId: part.id, lineage: [part] }], + effectiveScopes: [{ scope: part, source: "direct", directRole: "member" }], + directMembershipsHasMore: false, + effectiveScopesHasMore: false, + primaryScope: part, + primaryScopeLineage: [part], + isUnaffiliated: false, + hasPendingJoinRequest: false, + joinPromptEligible: false, + canReviewJoinRequests: true, + canManageOrganization: true, + canViewOrganizationAudit: true, + }); + } + if (url.includes("/scopes?")) { + return json({ + items: [ + { + ...part, + revision: 10, + lineage: [part], + capabilities, + requestState: "member", + requestEligible: false, + }, + ], + hasMore: false, + }); + } + if (url.includes("/management/scopes/part-unmapped?")) { + return json({ + scope: part, + members: [ + { + user: { id: "user-1", accountId: "person.one", status: "active" }, + role: "member", + managedBy: "ldap-login", + }, + ], + }); + } + return json({ error: "unexpected" }, 500); + }); + const element = await mount(fetchImpl); + expect(element.textContent).toContain("Runtime (aicm-part:runtime)"); + expect(element.textContent).toContain("Automatically managed"); + const primary = element.querySelector('select[aria-label="Primary scope"]')!; + expect( + [...primary.options] + .filter((option) => option.value) + .map((option) => option.textContent?.trim()), + ).toEqual(["Runtime (aicm-part:runtime)"]); + + await selectTab(element, "management"); + + await vi.waitFor(() => expect(element.textContent).toContain("person.one")); + const actions = [...element.querySelectorAll("button")].map((button) => + button.textContent?.trim(), + ); + expect(actions).not.toContain("Rename"); + expect(actions).not.toContain("Archive"); + }); + it("renders delegated roster controls from management capability", async () => { const fetchImpl = fixtureFetch({ manager: true }); const element = await mount(fetchImpl); diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 00000000000..ec300e3c40a --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".." + }, + "include": [ + "src/platformclaw/**/*", + "vite.config.ts", + "vite.platformclaw-login.config.ts", + "src/**/*.d.ts", + "../src/**/*.d.ts", + "../packages/**/*.d.ts", + "../extensions/**/*.d.ts" + ] +} From 688c6875dcae6d3e898ef406621554fc1fcb282e Mon Sep 17 00:00:00 2001 From: "hyeonho.jung" Date: Sat, 31 Oct 2026 14:26:05 +0900 Subject: [PATCH 2/3] fix: remove unused organization recovery exports --- .../src/employee-directory-snapshot.test.ts | 67 ++++++++++--------- .../src/employee-organization-backfill.ts | 11 --- .../src/sqlite-store-types.ts | 2 +- 3 files changed, 37 insertions(+), 43 deletions(-) diff --git a/packages/platformclaw-control-plane/src/employee-directory-snapshot.test.ts b/packages/platformclaw-control-plane/src/employee-directory-snapshot.test.ts index e76ebc258a3..76377249ff2 100644 --- a/packages/platformclaw-control-plane/src/employee-directory-snapshot.test.ts +++ b/packages/platformclaw-control-plane/src/employee-directory-snapshot.test.ts @@ -4,7 +4,7 @@ import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { backfillEmployeeOrganizations } from "./employee-organization-backfill.js"; +import { EmployeeOrganizationBackfill } from "./employee-organization-backfill.js"; import { InMemoryControlPlaneStore } from "./memory-store.js"; import { SqliteControlPlaneStore } from "./sqlite-store.js"; @@ -120,9 +120,10 @@ describe("authenticated employee directory snapshots", () => { return { status: "synced" as const, scopeId: "unused" }; }, }; - let scan: ReturnType | undefined; + const owner = new EmployeeOrganizationBackfill({ store }); + let scan: ReturnType | undefined; const server = createServer((_request, response) => { - scan = backfillEmployeeOrganizations({ store }); + scan = owner.start(); // A check-phase response must not wait for every immediately resolved DB task. setImmediate(() => { response.setHeader("Content-Type", "application/json"); @@ -139,7 +140,7 @@ describe("authenticated employee directory snapshots", () => { expect(await response.json()).toEqual({ pagesRead: 1 }); expect(await scan).toMatchObject({ found: 200, skipped: 200, failed: 0, limited: false }); } finally { - await scan; + await owner.stop(); await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); @@ -330,32 +331,36 @@ describe("authenticated employee directory snapshots", () => { expectedLastLoginAt: 1_000, }); // The scan captures the old user row synchronously before its first await. - const backfill = backfillEmployeeOrganizations({ - store, - now: () => 3_000, - }); - await store.upsertPrincipal(principal, 2_000); - await store.recordEmployeeDirectoryAuthentication({ - userId: user.id, - org: organization(1_000, "New source"), - isTestAccount: false, - observedAt: 2_000, - expectedLastLoginAt: 2_000, - }); - await store.syncDirectoryOrganization({ - userId: user.id, - org: organization(1_000, "New source"), - isTestAccount: false, - syncedAt: 2_000, - expectedLastLoginAt: 2_000, - }); - expect(await backfill).toMatchObject({ found: 1, skipped: 1, failed: 0 }); - expect((await store.listManagedScopes()).find((scope) => scope.kind === "part")).toMatchObject({ - name: "New source", - }); - expect(await store.getEmployeeDirectorySnapshot(user.id)).toEqual({ - org: organization(1_000, "New source"), - isTestAccount: false, - }); + const owner = new EmployeeOrganizationBackfill({ store, now: () => 3_000 }); + const scan = owner.start(); + try { + await store.upsertPrincipal(principal, 2_000); + await store.recordEmployeeDirectoryAuthentication({ + userId: user.id, + org: organization(1_000, "New source"), + isTestAccount: false, + observedAt: 2_000, + expectedLastLoginAt: 2_000, + }); + await store.syncDirectoryOrganization({ + userId: user.id, + org: organization(1_000, "New source"), + isTestAccount: false, + syncedAt: 2_000, + expectedLastLoginAt: 2_000, + }); + expect(await scan).toMatchObject({ found: 1, skipped: 1, failed: 0 }); + expect( + (await store.listManagedScopes()).find((scope) => scope.kind === "part"), + ).toMatchObject({ + name: "New source", + }); + expect(await store.getEmployeeDirectorySnapshot(user.id)).toEqual({ + org: organization(1_000, "New source"), + isTestAccount: false, + }); + } finally { + await owner.stop(); + } }); }); diff --git a/packages/platformclaw-control-plane/src/employee-organization-backfill.ts b/packages/platformclaw-control-plane/src/employee-organization-backfill.ts index a8666a0a77d..e2647414cdb 100644 --- a/packages/platformclaw-control-plane/src/employee-organization-backfill.ts +++ b/packages/platformclaw-control-plane/src/employee-organization-backfill.ts @@ -182,14 +182,3 @@ export class EmployeeOrganizationBackfill { return summary; } } - -export async function backfillEmployeeOrganizations( - options: Options, -): Promise { - const owner = new EmployeeOrganizationBackfill(options); - try { - return await owner.start(); - } finally { - await owner.stop(); - } -} diff --git a/packages/platformclaw-control-plane/src/sqlite-store-types.ts b/packages/platformclaw-control-plane/src/sqlite-store-types.ts index 9155547c1f4..07405e14370 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store-types.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store-types.ts @@ -30,7 +30,7 @@ export type EnterpriseIdentityRow = { last_authenticated_at: number; }; -export type EmployeeDirectorySnapshotRow = { +type EmployeeDirectorySnapshotRow = { user_id: string; org_json: string; source_updated_at: number; From 1a664d1416206c5739f3ee615b2de51adfe26eb4 Mon Sep 17 00:00:00 2001 From: "hyeonho.jung" Date: Sat, 31 Oct 2026 18:52:03 +0900 Subject: [PATCH 3/3] fix(deps): update follow-redirects to 1.16.2 --- pnpm-lock.yaml | 11 ++++++----- pnpm-workspace.yaml | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 244964b9e81..aaa76a1524c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,7 +20,7 @@ overrides: fast-uri: 4.1.4 undici@7: 7.30.1 undici@8: 8.12.2 - follow-redirects: 1.16.1 + follow-redirects: 1.16.2 defu: 6.1.7 fast-xml-parser: 5.10.1 request: npm:@cypress/request@4.0.1 @@ -5190,6 +5190,7 @@ packages: '@smithy/core@3.30.0': resolution: {integrity: sha512-dl2yRglDxfzH9uJ4fSo4zTaAHa0zH7+V7BZMRWy8hEYIKT1BiqMUK/CN6T3ADQ3kbA5N1tmUulroJ2UtONS7Kw==} engines: {node: '>=18.0.0'} + deprecated: Deprecated due to a bug in custom logger data redaction. See https://github.com/aws/aws-sdk-js-v3/issues/8393. '@smithy/credential-provider-imds@4.4.14': resolution: {integrity: sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==} @@ -6850,8 +6851,8 @@ packages: flatted@3.4.3: resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} - follow-redirects@1.16.1: - resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -13273,7 +13274,7 @@ snapshots: axios@1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - follow-redirects: 1.16.1(debug@4.4.3(supports-color@10.2.2)) + follow-redirects: 1.16.2(debug@4.4.3(supports-color@10.2.2)) form-data: 4.0.6 https-proxy-agent: 5.0.1(supports-color@10.2.2) proxy-from-env: 2.1.0 @@ -14109,7 +14110,7 @@ snapshots: flatted@3.4.3: {} - follow-redirects@1.16.1(debug@4.4.3(supports-color@10.2.2)): + follow-redirects@1.16.2(debug@4.4.3(supports-color@10.2.2)): optionalDependencies: debug: 4.4.3(supports-color@10.2.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 87d0d7ba527..0efe438c4ee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -136,7 +136,7 @@ overrides: fast-uri: 4.1.4 "undici@7": 7.30.1 "undici@8": 8.12.2 - follow-redirects: 1.16.1 + follow-redirects: 1.16.2 defu: 6.1.7 fast-xml-parser: 5.10.1 request: "npm:@cypress/request@4.0.1"