From 2eb2784b6fe0236a7b758098c26bc32e1f66952e Mon Sep 17 00:00:00 2001 From: devSejung Date: Sun, 19 Jul 2026 08:40:41 +0900 Subject: [PATCH] feat(platformclaw): add persistent control-plane foundation Keep enterprise identity, sessions, agent ownership, and group administration behind a private package so upstream Gateway core remains unchanged. Initialize the approved SQLite v1 schema without a legacy fallback and preserve the deployed account-to-agent ID convention. --- PLATFORMCLAW.md | 11 +- docs/platformclaw/control-plane-phase-1.md | 354 +++++ docs/platformclaw/decisions.md | 174 +++ docs/platformclaw/index.md | 44 + docs/upstream/status.md | 35 +- .../platformclaw-control-plane/package.json | 25 + .../src/contracts.ts | 287 ++++ .../platformclaw-control-plane/src/ids.ts | 45 + .../platformclaw-control-plane/src/index.ts | 5 + .../src/kysely-sync.ts | 49 + .../src/memory-store.test.ts | 447 ++++++ .../src/memory-store.ts | 559 +++++++ .../src/sqlite-schema.ts | 137 ++ .../src/sqlite-store.test.ts | 344 +++++ .../src/sqlite-store.ts | 1308 +++++++++++++++++ .../platformclaw-control-plane/tsconfig.json | 9 + pnpm-lock.yaml | 9 + 17 files changed, 3825 insertions(+), 17 deletions(-) create mode 100644 docs/platformclaw/control-plane-phase-1.md create mode 100644 docs/platformclaw/decisions.md create mode 100644 docs/platformclaw/index.md create mode 100644 packages/platformclaw-control-plane/package.json create mode 100644 packages/platformclaw-control-plane/src/contracts.ts create mode 100644 packages/platformclaw-control-plane/src/ids.ts create mode 100644 packages/platformclaw-control-plane/src/index.ts create mode 100644 packages/platformclaw-control-plane/src/kysely-sync.ts create mode 100644 packages/platformclaw-control-plane/src/memory-store.test.ts create mode 100644 packages/platformclaw-control-plane/src/memory-store.ts create mode 100644 packages/platformclaw-control-plane/src/sqlite-schema.ts create mode 100644 packages/platformclaw-control-plane/src/sqlite-store.test.ts create mode 100644 packages/platformclaw-control-plane/src/sqlite-store.ts create mode 100644 packages/platformclaw-control-plane/tsconfig.json diff --git a/PLATFORMCLAW.md b/PLATFORMCLAW.md index 6a402ec6d32..0bc64d5815c 100644 --- a/PLATFORMCLAW.md +++ b/PLATFORMCLAW.md @@ -22,7 +22,8 @@ The goals are to: - `upstream`: `https://github.com/openclaw/openclaw.git` - Baseline commit: `17c2ce05d8021b969f9e822a34e92535145922d5` - The Jammy company deployment profile is the first capability being migrated. -- No PlatformClaw application source changes, refactoring, or branding changes have started. +- The first control-plane contract slice now lives in + `packages/platformclaw-control-plane` with an in-memory implementation. - The clean OpenClaw Linux Docker build and focused credential-free test baseline has been validated. ## Why This Rebuild Exists @@ -64,7 +65,13 @@ Bring upstream changes from `upstream/main` into a sync branch, validate them th ## Current Phase -The current phase is production Docker deployment migration and validation. +Production Docker deployment migration and validation remains in progress. +Control-plane Phase 1 implementation is now also in progress for enterprise +identity, server sessions, idempotent agent provisioning, and Web and Knox +ingress authorization. The contract, in-memory store, and approved SQLite v1 +store slices are complete; runtime adapters have not started. See +`docs/platformclaw/control-plane-phase-1.md` and +`docs/platformclaw/decisions.md`. Next steps: diff --git a/docs/platformclaw/control-plane-phase-1.md b/docs/platformclaw/control-plane-phase-1.md new file mode 100644 index 00000000000..3afc8ec3d87 --- /dev/null +++ b/docs/platformclaw/control-plane-phase-1.md @@ -0,0 +1,354 @@ +--- +summary: "Phase 1 design for PlatformClaw identity, sessions, agent provisioning, and web and Knox ingress" +read_when: + - Implementing PlatformClaw login or user sessions + - Provisioning user or room agents + - Routing PlatformClaw Web or Knox conversations to agents +title: "PlatformClaw control plane phase 1" +--- + +# PlatformClaw control plane phase 1 + +Phase 1 creates one authoritative path from an authenticated ingress to an +OpenClaw agent. It supports two ingress families from the beginning: + +- PlatformClaw Web sessions authenticated through the current enterprise login + adapter and, later, a SAML adapter. +- Knox direct and group conversations authenticated by the Knox adapter. + +Phase 1 does not implement VM execution. It produces the stable identity and +agent binding that the later `platformclaw-vm` sandbox backend will consume. + +## Outcome + +After Phase 1: + +1. A web login resolves one stable PlatformClaw user. +2. The first successful login provisions at most one personal OpenClaw agent. +3. Later logins reuse that agent even if mutable employee attributes change. +4. A browser can access only the agent selected by its server-side session. +5. A Knox message routes through a verified channel binding, not an agent ID + supplied by message text or other untrusted payload fields. +6. Knox DMs share the personal agent's Web `main` session, while Knox rooms use + dedicated room agents and room sessions. + +## Implementation status + +Slices 1 and 2 are implemented in `packages/platformclaw-control-plane`. They +define the identity, browser-session, personal-agent provisioning, Knox room +binding, authenticated Knox DM routing, and managed group/part contracts. The +in-memory store covers contract behavior, and the SQLite store persists the +approved schema version 1. + +Focused tests cover LDAP metadata refresh, LDAP-to-SAML identity linking, +employee ID correction conflicts, concurrent personal and room provisioning, +room agent ID compatibility, browser session limits and expiry, account +disablement, Knox DM ownership checks, restart persistence, administrator +bootstrap, managed group/part permissions, and audit events. + +These slices do not contain credential encryption, LDAP or SAML network calls, +HTTP endpoints, Knox transport code, OpenClaw agent creation, or Gateway +mutations. + +## Process boundary + +`platformclaw-control` is the externally reachable web process. The name +describes one deployable component, not three separate services. + +```text +Browser ────────────────┐ + │ HTTPS / WebSocket +Knox adapter ──────────┤ verified ingress + ▼ + platformclaw-control + - auth adapters + - server sessions + - provisioning + - ingress authorization + - Gateway proxy + │ private operator connection + ▼ + OpenClaw Gateway + │ + ▼ + selected agent +``` + +The OpenClaw Gateway binds only to a private interface available to +`platformclaw-control`. Browser clients never receive the Gateway operator +credential. + +## Identity model + +Authentication adapters normalize provider-specific results into one shape: + +```ts +type EnterprisePrincipal = { + provider: "ldap" | "saml"; + subject: string; + employeeId: string; + displayName?: string; + email?: string; + department?: string; + groups?: string[]; +}; +``` + +`provider` and `subject` identify the authentication record. They do not become +the OpenClaw agent ID. PlatformClaw creates an opaque internal user ID and one +opaque agent ID on first provisioning. This prevents an LDAP-to-SAML migration, +employee ID correction, or department transfer from changing the agent's +workspace and session identity. + +Mutable directory attributes are refreshed after successful authentication. +They are metadata and policy inputs, not ownership keys. + +Each linked identity also records the last employee ID verified by its provider. +One provider may advance the canonical employee ID only while it agrees with the +current value. A lagging linked provider fails closed until it reports the new +canonical value, preventing LDAP/SAML login order from reverting a correction. +Authentication results also carry a per-identity version timestamp. Older +results and equal-version employee-ID disagreements fail closed. The persistent +store must reread and compare that version in the same write transaction before +changing the canonical employee ID. + +## Server session model + +The browser receives an opaque, random session cookie. The cookie contains no +employee attributes, agent ID, VM profile, or credential material. + +```text +browser cookie -> server session -> user -> personal agent +``` + +Required cookie properties are `HttpOnly`, `Secure`, and an explicit +`SameSite` policy. Logout and account disablement revoke the server session. +Authorization reads current ownership from the control-plane store instead of +trusting mutable claims embedded in a long-lived browser token. + +Sessions expire after 12 hours of inactivity or seven days absolutely, +whichever comes first. One user may hold at most three concurrent browser +sessions. + +## Idempotent personal-agent provisioning + +Provisioning uses a state machine because database reservation and OpenClaw +agent creation cannot be one database transaction. + +```text +NEW -> PROVISIONING -> ACTIVE + -> FAILED +ACTIVE -> DISABLED +``` + +The control-plane store enforces one personal agent per user and one owner per +agent. Concurrent first logins converge on the same provisioning record. + +Provisioning performs these steps: + +1. Upsert the enterprise principal and resolve the internal user ID. +2. Reserve an opaque agent ID under a uniqueness constraint. +3. Call the upstream `agents.create` Gateway method through the private admin + HTTP RPC plugin. +4. Verify the created agent ID and workspace path. +5. Seed only approved profile fields into the agent workspace. +6. Mark the binding active and issue the browser session. + +If a retry observes an existing OpenClaw agent, it adopts that agent only when +the control-plane ownership record matches. Name collision alone is not proof +of ownership. + +Upstream evidence: + +- `src/gateway/server-methods/agents.ts` owns `agents.create`. +- `extensions/admin-http-rpc/src/methods.ts` exposes the method to an + authenticated operator HTTP caller. +- `docs/concepts/multi-agent.md` defines the per-agent workspace and session + boundary. + +## Web Gateway proxy + +The browser connects to `platformclaw-control`, not directly to the OpenClaw +Gateway. For every accepted request the proxy resolves the server session and +uses its owned agent binding. + +The proxy must: + +- ignore browser-supplied agent IDs when the operation is user-scoped; +- construct or validate session keys against the owned agent; +- expose an explicit allowlist of user-facing Gateway methods; +- deny configuration, channel administration, node administration, Gateway + lifecycle, and other operator-only methods; +- filter response rows and asynchronous events by the owned agent and session; +- record an audit event for denied cross-agent attempts. + +UI hiding is not authorization. The proxy enforces the rule even when a client +sends a hand-crafted Gateway frame. + +## Knox ingress + +Knox remains a channel plugin and adapter integration. PlatformClaw does not add +a Knox special case to OpenClaw routing core. + +A verified Knox envelope supplies stable transport identifiers: + +```text +channel = knox +accountId = +peer.kind = direct | group +peer.id = +``` + +The adapter must authenticate the Knox Proxy request before using routing +fields. Display names, room names, and message text are never routing +authority. For DMs, the authenticated Proxy supplies the existing personal +agent ID and its `main` session. For rooms, PlatformClaw resolves the verified +room ID through the room binding and ignores inbound agent or session +overrides. + +OpenClaw already routes channel peers through `bindings`, with exact peer +matches taking precedence. Group sessions remain isolated from web main +sessions: + +```text +Web: agent::main +Knox DM: agent::main +Knox room: agent::knox:group: +``` + +Exact key formatting is owned by current OpenClaw channel routing helpers; the +PlatformClaw adapter must not construct session keys by string concatenation. + +Upstream evidence: + +- `src/routing/resolve-route.ts` owns deterministic binding precedence. +- `src/routing/session-key.ts` owns channel session-key construction. +- `docs/channels/channel-routing.md` documents peer-to-agent bindings. +- `extensions/feishu/src/dynamic-agent.ts` is an existing plugin-owned example + of guarded channel-driven agent creation and binding. + +## Knox room agents + +Each Knox group conversation receives a dedicated room-owned agent. A room is +not bound to an employee's personal agent. This preserves a separate workspace, +identity, and session history for the shared conversation. + +```text +verified Knox room ID -> room binding -> dedicated room agent +``` + +The legacy deployment has been observed to route rooms with an agent ID shaped +like `group-`. The current `devSejung/knox-adapter` source does not +derive that ID or create the agent. It accepts an explicit inbound `agentId` and +`sessionKey`, verifies only that the session key starts with the matching agent +prefix, and forwards them to the Gateway. Therefore the legacy Knox Proxy or +another component before the adapter supplies the `group-` value. + +In the rebuild, PlatformClaw owns this provisioning step. The first +authenticated message from an unbound room creates the room agent and exact +binding idempotently. The agent ID retains the legacy `group-` shape +after canonical encoding. Initially, authenticated Knox chatroom IDs must be +globally unique across configured adapter accounts. A cross-account collision +fails closed; PlatformClaw does not silently merge rooms or introduce an account +suffix without an explicit operator policy change. Concurrent first messages +converge on the same record, and inbound agent or session IDs do not override +the verified room binding. + +Room agents use a local workspace on the PlatformClaw server and set +`sandbox.mode: "off"`. They do not resolve a VM profile, use SafeConnect, or +receive an employee AD credential. Web personal agents continue to use the +later `platformclaw-vm` backend. + +Room agents may use `exec`, `process`, filesystem tools, and approved managed +skills on the PlatformClaw server. Most managed skills require execution, so +host execution is part of the accepted room-agent contract rather than an +exception. + +Each room still receives a distinct workspace for organization, agent identity, +and session history. The workspace is not a security boundary. With sandboxing +off, enabled host tools may reach the Gateway container and another room's +workspace. PlatformClaw therefore treats all local room agents as one trust +domain and must not represent workspace separation as filesystem or process +isolation. + +Only PlatformClaw administrators may manually change, disable, or remove a room +binding. Room agents do not receive `elevated`, Gateway administration, +cross-agent session, or browser-control capabilities by default. + +## Data ownership + +PlatformClaw owns these records: + +- enterprise users and authentication identities; +- LDAP/SAML directory group claims; +- administrator/member roles and active/disabled status; +- managed group/part hierarchy and leader/member assignments; +- browser sessions and revocation state; +- user-to-personal-agent ownership; +- Knox peer-to-agent ownership and approval metadata; +- provisioning state and audit records; +- VM profiles, credentials, and agent-to-VM bindings in a later phase. + +OpenClaw owns: + +- the configured agent entry; +- agent workspaces and agent state directories; +- session history; +- channel routing execution; +- sandbox backend execution after a backend is selected. + +The control-plane store does not duplicate OpenClaw transcripts or runtime +state. OpenClaw configuration does not store employee passwords, VM passwords, +or mutable PlatformClaw session records. + +`platformclaw-control` stores schema version 1 in +`state/platformclaw-control.sqlite`. It uses WAL, foreign keys, a five-second +busy timeout, Kysely-compiled runtime queries, synchronous write transactions, +and owner-only file permissions on Linux. New personal agents preserve the +deployed account-ID convention: replace `.` with `_`, canonicalize case, and +fail closed on collisions. The legacy database and activation JSON are not +runtime fallback sources. + +AD and VM passwords remain outside schema version 1. A later approved schema +stores them encrypted with a master key supplied as a Docker secret. The +credential-encryption key is separate from browser-session signing keys. + +## Implementation slices + +Phase 1 should land in bounded slices: + +1. Define control-plane identity, session, provisioning, and ingress-binding + contracts with an in-memory test implementation. +2. Add the approved SQLite v1 persistent store for identity, management, + bindings, sessions, and audit records. Credential encryption remains a + later schema decision. +3. Implement the LDAP adapter behind the normalized principal contract. +4. Implement idempotent `agents.create` provisioning. +5. Implement the web session and Gateway proxy allowlist. +6. Implement verified Knox direct ingress and dedicated room-agent bindings. +7. Add restart reconciliation for records left in `PROVISIONING`. +8. Add SAML without changing the downstream identity or provisioning contract. + +Each slice requires focused tests for duplicate login, cross-agent denial, +revoked sessions, forged Knox identifiers, binding changes, and recovery after +partial provisioning failure. + +## Phase 1 exit criteria + +Phase 1 is complete only when: + +- two simultaneous first logins create one user and one agent; +- a second user cannot read, write, invoke, or subscribe to the first user's + agent or sessions; +- a revoked browser session cannot reconnect; +- an authenticated Knox DM reaches the same personal agent and `main` session + as PlatformClaw Web; +- a forged Knox room, sender, or agent field cannot select an agent; +- one approved Knox room binding reaches only its dedicated local-workspace + agent and distinct room session; +- a Knox room agent cannot resolve a VM profile or employee credential; +- Gateway operator credentials never reach browser storage or browser frames; +- restart reconciliation resolves or reports every incomplete provisioning + record; +- no enterprise authentication or Knox ownership logic is added to OpenClaw + core. diff --git a/docs/platformclaw/decisions.md b/docs/platformclaw/decisions.md new file mode 100644 index 00000000000..7cfeda44c7f --- /dev/null +++ b/docs/platformclaw/decisions.md @@ -0,0 +1,174 @@ +--- +summary: "Accepted and open architecture decisions for the PlatformClaw rebuild" +read_when: + - Making a PlatformClaw product or security policy decision + - Reviewing whether implementation may begin for a PlatformClaw phase +title: "PlatformClaw architecture decisions" +--- + +# PlatformClaw architecture decisions + +This log separates approved architecture from decisions that still require +product or security policy. Implementation must not silently resolve an open +decision. + +## Accepted + +### PC-001 Preserve upstream ownership + +PlatformClaw enterprise behavior uses a private control-plane component, +plugins, and existing OpenClaw bindings. Enterprise-specific checks do not get +spread across OpenClaw Gateway methods. + +### PC-002 Use opaque sandbox scope keys + +The VM sandbox backend stores and looks up the exact `scopeKey` received from +the sandbox contract. It does not parse `agent:` or depend on private +session-key formatting. + +### PC-003 Preserve the deployed personal agent ID convention + +PlatformClaw keeps an opaque internal user ID, but a new personal agent ID is +derived from the enterprise account ID by replacing `.` with `_` and +canonicalizing case. This preserves the deployed workspace and routing +convention. The control-plane database enforces global agent-ID uniqueness and +fails closed if two account IDs, such as `a.b` and `a_b`, map to the same agent +ID. A later employee-ID or identity-provider change does not rename an existing +agent binding. + +### PC-004 Provision personal agents idempotently + +The first successful web login reserves and creates at most one personal agent. +Later logins update mutable directory metadata and reuse the existing agent. + +### PC-005 Keep the Gateway private + +Browser clients connect through `platformclaw-control`. They do not receive an +OpenClaw operator credential or connect directly to an externally exposed +Gateway listener. + +### PC-006 Reuse upstream channel bindings + +Knox direct and group peers route through upstream OpenClaw `bindings`. The +Knox adapter provides verified stable peer identifiers. PlatformClaw does not +add Knox-specific routing branches to core. + +### PC-007 Use dedicated local agents for Knox rooms + +Each Knox group room has a dedicated room-owned agent. It does not reuse a +participant's personal agent. The room agent uses a local PlatformClaw server +workspace with `sandbox.mode: "off"`. + +### PC-008 Keep Knox room agents outside VM credential policy + +A Knox room agent has no VM profile and receives no employee AD credential. It +does not use the `platformclaw-vm` sandbox backend. + +### PC-009 Treat room workspaces as operational separation + +Each Knox room agent has its own workspace for files, agent identity, and +session organization. This workspace boundary is not a security boundary. +Room agents running on the PlatformClaw server are one trust domain and may be +able to access another room workspace through enabled host tools. + +### PC-010 Allow local execution for Knox room agents + +Knox room agents may use `exec`, `process`, filesystem tools, and approved +managed skills, including execution-oriented skills loaded from the shared +OpenClaw skills directory. Commands run on the PlatformClaw server because the +room agent uses `sandbox.mode: "off"`. Skill selection and workspace routing do +not provide process or filesystem isolation. + +### PC-102 Restrict room binding administration + +Room-agent creation is automatic under PC-103. Only PlatformClaw +administrators may manually change, disable, or remove a room binding in the +first release. + +### PC-103 Automatically provision verified Knox rooms + +The first authenticated message from an unbound Knox room idempotently creates +a dedicated room agent and an exact room binding. The agent ID uses the legacy +`group-` shape after canonical encoding. Initially, verified Knox +chatroom IDs must be globally unique across configured adapter accounts. A +cross-account agent-ID collision fails closed instead of merging rooms; +introducing an account-scoped suffix is a later operator-visible policy change. +Concurrent first messages converge on the same provisioning record. +Caller-supplied agent or session IDs are not routing authority. + +### PC-104 Link Knox DMs to the personal main session + +The authenticated Knox Proxy resolves the employee's existing personal agent +and supplies its agent ID and main session. The adapter accepts those routing +fields only from the authenticated Proxy contract. A Knox DM therefore reaches +the same agent and `main` session used by PlatformClaw Web. + +### PC-105 Use bounded browser sessions + +Browser sessions expire after 12 hours of inactivity or seven days absolutely, +whichever comes first. One user may have at most three concurrent sessions. +Logout and account disablement revoke applicable sessions immediately. + +### PC-106 Use a dedicated control-plane store and encrypt credentials + +`platformclaw-control` owns a dedicated SQLite database. Schema version 1 does +not store AD or VM passwords. When credential storage is approved in a later +schema, passwords are encrypted before storage with a master key supplied +outside the database as a Docker secret. Browser-session signing keys and +credential-encryption keys are separate. + +### PC-107 Restrict remaining Knox room tools + +Knox room agents do not receive `elevated`, Gateway administration, +cross-agent session, or browser-control capabilities by default. Browser +control may be approved later for a named workflow. PC-010 continues to allow +local execution, process management, filesystem tools, and approved managed +skills. + +### PC-108 Use PlatformClaw control schema v1 + +The approved initial database is `state/platformclaw-control.sqlite` with +SQLite schema version 1. It stores users, LDAP/SAML identities, directory group +claims, personal and Knox-room agent bindings, server-side browser sessions, +managed group/part hierarchy and memberships, and control-plane audit events. +Runtime access uses Kysely-compiled queries over synchronous `node:sqlite` +transactions. The database uses WAL, foreign keys, a five-second busy timeout, +and owner-only permissions on Linux. + +### PC-109 Start without legacy data migration + +The rebuild does not read or import the legacy `platformclaw.sqlite` database +or employee-activation JSON at runtime. Users and bindings are provisioned into +the new database. If migration becomes necessary later, it must be an explicit +one-time import rather than a fallback reader or dual-write path. + +### PC-110 Preserve managed group and part administration + +Managed organizational groups and parts remain distinct from LDAP/SAML +directory group claims. Administrators create and archive groups and parts, +assign leaders, and change global user role or status. A group leader may +manage member assignments in that group and its child parts but cannot assign +leaders or remove their own leadership. Changes are audited, and the last +active administrator cannot be disabled or demoted. + +### PC-111 Bootstrap administrators from deployment configuration + +A new control-plane database requires at least one initial administrator +account ID supplied by deployment configuration backed by a Docker secret. +There is no source-code fallback administrator. After bootstrap, administrator +role changes use the control-plane management API and audit log. + +## Open operational decisions + +No remaining decision blocks the SQLite v1 store. Deployment work still needs +backup frequency and retention, behavior when a fourth browser session is +created, and any named workflow that requires browser control. + +## Decision procedure + +For each open decision: + +1. Record the chosen option and rationale here. +2. Record migration impact if the choice may change later. +3. Update the Phase 1 contracts and tests. +4. Only then implement the affected persistent schema or runtime behavior. diff --git a/docs/platformclaw/index.md b/docs/platformclaw/index.md new file mode 100644 index 00000000000..7a4dafbe163 --- /dev/null +++ b/docs/platformclaw/index.md @@ -0,0 +1,44 @@ +--- +summary: "PlatformClaw architecture and migration decisions" +read_when: + - Planning or implementing PlatformClaw capabilities + - Reviewing the boundary between PlatformClaw and upstream OpenClaw +title: "PlatformClaw architecture" +--- + +# PlatformClaw architecture + +PlatformClaw is a private multi-user control plane around an upstream-compatible +OpenClaw Gateway. PlatformClaw owns enterprise identity, user authorization, +agent provisioning, and enterprise execution policy. OpenClaw continues to own +agent runtime, sessions, channel routing, and plugin contracts. + +The current design work is intentionally split into small phases. A phase moves +to implementation only after its security and ownership decisions are recorded. + +## Current phase + +Phase 1 defines the control-plane boundary used by both authenticated web users +and Knox direct or group conversations: + +- [Control plane phase 1](/platformclaw/control-plane-phase-1) +- [Architecture decisions](/platformclaw/decisions) + +The first two implementation slices are in the private +`packages/platformclaw-control-plane` package. They contain contracts, an +in-memory store, and the approved SQLite v1 persistent store. Ingress processes +remain later work. + +The VM execution backend is a later phase. It depends on Phase 1 because the +sandbox must resolve an authenticated agent owner before selecting a VM profile +or credential. + +## Maintenance rules + +- Keep current behavior, approved decisions, proposals, and open decisions + visibly separate. +- Cite the upstream extension point that supports every proposed integration. +- Keep enterprise-only behavior outside OpenClaw core when a plugin, binding, + or control-plane boundary is sufficient. +- Never include credentials, private hostnames, employee records, or production + identifiers in these documents. diff --git a/docs/upstream/status.md b/docs/upstream/status.md index d789e644f97..7857ad2adf2 100644 --- a/docs/upstream/status.md +++ b/docs/upstream/status.md @@ -10,7 +10,7 @@ ## Current Phase -Environment setup complete. Project guidance documentation in progress. +Environment setup complete. Control-plane Phase 1 implementation in progress. ## Completed @@ -23,29 +23,34 @@ Environment setup complete. Project guidance documentation in progress. - `main` tracks `origin/main` - Clean upstream Linux Docker image build and Gateway health smoke validated - Focused credential-free Linux Docker tests validated +- Control-plane identity, session, provisioning, and Knox binding contracts + implemented with an in-memory store and focused tests +- Approved control-plane SQLite v1 store implemented for identities, roles, + directory groups, managed groups/parts, agent bindings, browser sessions, and + audit events ## Not Started - Previous PlatformClaw capability inventory - Previous core modification inventory -- Architecture migration plan -- PlatformClaw feature migration +- Remaining architecture migration plan +- Control-plane runtime adapters - Company Jammy image validation with the approved internal APT source ## Migration Status -| Capability | Status | Related PR/commit | -| --- | --- | --- | -| Branding and product boundary | Not started | - | -| Account/session/workspace isolation | Not started | - | -| Enterprise authentication | Not started | - | -| Credential runtime and policy | Not started | - | -| Skill Hub | Not started | - | -| Knox adapter | Not started | - | -| Remote execution and filesystem bridge | Not started | - | -| Operations UI, retry and recovery | Not started | - | -| Cron and automation | Not started | - | -| Production Docker deployment | In progress | `feature/jammy-company-build` | +| Capability | Status | Related PR/commit | +| -------------------------------------- | ----------------------------------- | ------------------------------------- | +| Branding and product boundary | Not started | - | +| Account/session/workspace isolation | Persistent store implemented | `packages/platformclaw-control-plane` | +| Enterprise authentication | Contracts implemented | `packages/platformclaw-control-plane` | +| Credential runtime and policy | Not started | - | +| Skill Hub | Not started | - | +| Knox adapter | Control-plane contracts implemented | `packages/platformclaw-control-plane` | +| Remote execution and filesystem bridge | Not started | - | +| Operations UI, retry and recovery | Not started | - | +| Cron and automation | Not started | - | +| Production Docker deployment | In progress | `feature/jammy-company-build` | ## Update Rule diff --git a/packages/platformclaw-control-plane/package.json b/packages/platformclaw-control-plane/package.json new file mode 100644 index 00000000000..563ec98b317 --- /dev/null +++ b/packages/platformclaw-control-plane/package.json @@ -0,0 +1,25 @@ +{ + "name": "@platformclaw/control-plane", + "version": "0.0.0-private", + "private": true, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + }, + "dependencies": { + "@openclaw/normalization-core": "workspace:*", + "kysely": "0.29.2" + } +} diff --git a/packages/platformclaw-control-plane/src/contracts.ts b/packages/platformclaw-control-plane/src/contracts.ts new file mode 100644 index 00000000000..55f252d0367 --- /dev/null +++ b/packages/platformclaw-control-plane/src/contracts.ts @@ -0,0 +1,287 @@ +export const BROWSER_SESSION_POLICY = { + idleTimeoutMs: 12 * 60 * 60 * 1000, + absoluteTimeoutMs: 7 * 24 * 60 * 60 * 1000, + maxConcurrentSessions: 3, +} as const; + +export type EnterpriseAuthProvider = "ldap" | "saml"; + +export type EnterprisePrincipal = { + provider: EnterpriseAuthProvider; + subject: string; + employeeId: string; + displayName?: string; + email?: string; + department?: string; + groups?: string[]; +}; + +export type PlatformUserStatus = "active" | "disabled"; +export type PlatformUserGlobalRole = "member" | "admin"; +export type ManagedScopeKind = "group" | "part"; +export type ManagedScopeStatus = "active" | "archived"; +export type ManagedScopeRole = "member" | "leader"; + +export type PlatformUser = { + id: string; + accountId: string; + employeeId: string; + status: PlatformUserStatus; + globalRole: PlatformUserGlobalRole; + displayName?: string; + email?: string; + department?: string; + timezone?: string; + groups: string[]; + createdAt: number; + updatedAt: number; + lastLoginAt?: number; +}; + +export type EnterpriseIdentity = { + provider: EnterpriseAuthProvider; + subject: string; + userId: string; + employeeId: string; + createdAt: number; + lastAuthenticatedAt: number; +}; + +export type ManagedScope = { + id: string; + kind: ManagedScopeKind; + name: string; + parentGroupId?: string; + status: ManagedScopeStatus; + createdByUserId: string; + createdAt: number; + updatedAt: number; +}; + +export type ManagedScopeMembership = { + scopeId: string; + userId: string; + role: ManagedScopeRole; + createdAt: number; + updatedAt: number; +}; + +export type ControlAuditEvent = { + id: string; + actorUserId?: string; + eventType: string; + targetType: string; + targetId: string; + details?: Record; + createdAt: number; +}; + +export type AgentProvisioningState = "provisioning" | "active" | "failed" | "disabled"; + +type AgentBindingBase = { + id: string; + agentId: string; + state: AgentProvisioningState; + createdAt: number; + updatedAt: number; + failureCode?: string; +}; + +export type PersonalAgentBinding = AgentBindingBase & { + kind: "personal"; + userId: string; +}; + +export type KnoxRoomAgentBinding = AgentBindingBase & { + kind: "knox-room"; + accountId: string; + roomId: string; +}; + +export type AgentBinding = PersonalAgentBinding | KnoxRoomAgentBinding; + +export type BrowserSession = { + id: string; + userId: string; + tokenHash: string; + createdAt: number; + lastSeenAt: number; + idleExpiresAt: number; + absoluteExpiresAt: number; + revokedAt?: number; +}; + +export type UpsertPrincipalResult = { + user: PlatformUser; + identity: EnterpriseIdentity; + createdUser: boolean; + createdIdentity: boolean; +}; + +export type AgentReservationResult = { + binding: TBinding; + created: boolean; +}; + +export type CreateBrowserSessionResult = + | { status: "created"; session: BrowserSession } + | { status: "limit-reached"; activeSessionCount: number }; + +export type BrowserSessionResolution = + | { status: "active"; session: BrowserSession; user: PlatformUser } + | { status: "not-found" } + | { status: "revoked"; session: BrowserSession } + | { status: "expired"; reason: "idle" | "absolute"; session: BrowserSession } + | { status: "user-disabled"; session: BrowserSession; user: PlatformUser }; + +export type KnoxDmRouteResolution = + | { + status: "resolved"; + user: PlatformUser; + binding: PersonalAgentBinding; + sessionKey: string; + } + | { status: "user-not-found" } + | { status: "agent-unavailable" } + | { status: "route-mismatch" }; + +export type ControlPlaneIdFactory = { + nextUserId(): string; + nextBindingId(): string; + nextSessionId(): string; + nextManagedScopeId(): string; + nextAuditEventId(): string; +}; + +export type MainSessionKeyBuilder = (params: { agentId: string }) => string; + +export type BrowserSessionPolicy = { + idleTimeoutMs: number; + absoluteTimeoutMs: number; + maxConcurrentSessions: number; +}; + +export interface ControlPlaneStore { + upsertPrincipal( + principal: EnterprisePrincipal, + authenticatedAt: number, + ): Promise; + getUserById(userId: string): Promise; + getUserByEmployeeId(employeeId: string): Promise; + reservePersonalAgent( + userId: string, + reservedAt: number, + ): Promise>; + reserveKnoxRoomAgent(params: { + accountId: string; + roomId: string; + reservedAt: number; + }): Promise>; + transitionAgent(params: { + bindingId: string; + state: AgentProvisioningState; + changedAt: number; + failureCode?: string; + }): Promise; + createBrowserSession(params: { + userId: string; + tokenHash: string; + createdAt: number; + }): Promise; + resolveBrowserSession(params: { + tokenHash: string; + resolvedAt: number; + touch?: boolean; + }): Promise; + revokeBrowserSession(sessionId: string, revokedAt: number): Promise; + resolveAuthenticatedKnoxDmRoute(params: { + employeeId: string; + agentId: string; + sessionKey: string; + }): Promise; +} + +export interface ControlPlaneManagementStore { + setManagedUserStatus(params: { + actorUserId: string; + targetUserId: string; + status: PlatformUserStatus; + changedAt: number; + }): Promise; + setUserGlobalRole(params: { + actorUserId: string; + targetUserId: string; + role: PlatformUserGlobalRole; + changedAt: number; + }): Promise; + createManagedScope(params: { + actorUserId: string; + kind: ManagedScopeKind; + name: string; + parentGroupId?: string; + createdAt: number; + }): Promise; + archiveManagedScope(params: { + actorUserId: string; + scopeId: string; + archivedAt: number; + }): Promise; + setManagedScopeMembership(params: { + actorUserId: string; + scopeId: string; + userId: string; + role: ManagedScopeRole; + changedAt: number; + }): Promise; + removeManagedScopeMembership(params: { + actorUserId: string; + scopeId: string; + userId: string; + changedAt: number; + }): Promise; + listManagedScopes(): Promise; + listManagedScopeMemberships(scopeId: string): Promise; + listAuditEvents(limit?: number): Promise; +} + +export type ControlPlaneConflictCode = + | "employee_id_conflict" + | "employee_id_mismatch" + | "stale_authentication" + | "agent_id_conflict" + | "managed_scope_name_conflict" + | "session_token_conflict"; + +export class ControlPlaneConflictError extends Error { + constructor( + readonly code: ControlPlaneConflictCode, + message: string, + ) { + super(message); + this.name = "ControlPlaneConflictError"; + } +} + +export class ControlPlaneNotFoundError extends Error { + constructor( + readonly resource: "user" | "agent-binding" | "managed-scope", + id: string, + ) { + super(`${resource} not found: ${id}`); + this.name = "ControlPlaneNotFoundError"; + } +} + +export class ControlPlaneAuthorizationError extends Error { + constructor(message: string) { + super(message); + this.name = "ControlPlaneAuthorizationError"; + } +} + +export class ControlPlaneStateError extends Error { + constructor(message: string) { + super(message); + this.name = "ControlPlaneStateError"; + } +} diff --git a/packages/platformclaw-control-plane/src/ids.ts b/packages/platformclaw-control-plane/src/ids.ts new file mode 100644 index 00000000000..d1f61aa85e1 --- /dev/null +++ b/packages/platformclaw-control-plane/src/ids.ts @@ -0,0 +1,45 @@ +import { createHash, randomUUID } from "node:crypto"; +import { isValidAgentId, normalizeAgentId } from "@openclaw/normalization-core/agent-id"; +import type { ControlPlaneIdFactory } from "./contracts.js"; + +const MAX_AGENT_ID_LENGTH = 64; +const ROOM_AGENT_PREFIX = "group-"; +const ROOM_AGENT_DIGEST_LENGTH = 12; + +export const defaultControlPlaneIdFactory: ControlPlaneIdFactory = { + nextUserId: () => `user-${randomUUID()}`, + nextBindingId: () => `binding-${randomUUID()}`, + nextSessionId: () => `session-${randomUUID()}`, + nextManagedScopeId: () => `scope-${randomUUID()}`, + nextAuditEventId: () => `audit-${randomUUID()}`, +}; + +/** Preserve the deployed personal-agent naming contract while validating its output. */ +export function derivePersonalAgentId(accountId: string): string { + const candidate = accountId.trim().replaceAll(".", "_").toLowerCase(); + if (!isValidAgentId(candidate)) { + throw new Error(`account id cannot form a valid personal agent id: ${accountId}`); + } + return candidate; +} + +/** + * Builds a stable OpenClaw-safe room agent id without exposing raw unsafe path characters. + * Safe room ids retain the deployed `group-` contract. The store fails closed if + * two Knox accounts claim the same legacy id; changing that namespace requires operator policy. + */ +export function deriveKnoxRoomAgentId(roomId: string): string { + const legacyCandidate = `${ROOM_AGENT_PREFIX}${roomId.trim().toLowerCase()}`; + if (isValidAgentId(legacyCandidate)) { + return legacyCandidate; + } + const normalizedRoomId = normalizeAgentId(roomId); + const digest = createHash("sha256") + .update(roomId) + .digest("hex") + .slice(0, ROOM_AGENT_DIGEST_LENGTH); + const readableLength = + MAX_AGENT_ID_LENGTH - ROOM_AGENT_PREFIX.length - 1 - ROOM_AGENT_DIGEST_LENGTH; + const readable = normalizedRoomId.slice(0, readableLength); + return `${ROOM_AGENT_PREFIX}${readable}-${digest}`; +} diff --git a/packages/platformclaw-control-plane/src/index.ts b/packages/platformclaw-control-plane/src/index.ts new file mode 100644 index 00000000000..80666355281 --- /dev/null +++ b/packages/platformclaw-control-plane/src/index.ts @@ -0,0 +1,5 @@ +export * from "./contracts.js"; +export * from "./ids.js"; +export * from "./memory-store.js"; +export * from "./sqlite-schema.js"; +export * from "./sqlite-store.js"; diff --git a/packages/platformclaw-control-plane/src/kysely-sync.ts b/packages/platformclaw-control-plane/src/kysely-sync.ts new file mode 100644 index 00000000000..ab4197af340 --- /dev/null +++ b/packages/platformclaw-control-plane/src/kysely-sync.ts @@ -0,0 +1,49 @@ +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { Compilable, Kysely, QueryResult } from "kysely"; +import { InsertQueryNode, Kysely as KyselyInstance, SqliteDialect } from "kysely"; + +const compileOnlyDialect = new SqliteDialect({ + database: async () => { + throw new Error("PlatformClaw uses Kysely for compilation and node:sqlite for sync execution"); + }, +}); + +export function createSyncKysely(): Kysely { + return new KyselyInstance({ dialect: compileOnlyDialect }); +} + +export function executeSync(db: DatabaseSync, query: Compilable): QueryResult { + const compiled = query.compile(); + const statement = db.prepare(compiled.sql); + const parameters = compiled.parameters as SQLInputValue[]; + if (statement.columns().length > 0) { + return { rows: statement.all(...parameters) as Row[] }; + } + const result = statement.run(...parameters); + return { + rows: [], + numAffectedRows: BigInt(result.changes), + ...(InsertQueryNode.is(compiled.query) && result.changes > 0 + ? { insertId: BigInt(result.lastInsertRowid) } + : {}), + }; +} + +export function takeFirstSync(db: DatabaseSync, query: Compilable): Row | undefined { + return executeSync(db, query).rows[0]; +} + +export function runImmediateTransaction(db: DatabaseSync, operation: () => T): T { + db.exec("BEGIN IMMEDIATE"); + try { + const result = operation(); + if (result && typeof result === "object" && "then" in result) { + throw new Error("SQLite write transactions must be synchronous"); + } + db.exec("COMMIT"); + return result; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} diff --git a/packages/platformclaw-control-plane/src/memory-store.test.ts b/packages/platformclaw-control-plane/src/memory-store.test.ts new file mode 100644 index 00000000000..8c52885c6f5 --- /dev/null +++ b/packages/platformclaw-control-plane/src/memory-store.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, it, vi } from "vitest"; +import { + BROWSER_SESSION_POLICY, + ControlPlaneStateError, + type ControlPlaneIdFactory, + type EnterprisePrincipal, +} from "./contracts.js"; +import { deriveKnoxRoomAgentId } from "./ids.js"; +import { InMemoryControlPlaneStore } from "./memory-store.js"; + +function createIdFactory(): ControlPlaneIdFactory { + let user = 0; + let binding = 0; + let session = 0; + let scope = 0; + let audit = 0; + return { + nextUserId: () => `user-${++user}`, + nextBindingId: () => `binding-${++binding}`, + nextSessionId: () => `session-${++session}`, + nextManagedScopeId: () => `scope-${++scope}`, + nextAuditEventId: () => `audit-${++audit}`, + }; +} + +function createStore() { + return new InMemoryControlPlaneStore({ + idFactory: createIdFactory(), + initialAdminAccountIds: ["admin.user"], + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + }); +} + +async function disableUser( + store: InMemoryControlPlaneStore, + targetUserId: string, + changedAt: number, +) { + const admin = await store.upsertPrincipal( + ldapPrincipal({ subject: "admin-subject", employeeId: "admin.user" }), + changedAt - 1, + ); + await store.setManagedUserStatus({ + actorUserId: admin.user.id, + targetUserId, + status: "disabled", + changedAt, + }); +} + +function ldapPrincipal(overrides: Partial = {}): EnterprisePrincipal { + return { + provider: "ldap", + subject: "ldap-subject-1", + employeeId: "employee-1", + displayName: "First Name", + email: "first@example.test", + department: "Platform", + groups: ["developers", "platform"], + ...overrides, + }; +} + +async function createActivePersonalAgent(store: InMemoryControlPlaneStore) { + const principal = await store.upsertPrincipal(ldapPrincipal(), 1_000); + const reservation = await store.reservePersonalAgent(principal.user.id, 2_000); + const binding = await store.transitionAgent({ + bindingId: reservation.binding.id, + state: "active", + changedAt: 3_000, + }); + if (binding.kind !== "personal") { + throw new Error("expected personal binding"); + } + return { user: principal.user, binding }; +} + +describe("personal agent id", () => { + it("preserves the deployed account-id dot replacement", async () => { + const store = createStore(); + const principal = await store.upsertPrincipal( + ldapPrincipal({ employeeId: "seungon.jung" }), + 1_000, + ); + const reservation = await store.reservePersonalAgent(principal.user.id, 2_000); + + expect(reservation.binding.agentId).toBe("seungon_jung"); + }); +}); + +describe("InMemoryControlPlaneStore enterprise identity", () => { + it("keeps one user while mutable LDAP attributes change", async () => { + const store = createStore(); + const first = await store.upsertPrincipal(ldapPrincipal(), 1_000); + const second = await store.upsertPrincipal( + ldapPrincipal({ + displayName: "Updated Name", + department: "AI Platform", + groups: ["platform", "admins", "platform"], + }), + 2_000, + ); + + expect(first.createdUser).toBe(true); + expect(second.createdUser).toBe(false); + expect(second.createdIdentity).toBe(false); + expect(second.user.id).toBe(first.user.id); + expect(second.user).toMatchObject({ + displayName: "Updated Name", + department: "AI Platform", + groups: ["admins", "platform"], + updatedAt: 2_000, + }); + }); + + it("links a verified SAML identity to the existing employee user", async () => { + const store = createStore(); + const ldap = await store.upsertPrincipal(ldapPrincipal(), 1_000); + const saml = await store.upsertPrincipal( + ldapPrincipal({ + provider: "saml", + subject: "saml-subject-9", + employeeId: "EMPLOYEE-1", + }), + 2_000, + ); + + expect(saml.createdUser).toBe(false); + expect(saml.createdIdentity).toBe(true); + expect(saml.user.employeeId).toBe("employee-1"); + expect(saml.user.id).toBe(ldap.user.id); + expect(saml.identity).toMatchObject({ + provider: "saml", + subject: "saml-subject-9", + userId: ldap.user.id, + }); + }); + + it("keeps the user and agent owner stable after an employee ID correction", async () => { + const store = createStore(); + const first = await store.upsertPrincipal(ldapPrincipal(), 1_000); + const agent = await store.reservePersonalAgent(first.user.id, 1_500); + const corrected = await store.upsertPrincipal( + ldapPrincipal({ employeeId: "employee-corrected" }), + 2_000, + ); + + expect(corrected.user.id).toBe(first.user.id); + expect(await store.getUserByEmployeeId("employee-1")).toBeNull(); + expect(await store.getUserByEmployeeId("employee-corrected")).toMatchObject({ + id: first.user.id, + }); + expect((await store.reservePersonalAgent(first.user.id, 2_500)).binding.id).toBe( + agent.binding.id, + ); + }); + + it("fails closed when a linked provider still reports a stale employee ID", async () => { + const store = createStore(); + await store.upsertPrincipal(ldapPrincipal(), 1_000); + await store.upsertPrincipal( + ldapPrincipal({ provider: "saml", subject: "saml-subject-9" }), + 1_100, + ); + const corrected = await store.upsertPrincipal( + ldapPrincipal({ employeeId: "employee-corrected" }), + 2_000, + ); + + await expect( + store.upsertPrincipal(ldapPrincipal({ provider: "saml", subject: "saml-subject-9" }), 2_100), + ).rejects.toMatchObject({ code: "employee_id_mismatch" }); + expect(await store.getUserByEmployeeId("employee-1")).toBeNull(); + expect(await store.getUserByEmployeeId("employee-corrected")).toMatchObject({ + id: corrected.user.id, + }); + + await expect( + store.upsertPrincipal( + ldapPrincipal({ + provider: "saml", + subject: "saml-subject-9", + employeeId: "employee-corrected", + }), + 2_200, + ), + ).resolves.toMatchObject({ + user: { id: corrected.user.id }, + identity: { employeeId: "employee-corrected" }, + }); + }); + + it("rejects out-of-order and equal-version employee ID changes", async () => { + const store = createStore(); + await store.upsertPrincipal(ldapPrincipal(), 1_000); + const corrected = await store.upsertPrincipal( + ldapPrincipal({ employeeId: "employee-corrected" }), + 2_000, + ); + + await expect(store.upsertPrincipal(ldapPrincipal(), 1_500)).rejects.toMatchObject({ + code: "stale_authentication", + }); + await expect(store.upsertPrincipal(ldapPrincipal(), 2_000)).rejects.toMatchObject({ + code: "stale_authentication", + }); + await expect( + store.upsertPrincipal(ldapPrincipal({ employeeId: "employee-corrected" }), 2_000), + ).resolves.toMatchObject({ user: { id: corrected.user.id } }); + expect(await store.getUserByEmployeeId("employee-1")).toBeNull(); + expect(await store.getUserByEmployeeId("employee-corrected")).toMatchObject({ + id: corrected.user.id, + }); + }); + + it("rejects an employee ID correction that would merge two existing users", async () => { + const store = createStore(); + await store.upsertPrincipal(ldapPrincipal(), 1_000); + await store.upsertPrincipal( + ldapPrincipal({ subject: "ldap-subject-2", employeeId: "employee-2" }), + 1_100, + ); + + await expect( + store.upsertPrincipal(ldapPrincipal({ employeeId: "employee-2" }), 2_000), + ).rejects.toMatchObject({ + code: "employee_id_conflict", + }); + }); +}); + +describe("InMemoryControlPlaneStore provisioning", () => { + it("converges concurrent personal-agent reservations on one record", async () => { + const store = createStore(); + const { user } = await store.upsertPrincipal(ldapPrincipal(), 1_000); + + const [first, second] = await Promise.all([ + store.reservePersonalAgent(user.id, 2_000), + store.reservePersonalAgent(user.id, 2_000), + ]); + + expect(first.binding).toEqual(second.binding); + expect([first.created, second.created].sort()).toEqual([false, true]); + expect(first.binding).toMatchObject({ + kind: "personal", + userId: user.id, + state: "provisioning", + }); + }); + + it("converges room reservations and fails closed on cross-account unsafe room IDs", async () => { + const store = createStore(); + const params = { accountId: "knox-primary", roomId: "room/123", reservedAt: 1_000 }; + + const [first, second] = await Promise.all([ + store.reserveKnoxRoomAgent(params), + store.reserveKnoxRoomAgent(params), + ]); + expect(first.binding).toEqual(second.binding); + expect([first.created, second.created].sort()).toEqual([false, true]); + expect(first.binding.agentId).toBe(deriveKnoxRoomAgentId("room/123")); + expect(first.binding.agentId).toMatch(/^group-[a-z0-9_-]+$/u); + expect(first.binding.agentId.length).toBeLessThanOrEqual(64); + await expect( + store.reserveKnoxRoomAgent({ ...params, accountId: "knox-secondary" }), + ).rejects.toMatchObject({ code: "agent_id_conflict" }); + }); + + it("preserves the legacy group-chatroom ID shape when it is already safe", () => { + expect(deriveKnoxRoomAgentId("room-123")).toBe("group-room-123"); + }); + + it("fails closed when two Knox accounts claim the same legacy room agent ID", async () => { + const store = createStore(); + await store.reserveKnoxRoomAgent({ + accountId: "knox-primary", + roomId: "room-123", + reservedAt: 1_000, + }); + + await expect( + store.reserveKnoxRoomAgent({ + accountId: "knox-secondary", + roomId: "room-123", + reservedAt: 2_000, + }), + ).rejects.toMatchObject({ code: "agent_id_conflict" }); + }); + + it("enforces provisioning state transitions", async () => { + const store = createStore(); + const { binding } = await store.reserveKnoxRoomAgent({ + accountId: "knox-primary", + roomId: "room-1", + reservedAt: 1_000, + }); + const active = await store.transitionAgent({ + bindingId: binding.id, + state: "active", + changedAt: 2_000, + }); + + expect(active.state).toBe("active"); + await expect( + store.transitionAgent({ + bindingId: binding.id, + state: "provisioning", + changedAt: 3_000, + }), + ).rejects.toBeInstanceOf(ControlPlaneStateError); + }); +}); + +describe("InMemoryControlPlaneStore browser sessions", () => { + it("enforces the three-session limit without choosing an eviction policy", async () => { + const store = createStore(); + const { user } = await store.upsertPrincipal(ldapPrincipal(), 1_000); + for (let index = 1; index <= BROWSER_SESSION_POLICY.maxConcurrentSessions; index += 1) { + await expect( + store.createBrowserSession({ + userId: user.id, + tokenHash: `hash-${index}`, + createdAt: 2_000, + }), + ).resolves.toMatchObject({ status: "created" }); + } + + await expect( + store.createBrowserSession({ + userId: user.id, + tokenHash: "hash-4", + createdAt: 2_000, + }), + ).resolves.toEqual({ status: "limit-reached", activeSessionCount: 3 }); + }); + + it("distinguishes idle and absolute expiry and caps idle extension", async () => { + const store = createStore(); + const { user } = await store.upsertPrincipal(ldapPrincipal(), 0); + await store.createBrowserSession({ userId: user.id, tokenHash: "idle", createdAt: 0 }); + + await expect( + store.resolveBrowserSession({ + tokenHash: "idle", + resolvedAt: BROWSER_SESSION_POLICY.idleTimeoutMs, + }), + ).resolves.toMatchObject({ status: "expired", reason: "idle" }); + + const shortPolicyStore = new InMemoryControlPlaneStore({ + idFactory: createIdFactory(), + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + sessionPolicy: { + idleTimeoutMs: 100, + absoluteTimeoutMs: 250, + maxConcurrentSessions: 3, + }, + }); + const shortPolicyUser = await shortPolicyStore.upsertPrincipal(ldapPrincipal(), 0); + await shortPolicyStore.createBrowserSession({ + userId: shortPolicyUser.user.id, + tokenHash: "absolute", + createdAt: 0, + }); + await shortPolicyStore.resolveBrowserSession({ tokenHash: "absolute", resolvedAt: 99 }); + await shortPolicyStore.resolveBrowserSession({ tokenHash: "absolute", resolvedAt: 198 }); + const touched = await shortPolicyStore.resolveBrowserSession({ + tokenHash: "absolute", + resolvedAt: 249, + }); + expect(touched).toMatchObject({ + status: "active", + session: { idleExpiresAt: 250 }, + }); + await expect( + shortPolicyStore.resolveBrowserSession({ + tokenHash: "absolute", + resolvedAt: 250, + }), + ).resolves.toMatchObject({ status: "expired", reason: "absolute" }); + }); + + it("revokes all sessions when the user is disabled", async () => { + const store = createStore(); + const { user } = await store.upsertPrincipal(ldapPrincipal(), 1_000); + await store.createBrowserSession({ userId: user.id, tokenHash: "hash", createdAt: 2_000 }); + await disableUser(store, user.id, 3_000); + + await expect( + store.resolveBrowserSession({ tokenHash: "hash", resolvedAt: 3_001 }), + ).resolves.toMatchObject({ status: "revoked", session: { revokedAt: 3_000 } }); + }); +}); + +describe("InMemoryControlPlaneStore authenticated Knox DM routing", () => { + it("resolves only the active personal agent main session", async () => { + const buildAgentMainSessionKey = vi.fn( + ({ agentId }: { agentId: string }) => `canonical:${agentId}:main`, + ); + const store = new InMemoryControlPlaneStore({ + idFactory: createIdFactory(), + buildAgentMainSessionKey, + }); + const { user, binding } = await createActivePersonalAgent(store); + const sessionKey = `canonical:${binding.agentId}:main`; + + await expect( + store.resolveAuthenticatedKnoxDmRoute({ + employeeId: user.employeeId, + agentId: binding.agentId, + sessionKey, + }), + ).resolves.toMatchObject({ + status: "resolved", + user: { id: user.id }, + binding: { id: binding.id }, + sessionKey, + }); + expect(buildAgentMainSessionKey).toHaveBeenCalledWith({ agentId: binding.agentId }); + }); + + it("rejects Knox DM routing after the enterprise user is disabled", async () => { + const store = createStore(); + const { user, binding } = await createActivePersonalAgent(store); + await disableUser(store, user.id, 4_000); + + await expect( + store.resolveAuthenticatedKnoxDmRoute({ + employeeId: user.employeeId, + agentId: binding.agentId, + sessionKey: `agent:${binding.agentId}:main`, + }), + ).resolves.toEqual({ status: "agent-unavailable" }); + }); + + it("rejects a trusted-proxy route that does not match current ownership", async () => { + const store = createStore(); + const { user, binding } = await createActivePersonalAgent(store); + + await expect( + store.resolveAuthenticatedKnoxDmRoute({ + employeeId: user.employeeId, + agentId: "another-agent", + sessionKey: `agent:${binding.agentId}:main`, + }), + ).resolves.toEqual({ status: "route-mismatch" }); + }); +}); diff --git a/packages/platformclaw-control-plane/src/memory-store.ts b/packages/platformclaw-control-plane/src/memory-store.ts new file mode 100644 index 00000000000..18481624b16 --- /dev/null +++ b/packages/platformclaw-control-plane/src/memory-store.ts @@ -0,0 +1,559 @@ +import { + BROWSER_SESSION_POLICY, + ControlPlaneConflictError, + ControlPlaneAuthorizationError, + ControlPlaneNotFoundError, + ControlPlaneStateError, + type AgentBinding, + type AgentProvisioningState, + type AgentReservationResult, + type BrowserSession, + type BrowserSessionPolicy, + type BrowserSessionResolution, + type ControlPlaneIdFactory, + type ControlPlaneStore, + type CreateBrowserSessionResult, + type EnterpriseIdentity, + type EnterprisePrincipal, + type KnoxDmRouteResolution, + type KnoxRoomAgentBinding, + type MainSessionKeyBuilder, + type PersonalAgentBinding, + type PlatformUser, + type PlatformUserStatus, + type UpsertPrincipalResult, +} from "./contracts.js"; +import { + defaultControlPlaneIdFactory, + deriveKnoxRoomAgentId, + derivePersonalAgentId, +} from "./ids.js"; + +type MemoryStoreOptions = { + buildAgentMainSessionKey: MainSessionKeyBuilder; + initialAdminAccountIds?: readonly string[]; + idFactory?: ControlPlaneIdFactory; + sessionPolicy?: BrowserSessionPolicy; +}; + +const ALLOWED_AGENT_TRANSITIONS: Record< + AgentProvisioningState, + ReadonlySet +> = { + provisioning: new Set(["active", "failed", "disabled"]), + active: new Set(["disabled"]), + failed: new Set(["provisioning", "disabled"]), + disabled: new Set(), +}; + +function requireNonEmpty(value: string, field: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new ControlPlaneStateError(`${field} must not be empty`); + } + return normalized; +} + +function identityKey(provider: string, subject: string): string { + return `${provider}\0${subject}`; +} + +function roomKey(accountId: string, roomId: string): string { + return `${accountId}\0${roomId}`; +} + +function cloneUser(user: PlatformUser): PlatformUser { + return { ...user, groups: [...user.groups] }; +} + +function cloneIdentity(identity: EnterpriseIdentity): EnterpriseIdentity { + return { ...identity }; +} + +function cloneBinding(binding: TBinding): TBinding { + return { ...binding }; +} + +function cloneSession(session: BrowserSession): BrowserSession { + return { ...session }; +} + +function copyOptional(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function normalizeGroups(groups: string[] | undefined): string[] { + return [...new Set((groups ?? []).map((group) => group.trim()).filter(Boolean))].toSorted(); +} + +function normalizeEmployeeId(employeeId: string): string { + return requireNonEmpty(employeeId, "principal.employeeId").toLowerCase(); +} + +export class InMemoryControlPlaneStore implements ControlPlaneStore { + private readonly buildAgentMainSessionKey: MainSessionKeyBuilder; + private readonly idFactory: ControlPlaneIdFactory; + private readonly sessionPolicy: BrowserSessionPolicy; + private readonly initialAdminAccountIds: ReadonlySet; + private readonly users = new Map(); + private readonly userIdByEmployeeId = new Map(); + private readonly identities = new Map(); + private readonly bindings = new Map(); + private readonly personalBindingIdByUserId = new Map(); + private readonly roomBindingIdByKey = new Map(); + private readonly bindingIdByAgentId = new Map(); + private readonly sessions = new Map(); + private readonly sessionIdByTokenHash = new Map(); + private readonly sessionIdsByUserId = new Map>(); + + constructor(options: MemoryStoreOptions) { + this.buildAgentMainSessionKey = options.buildAgentMainSessionKey; + this.idFactory = options.idFactory ?? defaultControlPlaneIdFactory; + this.sessionPolicy = options.sessionPolicy ?? BROWSER_SESSION_POLICY; + this.initialAdminAccountIds = new Set( + (options.initialAdminAccountIds ?? []) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean), + ); + } + + async upsertPrincipal( + principal: EnterprisePrincipal, + authenticatedAt: number, + ): Promise { + const subject = requireNonEmpty(principal.subject, "principal.subject"); + const employeeId = normalizeEmployeeId(principal.employeeId); + const key = identityKey(principal.provider, subject); + const existingIdentity = this.identities.get(key); + + if (existingIdentity) { + const user = this.requireUser(existingIdentity.userId); + if ( + authenticatedAt < existingIdentity.lastAuthenticatedAt || + (authenticatedAt === existingIdentity.lastAuthenticatedAt && + employeeId !== existingIdentity.employeeId) + ) { + throw new ControlPlaneConflictError( + "stale_authentication", + `authentication result is older than the current identity version: ${principal.provider}:${subject}`, + ); + } + this.reconcileIdentityEmployeeId(existingIdentity, user, employeeId); + this.updateUserFromPrincipal(user, principal, authenticatedAt); + existingIdentity.lastAuthenticatedAt = authenticatedAt; + return { + user: cloneUser(user), + identity: cloneIdentity(existingIdentity), + createdUser: false, + createdIdentity: false, + }; + } + + const existingUserId = this.userIdByEmployeeId.get(employeeId); + const user = existingUserId + ? this.requireUser(existingUserId) + : this.createUser(employeeId, principal, authenticatedAt); + if (existingUserId) { + this.updateUserFromPrincipal(user, principal, authenticatedAt); + } + + const identity: EnterpriseIdentity = { + provider: principal.provider, + subject, + userId: user.id, + employeeId, + createdAt: authenticatedAt, + lastAuthenticatedAt: authenticatedAt, + }; + this.identities.set(key, identity); + return { + user: cloneUser(user), + identity: cloneIdentity(identity), + createdUser: !existingUserId, + createdIdentity: true, + }; + } + + async getUserById(userId: string): Promise { + const user = this.users.get(userId); + return user ? cloneUser(user) : null; + } + + async getUserByEmployeeId(employeeId: string): Promise { + const userId = this.userIdByEmployeeId.get(employeeId.trim().toLowerCase()); + const user = userId ? this.users.get(userId) : undefined; + return user ? cloneUser(user) : null; + } + + async setManagedUserStatus(params: { + actorUserId: string; + targetUserId: string; + status: PlatformUserStatus; + changedAt: number; + }): Promise { + const actor = this.requireUser(params.actorUserId); + const user = this.requireUser(params.targetUserId); + if (actor.status !== "active" || actor.globalRole !== "admin") { + throw new ControlPlaneAuthorizationError("active administrator required"); + } + if (actor.id === user.id && actor.status !== params.status) { + throw new ControlPlaneStateError("administrators cannot change their own status"); + } + user.status = params.status; + user.updatedAt = params.changedAt; + if (params.status === "disabled") { + for (const sessionId of this.sessionIdsByUserId.get(user.id) ?? []) { + const session = this.sessions.get(sessionId); + if (session && session.revokedAt === undefined) { + session.revokedAt = params.changedAt; + } + } + } + return cloneUser(user); + } + + async reservePersonalAgent( + userId: string, + reservedAt: number, + ): Promise> { + const user = this.requireUser(userId); + if (user.status !== "active") { + throw new ControlPlaneStateError(`cannot provision agent for disabled user: ${userId}`); + } + const existingId = this.personalBindingIdByUserId.get(userId); + if (existingId) { + return { + binding: cloneBinding(this.requirePersonalBinding(existingId)), + created: false, + }; + } + + const binding: PersonalAgentBinding = { + id: this.idFactory.nextBindingId(), + kind: "personal", + userId, + agentId: derivePersonalAgentId(user.accountId), + state: "provisioning", + createdAt: reservedAt, + updatedAt: reservedAt, + }; + this.insertBinding(binding); + this.personalBindingIdByUserId.set(userId, binding.id); + return { binding: cloneBinding(binding), created: true }; + } + + async reserveKnoxRoomAgent(params: { + accountId: string; + roomId: string; + reservedAt: number; + }): Promise> { + const accountId = requireNonEmpty(params.accountId, "accountId"); + const roomId = requireNonEmpty(params.roomId, "roomId"); + const key = roomKey(accountId, roomId); + const existingId = this.roomBindingIdByKey.get(key); + if (existingId) { + return { + binding: cloneBinding(this.requireRoomBinding(existingId)), + created: false, + }; + } + + const binding: KnoxRoomAgentBinding = { + id: this.idFactory.nextBindingId(), + kind: "knox-room", + accountId, + roomId, + agentId: deriveKnoxRoomAgentId(roomId), + state: "provisioning", + createdAt: params.reservedAt, + updatedAt: params.reservedAt, + }; + this.insertBinding(binding); + this.roomBindingIdByKey.set(key, binding.id); + return { binding: cloneBinding(binding), created: true }; + } + + async transitionAgent(params: { + bindingId: string; + state: AgentProvisioningState; + changedAt: number; + failureCode?: string; + }): Promise { + const binding = this.requireBinding(params.bindingId); + if ( + binding.state !== params.state && + !ALLOWED_AGENT_TRANSITIONS[binding.state].has(params.state) + ) { + throw new ControlPlaneStateError( + `invalid agent transition: ${binding.state} -> ${params.state}`, + ); + } + binding.state = params.state; + binding.updatedAt = params.changedAt; + if (params.state === "failed") { + binding.failureCode = requireNonEmpty(params.failureCode ?? "unknown", "failureCode"); + } else { + delete binding.failureCode; + } + return cloneBinding(binding); + } + + async createBrowserSession(params: { + userId: string; + tokenHash: string; + createdAt: number; + }): Promise { + const user = this.requireUser(params.userId); + if (user.status !== "active") { + throw new ControlPlaneStateError(`cannot create session for disabled user: ${user.id}`); + } + const tokenHash = requireNonEmpty(params.tokenHash, "tokenHash"); + if (this.sessionIdByTokenHash.has(tokenHash)) { + throw new ControlPlaneConflictError( + "session_token_conflict", + "session token hash already exists", + ); + } + const activeSessionCount = this.countActiveSessions(user.id, params.createdAt); + if (activeSessionCount >= this.sessionPolicy.maxConcurrentSessions) { + return { status: "limit-reached", activeSessionCount }; + } + + const absoluteExpiresAt = params.createdAt + this.sessionPolicy.absoluteTimeoutMs; + const session: BrowserSession = { + id: this.idFactory.nextSessionId(), + userId: user.id, + tokenHash, + createdAt: params.createdAt, + lastSeenAt: params.createdAt, + idleExpiresAt: Math.min( + params.createdAt + this.sessionPolicy.idleTimeoutMs, + absoluteExpiresAt, + ), + absoluteExpiresAt, + }; + this.sessions.set(session.id, session); + this.sessionIdByTokenHash.set(tokenHash, session.id); + const userSessions = this.sessionIdsByUserId.get(user.id) ?? new Set(); + userSessions.add(session.id); + this.sessionIdsByUserId.set(user.id, userSessions); + return { status: "created", session: cloneSession(session) }; + } + + async resolveBrowserSession(params: { + tokenHash: string; + resolvedAt: number; + touch?: boolean; + }): Promise { + const sessionId = this.sessionIdByTokenHash.get(params.tokenHash); + const session = sessionId ? this.sessions.get(sessionId) : undefined; + if (!session) { + return { status: "not-found" }; + } + if (session.revokedAt !== undefined) { + return { status: "revoked", session: cloneSession(session) }; + } + const expiryReason = this.resolveExpiryReason(session, params.resolvedAt); + if (expiryReason) { + return { status: "expired", reason: expiryReason, session: cloneSession(session) }; + } + const user = this.requireUser(session.userId); + if (user.status !== "active") { + return { status: "user-disabled", session: cloneSession(session), user: cloneUser(user) }; + } + if (params.touch !== false) { + session.lastSeenAt = params.resolvedAt; + session.idleExpiresAt = Math.min( + params.resolvedAt + this.sessionPolicy.idleTimeoutMs, + session.absoluteExpiresAt, + ); + } + return { status: "active", session: cloneSession(session), user: cloneUser(user) }; + } + + async revokeBrowserSession(sessionId: string, revokedAt: number): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + return null; + } + session.revokedAt ??= revokedAt; + return cloneSession(session); + } + + async resolveAuthenticatedKnoxDmRoute(params: { + employeeId: string; + agentId: string; + sessionKey: string; + }): Promise { + const userId = this.userIdByEmployeeId.get(params.employeeId.trim().toLowerCase()); + const user = userId ? this.users.get(userId) : undefined; + if (!user) { + return { status: "user-not-found" }; + } + if (user.status !== "active") { + return { status: "agent-unavailable" }; + } + const bindingId = this.personalBindingIdByUserId.get(user.id); + const binding = bindingId ? this.bindings.get(bindingId) : undefined; + if (!binding || binding.kind !== "personal" || binding.state !== "active") { + return { status: "agent-unavailable" }; + } + const expectedSessionKey = this.buildAgentMainSessionKey({ agentId: binding.agentId }); + if (params.agentId !== binding.agentId || params.sessionKey !== expectedSessionKey) { + return { status: "route-mismatch" }; + } + return { + status: "resolved", + user: cloneUser(user), + binding: cloneBinding(binding), + sessionKey: expectedSessionKey, + }; + } + + private createUser( + employeeId: string, + principal: EnterprisePrincipal, + createdAt: number, + ): PlatformUser { + const user: PlatformUser = { + id: this.idFactory.nextUserId(), + accountId: employeeId, + employeeId, + status: "active", + globalRole: this.initialAdminAccountIds.has(employeeId.toLowerCase()) ? "admin" : "member", + groups: normalizeGroups(principal.groups), + createdAt, + updatedAt: createdAt, + ...this.profileFields(principal), + }; + this.users.set(user.id, user); + this.userIdByEmployeeId.set(employeeId, user.id); + return user; + } + + private updateUserFromPrincipal( + user: PlatformUser, + principal: EnterprisePrincipal, + updatedAt: number, + ) { + user.groups = normalizeGroups(principal.groups); + user.updatedAt = updatedAt; + const profile = this.profileFields(principal); + for (const field of ["displayName", "email", "department"] as const) { + if (profile[field] === undefined) { + delete user[field]; + } else { + user[field] = profile[field]; + } + } + } + + private profileFields(principal: EnterprisePrincipal) { + return { + displayName: copyOptional(principal.displayName), + email: copyOptional(principal.email), + department: copyOptional(principal.department), + }; + } + + private moveEmployeeId(user: PlatformUser, employeeId: string) { + if (user.employeeId === employeeId) { + return; + } + const owner = this.userIdByEmployeeId.get(employeeId); + if (owner && owner !== user.id) { + throw new ControlPlaneConflictError( + "employee_id_conflict", + `employee id already belongs to another user: ${employeeId}`, + ); + } + this.userIdByEmployeeId.delete(user.employeeId); + this.userIdByEmployeeId.set(employeeId, user.id); + user.employeeId = employeeId; + } + + private reconcileIdentityEmployeeId( + identity: EnterpriseIdentity, + user: PlatformUser, + employeeId: string, + ) { + if (employeeId === user.employeeId) { + identity.employeeId = employeeId; + return; + } + if (identity.employeeId !== user.employeeId) { + throw new ControlPlaneConflictError( + "employee_id_mismatch", + `identity employee id disagrees with the canonical user: ${identity.provider}:${identity.subject}`, + ); + } + this.moveEmployeeId(user, employeeId); + identity.employeeId = employeeId; + } + + private insertBinding(binding: AgentBinding) { + const existingBindingId = this.bindingIdByAgentId.get(binding.agentId); + if (existingBindingId) { + throw new ControlPlaneConflictError( + "agent_id_conflict", + `agent id already belongs to another binding: ${binding.agentId}`, + ); + } + this.bindings.set(binding.id, binding); + this.bindingIdByAgentId.set(binding.agentId, binding.id); + } + + private countActiveSessions(userId: string, at: number): number { + let count = 0; + for (const sessionId of this.sessionIdsByUserId.get(userId) ?? []) { + const session = this.sessions.get(sessionId); + if (session && session.revokedAt === undefined && !this.resolveExpiryReason(session, at)) { + count += 1; + } + } + return count; + } + + private resolveExpiryReason(session: BrowserSession, at: number): "idle" | "absolute" | null { + if (at >= session.absoluteExpiresAt) { + return "absolute"; + } + if (at >= session.idleExpiresAt) { + return "idle"; + } + return null; + } + + private requireUser(userId: string): PlatformUser { + const user = this.users.get(userId); + if (!user) { + throw new ControlPlaneNotFoundError("user", userId); + } + return user; + } + + private requireBinding(bindingId: string): AgentBinding { + const binding = this.bindings.get(bindingId); + if (!binding) { + throw new ControlPlaneNotFoundError("agent-binding", bindingId); + } + return binding; + } + + private requirePersonalBinding(bindingId: string): PersonalAgentBinding { + const binding = this.requireBinding(bindingId); + if (binding.kind !== "personal") { + throw new ControlPlaneStateError(`binding is not personal: ${bindingId}`); + } + return binding; + } + + private requireRoomBinding(bindingId: string): KnoxRoomAgentBinding { + const binding = this.requireBinding(bindingId); + if (binding.kind !== "knox-room") { + throw new ControlPlaneStateError(`binding is not a Knox room: ${bindingId}`); + } + return binding; + } +} diff --git a/packages/platformclaw-control-plane/src/sqlite-schema.ts b/packages/platformclaw-control-plane/src/sqlite-schema.ts new file mode 100644 index 00000000000..7eba81d6e34 --- /dev/null +++ b/packages/platformclaw-control-plane/src/sqlite-schema.ts @@ -0,0 +1,137 @@ +import type { DatabaseSync } from "node:sqlite"; + +export const PLATFORMCLAW_CONTROL_SCHEMA_VERSION = 1; + +const SCHEMA_V1 = ` +CREATE TABLE platform_users ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL UNIQUE, + employee_id TEXT NOT NULL UNIQUE, + display_name TEXT, + email TEXT, + department TEXT, + timezone TEXT, + status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), + global_role TEXT NOT NULL CHECK (global_role IN ('member', 'admin')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_login_at INTEGER +) STRICT; + +CREATE TABLE enterprise_identities ( + provider TEXT NOT NULL CHECK (provider IN ('ldap', 'saml')), + subject TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES platform_users(id) ON DELETE CASCADE, + employee_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_authenticated_at INTEGER NOT NULL, + PRIMARY KEY (provider, subject) +) STRICT; + +CREATE TABLE user_directory_groups ( + user_id TEXT NOT NULL REFERENCES platform_users(id) ON DELETE CASCADE, + group_name TEXT NOT NULL, + PRIMARY KEY (user_id, group_name) +) STRICT; + +CREATE TABLE agent_bindings ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('personal', 'knox-room')), + user_id TEXT REFERENCES platform_users(id) ON DELETE CASCADE, + knox_account_id TEXT, + room_id TEXT, + agent_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('provisioning', 'active', 'failed', 'disabled')), + failure_code TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK ( + (kind = 'personal' AND user_id IS NOT NULL AND knox_account_id IS NULL AND room_id IS NULL) OR + (kind = 'knox-room' AND user_id IS NULL AND knox_account_id IS NOT NULL AND room_id IS NOT NULL) + ) +) STRICT; + +CREATE UNIQUE INDEX agent_bindings_personal_user + ON agent_bindings(user_id) WHERE kind = 'personal'; +CREATE UNIQUE INDEX agent_bindings_knox_room + ON agent_bindings(knox_account_id, room_id) WHERE kind = 'knox-room'; + +CREATE TABLE browser_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES platform_users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + idle_expires_at INTEGER NOT NULL, + absolute_expires_at INTEGER NOT NULL, + revoked_at INTEGER +) 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 ('group', 'part')), + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + parent_group_id TEXT REFERENCES managed_scopes(id), + 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 = 'group' AND parent_group_id IS NULL) OR + (kind = 'part' AND parent_group_id IS NOT NULL) + ) +) STRICT; +CREATE UNIQUE INDEX managed_scopes_group_name + ON managed_scopes(normalized_name) WHERE kind = 'group'; +CREATE UNIQUE INDEX managed_scopes_part_name + ON managed_scopes(parent_group_id, normalized_name) WHERE kind = 'part'; + +CREATE TABLE managed_scope_memberships ( + scope_id TEXT NOT NULL REFERENCES managed_scopes(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES platform_users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('member', 'leader')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (scope_id, user_id) +) STRICT; +CREATE INDEX managed_scope_memberships_user ON managed_scope_memberships(user_id); + +CREATE TABLE control_audit_events ( + id TEXT PRIMARY KEY, + actor_user_id TEXT REFERENCES platform_users(id), + event_type TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + details_json TEXT, + created_at INTEGER NOT NULL +) STRICT; +CREATE INDEX control_audit_events_created ON control_audit_events(created_at); +`; + +export function initializeControlPlaneSchema(db: DatabaseSync): void { + db.exec("PRAGMA foreign_keys = ON"); + db.exec("PRAGMA busy_timeout = 5000"); + db.exec("PRAGMA journal_mode = WAL"); + db.exec("BEGIN IMMEDIATE"); + try { + // Another process may have initialized the database while this opener waited for the lock. + const currentVersion = db.prepare("PRAGMA user_version").get() as { user_version: number }; + if (currentVersion.user_version === PLATFORMCLAW_CONTROL_SCHEMA_VERSION) { + db.exec("COMMIT"); + return; + } + if (currentVersion.user_version !== 0) { + throw new Error( + `unsupported PlatformClaw control schema version: ${currentVersion.user_version}`, + ); + } + db.exec(SCHEMA_V1); + db.exec(`PRAGMA user_version = ${PLATFORMCLAW_CONTROL_SCHEMA_VERSION}`); + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} diff --git a/packages/platformclaw-control-plane/src/sqlite-store.test.ts b/packages/platformclaw-control-plane/src/sqlite-store.test.ts new file mode 100644 index 00000000000..541940a0465 --- /dev/null +++ b/packages/platformclaw-control-plane/src/sqlite-store.test.ts @@ -0,0 +1,344 @@ +import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ControlPlaneAuthorizationError, + ControlPlaneConflictError, + type ControlPlaneIdFactory, + type EnterprisePrincipal, +} from "./contracts.js"; +import { PLATFORMCLAW_CONTROL_SCHEMA_VERSION } from "./sqlite-schema.js"; +import { SqliteControlPlaneStore } from "./sqlite-store.js"; + +const temporaryDirectories: string[] = []; + +function createIdFactory(): ControlPlaneIdFactory { + let user = 0; + let binding = 0; + let session = 0; + let scope = 0; + let audit = 0; + return { + nextUserId: () => `user-${++user}`, + nextBindingId: () => `binding-${++binding}`, + nextSessionId: () => `session-${++session}`, + nextManagedScopeId: () => `scope-${++scope}`, + nextAuditEventId: () => `audit-${++audit}`, + }; +} + +function createDatabasePath(): string { + const directory = mkdtempSync(join(tmpdir(), "platformclaw-control-")); + temporaryDirectories.push(directory); + return join(directory, "state", "platformclaw-control.sqlite"); +} + +function createStore(databasePath = createDatabasePath()) { + return new SqliteControlPlaneStore({ + databasePath, + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + initialAdminAccountIds: ["admin.user"], + idFactory: createIdFactory(), + }); +} + +function principal(employeeId: string, overrides: Partial = {}) { + return { + provider: "ldap" as const, + subject: `subject:${employeeId}`, + employeeId, + displayName: employeeId, + department: "Platform", + groups: ["developers", "platform", "developers"], + ...overrides, + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("SqliteControlPlaneStore", () => { + it.skipIf(process.platform === "win32")( + "secures the database directory and WAL files before storing control-plane data", + () => { + const databasePath = createDatabasePath(); + const store = createStore(databasePath); + expect(statSync(dirname(databasePath)).mode & 0o777).toBe(0o700); + for (const path of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`]) { + if (existsSync(path)) { + expect(statSync(path).mode & 0o777).toBe(0o600); + } + } + store.close(); + }, + ); + + it("refuses to bootstrap a new database without an initial administrator", () => { + const databasePath = createDatabasePath(); + expect( + () => + new SqliteControlPlaneStore({ + databasePath, + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + }), + ).toThrow("requires an initial administrator"); + }); + + it("requires bootstrap configuration until an active administrator exists", async () => { + const databasePath = createDatabasePath(); + const first = new SqliteControlPlaneStore({ + databasePath, + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + initialAdminAccountIds: ["not-yet-present"], + idFactory: createIdFactory(), + }); + const member = await first.upsertPrincipal(principal("member.user"), 1_000); + expect(member.user.globalRole).toBe("member"); + first.close(); + + expect( + () => + new SqliteControlPlaneStore({ + databasePath, + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + }), + ).toThrow("without an active administrator"); + + const recovery = new SqliteControlPlaneStore({ + databasePath, + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + initialAdminAccountIds: ["member.user"], + idFactory: createIdFactory(), + }); + expect((await recovery.upsertPrincipal(principal("member.user"), 2_000)).user.globalRole).toBe( + "admin", + ); + recovery.close(); + }); + + it("creates schema v1 and persists identity, role, and directory groups", async () => { + const databasePath = createDatabasePath(); + const store = createStore(databasePath); + const first = await store.upsertPrincipal(principal("admin.user"), 1_000); + expect(first.user).toMatchObject({ + accountId: "admin.user", + employeeId: "admin.user", + globalRole: "admin", + groups: ["developers", "platform"], + lastLoginAt: 1_000, + }); + store.close(); + + const reopened = createStore(databasePath); + expect(await reopened.getUserByEmployeeId("admin.user")).toMatchObject({ + id: first.user.id, + globalRole: "admin", + groups: ["developers", "platform"], + }); + reopened.close(); + + const database = new DatabaseSync(databasePath, { readOnly: true }); + expect(database.prepare("PRAGMA user_version").get()).toEqual({ + user_version: PLATFORMCLAW_CONTROL_SCHEMA_VERSION, + }); + database.close(); + }); + + it("links LDAP and SAML identities when account casing differs", async () => { + const store = createStore(); + const ldap = await store.upsertPrincipal(principal("Mixed.User"), 1_000); + const saml = await store.upsertPrincipal( + principal("mixed.user", { provider: "saml", subject: "saml:mixed.user" }), + 2_000, + ); + + expect(saml.user.id).toBe(ldap.user.id); + expect(saml.user).toMatchObject({ accountId: "mixed.user", employeeId: "mixed.user" }); + expect(saml.createdUser).toBe(false); + store.close(); + }); + + it("uses account-id dot replacement and rejects an agent-id collision", async () => { + const store = createStore(); + const dotted = await store.upsertPrincipal(principal("first.user"), 1_000); + const underscored = await store.upsertPrincipal(principal("first_user"), 1_001); + + expect((await store.reservePersonalAgent(dotted.user.id, 2_000)).binding.agentId).toBe( + "first_user", + ); + await expect(store.reservePersonalAgent(underscored.user.id, 2_001)).rejects.toMatchObject({ + code: "agent_id_conflict", + } satisfies Partial); + store.close(); + }); + + it("persists server-side sessions and revokes them when a user is disabled", async () => { + const store = createStore(); + const { user } = await store.upsertPrincipal(principal("member.user"), 1_000); + const created = await store.createBrowserSession({ + userId: user.id, + tokenHash: "sha256:test-session", + createdAt: 2_000, + }); + expect(created.status).toBe("created"); + const admin = await store.upsertPrincipal(principal("admin.user"), 2_001); + await store.setManagedUserStatus({ + actorUserId: admin.user.id, + targetUserId: user.id, + status: "disabled", + changedAt: 3_000, + }); + expect( + await store.resolveBrowserSession({ tokenHash: "sha256:test-session", resolvedAt: 3_001 }), + ).toMatchObject({ status: "revoked" }); + store.close(); + }); + + it("supports admin-created group/part hierarchy and inherited leader management", async () => { + const store = createStore(); + const admin = await store.upsertPrincipal(principal("admin.user"), 1_000); + const leader = await store.upsertPrincipal(principal("leader.user"), 1_001); + const member = await store.upsertPrincipal(principal("member.user"), 1_002); + const group = await store.createManagedScope({ + actorUserId: admin.user.id, + kind: "group", + name: "AI Platform", + createdAt: 2_000, + }); + const part = await store.createManagedScope({ + actorUserId: admin.user.id, + kind: "part", + parentGroupId: group.id, + name: "Agent Runtime", + createdAt: 2_001, + }); + await store.setManagedScopeMembership({ + actorUserId: admin.user.id, + scopeId: group.id, + userId: leader.user.id, + role: "leader", + changedAt: 3_000, + }); + await store.setManagedScopeMembership({ + actorUserId: leader.user.id, + scopeId: part.id, + userId: member.user.id, + role: "member", + changedAt: 3_001, + }); + + expect(await store.listManagedScopeMemberships(part.id)).toMatchObject([ + { userId: member.user.id, role: "member" }, + ]); + await expect( + store.setManagedScopeMembership({ + actorUserId: leader.user.id, + scopeId: part.id, + userId: member.user.id, + role: "leader", + changedAt: 3_002, + }), + ).rejects.toBeInstanceOf(ControlPlaneAuthorizationError); + expect((await store.listAuditEvents()).map((event) => event.eventType)).toEqual([ + "scope.membership.set", + "scope.membership.set", + "scope.created", + "scope.created", + ]); + store.close(); + }); + + it("does not let leaders change or remove leader assignments", async () => { + const store = createStore(); + const admin = await store.upsertPrincipal(principal("admin.user"), 1_000); + const firstLeader = await store.upsertPrincipal(principal("first.leader"), 1_001); + const secondLeader = await store.upsertPrincipal(principal("second.leader"), 1_002); + const group = await store.createManagedScope({ + actorUserId: admin.user.id, + kind: "group", + name: "Security", + createdAt: 2_000, + }); + for (const userId of [firstLeader.user.id, secondLeader.user.id]) { + await store.setManagedScopeMembership({ + actorUserId: admin.user.id, + scopeId: group.id, + userId, + role: "leader", + changedAt: 3_000, + }); + } + + await expect( + store.setManagedScopeMembership({ + actorUserId: firstLeader.user.id, + scopeId: group.id, + userId: secondLeader.user.id, + role: "member", + changedAt: 4_000, + }), + ).rejects.toThrow("only administrators can change leader roles"); + await expect( + store.removeManagedScopeMembership({ + actorUserId: firstLeader.user.id, + scopeId: group.id, + userId: secondLeader.user.id, + changedAt: 4_001, + }), + ).rejects.toThrow("only administrators can remove leaders"); + store.close(); + }); + + it("rejects membership removal after a scope is archived", async () => { + const store = createStore(); + const admin = await store.upsertPrincipal(principal("admin.user"), 1_000); + const member = await store.upsertPrincipal(principal("member.user"), 1_001); + const group = await store.createManagedScope({ + actorUserId: admin.user.id, + kind: "group", + name: "Archived Group", + createdAt: 2_000, + }); + await store.setManagedScopeMembership({ + actorUserId: admin.user.id, + scopeId: group.id, + userId: member.user.id, + role: "member", + changedAt: 3_000, + }); + await store.archiveManagedScope({ + actorUserId: admin.user.id, + scopeId: group.id, + archivedAt: 4_000, + }); + + await expect( + store.removeManagedScopeMembership({ + actorUserId: admin.user.id, + scopeId: group.id, + userId: member.user.id, + changedAt: 5_000, + }), + ).rejects.toThrow("cannot change memberships for an archived scope"); + store.close(); + }); + + it("prevents an administrator from disabling their own account", async () => { + const store = createStore(); + const admin = await store.upsertPrincipal(principal("admin.user"), 1_000); + await expect( + store.setManagedUserStatus({ + actorUserId: admin.user.id, + targetUserId: admin.user.id, + status: "disabled", + changedAt: 2_000, + }), + ).rejects.toThrow("cannot change their own status"); + store.close(); + }); +}); diff --git a/packages/platformclaw-control-plane/src/sqlite-store.ts b/packages/platformclaw-control-plane/src/sqlite-store.ts new file mode 100644 index 00000000000..9a2a26e88b9 --- /dev/null +++ b/packages/platformclaw-control-plane/src/sqlite-store.ts @@ -0,0 +1,1308 @@ +import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import type { Kysely } from "kysely"; +import { + BROWSER_SESSION_POLICY, + ControlPlaneAuthorizationError, + ControlPlaneConflictError, + ControlPlaneNotFoundError, + ControlPlaneStateError, + type AgentBinding, + type AgentProvisioningState, + type AgentReservationResult, + type BrowserSession, + type BrowserSessionPolicy, + type BrowserSessionResolution, + type ControlAuditEvent, + type ControlPlaneIdFactory, + type ControlPlaneManagementStore, + type ControlPlaneStore, + type CreateBrowserSessionResult, + type EnterpriseIdentity, + type EnterprisePrincipal, + type KnoxDmRouteResolution, + type KnoxRoomAgentBinding, + type MainSessionKeyBuilder, + type ManagedScope, + type ManagedScopeKind, + type ManagedScopeMembership, + type ManagedScopeRole, + type PersonalAgentBinding, + type PlatformUser, + type PlatformUserGlobalRole, + type PlatformUserStatus, + type UpsertPrincipalResult, +} from "./contracts.js"; +import { + defaultControlPlaneIdFactory, + deriveKnoxRoomAgentId, + derivePersonalAgentId, +} from "./ids.js"; +import { + createSyncKysely, + executeSync, + runImmediateTransaction, + takeFirstSync, +} from "./kysely-sync.js"; +import { initializeControlPlaneSchema } from "./sqlite-schema.js"; + +type PlatformUserRow = { + id: string; + account_id: string; + employee_id: string; + display_name: string | null; + email: string | null; + department: string | null; + timezone: string | null; + status: PlatformUserStatus; + global_role: PlatformUserGlobalRole; + created_at: number; + updated_at: number; + last_login_at: number | null; +}; + +type EnterpriseIdentityRow = { + provider: "ldap" | "saml"; + subject: string; + user_id: string; + employee_id: string; + created_at: number; + last_authenticated_at: number; +}; + +type DirectoryGroupRow = { user_id: string; group_name: string }; +type AgentBindingRow = { + id: string; + kind: "personal" | "knox-room"; + user_id: string | null; + knox_account_id: string | null; + room_id: string | null; + agent_id: string; + state: AgentProvisioningState; + failure_code: string | null; + created_at: number; + updated_at: number; +}; +type BrowserSessionRow = { + id: string; + user_id: string; + token_hash: string; + created_at: number; + last_seen_at: number; + idle_expires_at: number; + absolute_expires_at: number; + revoked_at: number | null; +}; +type ManagedScopeRow = { + id: string; + kind: ManagedScopeKind; + name: string; + normalized_name: string; + parent_group_id: string | null; + status: "active" | "archived"; + created_by_user_id: string; + created_at: number; + updated_at: number; +}; +type ManagedScopeMembershipRow = { + scope_id: string; + user_id: string; + role: ManagedScopeRole; + created_at: number; + updated_at: number; +}; +type AuditEventRow = { + id: string; + actor_user_id: string | null; + event_type: string; + target_type: string; + target_id: string; + details_json: string | null; + created_at: number; +}; + +type ControlPlaneDatabase = { + platform_users: PlatformUserRow; + enterprise_identities: EnterpriseIdentityRow; + user_directory_groups: DirectoryGroupRow; + agent_bindings: AgentBindingRow; + browser_sessions: BrowserSessionRow; + managed_scopes: ManagedScopeRow; + managed_scope_memberships: ManagedScopeMembershipRow; + control_audit_events: AuditEventRow; +}; + +type SqliteControlPlaneStoreOptions = { + databasePath: string; + buildAgentMainSessionKey: MainSessionKeyBuilder; + initialAdminAccountIds?: readonly string[]; + idFactory?: ControlPlaneIdFactory; + sessionPolicy?: BrowserSessionPolicy; +}; + +const ALLOWED_AGENT_TRANSITIONS: Record< + AgentProvisioningState, + ReadonlySet +> = { + provisioning: new Set(["active", "failed", "disabled"]), + active: new Set(["disabled"]), + failed: new Set(["provisioning", "disabled"]), + disabled: new Set(), +}; + +function required(value: string, field: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new ControlPlaneStateError(`${field} must not be empty`); + } + return normalized; +} + +function optional(value: string | undefined): string | null { + return value?.trim() || null; +} + +function normalizedGroups(groups: string[] | undefined): string[] { + return [...new Set((groups ?? []).map((group) => group.trim()).filter(Boolean))].toSorted(); +} + +function normalizeEmployeeId(employeeId: string): string { + return required(employeeId, "principal.employeeId").toLowerCase(); +} + +function normalizeScopeName(name: string): string { + return required(name, "scope.name").toLocaleLowerCase("en-US"); +} + +function rowToIdentity(row: EnterpriseIdentityRow): EnterpriseIdentity { + return { + provider: row.provider, + subject: row.subject, + userId: row.user_id, + employeeId: row.employee_id, + createdAt: row.created_at, + lastAuthenticatedAt: row.last_authenticated_at, + }; +} + +function rowToBinding(row: AgentBindingRow): AgentBinding { + const base = { + id: row.id, + agentId: row.agent_id, + state: row.state, + createdAt: row.created_at, + updatedAt: row.updated_at, + ...(row.failure_code ? { failureCode: row.failure_code } : {}), + }; + if (row.kind === "personal") { + if (!row.user_id) { + throw new ControlPlaneStateError(`personal binding has no user: ${row.id}`); + } + return { ...base, kind: "personal", userId: row.user_id }; + } + if (!row.knox_account_id || !row.room_id) { + throw new ControlPlaneStateError(`Knox room binding is incomplete: ${row.id}`); + } + return { + ...base, + kind: "knox-room", + accountId: row.knox_account_id, + roomId: row.room_id, + }; +} + +function rowToSession(row: BrowserSessionRow): BrowserSession { + return { + id: row.id, + userId: row.user_id, + tokenHash: row.token_hash, + createdAt: row.created_at, + lastSeenAt: row.last_seen_at, + idleExpiresAt: row.idle_expires_at, + absoluteExpiresAt: row.absolute_expires_at, + ...(row.revoked_at === null ? {} : { revokedAt: row.revoked_at }), + }; +} + +function rowToScope(row: ManagedScopeRow): ManagedScope { + return { + id: row.id, + kind: row.kind, + name: row.name, + ...(row.parent_group_id ? { parentGroupId: row.parent_group_id } : {}), + status: row.status, + createdByUserId: row.created_by_user_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function rowToMembership(row: ManagedScopeMembershipRow): ManagedScopeMembership { + return { + scopeId: row.scope_id, + userId: row.user_id, + role: row.role, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export class SqliteControlPlaneStore implements ControlPlaneStore, ControlPlaneManagementStore { + private readonly db: DatabaseSync; + private readonly query: Kysely; + private readonly buildAgentMainSessionKey: MainSessionKeyBuilder; + private readonly idFactory: ControlPlaneIdFactory; + private readonly sessionPolicy: BrowserSessionPolicy; + private readonly initialAdminAccountIds: ReadonlySet; + + constructor(options: SqliteControlPlaneStoreOptions) { + const databaseDirectory = dirname(options.databasePath); + mkdirSync(databaseDirectory, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") { + chmodSync(databaseDirectory, 0o700); + if (existsSync(options.databasePath)) { + chmodSync(options.databasePath, 0o600); + } + } + this.db = new DatabaseSync(options.databasePath); + initializeControlPlaneSchema(this.db); + if (process.platform !== "win32") { + for (const path of [ + options.databasePath, + `${options.databasePath}-wal`, + `${options.databasePath}-shm`, + ]) { + if (existsSync(path)) { + chmodSync(path, 0o600); + } + } + } + this.query = createSyncKysely(); + this.buildAgentMainSessionKey = options.buildAgentMainSessionKey; + this.idFactory = options.idFactory ?? defaultControlPlaneIdFactory; + this.sessionPolicy = options.sessionPolicy ?? BROWSER_SESSION_POLICY; + this.initialAdminAccountIds = new Set( + (options.initialAdminAccountIds ?? []) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean), + ); + const activeAdmin = takeFirstSync( + this.db, + this.query + .selectFrom("platform_users") + .select("id") + .where("global_role", "=", "admin") + .where("status", "=", "active") + .limit(1), + ); + if (!activeAdmin && this.initialAdminAccountIds.size === 0) { + this.db.close(); + throw new ControlPlaneStateError( + "a control-plane database without an active administrator requires an initial administrator account id", + ); + } + } + + close(): void { + this.db.close(); + } + + async upsertPrincipal( + principal: EnterprisePrincipal, + authenticatedAt: number, + ): Promise { + return runImmediateTransaction(this.db, () => { + const subject = required(principal.subject, "principal.subject"); + const employeeId = normalizeEmployeeId(principal.employeeId); + const identity = this.selectIdentity(principal.provider, subject); + if (identity) { + if ( + authenticatedAt < identity.last_authenticated_at || + (authenticatedAt === identity.last_authenticated_at && + employeeId !== identity.employee_id) + ) { + throw new ControlPlaneConflictError( + "stale_authentication", + `authentication result is older than the current identity version: ${principal.provider}:${subject}`, + ); + } + const user = this.requireUserRow(identity.user_id); + if (employeeId !== user.employee_id) { + if (identity.employee_id !== user.employee_id) { + throw new ControlPlaneConflictError( + "employee_id_mismatch", + `identity employee id disagrees with the canonical user: ${principal.provider}:${subject}`, + ); + } + const owner = this.selectUserByEmployeeId(employeeId); + if (owner && owner.id !== user.id) { + throw new ControlPlaneConflictError( + "employee_id_conflict", + `employee id already belongs to another user: ${employeeId}`, + ); + } + } + this.updateUserFromPrincipal(user.id, employeeId, principal, authenticatedAt); + this.bootstrapExistingAdminIfNeeded(user.id, authenticatedAt); + executeSync( + this.db, + this.query + .updateTable("enterprise_identities") + .set({ employee_id: employeeId, last_authenticated_at: authenticatedAt }) + .where("provider", "=", principal.provider) + .where("subject", "=", subject), + ); + this.replaceDirectoryGroups(user.id, principal.groups); + return { + user: this.requireUser(user.id), + identity: rowToIdentity(this.selectIdentity(principal.provider, subject)!), + createdUser: false, + createdIdentity: false, + }; + } + + let user = this.selectUserByEmployeeId(employeeId); + const createdUser = !user; + if (!user) { + const id = this.idFactory.nextUserId(); + const accountId = employeeId.toLowerCase(); + executeSync( + this.db, + this.query.insertInto("platform_users").values({ + id, + account_id: accountId, + employee_id: employeeId, + display_name: optional(principal.displayName), + email: optional(principal.email), + department: optional(principal.department), + timezone: null, + status: "active", + global_role: this.initialAdminAccountIds.has(accountId) ? "admin" : "member", + created_at: authenticatedAt, + updated_at: authenticatedAt, + last_login_at: authenticatedAt, + }), + ); + user = this.requireUserRow(id); + } else { + this.updateUserFromPrincipal(user.id, employeeId, principal, authenticatedAt); + this.bootstrapExistingAdminIfNeeded(user.id, authenticatedAt); + } + this.replaceDirectoryGroups(user.id, principal.groups); + executeSync( + this.db, + this.query.insertInto("enterprise_identities").values({ + provider: principal.provider, + subject, + user_id: user.id, + employee_id: employeeId, + created_at: authenticatedAt, + last_authenticated_at: authenticatedAt, + }), + ); + return { + user: this.requireUser(user.id), + identity: rowToIdentity(this.selectIdentity(principal.provider, subject)!), + createdUser, + createdIdentity: true, + }; + }); + } + + async getUserById(userId: string): Promise { + const row = this.selectUserById(userId); + return row ? this.rowToUser(row) : null; + } + + async getUserByEmployeeId(employeeId: string): Promise { + const row = this.selectUserByEmployeeId(employeeId.trim().toLowerCase()); + return row ? this.rowToUser(row) : null; + } + + async reservePersonalAgent( + userId: string, + reservedAt: number, + ): Promise> { + return runImmediateTransaction(this.db, () => { + const user = this.requireUserRow(userId); + if (user.status !== "active") { + throw new ControlPlaneStateError(`cannot provision agent for disabled user: ${userId}`); + } + const existing = takeFirstSync( + this.db, + this.query + .selectFrom("agent_bindings") + .selectAll() + .where("kind", "=", "personal") + .where("user_id", "=", userId), + ); + if (existing) { + return { binding: rowToBinding(existing) as PersonalAgentBinding, created: false }; + } + const agentId = derivePersonalAgentId(user.account_id); + this.assertAgentIdAvailable(agentId); + const row: AgentBindingRow = { + id: this.idFactory.nextBindingId(), + kind: "personal", + user_id: userId, + knox_account_id: null, + room_id: null, + agent_id: agentId, + state: "provisioning", + failure_code: null, + created_at: reservedAt, + updated_at: reservedAt, + }; + executeSync(this.db, this.query.insertInto("agent_bindings").values(row)); + this.insertAudit(null, "agent.binding.created", "agent-binding", row.id, reservedAt, { + kind: row.kind, + agentId: row.agent_id, + userId, + }); + return { binding: rowToBinding(row) as PersonalAgentBinding, created: true }; + }); + } + + async reserveKnoxRoomAgent(params: { + accountId: string; + roomId: string; + reservedAt: number; + }): Promise> { + return runImmediateTransaction(this.db, () => { + const accountId = required(params.accountId, "accountId"); + const roomId = required(params.roomId, "roomId"); + const existing = takeFirstSync( + this.db, + this.query + .selectFrom("agent_bindings") + .selectAll() + .where("kind", "=", "knox-room") + .where("knox_account_id", "=", accountId) + .where("room_id", "=", roomId), + ); + if (existing) { + return { binding: rowToBinding(existing) as KnoxRoomAgentBinding, created: false }; + } + const agentId = deriveKnoxRoomAgentId(roomId); + this.assertAgentIdAvailable(agentId); + const row: AgentBindingRow = { + id: this.idFactory.nextBindingId(), + kind: "knox-room", + user_id: null, + knox_account_id: accountId, + room_id: roomId, + agent_id: agentId, + state: "provisioning", + failure_code: null, + created_at: params.reservedAt, + updated_at: params.reservedAt, + }; + executeSync(this.db, this.query.insertInto("agent_bindings").values(row)); + this.insertAudit(null, "agent.binding.created", "agent-binding", row.id, params.reservedAt, { + kind: row.kind, + agentId: row.agent_id, + accountId, + roomId, + }); + return { binding: rowToBinding(row) as KnoxRoomAgentBinding, created: true }; + }); + } + + async transitionAgent(params: { + bindingId: string; + state: AgentProvisioningState; + changedAt: number; + failureCode?: string; + }): Promise { + return runImmediateTransaction(this.db, () => { + const current = this.requireBindingRow(params.bindingId); + if ( + current.state !== params.state && + !ALLOWED_AGENT_TRANSITIONS[current.state].has(params.state) + ) { + throw new ControlPlaneStateError( + `invalid agent transition: ${current.state} -> ${params.state}`, + ); + } + executeSync( + this.db, + this.query + .updateTable("agent_bindings") + .set({ + state: params.state, + failure_code: + params.state === "failed" + ? required(params.failureCode ?? "unknown", "failureCode") + : null, + updated_at: params.changedAt, + }) + .where("id", "=", params.bindingId), + ); + this.insertAudit( + null, + "agent.binding.transitioned", + "agent-binding", + current.id, + params.changedAt, + { + from: current.state, + to: params.state, + ...(params.state === "failed" ? { failureCode: params.failureCode ?? "unknown" } : {}), + }, + ); + return rowToBinding(this.requireBindingRow(params.bindingId)); + }); + } + + async createBrowserSession(params: { + userId: string; + tokenHash: string; + createdAt: number; + }): Promise { + return runImmediateTransaction(this.db, () => { + const user = this.requireUserRow(params.userId); + if (user.status !== "active") { + throw new ControlPlaneStateError(`cannot create session for disabled user: ${user.id}`); + } + const tokenHash = required(params.tokenHash, "tokenHash"); + if ( + takeFirstSync( + this.db, + this.query + .selectFrom("browser_sessions") + .select("id") + .where("token_hash", "=", tokenHash), + ) + ) { + throw new ControlPlaneConflictError( + "session_token_conflict", + "session token hash already exists", + ); + } + const activeSessionCount = executeSync( + this.db, + this.query + .selectFrom("browser_sessions") + .select("id") + .where("user_id", "=", user.id) + .where("revoked_at", "is", null) + .where("idle_expires_at", ">", params.createdAt) + .where("absolute_expires_at", ">", params.createdAt), + ).rows.length; + if (activeSessionCount >= this.sessionPolicy.maxConcurrentSessions) { + return { status: "limit-reached", activeSessionCount }; + } + const absoluteExpiresAt = params.createdAt + this.sessionPolicy.absoluteTimeoutMs; + const row: BrowserSessionRow = { + id: this.idFactory.nextSessionId(), + user_id: user.id, + token_hash: tokenHash, + created_at: params.createdAt, + last_seen_at: params.createdAt, + idle_expires_at: Math.min( + params.createdAt + this.sessionPolicy.idleTimeoutMs, + absoluteExpiresAt, + ), + absolute_expires_at: absoluteExpiresAt, + revoked_at: null, + }; + executeSync(this.db, this.query.insertInto("browser_sessions").values(row)); + return { status: "created", session: rowToSession(row) }; + }); + } + + async resolveBrowserSession(params: { + tokenHash: string; + resolvedAt: number; + touch?: boolean; + }): Promise { + return runImmediateTransaction(this.db, () => { + const row = takeFirstSync( + this.db, + this.query + .selectFrom("browser_sessions") + .selectAll() + .where("token_hash", "=", params.tokenHash), + ); + if (!row) { + return { status: "not-found" }; + } + if (row.revoked_at !== null) { + return { status: "revoked", session: rowToSession(row) }; + } + if (params.resolvedAt >= row.absolute_expires_at) { + return { status: "expired", reason: "absolute", session: rowToSession(row) }; + } + if (params.resolvedAt >= row.idle_expires_at) { + return { status: "expired", reason: "idle", session: rowToSession(row) }; + } + const user = this.requireUser(row.user_id); + if (user.status !== "active") { + return { status: "user-disabled", session: rowToSession(row), user }; + } + if (params.touch !== false) { + const idleExpiresAt = Math.min( + params.resolvedAt + this.sessionPolicy.idleTimeoutMs, + row.absolute_expires_at, + ); + executeSync( + this.db, + this.query + .updateTable("browser_sessions") + .set({ last_seen_at: params.resolvedAt, idle_expires_at: idleExpiresAt }) + .where("id", "=", row.id), + ); + row.last_seen_at = params.resolvedAt; + row.idle_expires_at = idleExpiresAt; + } + return { status: "active", session: rowToSession(row), user }; + }); + } + + async revokeBrowserSession(sessionId: string, revokedAt: number): Promise { + return runImmediateTransaction(this.db, () => { + const row = takeFirstSync( + this.db, + this.query.selectFrom("browser_sessions").selectAll().where("id", "=", sessionId), + ); + if (!row) { + return null; + } + if (row.revoked_at === null) { + executeSync( + this.db, + this.query + .updateTable("browser_sessions") + .set({ revoked_at: revokedAt }) + .where("id", "=", sessionId), + ); + row.revoked_at = revokedAt; + } + return rowToSession(row); + }); + } + + async resolveAuthenticatedKnoxDmRoute(params: { + employeeId: string; + agentId: string; + sessionKey: string; + }): Promise { + const userRow = this.selectUserByEmployeeId(params.employeeId.trim().toLowerCase()); + if (!userRow) { + return { status: "user-not-found" }; + } + if (userRow.status !== "active") { + return { status: "agent-unavailable" }; + } + const row = takeFirstSync( + this.db, + this.query + .selectFrom("agent_bindings") + .selectAll() + .where("kind", "=", "personal") + .where("user_id", "=", userRow.id), + ); + if (!row || row.state !== "active") { + return { status: "agent-unavailable" }; + } + const binding = rowToBinding(row) as PersonalAgentBinding; + const sessionKey = this.buildAgentMainSessionKey({ agentId: binding.agentId }); + if (params.agentId !== binding.agentId || params.sessionKey !== sessionKey) { + return { status: "route-mismatch" }; + } + return { status: "resolved", user: this.rowToUser(userRow), binding, sessionKey }; + } + + async setUserGlobalRole(params: { + actorUserId: string; + targetUserId: string; + role: PlatformUserGlobalRole; + changedAt: number; + }): Promise { + return runImmediateTransaction(this.db, () => { + this.requireAdmin(params.actorUserId); + const target = this.requireUserRow(params.targetUserId); + if (params.actorUserId === params.targetUserId && target.global_role !== params.role) { + throw new ControlPlaneStateError("admins cannot change their own global role"); + } + if (target.global_role === params.role) { + return this.rowToUser(target); + } + if (target.global_role === "admin" && params.role === "member") { + const admins = executeSync( + this.db, + this.query.selectFrom("platform_users").select("id").where("global_role", "=", "admin"), + ).rows.length; + if (admins <= 1) { + throw new ControlPlaneStateError("cannot demote the last admin"); + } + } + executeSync( + this.db, + this.query + .updateTable("platform_users") + .set({ global_role: params.role, updated_at: params.changedAt }) + .where("id", "=", target.id), + ); + this.insertAudit( + params.actorUserId, + "user.role.changed", + "user", + target.id, + params.changedAt, + { + from: target.global_role, + to: params.role, + }, + ); + return this.requireUser(target.id); + }); + } + + async setManagedUserStatus(params: { + actorUserId: string; + targetUserId: string; + status: PlatformUserStatus; + changedAt: number; + }): Promise { + return runImmediateTransaction(this.db, () => { + this.requireAdmin(params.actorUserId); + const target = this.requireUserRow(params.targetUserId); + if (params.actorUserId === params.targetUserId && target.status !== params.status) { + throw new ControlPlaneStateError("administrators cannot change their own status"); + } + if (target.status === params.status) { + return this.rowToUser(target); + } + if (target.global_role === "admin" && params.status === "disabled") { + const activeAdmins = executeSync( + this.db, + this.query + .selectFrom("platform_users") + .select("id") + .where("global_role", "=", "admin") + .where("status", "=", "active"), + ).rows.length; + if (activeAdmins <= 1) { + throw new ControlPlaneStateError("cannot disable the last active administrator"); + } + } + executeSync( + this.db, + this.query + .updateTable("platform_users") + .set({ status: params.status, updated_at: params.changedAt }) + .where("id", "=", target.id), + ); + if (params.status === "disabled") { + executeSync( + this.db, + this.query + .updateTable("browser_sessions") + .set({ revoked_at: params.changedAt }) + .where("user_id", "=", target.id) + .where("revoked_at", "is", null), + ); + } + this.insertAudit( + params.actorUserId, + "user.status.changed", + "user", + target.id, + params.changedAt, + { from: target.status, to: params.status }, + ); + return this.requireUser(target.id); + }); + } + + async createManagedScope(params: { + actorUserId: string; + kind: ManagedScopeKind; + name: string; + parentGroupId?: string; + createdAt: number; + }): Promise { + return runImmediateTransaction(this.db, () => { + this.requireAdmin(params.actorUserId); + const name = required(params.name, "scope.name"); + let parentGroupId: string | null = null; + if (params.kind === "part") { + parentGroupId = required(params.parentGroupId ?? "", "parentGroupId"); + const parent = this.requireScopeRow(parentGroupId); + if (parent.kind !== "group" || parent.status !== "active") { + throw new ControlPlaneStateError("part parent must be an active group"); + } + } else if (params.parentGroupId) { + throw new ControlPlaneStateError("groups cannot have a parent"); + } + const row: ManagedScopeRow = { + id: this.idFactory.nextManagedScopeId(), + kind: params.kind, + name, + normalized_name: normalizeScopeName(name), + parent_group_id: parentGroupId, + status: "active", + created_by_user_id: params.actorUserId, + created_at: params.createdAt, + updated_at: params.createdAt, + }; + this.assertScopeNameAvailable(row); + executeSync(this.db, this.query.insertInto("managed_scopes").values(row)); + this.insertAudit( + params.actorUserId, + "scope.created", + "managed-scope", + row.id, + params.createdAt, + { + kind: row.kind, + name: row.name, + ...(parentGroupId ? { parentGroupId } : {}), + }, + ); + return rowToScope(row); + }); + } + + async archiveManagedScope(params: { + actorUserId: string; + scopeId: string; + archivedAt: number; + }): Promise { + return runImmediateTransaction(this.db, () => { + this.requireAdmin(params.actorUserId); + const scope = this.requireScopeRow(params.scopeId); + if (scope.status !== "archived") { + executeSync( + this.db, + this.query + .updateTable("managed_scopes") + .set({ status: "archived", updated_at: params.archivedAt }) + .where("id", "=", scope.id), + ); + if (scope.kind === "group") { + executeSync( + this.db, + this.query + .updateTable("managed_scopes") + .set({ status: "archived", updated_at: params.archivedAt }) + .where("parent_group_id", "=", scope.id), + ); + } + this.insertAudit( + params.actorUserId, + "scope.archived", + "managed-scope", + scope.id, + params.archivedAt, + ); + } + return rowToScope(this.requireScopeRow(scope.id)); + }); + } + + async setManagedScopeMembership(params: { + actorUserId: string; + scopeId: string; + userId: string; + role: ManagedScopeRole; + changedAt: number; + }): Promise { + return runImmediateTransaction(this.db, () => { + const actor = this.requireUserRow(params.actorUserId); + const scope = this.requireScopeRow(params.scopeId); + const target = this.requireUserRow(params.userId); + if (actor.status !== "active") { + throw new ControlPlaneAuthorizationError("active group leader required"); + } + if (target.status !== "active") { + throw new ControlPlaneStateError("cannot add a disabled user to a managed scope"); + } + if (scope.status !== "active") { + throw new ControlPlaneStateError("cannot change memberships for an archived scope"); + } + const actorIsAdmin = actor.global_role === "admin"; + if (!actorIsAdmin && !this.isLeaderForScope(actor.id, scope)) { + throw new ControlPlaneAuthorizationError( + "not allowed to manage memberships for this scope", + ); + } + if (!actorIsAdmin && params.role === "leader") { + throw new ControlPlaneAuthorizationError("leaders can only assign member role"); + } + const existing = this.selectMembership(scope.id, params.userId); + if (!actorIsAdmin && existing?.role === "leader") { + throw new ControlPlaneAuthorizationError("only administrators can change leader roles"); + } + if (existing) { + executeSync( + this.db, + this.query + .updateTable("managed_scope_memberships") + .set({ role: params.role, updated_at: params.changedAt }) + .where("scope_id", "=", scope.id) + .where("user_id", "=", params.userId), + ); + } else { + executeSync( + this.db, + this.query.insertInto("managed_scope_memberships").values({ + scope_id: scope.id, + user_id: params.userId, + role: params.role, + created_at: params.changedAt, + updated_at: params.changedAt, + }), + ); + } + this.insertAudit( + actor.id, + "scope.membership.set", + "managed-scope", + scope.id, + params.changedAt, + { userId: params.userId, role: params.role }, + ); + return rowToMembership(this.selectMembership(scope.id, params.userId)!); + }); + } + + async removeManagedScopeMembership(params: { + actorUserId: string; + scopeId: string; + userId: string; + changedAt: number; + }): Promise { + return runImmediateTransaction(this.db, () => { + const actor = this.requireUserRow(params.actorUserId); + const scope = this.requireScopeRow(params.scopeId); + if (scope.status !== "active") { + throw new ControlPlaneStateError("cannot change memberships for an archived scope"); + } + if (actor.status !== "active") { + throw new ControlPlaneAuthorizationError("active group leader required"); + } + const actorIsAdmin = actor.global_role === "admin"; + if (!actorIsAdmin && !this.isLeaderForScope(actor.id, scope)) { + throw new ControlPlaneAuthorizationError( + "not allowed to manage memberships for this scope", + ); + } + if (!actorIsAdmin && actor.id === params.userId) { + throw new ControlPlaneAuthorizationError("leaders cannot remove themselves"); + } + if (!actorIsAdmin && this.selectMembership(scope.id, params.userId)?.role === "leader") { + throw new ControlPlaneAuthorizationError("only administrators can remove leaders"); + } + const result = executeSync( + this.db, + this.query + .deleteFrom("managed_scope_memberships") + .where("scope_id", "=", scope.id) + .where("user_id", "=", params.userId), + ); + const removed = (result.numAffectedRows ?? 0n) > 0n; + if (removed) { + this.insertAudit( + actor.id, + "scope.membership.removed", + "managed-scope", + scope.id, + params.changedAt, + { userId: params.userId }, + ); + } + return removed; + }); + } + + async listManagedScopes(): Promise { + return executeSync( + this.db, + this.query + .selectFrom("managed_scopes") + .selectAll() + .orderBy("kind") + .orderBy("normalized_name"), + ).rows.map(rowToScope); + } + + async listManagedScopeMemberships(scopeId: string): Promise { + this.requireScopeRow(scopeId); + return executeSync( + this.db, + this.query + .selectFrom("managed_scope_memberships") + .selectAll() + .where("scope_id", "=", scopeId) + .orderBy("role") + .orderBy("user_id"), + ).rows.map(rowToMembership); + } + + async listAuditEvents(limit = 100): Promise { + const boundedLimit = Number.isFinite(limit) + ? Math.max(1, Math.min(Math.trunc(limit), 500)) + : 100; + return executeSync( + this.db, + this.query + .selectFrom("control_audit_events") + .selectAll() + .orderBy("created_at", "desc") + .orderBy("id", "desc") + .limit(boundedLimit), + ).rows.map((row) => ({ + id: row.id, + ...(row.actor_user_id ? { actorUserId: row.actor_user_id } : {}), + eventType: row.event_type, + targetType: row.target_type, + targetId: row.target_id, + ...(row.details_json + ? { details: JSON.parse(row.details_json) as Record } + : {}), + createdAt: row.created_at, + })); + } + + private rowToUser(row: PlatformUserRow): PlatformUser { + const groups = executeSync( + this.db, + this.query + .selectFrom("user_directory_groups") + .select("group_name") + .where("user_id", "=", row.id) + .orderBy("group_name"), + ).rows.map((entry) => entry.group_name); + return { + id: row.id, + accountId: row.account_id, + employeeId: row.employee_id, + status: row.status, + globalRole: row.global_role, + ...(row.display_name ? { displayName: row.display_name } : {}), + ...(row.email ? { email: row.email } : {}), + ...(row.department ? { department: row.department } : {}), + ...(row.timezone ? { timezone: row.timezone } : {}), + groups, + createdAt: row.created_at, + updatedAt: row.updated_at, + ...(row.last_login_at === null ? {} : { lastLoginAt: row.last_login_at }), + }; + } + + private selectUserById(userId: string): PlatformUserRow | undefined { + return takeFirstSync( + this.db, + this.query.selectFrom("platform_users").selectAll().where("id", "=", userId), + ); + } + + private selectUserByEmployeeId(employeeId: string): PlatformUserRow | undefined { + return takeFirstSync( + this.db, + this.query.selectFrom("platform_users").selectAll().where("employee_id", "=", employeeId), + ); + } + + private requireUserRow(userId: string): PlatformUserRow { + const row = this.selectUserById(userId); + if (!row) { + throw new ControlPlaneNotFoundError("user", userId); + } + return row; + } + + private requireUser(userId: string): PlatformUser { + return this.rowToUser(this.requireUserRow(userId)); + } + + private requireAdmin(userId: string): PlatformUserRow { + const user = this.requireUserRow(userId); + if (user.global_role !== "admin" || user.status !== "active") { + throw new ControlPlaneAuthorizationError("active administrator required"); + } + return user; + } + + private selectIdentity( + provider: "ldap" | "saml", + subject: string, + ): EnterpriseIdentityRow | undefined { + return takeFirstSync( + this.db, + this.query + .selectFrom("enterprise_identities") + .selectAll() + .where("provider", "=", provider) + .where("subject", "=", subject), + ); + } + + private updateUserFromPrincipal( + userId: string, + employeeId: string, + principal: EnterprisePrincipal, + updatedAt: number, + ): void { + executeSync( + this.db, + this.query + .updateTable("platform_users") + .set({ + employee_id: employeeId, + display_name: optional(principal.displayName), + email: optional(principal.email), + department: optional(principal.department), + updated_at: updatedAt, + last_login_at: updatedAt, + }) + .where("id", "=", userId), + ); + } + + private bootstrapExistingAdminIfNeeded(userId: string, changedAt: number): void { + const user = this.requireUserRow(userId); + if ( + user.status !== "active" || + user.global_role === "admin" || + !this.initialAdminAccountIds.has(user.account_id) + ) { + return; + } + const activeAdmin = takeFirstSync( + this.db, + this.query + .selectFrom("platform_users") + .select("id") + .where("global_role", "=", "admin") + .where("status", "=", "active") + .limit(1), + ); + if (activeAdmin) { + return; + } + executeSync( + this.db, + this.query + .updateTable("platform_users") + .set({ global_role: "admin", updated_at: changedAt }) + .where("id", "=", user.id), + ); + this.insertAudit(null, "user.admin.bootstrapped", "user", user.id, changedAt, { + accountId: user.account_id, + }); + } + + private replaceDirectoryGroups(userId: string, groups: string[] | undefined): void { + executeSync( + this.db, + this.query.deleteFrom("user_directory_groups").where("user_id", "=", userId), + ); + const rows = normalizedGroups(groups).map((groupName) => ({ + user_id: userId, + group_name: groupName, + })); + if (rows.length > 0) { + executeSync(this.db, this.query.insertInto("user_directory_groups").values(rows)); + } + } + + private requireBindingRow(bindingId: string): AgentBindingRow { + const row = takeFirstSync( + this.db, + this.query.selectFrom("agent_bindings").selectAll().where("id", "=", bindingId), + ); + if (!row) { + throw new ControlPlaneNotFoundError("agent-binding", bindingId); + } + return row; + } + + private assertAgentIdAvailable(agentId: string): void { + const existing = takeFirstSync( + this.db, + this.query.selectFrom("agent_bindings").select("id").where("agent_id", "=", agentId), + ); + if (existing) { + throw new ControlPlaneConflictError( + "agent_id_conflict", + `agent id already belongs to another binding: ${agentId}`, + ); + } + } + + private requireScopeRow(scopeId: string): ManagedScopeRow { + const row = takeFirstSync( + this.db, + this.query.selectFrom("managed_scopes").selectAll().where("id", "=", scopeId), + ); + if (!row) { + throw new ControlPlaneNotFoundError("managed-scope", scopeId); + } + return row; + } + + private assertScopeNameAvailable(scope: ManagedScopeRow): void { + let query = this.query + .selectFrom("managed_scopes") + .select("id") + .where("kind", "=", scope.kind) + .where("normalized_name", "=", scope.normalized_name); + query = + scope.kind === "group" + ? query.where("parent_group_id", "is", null) + : query.where("parent_group_id", "=", scope.parent_group_id); + if (takeFirstSync(this.db, query)) { + throw new ControlPlaneConflictError( + "managed_scope_name_conflict", + `${scope.kind} name already exists in its parent scope: ${scope.name}`, + ); + } + } + + private selectMembership(scopeId: string, userId: string): ManagedScopeMembershipRow | undefined { + return takeFirstSync( + this.db, + this.query + .selectFrom("managed_scope_memberships") + .selectAll() + .where("scope_id", "=", scopeId) + .where("user_id", "=", userId), + ); + } + + private isLeaderForScope(userId: string, scope: ManagedScopeRow): boolean { + if (this.selectMembership(scope.id, userId)?.role === "leader") { + return true; + } + return Boolean( + scope.parent_group_id && + this.selectMembership(scope.parent_group_id, userId)?.role === "leader", + ); + } + + private insertAudit( + actorUserId: string | null, + eventType: string, + targetType: string, + targetId: string, + createdAt: number, + details?: Record, + ): void { + executeSync( + this.db, + this.query.insertInto("control_audit_events").values({ + id: this.idFactory.nextAuditEventId(), + actor_user_id: actorUserId, + event_type: eventType, + target_type: targetType, + target_id: targetId, + details_json: details ? JSON.stringify(details) : null, + created_at: createdAt, + }), + ); + } +} diff --git a/packages/platformclaw-control-plane/tsconfig.json b/packages/platformclaw-control-plane/tsconfig.json new file mode 100644 index 00000000000..3e7f00d88d8 --- /dev/null +++ b/packages/platformclaw-control-plane/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e7f76694f2..257a776d989 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2178,6 +2178,15 @@ importers: packages/plugin-sdk: {} + packages/platformclaw-control-plane: + dependencies: + '@openclaw/normalization-core': + specifier: workspace:* + version: link:../normalization-core + kysely: + specifier: 0.29.2 + version: 0.29.2 + packages/retry: {} packages/sdk: