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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions docs/platformclaw/control-plane-phase-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,26 @@ After Phase 1:

## Implementation status

Slices 1 and 2 are implemented in `packages/platformclaw-control-plane`. They
Slices 1 through 3 are implemented in `packages/platformclaw-control-plane`. They
define the identity, browser-session, personal-agent provisioning, Knox room
binding, authenticated Knox DM routing, and managed group/part contracts. The
in-memory store covers contract behavior, and the SQLite store persists the
approved schema version 1.
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 in-memory store covers contract behavior, and
the SQLite store persists the approved schema version 1.

Focused tests cover LDAP metadata refresh, LDAP-to-SAML identity linking,
employee ID correction conflicts, concurrent personal and room provisioning,
room agent ID compatibility, browser session limits and expiry, account
disablement, Knox DM ownership checks, restart persistence, administrator
bootstrap, managed group/part permissions, and audit events.

These slices do not contain credential encryption, LDAP or SAML network calls,
HTTP endpoints, Knox transport code, OpenClaw agent creation, or Gateway
The LDAP-compatible adapter calls the existing employee-auth HTTP service. The
HTTP boundary exposes login, logout, and current-session handlers, but still
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, OpenClaw agent creation, workspace file writes, or Gateway
mutations.

## Process boundary
Expand Down Expand Up @@ -86,6 +92,7 @@ Authentication adapters normalize provider-specific results into one shape:
type EnterprisePrincipal = {
provider: "ldap" | "saml";
subject: string;
accountId?: string;
employeeId: string;
displayName?: string;
email?: string;
Expand Down
26 changes: 24 additions & 2 deletions docs/platformclaw/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,33 @@ account ID supplied by deployment configuration backed by a Docker secret.
There is no source-code fallback administrator. After bootstrap, administrator
role changes use the control-plane management API and audit log.

### PC-112 Reject a fourth browser session

When a user already has three active browser sessions, a new login returns a
session-limit response and does not silently terminate another device. Expired
and revoked sessions do not count toward the limit. This policy needs no schema
migration if product policy later changes.

### PC-113 Normalize employee authentication outside OpenClaw core

`platformclaw-control` calls the current employee authentication service using
the URL in `PLATFORMCLAW_EMPLOYEE_AUTH_LOGIN_URL`. The optional service bearer
uses `PLATFORMCLAW_EMPLOYEE_AUTH_BEARER_TOKEN`. LDAP results normalize into the
provider-independent principal contract; SAML can replace the adapter later.
External `agentId` and `sessionKey` fields are ignored because the control-plane
binding is routing authority.

The directory profile passed to personal-agent provisioning includes employee
ID, display name, email, department, part, Confluence space, notes, directory
groups, and explicitly supplied extensible attributes. The session cookie never
contains those fields. Workspace `USER.md` rendering belongs to the provisioner,
not the authentication adapter.

## Open operational decisions

No remaining decision blocks the SQLite v1 store. Deployment work still needs
backup frequency and retention, behavior when a fourth browser session is
created, and any named workflow that requires browser control.
backup frequency and retention and any named workflow that requires browser
control.

## Decision procedure

Expand Down
110 changes: 110 additions & 0 deletions docs/platformclaw/employee-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
summary: "PlatformClaw employee authentication, browser session, and profile provisioning contract"
read_when:
- Integrating the current LDAP-compatible employee login service
- Implementing the PlatformClaw web BFF or USER.md provisioning
title: "PlatformClaw employee authentication"
---

# PlatformClaw employee authentication

The current login adapter preserves the deployed employee-auth service boundary
without copying enterprise authentication into OpenClaw core. The login service
URL is deployment configuration:

```text
PLATFORMCLAW_EMPLOYEE_AUTH_LOGIN_URL=http://127.0.0.1:18080/login
PLATFORMCLAW_EMPLOYEE_AUTH_BEARER_TOKEN=<optional secret>
```

The bearer belongs in the deployment secret store. Neither value belongs in
source control. There is no fallback to the legacy `OPENCLAW_*` variable names;
deployment explicitly moves to the PlatformClaw-owned names.

The production URL must use HTTPS because the request contains the employee
password and may contain the service bearer. Plain HTTP is accepted only for a
loopback mock (`localhost`, `127.0.0.0/8`, or `::1`).

## Password login contract

The adapter sends a JSON `POST` with `identifier`, `password`, and optional
request context (`clientIp`, `gatewayUrl`, and `userAgent`). The password exists
only for that request and is never stored by the control-plane.

An authenticated response requires:

```json
{
"authenticated": true,
"employeeId": "account.name"
}
```

The deployed response may additionally provide `accountId`, `subject`, `name`,
`displayName`, `email`, `department`, `part`, `confluenceSpace`, `notes` (or
legacy `note`), `groups`, and a nested `attributes` object containing string or
string-array values. Missing optional fields do not fail login.

During the LDAP phase, `accountId` falls back to `employeeId`, and `subject`
falls back to the normalized account ID. A future SAML adapter must provide a
stable `subject`; it may not use the LDAP fallback implicitly.

Legacy `agentId` and `sessionKey` response fields are deliberately ignored.
The personal agent binding derives `agentId` from the canonical account ID by
replacing `.` with `_`, and current OpenClaw routing owns the session key.

## Profile provisioning

Successful authentication produces two different objects:

- `EnterprisePrincipal`: stable identity plus mutable authorization metadata
used by the control-plane store.
- `EmployeeDirectoryProfile`: approved workspace bootstrap input, including
part, Confluence space, notes, groups, and explicit extensible attributes.

The personal-agent provisioner receives the profile after the binding is
reserved. It owns OpenClaw `agents.create`, workspace validation, and the
managed block in `USER.md`. Authentication code does not write workspaces.
Provisioning failure marks a new binding `failed` and does not issue a browser
session. A later login retries the failed binding idempotently.

## Browser BFF contract

The framework-neutral HTTP boundary currently handles:

- `POST /platformclaw/api/auth/login`
- `GET /platformclaw/api/auth/session`
- `POST /platformclaw/api/auth/logout`

The host process must inject bounded JSON parsing, a trusted client IP, TLS
state, an exact same-origin allowlist, and an authentication rate limiter.
Login accepts only `application/json`; login and logout reject disallowed
origins. Production TLS state must produce a
`Secure`, `HttpOnly`, `SameSite=Lax` cookie named `platformclaw_session`.

The cookie contains a random opaque token. Only its SHA-256 hash is stored in
SQLite. It contains no directory profile, agent ID, VM credential, or Gateway
operator credential. The server enforces the 12-hour idle timeout, seven-day
absolute timeout, three-session limit, logout revocation, and account-disable
revocation.

Schema v1 has not shipped as a tagged PlatformClaw release. Development
databases created before the employee-auth adapter introduced a distinct
`accountId` contract must be recreated; PC-109 deliberately provides no runtime
legacy reader or migration. A migration requires a separate approved schema
change after deployment begins.

## Legacy mock smoke

Until the mock is moved into this repository, start the legacy fixture from the
adjacent `platform-agent` checkout:

```powershell
python ..\platform-agent\scripts\mock_employee_auth.py --bind 127.0.0.1 --port 18080
$env:PLATFORMCLAW_EMPLOYEE_AUTH_LOGIN_URL = "http://127.0.0.1:18080/login"
```

That mock intentionally implements only a subset of the production profile.
Use an `--accounts-file` fixture when testing optional profile fields. Tests
must use fake employees and must never copy a production auth response into the
repository.
8 changes: 5 additions & 3 deletions docs/platformclaw/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ and Knox direct or group conversations:

- [Control plane phase 1](/platformclaw/control-plane-phase-1)
- [Architecture decisions](/platformclaw/decisions)
- [Employee authentication](/platformclaw/employee-auth)

The first two implementation slices are in the private
The first three implementation slices are in the private
`packages/platformclaw-control-plane` package. They contain contracts, an
in-memory store, and the approved SQLite v1 persistent store. Ingress processes
remain later work.
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.

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
Expand Down
166 changes: 166 additions & 0 deletions packages/platformclaw-control-plane/src/browser-auth-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, expect, it, vi } from "vitest";
import {
handlePlatformClawBrowserAuthRequest,
PLATFORMCLAW_LOGIN_PATH,
PLATFORMCLAW_SESSION_COOKIE,
} from "./browser-auth-http.js";
import { BrowserAuthService } from "./browser-auth-service.js";
import type { ControlPlaneIdFactory } from "./contracts.js";
import { InMemoryControlPlaneStore } from "./memory-store.js";

