diff --git a/PLATFORMCLAW.md b/PLATFORMCLAW.md index 658954f6102..fc1fc8d0c5a 100644 --- a/PLATFORMCLAW.md +++ b/PLATFORMCLAW.md @@ -70,8 +70,9 @@ Bring upstream changes from `upstream/main` into a sync branch, validate them th 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 +ingress authorization. The contract, in-memory store, approved SQLite v1 store, +employee auth, personal-agent provisioner, and Web Gateway policy proxy slices +are complete. The deployable ingress host remains pending. See `docs/platformclaw/control-plane-phase-1.md` and `docs/platformclaw/decisions.md`. diff --git a/docs/platformclaw/control-plane-phase-1.md b/docs/platformclaw/control-plane-phase-1.md index a409d5484f7..b5b82a15d69 100644 --- a/docs/platformclaw/control-plane-phase-1.md +++ b/docs/platformclaw/control-plane-phase-1.md @@ -34,11 +34,14 @@ After Phase 1: ## Implementation status -Slices 1 through 4 are implemented in `packages/platformclaw-control-plane`. They +Slices 1 through 5 are implemented in `packages/platformclaw-control-plane`. They define the identity, browser-session, personal-agent provisioning, Knox room binding, authenticated Knox DM routing, managed group/part contracts, employee-auth HTTP adapter, opaque-session login service, and framework-neutral -browser-auth HTTP boundary. The employee browser-auth runtime now assembles the +browser-auth HTTP boundary. The Web Gateway policy proxy resolves the opaque +browser session for every request, pins it to the active personal agent binding, +and filters requests, responses, and events through fail-closed method and +parameter allowlists. The employee browser-auth runtime now assembles the LDAP-phase adapter and SQLite session store, and the personal-agent provisioner calls the Gateway Admin HTTP RPC boundary to create or adopt the exact agent and workspace. The in-memory store covers contract behavior, and the SQLite store @@ -48,7 +51,8 @@ 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. +bootstrap, managed group/part permissions, cross-agent Gateway denial, +response and event filtering, revoked-session rejection, and audit events. The LDAP-compatible adapter calls the existing employee-auth HTTP service. The HTTP boundary exposes login, logout, and current-session handlers, but still @@ -56,11 +60,11 @@ requires its host process to inject bounded JSON parsing, a trusted client IP, TLS state, and an auth rate limiter. These slices do not contain credential encryption, SAML protocol handling, -Knox transport code, or the deployable HTTP listener/Gateway proxy. OpenClaw -agent creation now occurs only through the private Admin HTTP RPC plugin; -PlatformClaw still does not import OpenClaw core. Initial `USER.md` profile -injection remains deferred until an atomic, ownership-aware Gateway/plugin -contract is approved. +Knox transport code, or the deployable HTTP/WebSocket listener that hosts the +implemented Gateway policy proxy. OpenClaw agent creation now occurs only +through the private Admin HTTP RPC plugin; PlatformClaw still does not import +OpenClaw core. Initial `USER.md` profile injection remains deferred until an +atomic, ownership-aware Gateway/plugin contract is approved. ## Process boundary @@ -190,11 +194,24 @@ 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; +- expose an explicit per-method parameter allowlist so new upstream operator + parameters fail closed until reviewed; - 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. +The operator Gateway credential remains only in the hosting control process. +It is never returned to the browser. The current package implements this policy +boundary independently of an HTTP or WebSocket framework; the deployable +listener will inject the private Gateway client and browser cookie extraction. +Until Gateway provides a least-privilege downstream identity, browser messages +use only command-suppressed `chat.send`; session creation cannot carry an +initial message or task. Browsers cannot request approval replay or deliver to +an external channel through the control process credential. +Browsers also cannot supply abort run IDs; cancellation is resolved from their +owned session key. + UI hiding is not authorization. The proxy enforces the rule even when a client sends a hand-crafted Gateway frame. diff --git a/docs/platformclaw/decisions.md b/docs/platformclaw/decisions.md index 741a28af054..3491b5a3519 100644 --- a/docs/platformclaw/decisions.md +++ b/docs/platformclaw/decisions.md @@ -205,6 +205,37 @@ read/write RPC composition cannot safely preserve concurrent user edits or enforce the expected workspace before mutation. That feature requires a later atomic Gateway/plugin contract decision. +### PC-116 Proxy browser Gateway access through fail-closed policy + +Browsers never receive an OpenClaw operator credential and never connect to the +private Gateway directly. `platformclaw-control` resolves the opaque browser +session on every request and event, requires its active personal agent binding, +and uses its own private Gateway client behind the control-plane package. + +The proxy maintains explicit method and per-method parameter allowlists. New +upstream methods or parameters are denied until reviewed, preventing an +upstream operator feature from silently becoming browser-accessible. Agent IDs +are pinned server-side, session keys are checked against the owned agent, and +agent/session collections, direct session results, and asynchronous events are +filtered again on return. Denials are audited without request payloads or +credentials. The deployable HTTP/WebSocket listener is a separate hosting +slice; it must use this policy boundary rather than reimplement authorization. +Events without an owned session key, including raw agent-run events, are +dropped; session-scoped chat and tool events are the browser streaming surface. + +Because the private Gateway client currently has operator authority, browser +messages use only `chat.send`, with command interpretation and external +delivery forced off. Session creation cannot include an initial message or +task, approval replay is not exposed, and operator command catalogs are not +advertised. Browser commands require a later least-privilege Gateway identity +or a separately approved command policy; they must not inherit the control +process credential. +Model selection is limited to the configured catalog and cannot carry a raw +model/auth-profile override. Browser session patches cannot archive sessions +because the operator path would also disable bound administrator cron jobs. +Abort requests target the authenticated session only; browsers cannot select a +run ID because the shared operator connection cannot prove ownership of it. + ## Open operational decisions No remaining decision blocks the SQLite v1 store. Deployment work still needs diff --git a/docs/platformclaw/index.md b/docs/platformclaw/index.md index c9c4e42c28f..2a3bc86abeb 100644 --- a/docs/platformclaw/index.md +++ b/docs/platformclaw/index.md @@ -26,11 +26,13 @@ and Knox direct or group conversations: - [Employee authentication](/platformclaw/employee-auth) - [Private downstream CI](/platformclaw/private-downstream-ci) -The first three implementation slices are in the private +The first five implementation slices are in the private `packages/platformclaw-control-plane` package. They contain contracts, an in-memory store, the approved SQLite v1 persistent store, the employee-auth -adapter, opaque browser-session service, and browser-auth HTTP boundary. The -deployable ingress process and Gateway proxy remain later work. +adapter, opaque browser-session service, browser-auth HTTP boundary, personal +agent provisioning adapter, and fail-closed Web Gateway policy proxy. The +deployable ingress process that hosts the HTTP and WebSocket boundaries remains +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 diff --git a/packages/platformclaw-control-plane/src/browser-auth-http.test.ts b/packages/platformclaw-control-plane/src/browser-auth-http.test.ts index 916f5880d08..cb90e9cc795 100644 --- a/packages/platformclaw-control-plane/src/browser-auth-http.test.ts +++ b/packages/platformclaw-control-plane/src/browser-auth-http.test.ts @@ -28,7 +28,7 @@ function responseHarness() { setHeader: (name: string, value: string | string[]) => headers.set(name, value), getHeader: (name: string) => headers.get(name), end: (value?: unknown) => { - body = String(value ?? ""); + body = typeof value === "string" ? value : ""; }, } as unknown as ServerResponse; return { res, headers, body: () => body }; diff --git a/packages/platformclaw-control-plane/src/browser-gateway-projections.ts b/packages/platformclaw-control-plane/src/browser-gateway-projections.ts new file mode 100644 index 00000000000..d914cfbd5ee --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-gateway-projections.ts @@ -0,0 +1,97 @@ +type JsonObject = Record; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function projectBrowserAgentSummary(value: unknown): JsonObject | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const source = value as JsonObject; + const id = optionalString(source.id); + if (!id) { + return null; + } + const projected: JsonObject = { id }; + const name = optionalString(source.name); + if (name) { + projected.name = name; + } + if (source.identity && typeof source.identity === "object" && !Array.isArray(source.identity)) { + const identitySource = source.identity as JsonObject; + const identity: JsonObject = {}; + for (const key of ["name", "theme", "emoji", "avatarUrl"]) { + const field = optionalString(identitySource[key]); + if (field) { + identity[key] = field; + } + } + if (Object.keys(identity).length > 0) { + projected.identity = identity; + } + } + if (source.model && typeof source.model === "object" && !Array.isArray(source.model)) { + const modelSource = source.model as JsonObject; + const primary = optionalString(modelSource.primary); + const fallbacks = Array.isArray(modelSource.fallbacks) + ? modelSource.fallbacks.map(optionalString).filter((entry) => entry !== undefined) + : undefined; + if (primary || fallbacks) { + projected.model = { ...(primary ? { primary } : {}), ...(fallbacks ? { fallbacks } : {}) }; + } + } + if (Array.isArray(source.thinkingLevels)) { + projected.thinkingLevels = source.thinkingLevels.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return []; + } + const level = entry as JsonObject; + const levelId = optionalString(level.id); + const label = optionalString(level.label); + return levelId && label ? [{ id: levelId, label }] : []; + }); + } + if (Array.isArray(source.thinkingOptions)) { + projected.thinkingOptions = source.thinkingOptions + .map(optionalString) + .filter((entry) => entry !== undefined); + } + const thinkingDefault = optionalString(source.thinkingDefault); + if (thinkingDefault) { + projected.thinkingDefault = thinkingDefault; + } + return projected; +} + +export function projectBrowserModelChoice(value: unknown): JsonObject | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const source = value as JsonObject; + const id = optionalString(source.id); + const name = optionalString(source.name); + const provider = optionalString(source.provider); + if (!id || !name || !provider) { + return null; + } + const projected: JsonObject = { id, name, provider }; + for (const key of ["alias"] as const) { + const field = optionalString(source[key]); + if (field) { + projected[key] = field; + } + } + for (const key of ["available", "reasoning"] as const) { + if (typeof source[key] === "boolean") { + projected[key] = source[key]; + } + } + if (typeof source.contextWindow === "number") { + projected.contextWindow = source.contextWindow; + } + if (Array.isArray(source.input)) { + projected.input = source.input.filter((entry): entry is string => typeof entry === "string"); + } + return projected; +} diff --git a/packages/platformclaw-control-plane/src/browser-gateway-proxy.test.ts b/packages/platformclaw-control-plane/src/browser-gateway-proxy.test.ts new file mode 100644 index 00000000000..194ac1e733e --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-gateway-proxy.test.ts @@ -0,0 +1,499 @@ +import { describe, expect, it, vi } from "vitest"; +import { BrowserAuthService, hashBrowserSessionToken } from "./browser-auth-service.js"; +import { BrowserGatewayProxy, type BrowserGatewayRpc } from "./browser-gateway-proxy.js"; +import type { + ControlAuditEvent, + ControlPlaneAuditWriter, + EnterprisePrincipal, +} from "./contracts.js"; +import { InMemoryControlPlaneStore } from "./memory-store.js"; + +const NOW = 1_000_000; + +function sessionAgentId(sessionKey: string): string | null { + const match = /^agent:([^:]+):/.exec(sessionKey.trim()); + return match?.[1] ?? null; +} + +async function setup() { + let sequence = 0; + const store = new InMemoryControlPlaneStore({ + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + idFactory: { + nextUserId: () => `user-${++sequence}`, + nextBindingId: () => `binding-${++sequence}`, + nextSessionId: () => `session-${++sequence}`, + nextManagedScopeId: () => `scope-${++sequence}`, + nextAuditEventId: () => `audit-${++sequence}`, + }, + }); + const principal: EnterprisePrincipal = { + provider: "ldap", + subject: "employee-1", + accountId: "first.user", + employeeId: "1001", + }; + const { user } = await store.upsertPrincipal(principal, NOW); + const reserved = await store.reservePersonalAgent(user.id, NOW); + const binding = await store.transitionAgent({ + bindingId: reserved.binding.id, + state: "active", + changedAt: NOW, + }); + if (binding.kind !== "personal") { + throw new Error("expected personal binding"); + } + const token = "test-token"; + const created = await store.createBrowserSession({ + userId: user.id, + tokenHash: hashBrowserSessionToken(token), + createdAt: NOW, + }); + if (created.status !== "created") { + throw new Error("expected browser session"); + } + const service = new BrowserAuthService({ + store, + authenticator: { + async authenticatePassword() { + return { status: "rejected" as const, message: "unused" }; + }, + }, + provisioner: { provisionOrRefresh: vi.fn(async () => undefined) }, + now: () => NOW, + }); + const request = vi.fn(async () => ({ ok: true })); + const auditEvents: ControlAuditEvent[] = []; + const auditWriter: ControlPlaneAuditWriter = { + async recordAuditEvent(params) { + const event: ControlAuditEvent = { id: `audit-${auditEvents.length + 1}`, ...params }; + auditEvents.push(event); + return event; + }, + }; + const proxy = new BrowserGatewayProxy({ + authService: service, + store, + auditWriter, + gateway: { request }, + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + resolveAgentIdFromSessionKey: sessionAgentId, + now: () => NOW, + }); + return { auditEvents, binding, created, proxy, request, store, token, user }; +} + +describe("BrowserGatewayProxy", () => { + it("pins chat requests to the authenticated user's agent", async () => { + const { binding, proxy, request, token } = await setup(); + request.mockResolvedValueOnce({ sessionKey: `agent:${binding.agentId}:main`, messages: [] }); + + await proxy.request(token, "chat.history", { + sessionKey: `agent:${binding.agentId}:main`, + limit: 20, + }); + + expect(request).toHaveBeenCalledWith("chat.history", { + agentId: binding.agentId, + sessionKey: `agent:${binding.agentId}:main`, + limit: 20, + }); + }); + + it("denies and audits cross-agent requests before Gateway dispatch", async () => { + const { auditEvents, proxy, request, token, user, binding } = await setup(); + + await expect( + proxy.request(token, "chat.send", { + agentId: "other", + sessionKey: "agent:other:main", + message: "hello", + idempotencyKey: "request-1", + }), + ).rejects.toMatchObject({ code: "cross-agent-denied" }); + + expect(request).not.toHaveBeenCalled(); + expect(auditEvents).toEqual([ + expect.objectContaining({ + actorUserId: user.id, + eventType: "browser.gateway.denied", + targetId: binding.id, + details: { method: "chat.send", reason: "cross-agent-denied" }, + }), + ]); + }); + + it("denies operator methods that are absent from the explicit allowlist", async () => { + const { proxy, request, token } = await setup(); + + await expect(proxy.request(token, "config.get", {})).rejects.toMatchObject({ + code: "method-not-allowed", + }); + expect(request).not.toHaveBeenCalled(); + }); + + it("projects browser-safe agent rows and preserves scoped session pagination", async () => { + const { binding, proxy, request, token } = await setup(); + request + .mockResolvedValueOnce({ + defaultId: "other", + mainKey: "main", + scope: "per-sender", + agents: [ + { + id: binding.agentId, + name: "First User", + workspace: "/srv/platformclaw/users/first", + agentRuntime: { id: "internal-runtime", source: "agent" }, + }, + { id: "other" }, + ], + }) + .mockResolvedValueOnce({ + total: 25, + sessions: [{ key: `agent:${binding.agentId}:main`, agentId: binding.agentId }], + }); + + const agents = await proxy.request<{ agents: Array> }>( + token, + "agents.list", + {}, + ); + expect(agents).toMatchObject({ + defaultId: binding.agentId, + agents: [{ id: binding.agentId, name: "First User" }], + }); + expect(agents.agents[0]).not.toHaveProperty("workspace"); + expect(agents.agents[0]).not.toHaveProperty("agentRuntime"); + await expect(proxy.request(token, "sessions.list", {})).resolves.toMatchObject({ + total: 25, + sessions: [{ key: `agent:${binding.agentId}:main` }], + }); + expect(request).toHaveBeenLastCalledWith("sessions.list", { + agentId: binding.agentId, + includeGlobal: false, + includeUnknown: false, + configuredAgentsOnly: true, + }); + }); + + it("filters chat startup agent metadata", async () => { + const { binding, proxy, request, token } = await setup(); + request.mockResolvedValueOnce({ + sessionKey: `agent:${binding.agentId}:main`, + messages: [], + agentsList: { + defaultId: "other", + mainKey: "main", + scope: "per-sender", + agents: [{ id: binding.agentId }, { id: "other" }], + }, + }); + + await expect( + proxy.request(token, "chat.startup", { sessionKey: `agent:${binding.agentId}:main` }), + ).resolves.toMatchObject({ + agentsList: { defaultId: binding.agentId, agents: [{ id: binding.agentId }] }, + }); + }); + + it("rejects host execution controls on browser-created sessions", async () => { + const { binding, proxy, request, token } = await setup(); + + await expect( + proxy.request(token, "sessions.create", { + agentId: binding.agentId, + worktree: true, + }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + expect(request).not.toHaveBeenCalled(); + }); + + it("rejects privileged session patches and future parameters by default", async () => { + const { binding, proxy, request, token } = await setup(); + const key = `agent:${binding.agentId}:main`; + + await expect( + proxy.request(token, "sessions.patch", { key, execHost: "gateway" }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + await expect( + proxy.request(token, "chat.send", { + sessionKey: key, + message: "hello", + idempotencyKey: "request-1", + futureOperatorOption: true, + }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + await expect( + proxy.request(token, "sessions.messages.subscribe", { key, includeApprovals: true }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + await expect( + proxy.request(token, "sessions.patch", { key, archived: true }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + await expect( + proxy.request(token, "sessions.patch", { key, model: "company/qwen@operator" }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + await expect( + proxy.request(token, "sessions.create", { key, model: "company/qwen@operator" }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + expect(request).not.toHaveBeenCalled(); + }); + + it("limits model discovery to a projected configured catalog", async () => { + const { proxy, request, token } = await setup(); + request.mockResolvedValueOnce({ + models: [ + { + id: "company/qwen", + name: "Qwen", + provider: "company", + available: true, + apiKeySupported: true, + agentRuntime: { id: "private-runtime", source: "session" }, + }, + ], + }); + + await expect(proxy.request(token, "models.list", {})).resolves.toEqual({ + models: [{ id: "company/qwen", name: "Qwen", provider: "company", available: true }], + }); + expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }); + await expect(proxy.request(token, "models.list", { view: "all" })).rejects.toMatchObject({ + code: "method-not-allowed", + }); + await expect( + proxy.request(token, "models.list", { includeProviderCapabilities: true }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + }); + + it("suppresses command interpretation and external delivery for browser chat", async () => { + const { binding, proxy, request, token } = await setup(); + const key = `agent:${binding.agentId}:main`; + request.mockResolvedValueOnce({ status: "started", runId: "run-1" }); + + await proxy.request(token, "chat.send", { + sessionKey: key, + message: "hello", + deliver: true, + idempotencyKey: "request-1", + __controlUiReconnectResume: true, + }); + + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + agentId: binding.agentId, + deliver: false, + suppressCommandInterpretation: true, + }), + ); + expect(request.mock.calls[0]?.[1]).not.toHaveProperty("__controlUiReconnectResume"); + }); + + it("removes operator command metadata from browser responses", async () => { + const { proxy, request, token } = await setup(); + request.mockResolvedValueOnce({ + models: [{ id: "company/qwen" }], + commands: [{ name: "config" }], + }); + + await expect(proxy.request(token, "chat.metadata", {})).resolves.toEqual({ + models: [{ id: "company/qwen" }], + }); + }); + + it("rejects message relay methods that cannot suppress Gateway commands", async () => { + const { binding, proxy, request, token } = await setup(); + const key = `agent:${binding.agentId}:main`; + + await expect( + proxy.request(token, "sessions.send", { key, message: "hello" }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + await expect( + proxy.request(token, "sessions.create", { agentId: binding.agentId, message: "hello" }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + await expect( + proxy.request(token, "sessions.create", { agentId: binding.agentId, task: "run a task" }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + expect(request).not.toHaveBeenCalled(); + }); + + it("projects session mutation responses without operator metadata", async () => { + const { binding, proxy, request, token } = await setup(); + const key = `agent:${binding.agentId}:dashboard:new`; + request + .mockResolvedValueOnce({ + ok: true, + key, + path: "/srv/platformclaw/state/sessions.json", + entry: { sessionFile: "/srv/platformclaw/agents/private/transcript.jsonl" }, + }) + .mockResolvedValueOnce({ + ok: true, + key, + path: "/srv/platformclaw/state/sessions.json", + entry: { authProfileOverride: "operator-profile" }, + }); + + await expect(proxy.request(token, "sessions.create", { key })).resolves.toEqual({ + ok: true, + key, + }); + await expect(proxy.request(token, "sessions.patch", { key, label: "New" })).resolves.toEqual({ + ok: true, + key, + }); + }); + + it("rejects browser-selected run IDs for both abort methods", async () => { + const { binding, proxy, request, token } = await setup(); + const key = `agent:${binding.agentId}:main`; + + await expect( + proxy.request(token, "sessions.abort", { key, runId: "foreign-run" }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + await expect( + proxy.request(token, "chat.abort", { sessionKey: key, runId: "foreign-run" }), + ).rejects.toMatchObject({ code: "method-not-allowed" }); + expect(request).not.toHaveBeenCalled(); + }); + + it("requires owned session keys for search and accepts a missing describe result", async () => { + const { binding, proxy, request, token } = await setup(); + const key = `agent:${binding.agentId}:main`; + + await expect( + proxy.request(token, "sessions.search", { query: "needle" }), + ).rejects.toMatchObject({ code: "invalid-params" }); + request.mockResolvedValueOnce({ session: null }); + await expect(proxy.request(token, "sessions.describe", { key })).resolves.toEqual({ + session: null, + }); + expect(request).toHaveBeenLastCalledWith("sessions.describe", { key }); + }); + + it("rejects foreign search and preview rows instead of leaking collection metadata", async () => { + const { binding, proxy, request, token } = await setup(); + const key = `agent:${binding.agentId}:main`; + request + .mockResolvedValueOnce({ + total: 2, + results: [ + { sessionKey: key, agentId: binding.agentId }, + { sessionKey: "agent:other:main", agentId: "other" }, + ], + }) + .mockResolvedValueOnce({ + total: 2, + previews: [{ key }, { key: "agent:other:main" }], + }); + + await expect( + proxy.request(token, "sessions.search", { sessionKeys: [key], query: "needle" }), + ).rejects.toMatchObject({ code: "upstream-result-denied" }); + await expect(proxy.request(token, "sessions.preview", { keys: [key] })).rejects.toMatchObject({ + code: "upstream-result-denied", + }); + }); + + it("projects direct message lookup results from the pinned session", async () => { + const { binding, proxy, request, token } = await setup(); + const key = `agent:${binding.agentId}:main`; + request.mockResolvedValueOnce({ + ok: true, + message: { role: "assistant", content: "hello" }, + internalPath: "/srv/platformclaw/other/transcript.jsonl", + }); + + await expect( + proxy.request(token, "chat.message.get", { sessionKey: key, messageId: "message-1" }), + ).resolves.toEqual({ + ok: true, + message: { role: "assistant", content: "hello" }, + }); + }); + + it("denies and audits cross-agent session identifiers returned by Gateway", async () => { + const { auditEvents, binding, proxy, request, token } = await setup(); + request.mockResolvedValueOnce({ ok: true, key: "agent:other:new" }); + + await expect( + proxy.request(token, "sessions.create", { agentId: binding.agentId }), + ).rejects.toMatchObject({ code: "upstream-result-denied" }); + expect(auditEvents).toEqual([ + expect.objectContaining({ + eventType: "browser.gateway.denied", + details: { method: "sessions.create", reason: "upstream-result-denied" }, + }), + ]); + }); + + it("drops cross-agent and unscoped events", async () => { + const { binding, proxy, token } = await setup(); + const owned = { + event: "chat", + payload: { sessionKey: `agent:${binding.agentId}:main`, agentId: binding.agentId }, + }; + + await expect(proxy.filterEvent(token, owned)).resolves.toEqual(owned); + await expect( + proxy.filterEvent(token, { + event: "chat", + payload: { sessionKey: "agent:other:main", agentId: "other" }, + }), + ).resolves.toBeNull(); + const ordinaryNestedPayload = { + event: "session.tool", + payload: { + sessionKey: `agent:${binding.agentId}:main`, + agentId: binding.agentId, + data: { agentId: "external-service-id", sessionKey: "opaque-tool-input" }, + }, + }; + await expect(proxy.filterEvent(token, ordinaryNestedPayload)).resolves.toEqual( + ordinaryNestedPayload, + ); + await expect( + proxy.filterEvent(token, { + event: "sessions.changed", + payload: { + sessionKey: `agent:${binding.agentId}:main`, + agentId: binding.agentId, + childSessions: [`agent:${binding.agentId}:child`, "agent:other:child"], + }, + }), + ).resolves.toBeNull(); + await expect( + proxy.filterEvent(token, { + event: "chat", + payload: { agentId: binding.agentId }, + }), + ).resolves.toBeNull(); + await expect( + proxy.filterEvent(token, { + event: "agent", + payload: { runId: "run-1", agentId: binding.agentId }, + }), + ).resolves.toBeNull(); + await expect(proxy.filterEvent(token, { event: "presence", payload: {} })).resolves.toBeNull(); + await expect( + proxy.filterEvent(token, { event: "tick", payload: { ts: NOW } }), + ).resolves.toEqual({ + event: "tick", + payload: { ts: NOW }, + }); + }); + + it("rejects requests after browser-session revocation", async () => { + const { created, proxy, request, store, token } = await setup(); + if (created.status !== "created") { + throw new Error("expected browser session"); + } + await store.revokeBrowserSession(created.session.id, NOW); + + await expect(proxy.request(token, "models.list", {})).rejects.toMatchObject({ + code: "unauthenticated", + }); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/platformclaw-control-plane/src/browser-gateway-proxy.ts b/packages/platformclaw-control-plane/src/browser-gateway-proxy.ts new file mode 100644 index 00000000000..c2c2ae965e7 --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-gateway-proxy.ts @@ -0,0 +1,721 @@ +import type { BrowserAuthService } from "./browser-auth-service.js"; +import { + projectBrowserAgentSummary, + projectBrowserModelChoice, +} from "./browser-gateway-projections.js"; +import type { + ControlPlaneAuditWriter, + ControlPlaneStore, + PersonalAgentBinding, + PlatformUser, +} from "./contracts.js"; + +export const PLATFORMCLAW_WEB_GATEWAY_METHODS = [ + "agent.identity.get", + "agents.list", + "chat.abort", + "chat.history", + "chat.message.get", + "chat.metadata", + "chat.send", + "chat.startup", + "chat.toolTitles", + "models.list", + "sessions.abort", + "sessions.create", + "sessions.describe", + "sessions.list", + "sessions.messages.subscribe", + "sessions.messages.unsubscribe", + "sessions.patch", + "sessions.preview", + "sessions.resolve", + "sessions.search", + "tools.catalog", + "tools.effective", +] as const; + +export type PlatformClawWebGatewayMethod = (typeof PLATFORMCLAW_WEB_GATEWAY_METHODS)[number]; + +const ALLOWED_METHODS = new Set(PLATFORMCLAW_WEB_GATEWAY_METHODS); +const ALLOWED_PARAMS = new Map>([ + ["agent.identity.get", new Set(["agentId", "sessionKey"])], + ["agents.list", new Set()], + ["chat.abort", new Set(["sessionKey", "agentId", "preserveSideRuns"])], + [ + "chat.history", + new Set(["sessionKey", "agentId", "limit", "offset", "messageId", "sessionId", "maxChars"]), + ], + ["chat.message.get", new Set(["sessionKey", "agentId", "messageId", "maxChars"])], + ["chat.metadata", new Set(["agentId"])], + [ + "chat.send", + new Set([ + "sessionKey", + "agentId", + "sessionId", + "message", + "thinking", + "fastMode", + "fastAutoOnSeconds", + "queueMode", + "deliver", + "attachments", + "timeoutMs", + "idempotencyKey", + "__controlUiReconnectResume", + ]), + ], + [ + "chat.startup", + new Set(["sessionKey", "agentId", "limit", "offset", "messageId", "sessionId", "maxChars"]), + ], + ["chat.toolTitles", new Set(["sessionKey", "agentId", "items"])], + ["models.list", new Set(["view"])], + ["sessions.abort", new Set(["key", "agentId"])], + [ + "sessions.create", + new Set([ + "key", + "agentId", + "label", + "thinkingLevel", + "catalogId", + "parentSessionKey", + "fork", + "emitCommandHooks", + "succeedsParent", + ]), + ], + ["sessions.describe", new Set(["key", "includeDerivedTitles", "includeLastMessage"])], + [ + "sessions.list", + new Set([ + "limit", + "offset", + "activeMinutes", + "requireLastInteraction", + "sortBy", + "includeGlobal", + "includeUnknown", + "configuredAgentsOnly", + "includeDerivedTitles", + "includeLastMessage", + "label", + "spawnedBy", + "agentId", + "search", + "archived", + ]), + ], + ["sessions.messages.subscribe", new Set(["key", "agentId"])], + ["sessions.messages.unsubscribe", new Set(["key", "agentId"])], + [ + "sessions.patch", + new Set([ + "key", + "agentId", + "label", + "category", + "pinned", + "unread", + "thinkingLevel", + "fastMode", + "verboseLevel", + "traceLevel", + "reasoningLevel", + "responseUsage", + ]), + ], + ["sessions.preview", new Set(["keys", "limit", "maxChars"])], + [ + "sessions.resolve", + new Set([ + "key", + "sessionId", + "label", + "agentId", + "spawnedBy", + "includeGlobal", + "includeUnknown", + "allowMissing", + ]), + ], + ["sessions.search", new Set(["agentId", "sessionKeys", "query", "limit"])], + ["tools.catalog", new Set(["agentId", "includePlugins"])], + ["tools.effective", new Set(["agentId", "sessionKey"])], +]); +const AGENT_ONLY_METHODS = new Set(["agent.identity.get", "chat.metadata", "tools.catalog"]); +const SESSION_KEY_METHODS = new Map([ + ["chat.abort", "sessionKey"], + ["chat.history", "sessionKey"], + ["chat.message.get", "sessionKey"], + ["chat.send", "sessionKey"], + ["chat.startup", "sessionKey"], + ["chat.toolTitles", "sessionKey"], + ["sessions.abort", "key"], + ["sessions.describe", "key"], + ["sessions.messages.subscribe", "key"], + ["sessions.messages.unsubscribe", "key"], + ["sessions.patch", "key"], + ["tools.effective", "sessionKey"], +]); +const SAFE_GLOBAL_EVENTS = new Set(["shutdown", "tick"]); +const SESSION_SCOPED_EVENTS = new Set([ + "chat", + "chat.send_timing", + "chat.side_result", + "session.message", + "session.operation", + "session.tool", + "sessions.changed", +]); +const SESSION_KEY_FIELDS = new Set([ + "sessionKey", + "parentSessionKey", + "childSessionKey", + "spawnedBy", +]); + +type JsonObject = Record; + +export type BrowserGatewayEvent = { + event: string; + payload?: unknown; + seq?: number; + stateVersion?: Record; +}; + +export type BrowserGatewayRpc = { + request(method: string, params?: unknown): Promise; +}; + +export type BrowserGatewayAccess = { + user: PlatformUser; + binding: PersonalAgentBinding; + mainSessionKey: string; +}; + +export type BrowserGatewayProxyErrorCode = + | "unauthenticated" + | "agent-unavailable" + | "method-not-allowed" + | "invalid-params" + | "cross-agent-denied" + | "upstream-result-denied"; + +export class BrowserGatewayProxyError extends Error { + constructor( + readonly code: BrowserGatewayProxyErrorCode, + message: string, + ) { + super(message); + this.name = "BrowserGatewayProxyError"; + } +} + +export type BrowserGatewayProxyOptions = { + authService: BrowserAuthService; + store: ControlPlaneStore; + auditWriter: ControlPlaneAuditWriter; + gateway: BrowserGatewayRpc; + buildAgentMainSessionKey(params: { agentId: string }): string; + resolveAgentIdFromSessionKey(sessionKey: string): string | null; + now?: () => number; +}; + +function asObject(value: unknown, label: string): JsonObject { + if (value === undefined) { + return {}; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new BrowserGatewayProxyError("invalid-params", `${label} must be an object`); + } + return { ...(value as JsonObject) }; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +/** Enforces the browser-session-to-agent boundary before using operator Gateway RPC. */ +export class BrowserGatewayProxy { + constructor(private readonly options: BrowserGatewayProxyOptions) {} + + async resolveAccess(token: string, touch = true): Promise { + const auth = await this.options.authService.authenticateToken(token, touch); + if (auth.status !== "active") { + throw new BrowserGatewayProxyError("unauthenticated", "active browser session required"); + } + const binding = await this.options.store.getPersonalAgentBinding(auth.user.id); + if (!binding || binding.state !== "active") { + throw new BrowserGatewayProxyError( + "agent-unavailable", + "active personal agent binding required", + ); + } + return { + user: auth.user, + binding, + mainSessionKey: this.options.buildAgentMainSessionKey({ agentId: binding.agentId }), + }; + } + + async request(token: string, method: string, params?: unknown): Promise { + const access = await this.resolveAccess(token); + let prepared: JsonObject; + try { + prepared = this.prepareRequest(access, method, params); + } catch (error) { + if (error instanceof BrowserGatewayProxyError) { + await this.auditDeniedRequest(access, method, error.code); + } + throw error; + } + const result = await this.options.gateway.request(method, prepared); + try { + return this.filterResult(access, method, prepared, result) as T; + } catch (error) { + if (error instanceof BrowserGatewayProxyError) { + await this.auditDeniedRequest(access, method, error.code); + } + throw error; + } + } + + async filterEvent( + token: string, + event: BrowserGatewayEvent, + ): Promise { + let access: BrowserGatewayAccess; + try { + // Server-pushed traffic must not keep an unattended browser session alive. + access = await this.resolveAccess(token, false); + } catch { + return null; + } + if (SAFE_GLOBAL_EVENTS.has(event.event)) { + return event; + } + if ( + !SESSION_SCOPED_EVENTS.has(event.event) || + !this.eventPayloadBelongsToAccess(access, event.payload) + ) { + return null; + } + return event; + } + + private prepareRequest( + access: BrowserGatewayAccess, + method: string, + rawParams: unknown, + ): JsonObject { + if (!ALLOWED_METHODS.has(method)) { + throw new BrowserGatewayProxyError( + "method-not-allowed", + `Gateway method is not available to browser users: ${method}`, + ); + } + const params = asObject(rawParams, `${method} params`); + this.assertAllowedParams(method, params); + if (method === "models.list") { + if (params.view !== undefined && params.view !== "configured") { + throw new BrowserGatewayProxyError( + "method-not-allowed", + "browser model catalog is limited to configured models", + ); + } + return { view: "configured" }; + } + if (method === "agents.list") { + return {}; + } + if (method === "sessions.list") { + this.assertOptionalAgentId(access, params.agentId, method); + return { + ...params, + agentId: access.binding.agentId, + includeGlobal: false, + includeUnknown: false, + configuredAgentsOnly: true, + }; + } + if (method === "sessions.search") { + this.assertOptionalAgentId(access, params.agentId, method); + this.assertSessionKeyArray(access, params.sessionKeys, "sessionKeys", true); + return { ...params, agentId: access.binding.agentId }; + } + if (method === "sessions.preview") { + this.assertSessionKeyArray(access, params.keys, "keys", true); + return params; + } + if (method === "sessions.describe") { + this.assertOwnedSessionKey(access, params.key, "key"); + return params; + } + if (method === "sessions.resolve") { + this.assertOptionalAgentId(access, params.agentId, method); + if (params.key !== undefined) { + this.assertOwnedSessionKey(access, params.key, "key"); + } + return { + ...params, + agentId: access.binding.agentId, + includeGlobal: false, + includeUnknown: false, + }; + } + if (method === "sessions.create") { + this.assertOptionalAgentId(access, params.agentId, method); + if (params.key !== undefined) { + this.assertOwnedSessionKey(access, params.key, "key"); + } + if (params.parentSessionKey !== undefined) { + this.assertOwnedSessionKey(access, params.parentSessionKey, "parentSessionKey"); + } + return { ...params, agentId: access.binding.agentId, emitCommandHooks: false }; + } + if (AGENT_ONLY_METHODS.has(method)) { + this.assertOptionalAgentId(access, params.agentId, method); + if (params.sessionKey !== undefined) { + this.assertOwnedSessionKey(access, params.sessionKey, "sessionKey"); + } + return { ...params, agentId: access.binding.agentId }; + } + const keyField = SESSION_KEY_METHODS.get(method); + if (keyField) { + this.assertOptionalAgentId(access, params.agentId, method); + this.assertOwnedSessionKey(access, params[keyField], keyField); + if (method === "chat.send") { + const gatewayParams = { ...params }; + delete gatewayParams["__controlUiReconnectResume"]; + return { + ...gatewayParams, + agentId: access.binding.agentId, + deliver: false, + suppressCommandInterpretation: true, + }; + } + return { ...params, agentId: access.binding.agentId }; + } + throw new BrowserGatewayProxyError( + "method-not-allowed", + `Gateway method has no browser policy: ${method}`, + ); + } + + private filterResult( + access: BrowserGatewayAccess, + method: string, + prepared: JsonObject, + result: unknown, + ): unknown { + if (method === "models.list") { + const payload = asObject(result, "models.list result"); + const models = Array.isArray(payload.models) + ? payload.models.map(projectBrowserModelChoice).filter((entry) => entry !== null) + : []; + return { models }; + } + if (method === "chat.metadata") { + const payload = asObject(result, "chat.metadata result"); + // Browser users may choose configured models, but operator-only slash commands stay hidden. + return payload.models === undefined ? {} : { models: payload.models }; + } + if (method === "agents.list") { + const payload = asObject(result, "agents.list result"); + const agents = Array.isArray(payload.agents) + ? payload.agents + .map(projectBrowserAgentSummary) + .filter((entry) => entry?.id === access.binding.agentId) + : []; + if (agents.length !== 1) { + throw new BrowserGatewayProxyError( + "upstream-result-denied", + "owned agent missing from Gateway response", + ); + } + return { ...payload, defaultId: access.binding.agentId, agents }; + } + if (method === "chat.history" || method === "chat.startup") { + const payload = asObject(result, `${method} result`); + if (payload.sessionKey !== undefined) { + this.assertOwnedResultSessionKey(access, payload.sessionKey); + } + if ( + payload.sessionInfo !== undefined && + !this.payloadBelongsToAccess(access, payload.sessionInfo) + ) { + throw new BrowserGatewayProxyError( + "upstream-result-denied", + "Gateway returned session metadata outside the browser binding", + ); + } + if (method === "chat.history") { + return payload; + } + const agentsList = asObject(payload.agentsList, "chat.startup agentsList"); + const agents = Array.isArray(agentsList.agents) + ? agentsList.agents + .map(projectBrowserAgentSummary) + .filter((entry) => entry?.id === access.binding.agentId) + : []; + if (agents.length !== 1) { + throw new BrowserGatewayProxyError( + "upstream-result-denied", + "owned agent missing from Gateway startup response", + ); + } + return { + ...payload, + agentsList: { ...agentsList, defaultId: access.binding.agentId, agents }, + }; + } + if (method === "chat.message.get") { + // Upstream resolves message IDs inside the already-pinned session transcript. + this.assertOwnedResultSessionKey(access, prepared.sessionKey); + const payload = asObject(result, "chat.message.get result"); + if (payload.ok === true) { + if (payload.message === undefined) { + throw new BrowserGatewayProxyError( + "upstream-result-denied", + "Gateway returned an invalid direct message result", + ); + } + return { ok: true, message: payload.message }; + } + return payload.unavailableReason === undefined + ? { ok: false } + : { ok: false, unavailableReason: payload.unavailableReason }; + } + if (method === "sessions.list") { + const payload = asObject(result, "sessions.list result"); + const sessions = Array.isArray(payload.sessions) ? payload.sessions : []; + if (sessions.some((entry) => !this.payloadBelongsToAccess(access, entry))) { + throw new BrowserGatewayProxyError( + "upstream-result-denied", + "Gateway returned a session outside the browser binding", + ); + } + return payload; + } + if (method === "sessions.search") { + const payload = asObject(result, "sessions.search result"); + const results = Array.isArray(payload.results) ? payload.results : []; + if (results.some((entry) => !this.payloadBelongsToAccess(access, entry))) { + throw new BrowserGatewayProxyError( + "upstream-result-denied", + "Gateway returned a search result outside the browser binding", + ); + } + return payload; + } + if (method === "sessions.preview") { + const payload = asObject(result, "sessions.preview result"); + const previews = Array.isArray(payload.previews) ? payload.previews : []; + if (previews.some((entry) => !this.payloadBelongsToAccess(access, entry))) { + throw new BrowserGatewayProxyError( + "upstream-result-denied", + "Gateway returned a preview outside the browser binding", + ); + } + return payload; + } + if (method === "sessions.create" || method === "sessions.resolve") { + const payload = asObject(result, `${method} result`); + if (payload.ok === false) { + return { ok: false }; + } + this.assertOwnedResultSessionKey(access, payload.key); + return { ok: true, key: payload.key }; + } + if (method === "sessions.patch") { + const payload = asObject(result, "sessions.patch result"); + if (payload.ok !== true) { + throw new BrowserGatewayProxyError( + "upstream-result-denied", + "Gateway returned an invalid session patch result", + ); + } + this.assertOwnedResultSessionKey(access, prepared.key); + return { ok: true, key: prepared.key }; + } + if (method === "sessions.describe") { + const payload = asObject(result, "sessions.describe result"); + if (payload.session !== null && !this.payloadBelongsToAccess(access, payload.session)) { + throw new BrowserGatewayProxyError( + "upstream-result-denied", + "Gateway returned a session outside the browser binding", + ); + } + return payload; + } + return result; + } + + private payloadBelongsToAccess(access: BrowserGatewayAccess, payload: unknown): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return false; + } + const record = payload as JsonObject; + if (this.hasForeignOwnershipFields(access, record)) { + return false; + } + const agentId = optionalString(record.agentId); + const sessionKey = optionalString(record.sessionKey) ?? optionalString(record.key); + if (agentId && agentId !== access.binding.agentId) { + return false; + } + if (sessionKey) { + return this.options.resolveAgentIdFromSessionKey(sessionKey) === access.binding.agentId; + } + return agentId === access.binding.agentId; + } + + private eventPayloadBelongsToAccess(access: BrowserGatewayAccess, payload: unknown): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return false; + } + const record = payload as JsonObject; + const sessionKey = optionalString(record.sessionKey) ?? optionalString(record.key); + if ( + !sessionKey || + this.options.resolveAgentIdFromSessionKey(sessionKey) !== access.binding.agentId + ) { + return false; + } + return !this.hasForeignOwnershipFields(access, record); + } + + private hasForeignOwnershipFields(access: BrowserGatewayAccess, record: JsonObject): boolean { + const agentId = optionalString(record.agentId); + if (agentId && agentId !== access.binding.agentId) { + return true; + } + for (const field of SESSION_KEY_FIELDS) { + const sessionKey = optionalString(record[field]); + if ( + sessionKey && + this.options.resolveAgentIdFromSessionKey(sessionKey) !== access.binding.agentId + ) { + return true; + } + } + const rowKey = optionalString(record.key); + if (rowKey) { + const resolvedAgentId = this.options.resolveAgentIdFromSessionKey(rowKey); + if (resolvedAgentId && resolvedAgentId !== access.binding.agentId) { + return true; + } + } + if (record.childSessions !== undefined) { + if (!Array.isArray(record.childSessions)) { + return true; + } + for (const childSession of record.childSessions) { + const sessionKey = optionalString(childSession); + if ( + !sessionKey || + this.options.resolveAgentIdFromSessionKey(sessionKey) !== access.binding.agentId + ) { + return true; + } + } + } + return false; + } + + private assertOptionalAgentId( + access: BrowserGatewayAccess, + rawAgentId: unknown, + label: string, + ): void { + const agentId = optionalString(rawAgentId); + if (agentId && agentId !== access.binding.agentId) { + throw new BrowserGatewayProxyError( + "cross-agent-denied", + `browser access denied for ${label}`, + ); + } + } + + private assertOwnedSessionKey( + access: BrowserGatewayAccess, + rawSessionKey: unknown, + label: string, + ): void { + const sessionKey = optionalString(rawSessionKey); + if (!sessionKey) { + throw new BrowserGatewayProxyError("invalid-params", `${label} is required`); + } + if (this.options.resolveAgentIdFromSessionKey(sessionKey) !== access.binding.agentId) { + throw new BrowserGatewayProxyError( + "cross-agent-denied", + `browser access denied for ${label}`, + ); + } + } + + private assertSessionKeyArray( + access: BrowserGatewayAccess, + value: unknown, + label: string, + required: boolean, + ): void { + if (value === undefined && !required) { + return; + } + if (!Array.isArray(value) || (required && value.length === 0)) { + throw new BrowserGatewayProxyError("invalid-params", `${label} must be a non-empty array`); + } + for (const sessionKey of value) { + this.assertOwnedSessionKey(access, sessionKey, label); + } + } + + private assertOwnedResultSessionKey(access: BrowserGatewayAccess, rawSessionKey: unknown): void { + const sessionKey = optionalString(rawSessionKey); + if ( + !sessionKey || + this.options.resolveAgentIdFromSessionKey(sessionKey) !== access.binding.agentId + ) { + throw new BrowserGatewayProxyError( + "upstream-result-denied", + "Gateway returned a session outside the browser binding", + ); + } + } + + private assertAllowedParams(method: string, params: JsonObject): void { + const allowed = ALLOWED_PARAMS.get(method); + if (!allowed) { + throw new BrowserGatewayProxyError( + "method-not-allowed", + `Gateway method has no browser parameter policy: ${method}`, + ); + } + const disallowed = Object.keys(params).find((key) => !allowed.has(key)); + if (disallowed) { + throw new BrowserGatewayProxyError( + "method-not-allowed", + `Gateway parameter is not available to browser users: ${method}.${disallowed}`, + ); + } + } + + private async auditDeniedRequest( + access: BrowserGatewayAccess, + method: string, + reason: BrowserGatewayProxyErrorCode, + ): Promise { + await this.options.auditWriter.recordAuditEvent({ + actorUserId: access.user.id, + eventType: "browser.gateway.denied", + targetType: "agent-binding", + targetId: access.binding.id, + details: { method, reason }, + createdAt: (this.options.now ?? Date.now)(), + }); + } +} diff --git a/packages/platformclaw-control-plane/src/contracts.ts b/packages/platformclaw-control-plane/src/contracts.ts index 91788e1cc5a..a4f9b8ae8d7 100644 --- a/packages/platformclaw-control-plane/src/contracts.ts +++ b/packages/platformclaw-control-plane/src/contracts.ts @@ -169,6 +169,7 @@ export interface ControlPlaneStore { ): Promise; getUserById(userId: string): Promise; getUserByEmployeeId(employeeId: string): Promise; + getPersonalAgentBinding(userId: string): Promise; reservePersonalAgent( userId: string, reservedAt: number, @@ -202,6 +203,17 @@ export interface ControlPlaneStore { }): Promise; } +export interface ControlPlaneAuditWriter { + recordAuditEvent(params: { + actorUserId?: string; + eventType: string; + targetType: string; + targetId: string; + details?: Record; + createdAt: number; + }): Promise; +} + export interface ControlPlaneManagementStore { setManagedUserStatus(params: { actorUserId: string; diff --git a/packages/platformclaw-control-plane/src/employee-auth-client.test.ts b/packages/platformclaw-control-plane/src/employee-auth-client.test.ts index a242ede5685..6bd08cf061c 100644 --- a/packages/platformclaw-control-plane/src/employee-auth-client.test.ts +++ b/packages/platformclaw-control-plane/src/employee-auth-client.test.ts @@ -100,7 +100,11 @@ describe("HttpEmployeeAuthenticator", () => { Authorization: "Bearer test-bearer-token", }); expect(request?.redirect).toBe("error"); - expect(JSON.parse(String(request?.body))).toEqual({ + expect(typeof request?.body).toBe("string"); + if (typeof request?.body !== "string") { + throw new Error("employee auth request body must be JSON text"); + } + expect(JSON.parse(request.body)).toEqual({ identifier: "seungon.jung", password: "test-password", clientIp: "192.0.2.10", diff --git a/packages/platformclaw-control-plane/src/employee-auth-client.ts b/packages/platformclaw-control-plane/src/employee-auth-client.ts index 19faeccc167..91c53f3d5b0 100644 --- a/packages/platformclaw-control-plane/src/employee-auth-client.ts +++ b/packages/platformclaw-control-plane/src/employee-auth-client.ts @@ -232,11 +232,11 @@ export class HttpEmployeeAuthenticator implements EmployeeAuthenticator { return { status: "unavailable", message: "employee authentication service unavailable" }; } - let parsed: ExternalAuthResponse | null = null; + let parsed: ExternalAuthResponse | null; try { parsed = parseResponse(await response.json()); } catch { - parsed = null; + return { status: "unavailable", message: "employee authentication response was invalid" }; } if (!parsed) { return { status: "unavailable", message: "employee authentication response was invalid" }; diff --git a/packages/platformclaw-control-plane/src/index.ts b/packages/platformclaw-control-plane/src/index.ts index 091417a4262..0eb33320272 100644 --- a/packages/platformclaw-control-plane/src/index.ts +++ b/packages/platformclaw-control-plane/src/index.ts @@ -1,6 +1,7 @@ export * from "./browser-auth-service.js"; export * from "./browser-auth-http.js"; export * from "./browser-auth-runtime.js"; +export * from "./browser-gateway-proxy.js"; export * from "./contracts.js"; export * from "./employee-auth-client.js"; export * from "./gateway-admin-rpc-client.js"; diff --git a/packages/platformclaw-control-plane/src/memory-store.test.ts b/packages/platformclaw-control-plane/src/memory-store.test.ts index 4c2672cd7c3..7ad9502120c 100644 --- a/packages/platformclaw-control-plane/src/memory-store.test.ts +++ b/packages/platformclaw-control-plane/src/memory-store.test.ts @@ -252,6 +252,28 @@ describe("InMemoryControlPlaneStore enterprise identity", () => { }); describe("InMemoryControlPlaneStore provisioning", () => { + it("resolves the owned personal binding and records proxy audit events", async () => { + const store = createStore(); + const { user, binding } = await createActivePersonalAgent(store); + + await expect(store.getPersonalAgentBinding(user.id)).resolves.toEqual(binding); + await expect( + store.recordAuditEvent({ + actorUserId: user.id, + eventType: "browser.gateway.denied", + targetType: "agent-binding", + targetId: binding.id, + details: { method: "config.get", reason: "method-not-allowed" }, + createdAt: 4_000, + }), + ).resolves.toMatchObject({ + actorUserId: user.id, + eventType: "browser.gateway.denied", + targetId: binding.id, + details: { method: "config.get", reason: "method-not-allowed" }, + }); + }); + it("converges concurrent personal-agent reservations on one record", async () => { const store = createStore(); const { user } = await store.upsertPrincipal(ldapPrincipal(), 1_000); @@ -262,7 +284,9 @@ describe("InMemoryControlPlaneStore provisioning", () => { ]); expect(first.binding).toEqual(second.binding); - expect([first.created, second.created].sort()).toEqual([false, true]); + expect( + [first.created, second.created].toSorted((left, right) => Number(left) - Number(right)), + ).toEqual([false, true]); expect(first.binding).toMatchObject({ kind: "personal", userId: user.id, @@ -279,7 +303,9 @@ describe("InMemoryControlPlaneStore provisioning", () => { store.reserveKnoxRoomAgent(params), ]); expect(first.binding).toEqual(second.binding); - expect([first.created, second.created].sort()).toEqual([false, true]); + expect( + [first.created, second.created].toSorted((left, right) => Number(left) - Number(right)), + ).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); diff --git a/packages/platformclaw-control-plane/src/memory-store.ts b/packages/platformclaw-control-plane/src/memory-store.ts index a9fab4b2a56..1c782f078a4 100644 --- a/packages/platformclaw-control-plane/src/memory-store.ts +++ b/packages/platformclaw-control-plane/src/memory-store.ts @@ -10,7 +10,9 @@ import { type BrowserSession, type BrowserSessionPolicy, type BrowserSessionResolution, + type ControlAuditEvent, type ControlPlaneIdFactory, + type ControlPlaneAuditWriter, type ControlPlaneStore, type CreateBrowserSessionResult, type EnterpriseIdentity, @@ -91,7 +93,7 @@ function normalizeEmployeeId(employeeId: string): string { return requireNonEmpty(employeeId, "principal.employeeId").toLowerCase(); } -export class InMemoryControlPlaneStore implements ControlPlaneStore { +export class InMemoryControlPlaneStore implements ControlPlaneStore, ControlPlaneAuditWriter { private readonly buildAgentMainSessionKey: MainSessionKeyBuilder; private readonly idFactory: ControlPlaneIdFactory; private readonly sessionPolicy: BrowserSessionPolicy; @@ -107,6 +109,7 @@ export class InMemoryControlPlaneStore implements ControlPlaneStore { private readonly sessions = new Map(); private readonly sessionIdByTokenHash = new Map(); private readonly sessionIdsByUserId = new Map>(); + private readonly auditEvents: ControlAuditEvent[] = []; constructor(options: MemoryStoreOptions) { this.buildAgentMainSessionKey = options.buildAgentMainSessionKey; @@ -214,6 +217,32 @@ export class InMemoryControlPlaneStore implements ControlPlaneStore { return user ? cloneUser(user) : null; } + async getPersonalAgentBinding(userId: string): Promise { + const bindingId = this.personalBindingIdByUserId.get(userId); + return bindingId ? cloneBinding(this.requirePersonalBinding(bindingId)) : null; + } + + async recordAuditEvent(params: { + actorUserId?: string; + eventType: string; + targetType: string; + targetId: string; + details?: Record; + createdAt: number; + }): Promise { + const event: ControlAuditEvent = { + id: this.idFactory.nextAuditEventId(), + ...(params.actorUserId ? { actorUserId: params.actorUserId } : {}), + eventType: requireNonEmpty(params.eventType, "eventType"), + targetType: requireNonEmpty(params.targetType, "targetType"), + targetId: requireNonEmpty(params.targetId, "targetId"), + ...(params.details ? { details: structuredClone(params.details) } : {}), + createdAt: params.createdAt, + }; + this.auditEvents.push(event); + return structuredClone(event); + } + async setManagedUserStatus(params: { actorUserId: string; targetUserId: string; diff --git a/packages/platformclaw-control-plane/src/sqlite-store-auth.ts b/packages/platformclaw-control-plane/src/sqlite-store-auth.ts new file mode 100644 index 00000000000..0a08d19b480 --- /dev/null +++ b/packages/platformclaw-control-plane/src/sqlite-store-auth.ts @@ -0,0 +1,508 @@ +import { + ControlPlaneConflictError, + ControlPlaneStateError, + type AgentBinding, + type AgentProvisioningState, + type AgentReservationResult, + type BrowserSession, + type BrowserSessionResolution, + type ControlAuditEvent, + type CreateBrowserSessionResult, + type EnterprisePrincipal, + type KnoxDmRouteResolution, + type KnoxRoomAgentBinding, + type PersonalAgentBinding, + type PlatformUser, + type UpsertPrincipalResult, +} from "./contracts.js"; +import { deriveKnoxRoomAgentId, derivePersonalAgentId } from "./ids.js"; +import { executeSync, runImmediateTransaction, takeFirstSync } from "./kysely-sync.js"; +import { + ALLOWED_AGENT_TRANSITIONS, + normalizeAccountId, + normalizeEmployeeId, + optional, + required, + rowToBinding, + rowToIdentity, + rowToSession, + SqliteControlPlaneStoreCore, +} from "./sqlite-store-core.js"; +import type { AgentBindingRow, BrowserSessionRow } from "./sqlite-store-types.js"; + +export abstract class SqliteControlPlaneAuthStore extends SqliteControlPlaneStoreCore { + async upsertPrincipal( + principal: EnterprisePrincipal, + authenticatedAt: number, + ): Promise { + return runImmediateTransaction(this.db, () => { + const subject = required(principal.subject, "principal.subject"); + const employeeId = normalizeEmployeeId(principal.employeeId); + const requestedAccountId = principal.accountId + ? normalizeAccountId(principal.accountId) + : undefined; + 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 (requestedAccountId && requestedAccountId !== user.account_id) { + throw new ControlPlaneConflictError( + "account_id_mismatch", + `identity account id disagrees with the canonical user: ${principal.provider}:${subject}`, + ); + } + 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, + }; + } + + const accountOwner = requestedAccountId + ? this.selectUserByAccountId(requestedAccountId) + : undefined; + const employeeOwner = this.selectUserByEmployeeId(employeeId); + if (accountOwner && employeeOwner && accountOwner.id !== employeeOwner.id) { + throw new ControlPlaneConflictError( + "account_id_conflict", + `account id and employee id belong to different users: ${requestedAccountId}`, + ); + } + let user = accountOwner ?? employeeOwner; + const accountId = requestedAccountId ?? user?.account_id ?? employeeId; + const createdUser = !user; + if (!user) { + const id = this.idFactory.nextUserId(); + 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 { + if (requestedAccountId && user.account_id !== requestedAccountId) { + throw new ControlPlaneConflictError( + "account_id_mismatch", + `account id disagrees with the canonical user: ${requestedAccountId}`, + ); + } + 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 getPersonalAgentBinding(userId: string): Promise { + const row = takeFirstSync( + this.db, + this.query + .selectFrom("agent_bindings") + .selectAll() + .where("kind", "=", "personal") + .where("user_id", "=", userId), + ); + return row ? (rowToBinding(row) as PersonalAgentBinding) : null; + } + + async recordAuditEvent(params: { + actorUserId?: string; + eventType: string; + targetType: string; + targetId: string; + details?: Record; + createdAt: number; + }): Promise { + const event: ControlAuditEvent = { + id: this.idFactory.nextAuditEventId(), + ...(params.actorUserId ? { actorUserId: params.actorUserId } : {}), + eventType: required(params.eventType, "eventType"), + targetType: required(params.targetType, "targetType"), + targetId: required(params.targetId, "targetId"), + ...(params.details ? { details: params.details } : {}), + createdAt: params.createdAt, + }; + executeSync( + this.db, + this.query.insertInto("control_audit_events").values({ + id: event.id, + actor_user_id: event.actorUserId ?? null, + event_type: event.eventType, + target_type: event.targetType, + target_id: event.targetId, + details_json: event.details ? JSON.stringify(event.details) : null, + created_at: event.createdAt, + }), + ); + return event; + } + + 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 }; + } +} diff --git a/packages/platformclaw-control-plane/src/sqlite-store-core.ts b/packages/platformclaw-control-plane/src/sqlite-store-core.ts new file mode 100644 index 00000000000..a8e1d20ff14 --- /dev/null +++ b/packages/platformclaw-control-plane/src/sqlite-store-core.ts @@ -0,0 +1,463 @@ +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 BrowserSession, + type BrowserSessionPolicy, + type ControlPlaneIdFactory, + type EnterpriseIdentity, + type EnterprisePrincipal, + type MainSessionKeyBuilder, + type ManagedScope, + type ManagedScopeMembership, + type PlatformUser, +} from "./contracts.js"; +import { defaultControlPlaneIdFactory } from "./ids.js"; +import { createSyncKysely, executeSync, takeFirstSync } from "./kysely-sync.js"; +import { initializeControlPlaneSchema } from "./sqlite-schema.js"; +import type { + AgentBindingRow, + BrowserSessionRow, + ControlPlaneDatabase, + EnterpriseIdentityRow, + ManagedScopeMembershipRow, + ManagedScopeRow, + PlatformUserRow, +} from "./sqlite-store-types.js"; + +export type SqliteControlPlaneStoreOptions = { + databasePath: string; + buildAgentMainSessionKey: MainSessionKeyBuilder; + initialAdminAccountIds?: readonly string[]; + idFactory?: ControlPlaneIdFactory; + sessionPolicy?: BrowserSessionPolicy; +}; + +export 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(), +}; + +export function required(value: string, field: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new ControlPlaneStateError(`${field} must not be empty`); + } + return normalized; +} + +export function optional(value: string | undefined): string | null { + return value?.trim() || null; +} + +export function normalizedGroups(groups: string[] | undefined): string[] { + return [...new Set((groups ?? []).map((group) => group.trim()).filter(Boolean))].toSorted(); +} + +export function normalizeEmployeeId(employeeId: string): string { + return required(employeeId, "principal.employeeId").toLowerCase(); +} + +export function normalizeAccountId(accountId: string): string { + return required(accountId, "principal.accountId").toLowerCase(); +} + +export function normalizeScopeName(name: string): string { + return required(name, "scope.name").toLocaleLowerCase("en-US"); +} + +export 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, + }; +} + +export 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, + }; +} + +export 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 }), + }; +} + +export 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, + }; +} + +export 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 abstract class SqliteControlPlaneStoreCore { + protected readonly db: DatabaseSync; + protected readonly query: Kysely; + protected readonly buildAgentMainSessionKey: MainSessionKeyBuilder; + protected readonly idFactory: ControlPlaneIdFactory; + protected readonly sessionPolicy: BrowserSessionPolicy; + protected 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(); + } + + protected 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 }), + }; + } + + protected selectUserById(userId: string): PlatformUserRow | undefined { + return takeFirstSync( + this.db, + this.query.selectFrom("platform_users").selectAll().where("id", "=", userId), + ); + } + + protected selectUserByEmployeeId(employeeId: string): PlatformUserRow | undefined { + return takeFirstSync( + this.db, + this.query.selectFrom("platform_users").selectAll().where("employee_id", "=", employeeId), + ); + } + + protected selectUserByAccountId(accountId: string): PlatformUserRow | undefined { + return takeFirstSync( + this.db, + this.query.selectFrom("platform_users").selectAll().where("account_id", "=", accountId), + ); + } + + protected requireUserRow(userId: string): PlatformUserRow { + const row = this.selectUserById(userId); + if (!row) { + throw new ControlPlaneNotFoundError("user", userId); + } + return row; + } + + protected requireUser(userId: string): PlatformUser { + return this.rowToUser(this.requireUserRow(userId)); + } + + protected 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; + } + + protected selectIdentity( + provider: "ldap" | "saml", + subject: string, + ): EnterpriseIdentityRow | undefined { + return takeFirstSync( + this.db, + this.query + .selectFrom("enterprise_identities") + .selectAll() + .where("provider", "=", provider) + .where("subject", "=", subject), + ); + } + + protected 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), + ); + } + + protected 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, + }); + } + + protected 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)); + } + } + + protected 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; + } + + protected 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}`, + ); + } + } + + protected 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; + } + + protected 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}`, + ); + } + } + + protected 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), + ); + } + + protected 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", + ); + } + + protected 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/src/sqlite-store-types.ts b/packages/platformclaw-control-plane/src/sqlite-store-types.ts new file mode 100644 index 00000000000..6b3e4e08e55 --- /dev/null +++ b/packages/platformclaw-control-plane/src/sqlite-store-types.ts @@ -0,0 +1,93 @@ +import type { + AgentProvisioningState, + ManagedScopeKind, + ManagedScopeRole, + PlatformUserGlobalRole, + PlatformUserStatus, +} from "./contracts.js"; + +export 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; +}; + +export type EnterpriseIdentityRow = { + provider: "ldap" | "saml"; + subject: string; + user_id: string; + employee_id: string; + created_at: number; + last_authenticated_at: number; +}; + +export type DirectoryGroupRow = { user_id: string; group_name: string }; +export 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; +}; +export 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; +}; +export 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; +}; +export type ManagedScopeMembershipRow = { + scope_id: string; + user_id: string; + role: ManagedScopeRole; + created_at: number; + updated_at: number; +}; +export 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; +}; + +export 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; +}; diff --git a/packages/platformclaw-control-plane/src/sqlite-store.test.ts b/packages/platformclaw-control-plane/src/sqlite-store.test.ts index d46a8cb4fb3..c367b989108 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store.test.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store.test.ts @@ -208,6 +208,43 @@ describe("SqliteControlPlaneStore", () => { store.close(); }); + it("resolves the owned personal binding and persists proxy audit events", async () => { + const databasePath = createDatabasePath(); + const store = createStore(databasePath); + const { user } = await store.upsertPrincipal(principal("member.user"), 1_000); + const reserved = await store.reservePersonalAgent(user.id, 2_000); + const binding = await store.transitionAgent({ + bindingId: reserved.binding.id, + state: "active", + changedAt: 3_000, + }); + + await expect(store.getPersonalAgentBinding(user.id)).resolves.toEqual(binding); + const audit = await store.recordAuditEvent({ + actorUserId: user.id, + eventType: "browser.gateway.denied", + targetType: "agent-binding", + targetId: binding.id, + details: { method: "config.get", reason: "method-not-allowed" }, + createdAt: 4_000, + }); + store.close(); + + const database = new DatabaseSync(databasePath, { readOnly: true }); + expect( + database + .prepare( + "SELECT event_type, target_id, details_json FROM control_audit_events WHERE id = ?", + ) + .get(audit.id), + ).toEqual({ + event_type: "browser.gateway.denied", + target_id: binding.id, + details_json: JSON.stringify({ method: "config.get", reason: "method-not-allowed" }), + }); + database.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); diff --git a/packages/platformclaw-control-plane/src/sqlite-store.ts b/packages/platformclaw-control-plane/src/sqlite-store.ts index f35aaca791d..f13ef40996d 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store.ts @@ -1,748 +1,27 @@ -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 ControlPlaneAuditWriter, 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 normalizeAccountId(accountId: string): string { - return required(accountId, "principal.accountId").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 requestedAccountId = principal.accountId - ? normalizeAccountId(principal.accountId) - : undefined; - 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 (requestedAccountId && requestedAccountId !== user.account_id) { - throw new ControlPlaneConflictError( - "account_id_mismatch", - `identity account id disagrees with the canonical user: ${principal.provider}:${subject}`, - ); - } - 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, - }; - } - - const accountOwner = requestedAccountId - ? this.selectUserByAccountId(requestedAccountId) - : undefined; - const employeeOwner = this.selectUserByEmployeeId(employeeId); - if (accountOwner && employeeOwner && accountOwner.id !== employeeOwner.id) { - throw new ControlPlaneConflictError( - "account_id_conflict", - `account id and employee id belong to different users: ${requestedAccountId}`, - ); - } - let user = accountOwner ?? employeeOwner; - const accountId = requestedAccountId ?? user?.account_id ?? employeeId; - const createdUser = !user; - if (!user) { - const id = this.idFactory.nextUserId(); - 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 { - if (requestedAccountId && user.account_id !== requestedAccountId) { - throw new ControlPlaneConflictError( - "account_id_mismatch", - `account id disagrees with the canonical user: ${requestedAccountId}`, - ); - } - 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 }; - } - +import { executeSync, runImmediateTransaction } from "./kysely-sync.js"; +import { SqliteControlPlaneAuthStore } from "./sqlite-store-auth.js"; +import { normalizeScopeName, required, rowToMembership, rowToScope } from "./sqlite-store-core.js"; +import type { ManagedScopeRow } from "./sqlite-store-types.js"; + +export class SqliteControlPlaneStore + extends SqliteControlPlaneAuthStore + implements ControlPlaneStore, ControlPlaneManagementStore, ControlPlaneAuditWriter +{ async setUserGlobalRole(params: { actorUserId: string; targetUserId: string; @@ -1083,262 +362,21 @@ export class SqliteControlPlaneStore implements ControlPlaneStore, ControlPlaneM .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 selectUserByAccountId(accountId: string): PlatformUserRow | undefined { - return takeFirstSync( - this.db, - this.query.selectFrom("platform_users").selectAll().where("account_id", "=", accountId), - ); - } - - 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, + ).rows.map((row) => { + const event: ControlAuditEvent = { + id: row.id, + eventType: row.event_type, + targetType: row.target_type, + targetId: row.target_id, + createdAt: row.created_at, + }; + if (row.actor_user_id) { + event.actorUserId = row.actor_user_id; + } + if (row.details_json) { + event.details = JSON.parse(row.details_json) as Record; + } + return event; }); } - - 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, - }), - ); - } }