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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/platformclaw/employee-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
100 changes: 78 additions & 22 deletions docs/platformclaw/organization-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions packages/platformclaw-coding-agent-contract/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "../.."
},
"include": [
"**/*",
"../../src/**/*.d.ts",
"../../ui/**/*.d.ts",
"../../packages/**/*.d.ts",
"../../extensions/**/*.d.ts"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> }> = [];

afterEach(() => {
afterEach(async () => {
for (const runtime of runtimes.splice(0)) {
runtime.close();
await runtime.close();
}
});

Expand Down
31 changes: 27 additions & 4 deletions packages/platformclaw-control-plane/src/browser-auth-runtime.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -35,7 +40,9 @@ export type EmployeeBrowserAuthRuntime = {
credentialVault?: SshCredentialVault;
mcpCredentialVault?: McpCredentialVault;
execCredentialCipher?: ExecCredentialCipher;
close(): void;
prepareOrganizationBackfill(): Promise<EmployeeOrganizationBackfillSummary>;
prepareUserOrganization(user: PlatformUser): Promise<EmployeeDirectoryBackfillOutcome>;
close(): Promise<void>;
};

/** Build the production LDAP-phase browser-auth runtime around the persistent store. */
Expand All @@ -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 } : {}),
Expand All @@ -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();
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ describe("BrowserAuthService", () => {
org: undefined,
isTestAccount: false,
syncedAt: 2_000,
expectedLastLoginAt: 2_000,
});
});

Expand All @@ -180,6 +181,7 @@ describe("BrowserAuthService", () => {
org,
isTestAccount: false,
syncedAt: 1_000,
expectedLastLoginAt: 1_000,
});
});

Expand Down
Loading