function ids(): ControlPlaneIdFactory {
let value = 0;
return {
nextUserId: () => `user-${++value}`,
nextBindingId: () => `binding-${++value}`,
nextSessionId: () => `session-${++value}`,
nextManagedScopeId: () => `scope-${++value}`,
nextAuditEventId: () => `audit-${++value}`,
};
}

function responseHarness() {
const headers = new Map<string, string | string[]>();
let body = "";
const res = {
statusCode: 200,
setHeader: (name: string, value: string | string[]) => headers.set(name, value),
getHeader: (name: string) => headers.get(name),
end: (value?: unknown) => {
body = String(value ?? "");
},
} as unknown as ServerResponse;
return { res, headers, body: () => body };
}

function createService() {
return new BrowserAuthService({
store: new InMemoryControlPlaneStore({
idFactory: ids(),
buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`,
}),
authenticator: {
async authenticatePassword() {
return {
status: "authenticated" as const,
principal: {
provider: "ldap" as const,
subject: "seungon.jung",
accountId: "seungon.jung",
employeeId: "seungon.jung",
displayName: "Seungon Jung",
department: "Platform",
},
profile: {
employeeId: "seungon.jung",
accountId: "seungon.jung",
subject: "seungon.jung",
displayName: "Seungon Jung",
department: "Platform",
groups: [],
attributes: {},
},
};
},
},
provisioner: { provisionOrRefresh: vi.fn(async () => undefined) },
now: () => 1_000,
tokenFactory: () => "test-token-factory",
});
}

function createRateLimiter() {
return {
check: () => ({ allowed: true, retryAfterMs: 0 }),
recordFailure: vi.fn(),
};
}

describe("PlatformClaw browser auth HTTP boundary", () => {
it("sets an HttpOnly secure cookie and never returns the opaque token in JSON", async () => {
const harness = responseHarness();
const handled = await handlePlatformClawBrowserAuthRequest(
{
url: PLATFORMCLAW_LOGIN_PATH,
method: "POST",
headers: { "content-type": "application/json", "user-agent": "test-browser" },
} as IncomingMessage,
harness.res,
{
service: createService(),
requestIsSecure: true,
isMutationOriginAllowed: () => true,
rateLimiter: createRateLimiter(),
readJsonBody: async () => ({
ok: true,
value: { identifier: "seungon.jung", password: "test-password" },
}),
},
);

expect(handled).toBe(true);
expect(harness.res.statusCode).toBe(200);
expect(harness.headers.get("Set-Cookie")).toContain(
`${PLATFORMCLAW_SESSION_COOKIE}=test-token-factory`,
);
expect(harness.headers.get("Set-Cookie")).toContain("HttpOnly");
expect(harness.headers.get("Set-Cookie")).toContain("Secure");
expect(harness.headers.get("Set-Cookie")).toContain("SameSite=Lax");
expect(harness.body()).not.toContain("test-token-factory");
expect(JSON.parse(harness.body())).toMatchObject({
authenticated: true,
user: { accountId: "seungon.jung" },
agent: { agentId: "seungon_jung", state: "active" },
});
});

it("rate limits before reading credentials or calling the auth service", async () => {
const readJsonBody = vi.fn();
const harness = responseHarness();
await handlePlatformClawBrowserAuthRequest(
{
url: PLATFORMCLAW_LOGIN_PATH,
method: "POST",
headers: { "content-type": "application/json" },
} as IncomingMessage,
harness.res,
{
service: createService(),
requestIsSecure: false,
isMutationOriginAllowed: () => true,
readJsonBody,
clientIp: "192.0.2.10",
rateLimiter: {
check: () => ({ allowed: false, retryAfterMs: 15_000 }),
recordFailure: vi.fn(),
},
},
);
expect(harness.res.statusCode).toBe(429);
expect(harness.headers.get("Retry-After")).toBe("15");
expect(readJsonBody).not.toHaveBeenCalled();
});

it("rejects a disallowed login origin before reading the password", async () => {
const readJsonBody = vi.fn();
const harness = responseHarness();
await handlePlatformClawBrowserAuthRequest(
{
url: PLATFORMCLAW_LOGIN_PATH,
method: "POST",
headers: { "content-type": "application/json", origin: "https://cross-site.test" },
} as IncomingMessage,
harness.res,
{
service: createService(),
requestIsSecure: true,
isMutationOriginAllowed: () => false,
rateLimiter: createRateLimiter(),
readJsonBody,
},
);
expect(harness.res.statusCode).toBe(403);
expect(readJsonBody).not.toHaveBeenCalled();
});
});
Loading
Loading