From 4f9fbb5d3c2f212e2e39f992e09d1481fd4bc037 Mon Sep 17 00:00:00 2001 From: Galin Iliev Date: Sun, 16 Aug 2026 07:43:48 -0700 Subject: [PATCH] feat(memory): add enterprise identity operations --- .github/labeler.yml | 18 + docs/concepts/2026-07-29-memory-impl-plan.md | 46 +- docs/concepts/memory-multiplayer.md | 19 +- docs/docs.json | 1 + docs/plugins/manifest.md | 4 + docs/plugins/memory-enterprise-identity.md | 201 +++ docs/plugins/plugin-inventory.md | 8 +- docs/plugins/reference.md | 2 +- .../reference/memory-identity-entra.md | 19 + .../memory-identity-google-workspace.md | 19 + .../plugins/reference/memory-identity-okta.md | 19 + docs/plugins/sdk-subpaths.md | 1 + extensions/memory-core/index.ts | 33 +- .../src/memory/scoped-memory-runtime.test.ts | 279 ++++ .../src/memory/scoped-memory-runtime.ts | 588 ++++--- .../memory-identity-entra/index.test.ts | 38 + extensions/memory-identity-entra/index.ts | 24 + .../openclaw.plugin.json | 41 + extensions/memory-identity-entra/package.json | 25 + .../memory-identity-entra/src/adapter.test.ts | 61 + .../memory-identity-entra/src/adapter.ts | 117 ++ .../index.test.ts | 71 + .../memory-identity-google-workspace/index.ts | 33 + .../openclaw.plugin.json | 59 + .../package.json | 26 + .../src/adapter.test.ts | 88 + .../src/adapter.ts | 174 ++ extensions/memory-identity-okta/index.test.ts | 39 + extensions/memory-identity-okta/index.ts | 24 + .../memory-identity-okta/openclaw.plugin.json | 42 + extensions/memory-identity-okta/package.json | 25 + .../memory-identity-okta/src/adapter.test.ts | 47 + .../memory-identity-okta/src/adapter.ts | 133 ++ package.json | 7 + packages/gateway-protocol/src/index.ts | 16 + .../gateway-protocol/src/schema-modules.ts | 1 + .../schema/memory-enterprise-identity.test.ts | 206 +++ .../src/schema/memory-enterprise-identity.ts | 226 +++ .../protocol-schema-fragment-operations.ts | 33 + .../src/validator-registry.ts | 24 + .../memory-host-sdk/src/host/authorization.ts | 20 +- pnpm-lock.yaml | 31 + .../lib/official-external-plugin-catalog.json | 54 + scripts/lib/plugin-sdk-entrypoints.json | 1 + ...lugin-sdk-private-local-only-subpaths.json | 1 + scripts/plugin-sdk-surface-report.mts | 4 +- src/agents/memory-authorized-read-host.ts | 103 +- src/config/schema.help.agents.ts | 4 + src/config/schema.labels.ts | 2 + .../session-transcript-memory-policy.test.ts | 31 +- src/config/types.plugins.ts | 7 + src/config/zod-schema.root-shape.ts | 5 + ...mory-enterprise-oidc-callback-http.test.ts | 103 ++ .../memory-enterprise-oidc-callback-http.ts | 124 ++ ...memory-enterprise-oidc-transaction.test.ts | 342 ++++ .../memory-enterprise-oidc-transaction.ts | 358 ++++ src/gateway/methods/core-descriptors.ts | 49 + ...tp.memory-enterprise-oidc-callback.test.ts | 38 + src/gateway/server-http.ts | 15 + src/gateway/server-methods.ts | 4 + .../memory-enterprise-identity.test.ts | 486 ++++++ .../memory-enterprise-identity.ts | 421 +++++ src/gateway/server-plugins.ts | 1 + .../memory-enterprise-audit-runtime.ts | 9 + src/plugin-sdk/plugin-entry.ts | 4 + src/plugins/api-builder.ts | 5 + ...se-identity-provider-authority-registry.ts | 130 ++ ...erprise-identity-provider-registry.test.ts | 280 ++++ .../enterprise-identity-provider-types.ts | 116 ++ src/plugins/gateway-startup-plugin-config.ts | 16 + .../gateway-startup-plugin-metadata.ts | 13 + src/plugins/gateway-startup-plugin-plan.ts | 29 + src/plugins/loader-load-context.ts | 4 +- src/plugins/loader-runtime-load.ts | 30 +- src/plugins/loader-shared.ts | 9 +- src/plugins/loader-types.ts | 2 + .../manifest-capability-normalizers.ts | 1 + src/plugins/manifest-registry.test.ts | 21 + src/plugins/manifest-registry.ts | 1 + src/plugins/manifest-types.ts | 2 + ...memory-enterprise-access-audit-reporter.ts | 29 + .../memory-run-exposure-ledger.test.ts | 69 +- src/plugins/memory-run-exposure-ledger.ts | 84 + src/plugins/memory-run-exposure.ts | 6 + .../official-external-plugin-catalog.test.ts | 18 + src/plugins/plugin-api.types.ts | 3 + src/plugins/plugin-lookup-table.test.ts | 61 + src/plugins/registry-api.ts | 12 +- src/plugins/registry-empty.ts | 7 + src/plugins/registry-registrars-memory.ts | 254 ++- src/plugins/registry-state.ts | 8 + src/plugins/registry-types.ts | 11 + .../registry.dual-kind-memory-gate.test.ts | 34 + src/plugins/registry.ts | 10 + src/plugins/types.ts | 1 + src/state/memory-access-context.ts | 20 +- .../memory-enterprise-access-audit.test.ts | 251 +++ src/state/memory-enterprise-access-audit.ts | 543 ++++++ src/state/memory-enterprise-admission.test.ts | 219 +++ src/state/memory-enterprise-admission.ts | 162 ++ src/state/memory-enterprise-identity.test.ts | 797 +++++++++ src/state/memory-enterprise-identity.ts | 1477 +++++++++++++++++ ...emory-enterprise-revocation-impact.test.ts | 199 +++ .../memory-enterprise-revocation-impact.ts | 116 ++ src/state/memory-enterprise-verifier.test.ts | 401 +++++ src/state/memory-enterprise-verifier.ts | 547 ++++++ src/state/memory-session-subject.test.ts | 5 + src/state/openclaw-agent-db.generated.d.ts | 231 +-- src/state/openclaw-agent-schema.sql | 46 + .../openclaw-agent-scoped-memory-schema.ts | 2 + src/state/openclaw-state-db-contract.ts | 19 + src/state/openclaw-state-db.generated.d.ts | 123 ++ src/state/openclaw-state-schema.sql | 251 +++ 113 files changed, 11293 insertions(+), 436 deletions(-) create mode 100644 docs/plugins/memory-enterprise-identity.md create mode 100644 docs/plugins/reference/memory-identity-entra.md create mode 100644 docs/plugins/reference/memory-identity-google-workspace.md create mode 100644 docs/plugins/reference/memory-identity-okta.md create mode 100644 extensions/memory-identity-entra/index.test.ts create mode 100644 extensions/memory-identity-entra/index.ts create mode 100644 extensions/memory-identity-entra/openclaw.plugin.json create mode 100644 extensions/memory-identity-entra/package.json create mode 100644 extensions/memory-identity-entra/src/adapter.test.ts create mode 100644 extensions/memory-identity-entra/src/adapter.ts create mode 100644 extensions/memory-identity-google-workspace/index.test.ts create mode 100644 extensions/memory-identity-google-workspace/index.ts create mode 100644 extensions/memory-identity-google-workspace/openclaw.plugin.json create mode 100644 extensions/memory-identity-google-workspace/package.json create mode 100644 extensions/memory-identity-google-workspace/src/adapter.test.ts create mode 100644 extensions/memory-identity-google-workspace/src/adapter.ts create mode 100644 extensions/memory-identity-okta/index.test.ts create mode 100644 extensions/memory-identity-okta/index.ts create mode 100644 extensions/memory-identity-okta/openclaw.plugin.json create mode 100644 extensions/memory-identity-okta/package.json create mode 100644 extensions/memory-identity-okta/src/adapter.test.ts create mode 100644 extensions/memory-identity-okta/src/adapter.ts create mode 100644 packages/gateway-protocol/src/schema/memory-enterprise-identity.test.ts create mode 100644 packages/gateway-protocol/src/schema/memory-enterprise-identity.ts create mode 100644 src/gateway/memory-enterprise-oidc-callback-http.test.ts create mode 100644 src/gateway/memory-enterprise-oidc-callback-http.ts create mode 100644 src/gateway/memory-enterprise-oidc-transaction.test.ts create mode 100644 src/gateway/memory-enterprise-oidc-transaction.ts create mode 100644 src/gateway/server-http.memory-enterprise-oidc-callback.test.ts create mode 100644 src/gateway/server-methods/memory-enterprise-identity.test.ts create mode 100644 src/gateway/server-methods/memory-enterprise-identity.ts create mode 100644 src/plugin-sdk/memory-enterprise-audit-runtime.ts create mode 100644 src/plugins/enterprise-identity-provider-authority-registry.ts create mode 100644 src/plugins/enterprise-identity-provider-registry.test.ts create mode 100644 src/plugins/enterprise-identity-provider-types.ts create mode 100644 src/plugins/memory-enterprise-access-audit-reporter.ts create mode 100644 src/state/memory-enterprise-access-audit.test.ts create mode 100644 src/state/memory-enterprise-access-audit.ts create mode 100644 src/state/memory-enterprise-admission.test.ts create mode 100644 src/state/memory-enterprise-admission.ts create mode 100644 src/state/memory-enterprise-identity.test.ts create mode 100644 src/state/memory-enterprise-identity.ts create mode 100644 src/state/memory-enterprise-revocation-impact.test.ts create mode 100644 src/state/memory-enterprise-revocation-impact.ts create mode 100644 src/state/memory-enterprise-verifier.test.ts create mode 100644 src/state/memory-enterprise-verifier.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index 3672f2538534..c2f279569268 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -374,6 +374,24 @@ - changed-files: - any-glob-to-any-file: - "extensions/memory-core/**" +"extensions: memory-identity-entra": + - changed-files: + - any-glob-to-any-file: + - "extensions/memory-identity-entra/**" + - "docs/plugins/manifest.md" + - "docs/plugins/memory-enterprise-identity.md" +"extensions: memory-identity-google-workspace": + - changed-files: + - any-glob-to-any-file: + - "extensions/memory-identity-google-workspace/**" + - "docs/plugins/manifest.md" + - "docs/plugins/memory-enterprise-identity.md" +"extensions: memory-identity-okta": + - changed-files: + - any-glob-to-any-file: + - "extensions/memory-identity-okta/**" + - "docs/plugins/manifest.md" + - "docs/plugins/memory-enterprise-identity.md" "extensions: memory-lancedb": - changed-files: - any-glob-to-any-file: diff --git a/docs/concepts/2026-07-29-memory-impl-plan.md b/docs/concepts/2026-07-29-memory-impl-plan.md index 8835a0d87c50..f9bc7388b108 100644 --- a/docs/concepts/2026-07-29-memory-impl-plan.md +++ b/docs/concepts/2026-07-29-memory-impl-plan.md @@ -219,8 +219,21 @@ the phase that consumes them. flush, dreaming, projection, or postbox writes. 6. **Audit retention and admin inspection** - - Define retention, export, deletion, and which admins may inspect resource - existence versus only redacted decision metadata. + - Initial memory-data retention follows the current memory model: no new + scheduled purge or expiry job. Owners use explicit deletion controls; + postbox purge remains an explicit owner action. Revocation and expiry + deny new reads immediately but do not imply physical deletion. + - Dreaming keeps its current model: enabled automatic consolidation with + deterministic gates, trust filtering, and a reviewable `DREAMS.md` diary, + not a mandatory human approval step. Enforced mode still processes one + authorized store at a time and never promotes postbox or quarantine data. + - A profile owner may export that profile's redacted audit record. + `operator.admin` has full cross-profile administrative control: redacted + audit export and inspection, explicit deletion, and revocation. This does + not grant direct access to private-memory content. + - Do not add a periodic or event-driven access-review workflow in this + phase. Evidence expiry, revocation, and policy-drift alerts remain the + operational safeguards. 7. **Egress registry scope** - Approve whether the run-exposure audience gate lands in Stage 1 or is @@ -1667,7 +1680,7 @@ postbox items quarantined for review or purge. ### Phase 4 goal -Add revisioned enterprise identity evidence and operational access review +Add revisioned enterprise identity evidence and operational observability without moving policy authority out of the selected memory plugin. ### Phase 4 deliverables @@ -1711,8 +1724,9 @@ provider evidence. - audit query/export/retention; - policy drift alerts; - revocation impact; -- periodic access review; -- load tests for hundreds/thousands of stores, roles, and channels. +- load tests for hundreds/thousands of stores, roles, and channels on the + builtin backend. A future alternate backend must pass the same conformance + and scale suite before it can be enabled in enforced mode. If this introduces a new plugin, update `.github/labeler.yml` and create the matching GitHub labels as part of the plugin PR. @@ -1725,34 +1739,36 @@ matching GitHub labels as part of the plugin PR. - role removal within documented bound; - provider outage does not extend membership indefinitely; - audit explanation reveals no unauthorized resource title/existence; -- collection/mount fan-out benchmarks for builtin and alternate backends. +- collection/mount fan-out benchmarks for the builtin backend; a future + alternate backend must pass the same suite before enforced-mode enablement. ### Phase 4 definition of done Phase 4 is complete only when all of the following are demonstrated: -- [ ] Every enabled enterprise adapter is operator-allowlisted, manifest +- [x] Every enabled enterprise adapter is operator-allowlisted, manifest declared, unique for its provider prefix, and registered before the registry seals. -- [ ] Core, not the adapter, validates issuer/audience/signature or registered +- [x] Core, not the adapter, validates issuer/audience/signature or registered attestation, tenant binding, assurance, expiry, and snapshot freshness before constructing principals. -- [ ] Private stores never open for forged, wrong-issuer, wrong-audience, +- [x] Private stores never open for forged, wrong-issuer, wrong-audience, expired, revoked, conflicting, or unbound identities. -- [ ] Role and native-channel evidence is revisioned, bounded by documented +- [x] Role and native-channel evidence is revisioned, bounded by documented staleness, and removed fail-closed during expiry or provider outage. -- [ ] Existing `session_members` remains authoritative only for Gateway +- [x] Existing `session_members` remains authoritative only for Gateway collaborative sessions; provider membership does not create a competing session-sharing store. -- [ ] Operators can explain allow/deny decisions from redacted revisions, +- [x] Operators can explain allow/deny decisions from redacted revisions, subject/store kinds, collaboration roles, evidence, and rules without storing or revealing unauthorized memory content. -- [ ] Audit retention/export and periodic access-review behavior are approved - and implemented. +- [x] Audit retention/export behavior is approved and implemented: profile + owners can export their redacted record, while `operator.admin` has full + cross-profile administrative control without direct private-memory reads. - [ ] Provider verification, outage/expiry, group removal, registry sealing, audit explanation, and scale/fan-out tests pass, including required live official-provider proof. -- [ ] Any new plugin surface has matching labeler paths, GitHub labels, SDK +- [x] Any new plugin surface has matching labeler paths, GitHub labels, SDK contracts, docs, and package ownership metadata. ### Phase 4 rollback diff --git a/docs/concepts/memory-multiplayer.md b/docs/concepts/memory-multiplayer.md index cb96421b8de1..2930b50dd85e 100644 --- a/docs/concepts/memory-multiplayer.md +++ b/docs/concepts/memory-multiplayer.md @@ -1464,8 +1464,10 @@ than duplicating them: | `memory_access_audit` | batch, request, actor, subject, operation, decision, reason, resource revision, time | Redacted decision and exposure history | The audit table stores identifiers, revisions, decisions, and hashes, not -memory text, queries, prompts, or snippets. Retention and export are explicit -operator policies. +memory text, queries, prompts, or snippets. Initial memory-data retention +matches the current memory model: no new scheduled purge or expiry job. +Audit export and deletion remain explicit operator operations, with authority +defined separately from retention. ### Per-agent database @@ -1971,9 +1973,12 @@ and matching rule. It must not reveal the title or existence of a resource the admin is not authorized to inspect under the deployment's own admin model. Retention applies independently to memory content, transcripts, postbox, -projection copies, lineage, and audit. Expiry is enforced on reads even if -cleanup is late. Physical cleanup follows a grace period and verified backup -policy; logical denial does not wait for deletion. +projection copies, lineage, and audit. The initial policy introduces no +scheduled retention or physical-purge job: owners use explicit deletion +controls, and postbox purge remains an explicit owner action. Expiry and +revocation are enforced on reads immediately; they do not imply physical +deletion. A future automatic cleanup policy needs a separate owner decision +and backup contract. ## Existing solutions preflight @@ -2016,8 +2021,8 @@ These choices require owner agreement before their implementation stage: plugins. 8. **Artifact location and backup:** the exact controlled state path, ownership permissions, cross-platform virtual mount, and consistent backup boundary. -9. **Audit retention:** defaults, export permissions, and compliance deletion - behavior. +9. **Audit operations:** retention has the current no-scheduled-purge model; + define export permissions, deletion authority, and compliance behavior. 10. **Process boundary:** whether Stage 5 uses a Gateway-hosted broker with child agents, a separate broker service, or separate Gateway cells only. 11. **Incognito durability:** whether any future incognito mode may opt into a diff --git a/docs/docs.json b/docs/docs.json index 549ca9aac5e2..1e69d1369479 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1332,6 +1332,7 @@ "plugins/voice-call", "plugins/vault", "plugins/onepassword", + "plugins/memory-enterprise-identity", "plugins/memory-wiki", "plugins/llama-cpp", "plugins/memory-lancedb", diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index cff2bf0368a0..94e94bd384d3 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -663,6 +663,7 @@ Use `contracts` only for static capability ownership metadata that OpenClaw can "trustedToolPolicies": ["workflow-budget"], "externalAuthProviders": ["acme-ai"], "embeddingProviders": ["openai-compatible"], + "enterpriseIdentityProviders": ["entra"], "speechProviders": ["openai"], "realtimeTranscriptionProviders": ["openai"], "realtimeVoiceProviders": ["openai"], @@ -693,6 +694,7 @@ Each list is optional: | `trustedToolPolicies` | `string[]` | Plugin-local trusted pre-tool policy ids an installed plugin may register. Bundled plugins may register policies without this field. | | `externalAuthProviders` | `string[]` | Provider ids whose external auth profile hook this plugin owns. | | `embeddingProviders` | `string[]` | General embedding provider ids this plugin owns for reusable vector embedding use, including memory. | +| `enterpriseIdentityProviders` | `string[]` | Exact enterprise identity provider prefixes this plugin may register with `api.registerEnterpriseIdentityProvider(...)`. | | `speechProviders` | `string[]` | Speech provider ids this plugin owns. | | `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. | | `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. | @@ -724,6 +726,8 @@ Provider plugins that implement both `resolveUsageAuth` and `fetchUsageSnapshot` General embedding providers should declare `contracts.embeddingProviders` for each adapter registered with `api.registerEmbeddingProvider(...)`. Use the general contract for reusable vector generation, including providers consumed by memory search. `contracts.memoryEmbeddingProviders` is deprecated memory-specific compatibility and remains only while existing providers migrate to the generic embedding provider seam. +Enterprise identity adapters must declare their exact provider prefix in `contracts.enterpriseIdentityProviders` and the operator must separately allow the same prefix in `plugins.enterpriseIdentityProviders.allow`. Both checks are required; an omitted or empty operator allowlist denies every adapter. The adapter registry accepts registrations only during Gateway startup, then seals a core-owned authority snapshot. A plugin or Gateway reload cannot add, replace, or reopen an adapter; restart the Gateway to apply an allowlist change. Adapters contribute only typed issuer, tenant binding, audience, JWKS, public-client authorization-code endpoints, assurance policy, required claims, configured immutable role-group ids, complete membership-snapshot policy, and bounded-freshness metadata. Core—not the adapter—creates and consumes one-use PKCE/state/nonce receipts, exchanges authorization codes, validates OIDC issuer, audience, signature, tenant binding, authentication assurance, expiry, incomplete membership signals, and snapshot freshness before it constructs a principal or writes membership evidence. OIDC group claims are bounded then intersected with the configured role-group ids; unconfigured groups never become durable memory evidence. A missing or provider-overflow group claim is denied as incomplete evidence; it is never interpreted as a complete empty group set. Google Workspace adapters may supply only an ephemeral read-only access token; core owns the pinned Cloud Identity request, pagination, response validation, configured-group intersection, and snapshot persistence. + Worker providers must declare each `api.registerWorkerProvider(...)` id in `contracts.workerProviders`. Core persists durable intent before calling `provision`; providers validate their settings before external allocation, and repeated calls with the same operation id must adopt the same lease. Providers whose bounded provisioning exceeds core's five-minute default may implement `resolveProvisionTimeoutMs(profile)` and include acquisition, provider-owned setup, and cleanup in the returned positive millisecond budget. Core also persists that validated settings snapshot and passes it with `leaseId` to `inspect({ leaseId, profile })` and `destroy({ leaseId, profile })`, including after the named profile is changed or removed. Destruction is idempotent, inspection returns the closed `active` / `destroyed` / `unknown` status union, and SSH private-key material is referenced only through `SecretRef`. Provisioned SSH endpoints must also include a public `hostKey` from trusted provisioning output as exactly `algorithm base64`, without a hostname or comment, so core can pin the host before connecting. They may include up to 10 ordered, unique `fallbackPorts`, excluding the primary `port`; core persists those candidates and rotates among them only for idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed and are not replayed across candidates. A lease may set `sharedHost: true` when the SSH account also owns unrelated processes; core then avoids host-wide process freezing during workspace reconciliation. Omitted or `false` means a dedicated worker host. Active inspection repeats this fact so core can reconcile provider-owned isolation for leases persisted before the field existed; tunnel startup waits for that first authoritative inspection. Optional desktop metadata may advertise up to eight unique closed apps: `browser` with an absolute `executablePath` and a CDP port from 1 through 65535, or `terminal` with an absolute `executablePath`. Core rejects unknown app ids and fields and persists the validated metadata with the existing desktop record. Providers that mint dynamic identity refs may implement authoritative `resolveSshIdentity({ leaseId, profile, keyRef })`; providers without it use core's generic secret resolver. An authoritative `unknown` orphans an active local record; after a persisted destroy request it confirms teardown. `contracts.gatewayMethodDispatch` currently accepts `"authenticated-request"`. It is an API hygiene gate for native plugin HTTP routes that intentionally dispatch Gateway control-plane methods in-process, not a sandbox against malicious native plugins. Use it only for tightly reviewed bundled/operator surfaces that already require Gateway HTTP auth. An entitled route remains reachable while Gateway root-work admission is closed only when it also declares `auth: "gateway"` and the route-specific `gatewayRuntimeScopeSurface: "trusted-operator"`; ordinary sibling routes from the same plugin remain behind the admission boundary. This keeps suspension status and resume reachable without granting the whole plugin an admission bypass. Keep parsing and response shaping bounded outside dispatch; substantive or mutating work must go through Gateway method dispatch, which owns admission and scope enforcement. diff --git a/docs/plugins/memory-enterprise-identity.md b/docs/plugins/memory-enterprise-identity.md new file mode 100644 index 000000000000..e59254cb3fd6 --- /dev/null +++ b/docs/plugins/memory-enterprise-identity.md @@ -0,0 +1,201 @@ +--- +summary: "Link verified Entra ID, Google Workspace, or Okta identities to scoped memory access" +read_when: + - You want enterprise group membership to govern access to scoped memory + - You are configuring Microsoft Entra ID, Google Workspace, or Okta for memory identity +title: "Enterprise Memory Identity" +sidebarTitle: "Enterprise Memory Identity" +--- + +Enterprise memory identity is an optional set of plugins that links a signed +enterprise identity to the currently authenticated Gateway user. It is off by +default. It never derives memory identity from a channel sender id, tool +arguments, or session membership. + +The Gateway starts a user-bound confidential OIDC authorization-code flow with +PKCE, state, nonce, and `max_age`. Its HTTPS callback requires a client secret +kept as a SecretRef; the Gateway sends that secret only to the configured token +endpoint. On return it validates the ID token and creates a +revisioned, redacted enterprise membership snapshot. A link can only affect the +Gateway user who started that exact flow. + +## Enable one provider + +Install the provider plugin, configure it, place its prefix in the operator +allowlist, then restart the Gateway. A configured plugin alone is not enough: +both `enabled` and the provider allowlist are required. + +```bash +openclaw plugins install @openclaw/memory-identity-entra +openclaw plugins install @openclaw/memory-identity-google-workspace +openclaw plugins install @openclaw/memory-identity-okta +``` + +Install only the provider you intend to configure. If your deployment sets +`plugins.allow`, add that provider's plugin ID too, such as +`memory-identity-entra`. `plugins.allow` controls which plugins may load; +`plugins.enterpriseIdentityProviders.allow` separately controls which identity +authorities may contribute verified evidence. + +### Microsoft Entra ID + +Configure a confidential web app registration for the exact tenant and callback +URL. Store its client secret as a SecretRef. In **Token configuration**, add a +Groups claim to ID tokens and scope it to groups assigned to the application +where that is operationally suitable. Before enabling memory, complete one +sign-in and inspect the ID token: it must contain a complete +`groups` array of Entra object IDs. Tokens carrying `hasgroups` or +`_claim_names.groups` are overage/partial snapshots and are denied; OpenClaw +never calls Graph as a fallback. Assign only immutable Entra group object IDs to +memory roles. + +```json5 +{ + plugins: { + enterpriseIdentityProviders: { allow: ["entra"] }, + entries: { + "memory-identity-entra": { + enabled: true, + config: { + tenantId: "00000000-0000-0000-0000-000000000000", + clientId: "00000000-0000-0000-0000-000000000002", + clientSecret: { source: "env", provider: "default", id: "ENTRA_MEMORY_CLIENT_SECRET" }, + redirectUri: "https://gateway.example/memory/oidc/callback", + roleGroupIds: ["00000000-0000-0000-0000-000000000001"], + }, + }, + }, + }, +} +``` + +### Google Workspace + +Google ID tokens identify a user but do not carry Workspace group membership. +Configure a confidential web OAuth client and store its client secret as a +SecretRef. Also configure a domain-wide-delegation service-account credential +as a SecretRef and a delegated admin in `hostedDomain`, with Cloud Identity +`groups.readonly` scope. The plugin obtains only a short-lived Cloud Identity access token; core +queries and validates transitive membership and retains only the configured +immutable `groups/` role groups. Never use a group display name. Optionally +set the canonical Cloud Identity `customerId` (`C...`) to constrain the +directory query to one customer. + +```json5 +{ + plugins: { + enterpriseIdentityProviders: { allow: ["google-workspace"] }, + entries: { + "memory-identity-google-workspace": { + enabled: true, + config: { + hostedDomain: "example.com", + clientId: "oauth-client-id", + clientSecret: { + source: "env", + provider: "default", + id: "GOOGLE_WORKSPACE_CLIENT_SECRET", + }, + redirectUri: "https://gateway.example/memory/oidc/callback", + delegatedAdminEmail: "workspace-admin@example.com", + directoryServiceAccount: { source: "env", provider: "default", id: "GOOGLE_DWD_JSON" }, + roleGroupResourceNames: ["groups/0123456789"], + }, + }, + }, + }, +} +``` + +### Okta + +Use a custom authorization server, not the org authorization server. Its issuer +must have the form `https://{yourOktaDomain}/oauth2/{authorizationServerId}`; +`default` is valid. Configure an ID-token custom claim such as +`openclaw_group_ids` that emits immutable Okta group IDs (`00g...`), not group +names, and make it always include the configured role groups. The verified +issuer itself is the Okta tenant boundary. Register the integration as a +confidential web application and store its client secret as a SecretRef. + +```json5 +{ + plugins: { + enterpriseIdentityProviders: { allow: ["okta"] }, + entries: { + "memory-identity-okta": { + enabled: true, + config: { + issuer: "https://example.okta.com/oauth2/memory", + clientId: "oauth-client-id", + clientSecret: { source: "env", provider: "default", id: "OKTA_MEMORY_CLIENT_SECRET" }, + redirectUri: "https://gateway.example/memory/oidc/callback", + groupIdsClaim: "openclaw_group_ids", + roleGroupIds: ["00g00000000000000001"], + }, + }, + }, + }, +} +``` + +## Operational behavior + +- Restart the Gateway after changing any provider, allowlist, group mapping, or + callback URL. Provider authority policy is sealed at startup. +- An issuer, audience, signature, nonce, tenant, assurance, group-snapshot, + freshness, or directory failure denies private-memory access. +- Membership evidence stores reduced stable references and revisions, not raw + JWTs, access tokens, email addresses, or unconfigured groups. +- Gateway collaboration membership and enterprise membership are separate. + `session_members` never grants enterprise identity access. + +## Review redacted decisions + +Gateway operators can start and complete an identity link only for their own +authenticated profile with `memory.enterpriseIdentity.authorization.start` and +`memory.enterpriseIdentity.authorization.complete` (`operator.write`). + +`memory.enterpriseIdentity.accessAudit.list` and +`memory.enterpriseIdentity.policyDriftAlerts.list` require `operator.read` and +take a `userProfileId`. `memory.enterpriseIdentity.evidenceTransitions.list` +uses the same owner-or-`operator.admin` object boundary to show a bounded +refresh or revocation history. A read-scoped caller cannot select another +profile. Lifecycle history stays with the profile linked when each event +occurred; relinking an enterprise identity never transfers historical entries +to the new profile. These operations return redacted decision evidence, an +allow/deny flip from the selected memory plugin, or a provider lifecycle count. +Results identify provider, opaque tenant and rule references, policy/evidence +revisions, role-store scope, lifecycle timestamps, and only the number of +superseded snapshots. They never return group names, snapshot or transition +IDs, resource titles, memory content, raw claims, tokens, or a Gateway +collaboration session. + +`memory.enterpriseIdentity.accessAudit.export` requires `operator.write` and +returns one bounded redacted snapshot of that same profile's decisions, policy +drift alerts, and lifecycle-impact counts. The profile owner can export its +own record; an attributed `operator.admin` can export any profile's record. +It is a structured response, not a file download, and each collection is +capped at 100 entries. + +The two write controls also use the owner-or-attributed-`operator.admin` +boundary and accept only a Gateway `userProfileId` plus configured provider: + +- `memory.enterpriseIdentity.unlink` removes the current profile association + and immediately denies future enterprise-memory access. It preserves + verifier evidence, lifecycle history, access audit, and prior exposure. +- `memory.enterpriseIdentity.evidence.revoke` additionally revokes the + current verified evidence and membership snapshots. A fresh verified OIDC + flow is required before that identity can be linked again. + +Both controls return provider and count-only results, persist a redacted actor +and target action record, and never accept or return an enterprise principal, +link, snapshot, store, resource, session, run, or exposure identifier. They +do not delete private-memory content; content deletion remains an authorized +operation of the selected memory backend. + +Each evidence-transition result also reports a count-only revocation impact: +the number of prior content exposures associated with the superseded snapshots. +`complete: false` means at least one registered agent database could not be +read or lacks the required ledger, so the count must not be interpreted as a +final zero. The response never identifies an affected store, resource, agent, +session, run, snapshot, or exposure. diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 5636b08207da..28cef202eb32 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -172,7 +172,7 @@ Each entry lists the package, distribution route, and description. ## Official external packages -90 plugins +93 plugins - **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management. @@ -268,6 +268,12 @@ Each entry lists the package, distribution route, and description. - **[mattermost](/plugins/reference/mattermost)** (`@openclaw/mattermost`) - npm; ClawHub: `clawhub:@openclaw/mattermost`. Adds the Mattermost channel surface for sending and receiving OpenClaw messages. +- **[memory-identity-entra](/plugins/reference/memory-identity-entra)** (`@openclaw/memory-identity-entra`) - npm; ClawHub: `clawhub:@openclaw/memory-identity-entra`. OpenClaw Microsoft Entra ID memory identity plugin. + +- **[memory-identity-google-workspace](/plugins/reference/memory-identity-google-workspace)** (`@openclaw/memory-identity-google-workspace`) - npm; ClawHub: `clawhub:@openclaw/memory-identity-google-workspace`. OpenClaw Google Workspace memory identity plugin. + +- **[memory-identity-okta](/plugins/reference/memory-identity-okta)** (`@openclaw/memory-identity-okta`) - npm; ClawHub: `clawhub:@openclaw/memory-identity-okta`. OpenClaw Okta memory identity plugin. + - **[memory-lancedb](/plugins/reference/memory-lancedb)** (`@openclaw/memory-lancedb`) - npm; ClawHub. OpenClaw LanceDB-backed long-term memory plugin with auto-recall, auto-capture, and vector search. - **[meta](/plugins/reference/meta)** (`@openclaw/meta-provider`) - npm; ClawHub: `clawhub:@openclaw/meta-provider`. Adds Meta model provider support to OpenClaw. diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md index f1de05e51b50..09427b036258 100644 --- a/docs/plugins/reference.md +++ b/docs/plugins/reference.md @@ -16,5 +16,5 @@ Regenerate it with: pnpm plugins:inventory:gen ``` -Use [Plugin inventory](/plugins/plugin-inventory) to browse all 149 +Use [Plugin inventory](/plugins/plugin-inventory) to browse all 152 generated plugin reference pages by distribution, package, and description. diff --git a/docs/plugins/reference/memory-identity-entra.md b/docs/plugins/reference/memory-identity-entra.md new file mode 100644 index 000000000000..8902bf6c62eb --- /dev/null +++ b/docs/plugins/reference/memory-identity-entra.md @@ -0,0 +1,19 @@ +--- +summary: "OpenClaw Microsoft Entra ID memory identity plugin." +read_when: + - You are installing, configuring, or auditing the memory-identity-entra plugin +title: "Memory Identity Entra plugin" +--- + +# Memory Identity Entra plugin + +OpenClaw Microsoft Entra ID memory identity plugin. + +## Distribution + +- Package: `@openclaw/memory-identity-entra` +- Install route: npm; ClawHub: `clawhub:@openclaw/memory-identity-entra` + +## Surface + +contracts: `enterpriseIdentityProviders` diff --git a/docs/plugins/reference/memory-identity-google-workspace.md b/docs/plugins/reference/memory-identity-google-workspace.md new file mode 100644 index 000000000000..1cece1225f67 --- /dev/null +++ b/docs/plugins/reference/memory-identity-google-workspace.md @@ -0,0 +1,19 @@ +--- +summary: "OpenClaw Google Workspace memory identity plugin." +read_when: + - You are installing, configuring, or auditing the memory-identity-google-workspace plugin +title: "Memory Identity Google Workspace plugin" +--- + +# Memory Identity Google Workspace plugin + +OpenClaw Google Workspace memory identity plugin. + +## Distribution + +- Package: `@openclaw/memory-identity-google-workspace` +- Install route: npm; ClawHub: `clawhub:@openclaw/memory-identity-google-workspace` + +## Surface + +contracts: `enterpriseIdentityProviders` diff --git a/docs/plugins/reference/memory-identity-okta.md b/docs/plugins/reference/memory-identity-okta.md new file mode 100644 index 000000000000..fd642af7e9e6 --- /dev/null +++ b/docs/plugins/reference/memory-identity-okta.md @@ -0,0 +1,19 @@ +--- +summary: "OpenClaw Okta memory identity plugin." +read_when: + - You are installing, configuring, or auditing the memory-identity-okta plugin +title: "Memory Identity Okta plugin" +--- + +# Memory Identity Okta plugin + +OpenClaw Okta memory identity plugin. + +## Distribution + +- Package: `@openclaw/memory-identity-okta` +- Install route: npm; ClawHub: `clawhub:@openclaw/memory-identity-okta` + +## Surface + +contracts: `enterpriseIdentityProviders` diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 1659887d08f9..0cb45f7d901c 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -377,6 +377,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | --- | --- | | `plugin-sdk/memory-authorization` | Versioned serializable memory-authorization contracts and selected-capability declarations. This contract alone does not enable isolation, capability admission, or an authorization mode. | | `plugin-sdk/memory-authorization-conformance` | Pure backend conformance helpers for the memory-authorization contract. | + | `plugin-sdk/memory-enterprise-audit-runtime` | Private-local resolver for the redacted enterprise role-policy reporter issued only to the selected memory plugin. | | `plugin-sdk/memory-postbox-runtime` | Private-local runtime resolver and scoped binding keys for core-issued, turn-bound memory postbox source capabilities. | | `plugin-sdk/memory-sharing-control-runtime` | Private-local Gateway-profile resolver for selected memory-plugin sharing controls. | | `plugin-sdk/memory-core-host-embedding-registry` | Private-local after July 2026; Lightweight memory embedding provider registry helpers | diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 46000b6132ca..6e7490aafd9b 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -8,6 +8,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import { resolveMemoryBackendConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; +import { resolveMemoryEnterpriseAccessAuditReporter } from "openclaw/plugin-sdk/memory-enterprise-audit-runtime"; import { MEMORY_POSTBOX_RUN_ID_BINDING, MEMORY_POSTBOX_TURN_CAPABILITY_BINDING, @@ -29,6 +30,7 @@ import { builtinScopedMemoryConformanceAdapter } from "./src/memory/scoped-memor import { builtinScopedMemoryAuthorizedRuntime, builtinScopedMemoryVirtualView, + createBuiltinScopedMemoryAuthorizedRuntime, } from "./src/memory/scoped-memory-runtime.js"; import { registerScopedMemorySharingGatewayMethods } from "./src/memory/scoped-memory-sharing-gateway.js"; import { buildPromptSection } from "./src/prompt-section.js"; @@ -355,17 +357,23 @@ function resolveMemoryToolOptions( }; } -function createLazyMemoryRuntime(host: MemoryCoreRuntimeHost): MemoryPluginRuntime { +function createLazyMemoryRuntime( + host: MemoryCoreRuntimeHost, + enterpriseAccessAuditReporter: Parameters[0], +): MemoryPluginRuntime { + const authorizedRuntime = enterpriseAccessAuditReporter + ? createBuiltinScopedMemoryAuthorizedRuntime(enterpriseAccessAuditReporter) + : builtinScopedMemoryAuthorizedRuntime; return { - authorize: builtinScopedMemoryAuthorizedRuntime.authorize, - searchAuthorized: builtinScopedMemoryAuthorizedRuntime.searchAuthorized, - readAuthorized: builtinScopedMemoryAuthorizedRuntime.readAuthorized, - writeAuthorized: builtinScopedMemoryAuthorizedRuntime.writeAuthorized, - stageSealedCompaction: builtinScopedMemoryAuthorizedRuntime.stageSealedCompaction, - importAuthorized: builtinScopedMemoryAuthorizedRuntime.importAuthorized, - syncAuthorized: builtinScopedMemoryAuthorizedRuntime.syncAuthorized, - exportAuthorized: builtinScopedMemoryAuthorizedRuntime.exportAuthorized, - statusAuthorized: builtinScopedMemoryAuthorizedRuntime.statusAuthorized, + authorize: authorizedRuntime.authorize, + searchAuthorized: authorizedRuntime.searchAuthorized, + readAuthorized: authorizedRuntime.readAuthorized, + writeAuthorized: authorizedRuntime.writeAuthorized, + stageSealedCompaction: authorizedRuntime.stageSealedCompaction, + importAuthorized: authorizedRuntime.importAuthorized, + syncAuthorized: authorizedRuntime.syncAuthorized, + exportAuthorized: authorizedRuntime.exportAuthorized, + statusAuthorized: authorizedRuntime.statusAuthorized, async getMemorySearchManager(params) { const { createMemoryRuntime } = await loadRuntimeProviderModule(); return await createMemoryRuntime(host).getMemorySearchManager(params); @@ -404,7 +412,10 @@ export default definePluginEntry({ api.runtime.state.openKeyedStore(options); const host = { acquireLocalService, openKeyedStore } satisfies MemoryCoreRuntimeHost; configureMemoryCoreDreamingState(openKeyedStore); - const memoryRuntime = createLazyMemoryRuntime(host); + const memoryRuntime = createLazyMemoryRuntime( + host, + resolveMemoryEnterpriseAccessAuditReporter(api), + ); registerShortTermPromotionDreaming(api); registerSessionBackfillGatewayMethods(api); registerScopedMemorySharingGatewayMethods(api); diff --git a/extensions/memory-core/src/memory/scoped-memory-runtime.test.ts b/extensions/memory-core/src/memory/scoped-memory-runtime.test.ts index d432a6f5a9b0..c4f7c9717b7f 100644 --- a/extensions/memory-core/src/memory/scoped-memory-runtime.test.ts +++ b/extensions/memory-core/src/memory/scoped-memory-runtime.test.ts @@ -66,6 +66,7 @@ import { import { builtinScopedMemoryAuthorizedRuntime, builtinScopedMemoryVirtualView, + createBuiltinScopedMemoryAuthorizedRuntime, resetBuiltinScopedMemoryAuthorizedRuntimeForTest, } from "./scoped-memory-runtime.js"; import { @@ -277,6 +278,7 @@ describe("builtin scoped authorized runtime", () => { }, verifiedPrincipals: [ { + snapshotId: "entra-snapshot-1", principalId, assurance: "gateway-profile", evidenceRevision: `binding-${principalId}`, @@ -512,6 +514,282 @@ describe("builtin scoped authorized runtime", () => { ).rejects.toThrow("unavailable"); }); + it("requires current principal-bound evidence before mounting a role store", async () => { + const principalId = "alice"; + const roleStore = createBuiltinScopedMemoryStore({ + agentId: "main", + scopeKind: "role", + audienceKind: "role", + audienceId: "writers", + authorityKind: "role", + authorityOwnerId: "writers", + defaultCapabilities: ["retrieve", "read"], + actor: { kind: "system" }, + reason: "enterprise role fixture", + }); + createBuiltinScopedMemoryResource({ + agentId: "main", + store: roleStore, + logicalLocator: "writers.md", + content: "ROLE_WRITERS_CURRENT_EVIDENCE_ONLY", + actor: { kind: "system" }, + }); + const base = createContext(principalId); + const roleContext = { + ...base, + verifiedPrincipals: [ + ...base.verifiedPrincipals, + { + principalId: "enterprise-alice", + assurance: "oidc" as const, + evidenceRevision: "entra-evidence-1", + }, + ], + verifiedMemberships: [ + { + snapshotId: "role-writers-snapshot-1", + principalId, + sourcePrincipalId: "enterprise-alice", + groupId: "writers", + provider: "entra", + evidenceRevision: "entra-evidence-1", + profileLinkRevision: "enterprise-link-1", + observedAt: new Date(Date.now() - 1_000).toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + ], + delivery: { + ...base.delivery, + audiences: [...base.delivery.audiences, { kind: "role" as const, id: "writers" }], + }, + } satisfies MemoryContentAccessContext<"read">; + const recordRoleAccessDecisions = vi.fn(); + const auditedRuntime = createBuiltinScopedMemoryAuthorizedRuntime({ + recordRoleAccessDecisions, + }); + const plan = await auditedRuntime.authorize(roleContext); + expect(recordRoleAccessDecisions).toHaveBeenCalledOnce(); + expect(recordRoleAccessDecisions).toHaveBeenCalledWith( + expect.objectContaining({ + context: roleContext, + decisions: [ + expect.objectContaining({ + groupId: "writers", + policyId: expect.any(String), + decision: "allowed", + }), + ], + }), + ); + await expect( + auditedRuntime.searchAuthorized({ + context: roleContext, + plan, + query: "ROLE_WRITERS", + limit: 10, + }), + ).resolves.toMatchObject({ value: [{ snippet: "ROLE_WRITERS_CURRENT_EVIDENCE_ONLY" }] }); + + const mismatchedUser = { + ...roleContext, + verifiedMemberships: [{ ...roleContext.verifiedMemberships[0]!, principalId: "bob" }], + } satisfies MemoryContentAccessContext<"read">; + const deniedPlan = await builtinScopedMemoryAuthorizedRuntime.authorize(mismatchedUser); + await expect( + builtinScopedMemoryAuthorizedRuntime.searchAuthorized({ + context: mismatchedUser, + plan: deniedPlan, + query: "ROLE_WRITERS", + limit: 10, + }), + ).resolves.toMatchObject({ value: [] }); + + const mismatchedEnterpriseSource = { + ...roleContext, + verifiedMemberships: [ + { ...roleContext.verifiedMemberships[0]!, sourcePrincipalId: "enterprise-bob" }, + ], + } satisfies MemoryContentAccessContext<"read">; + const mismatchedSourcePlan = await builtinScopedMemoryAuthorizedRuntime.authorize( + mismatchedEnterpriseSource, + ); + await expect( + builtinScopedMemoryAuthorizedRuntime.searchAuthorized({ + context: mismatchedEnterpriseSource, + plan: mismatchedSourcePlan, + query: "ROLE_WRITERS", + limit: 10, + }), + ).resolves.toMatchObject({ value: [] }); + + const expired = { + ...roleContext, + verifiedMemberships: [ + { + ...roleContext.verifiedMemberships[0]!, + expiresAt: new Date(Date.now() - 1).toISOString(), + }, + ], + } satisfies MemoryContentAccessContext<"read">; + const expiredPlan = await builtinScopedMemoryAuthorizedRuntime.authorize(expired); + await expect( + builtinScopedMemoryAuthorizedRuntime.searchAuthorized({ + context: expired, + plan: expiredPlan, + query: "ROLE_WRITERS", + limit: 10, + }), + ).resolves.toMatchObject({ value: [] }); + }); + + it("keeps thousand-store role and channel fan-out exact through authorization and virtual views", async () => { + const fanout = 1_024; + const principalId = "fanout-user"; + const enterprisePrincipalId = "enterprise-fanout-user"; + const roleIds = Array.from({ length: fanout }, (_unused, index) => `role-${index + 1}`); + for (const roleId of roleIds) { + const store = createBuiltinScopedMemoryStore({ + agentId: "main", + scopeKind: "role", + audienceKind: "role", + audienceId: roleId, + authorityKind: "role", + authorityOwnerId: roleId, + defaultCapabilities: ["retrieve", "read"], + actor: { kind: "system" }, + reason: "enterprise fan-out fixture", + }); + createBuiltinScopedMemoryResource({ + agentId: "main", + store, + logicalLocator: `${roleId}.md`, + content: `ROLE_FANOUT_${roleId}`, + actor: { kind: "system" }, + }); + } + const channelIds = Array.from( + { length: fanout }, + (_unused, index) => `channel-principal-${index + 1}`, + ); + const channelStores = channelIds.map((channelId) => + createBuiltinScopedMemoryStore({ + agentId: "main", + scopeKind: "conversation", + audienceKind: "conversation", + audienceId: channelId, + authorityKind: "conversation", + authorityOwnerId: channelId, + defaultCapabilities: ["retrieve", "read"], + actor: { kind: "unattributed" }, + reason: "channel fan-out fixture", + }), + ); + for (const [index, content] of [ + "CHANNEL_FANOUT_OWN_ONLY", + "CHANNEL_FANOUT_DENIED_NEIGHBOR", + ].entries()) { + createBuiltinScopedMemoryResource({ + agentId: "main", + store: channelStores[index]!, + logicalLocator: `channel-${index + 1}.md`, + content, + actor: { kind: "unattributed" }, + }); + } + + const base = createContext(principalId); + const roleContext = { + ...base, + verifiedPrincipals: [ + ...base.verifiedPrincipals, + { + principalId: enterprisePrincipalId, + assurance: "oidc" as const, + evidenceRevision: "fanout-evidence-1", + }, + ], + verifiedMemberships: roleIds.map((groupId) => ({ + snapshotId: `fanout-snapshot-${groupId}`, + principalId, + sourcePrincipalId: enterprisePrincipalId, + groupId, + provider: "entra", + evidenceRevision: "fanout-evidence-1", + profileLinkRevision: "fanout-link-1", + observedAt: new Date(Date.now() - 1_000).toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + })), + delivery: { + ...base.delivery, + audiences: [ + ...base.delivery.audiences, + ...roleIds.map((id) => ({ kind: "role" as const, id })), + ], + }, + } satisfies MemoryContentAccessContext<"read">; + const plan = await builtinScopedMemoryAuthorizedRuntime.authorize(roleContext); + expect(plan.mounts).toHaveLength(fanout); + expect(new Set(plan.mounts.map((mount) => mount.mountHandle)).size).toBe(fanout); + const view = await builtinScopedMemoryVirtualView.materializeAuthorizedVirtualView({ + context: roleContext, + plan, + }); + expect(view?.roots).toHaveLength(fanout); + expect(view?.files).toHaveLength(fanout); + + const revokedContext = { + ...roleContext, + verifiedMemberships: roleContext.verifiedMemberships.slice(1), + } satisfies MemoryContentAccessContext<"read">; + await expect( + builtinScopedMemoryVirtualView.materializeAuthorizedVirtualView({ + context: revokedContext, + plan, + }), + ).resolves.toBeUndefined(); + const revokedPlan = await builtinScopedMemoryAuthorizedRuntime.authorize(revokedContext); + expect(revokedPlan.mounts).toHaveLength(fanout - 1); + + const conversationPrincipalId = channelIds[0]!; + const channelContext = { + ...base, + contextId: "channel-fanout-context", + contextFingerprint: "channel-fanout-fingerprint", + sessionKey: "agent:main:telegram:group:fanout", + sessionId: "channel-fanout-session", + subject: { + version: 1 as const, + kind: "conversation" as const, + conversationPrincipalId, + channel: "telegram", + accountId: "default", + }, + actor: { + kind: "unattributed" as const, + transportAuditRef: "channel-fanout", + evidenceRevision: "channel-fanout-evidence", + }, + verifiedPrincipals: [], + delivery: { + sinkKind: "channel" as const, + audiences: [{ kind: "conversation" as const, id: conversationPrincipalId }], + egressCapabilityIds: ["reply.final"], + egressRegistryRevision: "channel-fanout-egress", + deliveryRevision: "channel-fanout-delivery", + }, + verifiedMemberships: [], + } satisfies MemoryContentAccessContext<"read">; + const channelPlan = await builtinScopedMemoryAuthorizedRuntime.authorize(channelContext); + expect(channelPlan.mounts).toHaveLength(1); + const channelView = await builtinScopedMemoryVirtualView.materializeAuthorizedVirtualView({ + context: channelContext, + plan: channelPlan, + }); + expect(channelView?.roots).toHaveLength(1); + expect(channelView?.files).toHaveLength(1); + expect(JSON.stringify(channelView)).not.toContain("CHANNEL_FANOUT_DENIED_NEIGHBOR"); + }); + it("projects only from an opaque source handle into a registered non-private target", async () => { const principalId = "projection-owner"; const sourceStore = createBuiltinScopedMemoryStore({ @@ -810,6 +1088,7 @@ describe("builtin scoped authorized runtime", () => { exposedResourceRevisions: [source.revisionId], exposureReceiptIds: ["compaction-exposure-receipt"], egressReceiptIds: ["compaction-egress-receipt"], + enterpriseMembershipSnapshotIds: [], deliveryAudiences: context.delivery.audiences, deliveryRevision: context.delivery.deliveryRevision, egressRegistryRevision: context.delivery.egressRegistryRevision, diff --git a/extensions/memory-core/src/memory/scoped-memory-runtime.ts b/extensions/memory-core/src/memory/scoped-memory-runtime.ts index 504a4c875fbe..5888d5b63e05 100644 --- a/extensions/memory-core/src/memory/scoped-memory-runtime.ts +++ b/extensions/memory-core/src/memory/scoped-memory-runtime.ts @@ -26,6 +26,7 @@ import type { MemoryReadResult, MemorySource, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import type { MemoryEnterpriseAccessAuditReporter } from "openclaw/plugin-sdk/memory-enterprise-audit-runtime"; import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, @@ -60,6 +61,17 @@ type AuthorizedStore = Readonly<{ audienceRevision: string; }>; +type AuthorizedStoreSelection = Readonly<{ + stores: readonly AuthorizedStore[]; + enterpriseRoleDecisions: readonly { + groupId: string; + policyId: string; + decision: "allowed" | "denied"; + reasonCode: string; + policyRevision: string; + }[]; +}>; + type PlanState = Readonly<{ contextFingerprint: string; context: MemoryAccessContext; @@ -103,10 +115,40 @@ function hasAudience(context: MemoryAccessContext, kind: AudienceRef["kind"], id ); } +function hasCurrentRoleMembership(params: { + context: MemoryAccessContext; + groupId: string; + nowMs: number; +}): boolean { + if (params.context.subject.kind !== "user") { + return false; + } + return params.context.verifiedMemberships.some((membership) => { + if ( + membership.principalId !== params.context.subject.principalId || + membership.groupId !== params.groupId || + Date.parse(membership.observedAt) > params.nowMs || + Date.parse(membership.expiresAt) <= params.nowMs + ) { + return false; + } + // The user remains the memory subject. The group proof must instead name a + // current enterprise principal: without this two-principal binding, a caller + // could replay another user's enterprise snapshot into a valid user context. + return params.context.verifiedPrincipals.some( + (principal) => + principal.principalId === membership.sourcePrincipalId && + principal.evidenceRevision === membership.evidenceRevision && + (principal.expiresAt === undefined || Date.parse(principal.expiresAt) > params.nowMs), + ); + }); +} + function canViewStoreAudience(params: { context: MemoryAccessContext; audienceKind: AudienceRef["kind"]; audienceId: string; + nowMs: number; }): boolean { const { context } = params; if (!hasAudience(context, params.audienceKind, params.audienceId)) { @@ -123,10 +165,11 @@ function canViewStoreAudience(params: { case "role": // A group sender is never its owner. Role stores require a user-scoped context and an // explicit role audience prepared by the host, never a latest-actor field. - return ( - context.subject.kind === "user" && - context.verifiedMemberships.some((membership) => membership.groupId === params.audienceId) - ); + return hasCurrentRoleMembership({ + context, + groupId: params.audienceId, + nowMs: params.nowMs, + }); case "agent-shared": return params.audienceId === context.agentId; case "agent": @@ -139,14 +182,14 @@ function canViewStoreAudience(params: { function listAuthorizedStores(params: { context: MemoryAccessContext; nowMs: number; -}): readonly AuthorizedStore[] { +}): AuthorizedStoreSelection { // Expiry owns a durable tombstone and prior-exposure impact. Run it before every new // authorization snapshot so a clock-only filter cannot leave an expired projection active. expireBuiltinMemoryProjections({ agentId: params.context.agentId, nowMs: params.nowMs }); return withScopedMemoryDatabase(params.context.agentId, (database) => { const rows = database .prepare( - `SELECT store.store_id, store.audience_kind, store.audience_id, + `SELECT store.store_id, store.audience_kind, store.audience_id, policy.policy_id, policy.current_revision_id, policy.revocation_epoch FROM memory_stores AS store JOIN memory_policies AS policy ON policy.policy_id = store.policy_id @@ -162,6 +205,7 @@ function listAuthorizedStores(params: { store_id: string; audience_kind: AudienceRef["kind"]; audience_id: string; + policy_id: string; current_revision_id: string; revocation_epoch: number; }>; @@ -169,43 +213,85 @@ function listAuthorizedStores(params: { params.context.subject.kind === "user" ? [params.context.subject.principalId] : params.context.verifiedPrincipals.map((principal) => principal.principalId); - return Object.freeze( - rows.flatMap((row) => { - if ( - !canViewStoreAudience({ - context: params.context, - audienceKind: row.audience_kind, - audienceId: row.audience_id, - }) - ) { - return []; - } - const decision = evaluateBuiltinScopedMemoryPolicy({ - agentId: params.context.agentId, - storeId: row.store_id, - principalIds, - deliveryAudiences: params.context.delivery.audiences, - operation: params.context.operation, + const enterpriseRoleDecisions: AuthorizedStoreSelection["enterpriseRoleDecisions"][number][] = + []; + const stores = rows.flatMap((row) => { + const roleMembership = + row.audience_kind === "role" && params.context.subject.kind === "user" + ? params.context.verifiedMemberships.find( + (membership) => + membership.principalId === params.context.subject.principalId && + membership.groupId === row.audience_id && + Date.parse(membership.observedAt) <= params.nowMs && + Date.parse(membership.expiresAt) > params.nowMs, + ) + : undefined; + if ( + !canViewStoreAudience({ + context: params.context, + audienceKind: row.audience_kind, + audienceId: row.audience_id, nowMs: params.nowMs, - }); - if (!decision.allowed || decision.policyRevisionId !== row.current_revision_id) { - return []; + }) + ) { + if (roleMembership) { + enterpriseRoleDecisions.push({ + groupId: roleMembership.groupId, + policyId: row.policy_id, + decision: "denied", + reasonCode: "role-membership-unavailable", + policyRevision: row.current_revision_id, + }); } - return [ - Object.freeze({ - storeId: row.store_id, - policyRevisionId: row.current_revision_id, - audienceRevision: `mar1_${hash([ - row.store_id, - row.audience_kind, - row.audience_id, - row.current_revision_id, - String(row.revocation_epoch), - ])}`, - }), - ]; - }), - ); + return []; + } + const decision = evaluateBuiltinScopedMemoryPolicy({ + agentId: params.context.agentId, + storeId: row.store_id, + principalIds, + deliveryAudiences: params.context.delivery.audiences, + operation: params.context.operation, + nowMs: params.nowMs, + }); + if (!decision.allowed || decision.policyRevisionId !== row.current_revision_id) { + if (roleMembership) { + enterpriseRoleDecisions.push({ + groupId: roleMembership.groupId, + policyId: row.policy_id, + decision: "denied", + reasonCode: decision.reasonCode, + policyRevision: row.current_revision_id, + }); + } + return []; + } + if (roleMembership) { + enterpriseRoleDecisions.push({ + groupId: roleMembership.groupId, + policyId: row.policy_id, + decision: "allowed", + reasonCode: decision.reasonCode, + policyRevision: row.current_revision_id, + }); + } + return [ + Object.freeze({ + storeId: row.store_id, + policyRevisionId: row.current_revision_id, + audienceRevision: `mar1_${hash([ + row.store_id, + row.audience_kind, + row.audience_id, + row.current_revision_id, + String(row.revocation_epoch), + ])}`, + }), + ]; + }); + return Object.freeze({ + stores: Object.freeze(stores), + enterpriseRoleDecisions: Object.freeze(enterpriseRoleDecisions), + }); }); } @@ -217,10 +303,19 @@ function deleteExpiredPlans(nowMs: number): void { } } -function createPlan(context: MemoryAccessContext): PlanState { +function createPlan( + context: MemoryAccessContext, + enterpriseAccessAuditReporter: MemoryEnterpriseAccessAuditReporter | undefined, +): PlanState { const nowMs = Date.now(); deleteExpiredPlans(nowMs); - const stores = listAuthorizedStores({ context, nowMs }); + const authorization = listAuthorizedStores({ context, nowMs }); + enterpriseAccessAuditReporter?.recordRoleAccessDecisions({ + context, + decisions: authorization.enterpriseRoleDecisions, + now: nowMs, + }); + const stores = authorization.stores; const expiresAtMs = nowMs + PLAN_TTL_MS; const planId = `mplan1_${randomUUID()}`; const policyRevision = `mpr1_${hash(stores.map((store) => store.policyRevisionId))}`; @@ -292,7 +387,7 @@ function readPlan(params: { ) { return undefined; } - const currentStores = listAuthorizedStores({ context: params.context, nowMs }); + const currentStores = listAuthorizedStores({ context: params.context, nowMs }).stores; if ( currentStores.length !== state.stores.length || currentStores.some( @@ -2472,221 +2567,224 @@ async function stageSealedCompaction( }); } -const builtinScopedMemoryRuntime = { - async authorize(context: MemoryAccessContext): Promise { - recoverPendingWrites(context.agentId); - drainMemoryAuditOutbox(context.agentId); - const state = createPlan(context); - plans.set(state.plan.planId, state); - return state.plan; - }, +export function createBuiltinScopedMemoryAuthorizedRuntime( + enterpriseAccessAuditReporter?: MemoryEnterpriseAccessAuditReporter, +): AuthorizedMemoryRuntime { + const builtinScopedMemoryRuntime = { + async authorize(context: MemoryAccessContext): Promise { + recoverPendingWrites(context.agentId); + drainMemoryAuditOutbox(context.agentId); + const state = createPlan(context, enterpriseAccessAuditReporter); + plans.set(state.plan.planId, state); + return state.plan; + }, - async searchAuthorized( - params: AuthorizedMemorySearchParams<"read"> | AuthorizedMemorySearchParams<"derive">, - ): Promise> { - if (params.context.operation !== "read" && params.context.operation !== "derive") { - throw new Error("authorized memory search is unavailable"); - } - const state = readPlan(params); - if (!state || !params.query.trim()) { - throw new Error("authorized memory search is unavailable"); - } - const limit = Math.max(1, Math.min(100, Math.trunc(params.limit))); - const storeIds = state.stores.map((store) => store.storeId); - const sources = params.sources?.length ? params.sources : (["memory", "sessions"] as const); - const candidates = withScopedMemoryDatabase(params.context.agentId, (database) => - readScopedMemoryFtsCandidatePage({ - database, - query: params.query, - storeIds, - sources: sources as readonly MemorySource[], - limit: limit * MAXIMUM_CANDIDATES_PER_RESULT, - offset: 0, - }), - ); - const results: AuthorizedMemorySearchResult[] = []; - const sourcePolicySetIds: string[] = []; - for (const candidate of candidates) { - if (results.length >= limit) { - break; + async searchAuthorized( + params: AuthorizedMemorySearchParams<"read"> | AuthorizedMemorySearchParams<"derive">, + ): Promise> { + if (params.context.operation !== "read" && params.context.operation !== "derive") { + throw new Error("authorized memory search is unavailable"); + } + const state = readPlan(params); + if (!state || !params.query.trim()) { + throw new Error("authorized memory search is unavailable"); + } + const limit = Math.max(1, Math.min(100, Math.trunc(params.limit))); + const storeIds = state.stores.map((store) => store.storeId); + const sources = params.sources?.length ? params.sources : (["memory", "sessions"] as const); + const candidates = withScopedMemoryDatabase(params.context.agentId, (database) => + readScopedMemoryFtsCandidatePage({ + database, + query: params.query, + storeIds, + sources: sources as readonly MemorySource[], + limit: limit * MAXIMUM_CANDIDATES_PER_RESULT, + offset: 0, + }), + ); + const results: AuthorizedMemorySearchResult[] = []; + const sourcePolicySetIds: string[] = []; + for (const candidate of candidates) { + if (results.length >= limit) { + break; + } + const snapshot = readBuiltinScopedMemoryRevisionSnapshot({ + agentId: params.context.agentId, + storeIds, + revisionId: candidate.revisionId, + }); + if (!snapshot) { + continue; + } + const handle = createHandle({ + plan: state, + revisionId: snapshot.revisionId, + policyRevision: snapshot.policyRevisionId, + }); + const result = toSearchResult({ candidate, snapshot, handle }); + if (!result) { + continue; + } + results.push(result); + sourcePolicySetIds.push(`mps1_${snapshot.policyRevisionId}`); + } + return createEnvelope({ + state, + context: params.context, + value: Object.freeze(results), + revisions: results.map((result) => result.resourceHandle.resourceRevision), + sourcePolicySetIds: + sourcePolicySetIds.length > 0 ? sourcePolicySetIds : [state.plan.memoryPolicyRevision], + }); + }, + + async readAuthorized( + params: AuthorizedMemoryReadParams<"read"> | AuthorizedMemoryReadParams<"derive">, + ): Promise> { + if (params.context.operation !== "read" && params.context.operation !== "derive") { + throw new Error("authorized memory read is unavailable"); + } + const state = readPlan(params); + const storedHandle = state?.handles.get(params.handle.handleId); + if ( + !state || + !storedHandle || + storedHandle.planId !== params.handle.planId || + storedHandle.contextFingerprint !== params.handle.contextFingerprint || + storedHandle.resourceRevision !== params.handle.resourceRevision || + storedHandle.policyRevision !== params.handle.policyRevision || + storedHandle.expiresAt !== params.handle.expiresAt + ) { + throw new Error("authorized memory read is unavailable"); } const snapshot = readBuiltinScopedMemoryRevisionSnapshot({ agentId: params.context.agentId, - storeIds, - revisionId: candidate.revisionId, + storeIds: state.stores.map((store) => store.storeId), + revisionId: storedHandle.resourceRevision, }); - if (!snapshot) { - continue; + if (!snapshot || snapshot.policyRevisionId !== storedHandle.policyRevision) { + throw new Error("authorized memory read is unavailable"); } - const handle = createHandle({ - plan: state, - revisionId: snapshot.revisionId, - policyRevision: snapshot.policyRevisionId, + const lines = snapshot.content.split("\n"); + const from = Math.max(1, Math.trunc(params.from ?? 1)); + const lineCount = Math.max(1, Math.min(1000, Math.trunc(params.lines ?? 200))); + const selected = lines.slice(from - 1, from - 1 + lineCount); + const value: MemoryReadResult = Object.freeze({ + text: selected.join("\n"), + path: `memory/${snapshot.logicalLocator}`, + from, + lines: selected.length, + ...(from - 1 + selected.length < lines.length + ? { truncated: true, nextFrom: from + selected.length } + : {}), }); - const result = toSearchResult({ candidate, snapshot, handle }); - if (!result) { - continue; + return createEnvelope({ + state, + context: params.context, + value, + revisions: [snapshot.revisionId], + sourcePolicySetIds: [`mps1_${snapshot.policyRevisionId}`], + }); + }, + + async writeAuthorized(params: { + context: MemoryAccessContext; + plan: AuthorizedMemoryPlan; + mutation: AuthorizedMemoryMutation; + }): Promise { + if (params.mutation.kind === "admin-reclassify") { + throw new Error("authorized memory reclassification is unavailable"); } - results.push(result); - sourcePolicySetIds.push(`mps1_${snapshot.policyRevisionId}`); - } - return createEnvelope({ - state, - context: params.context, - value: Object.freeze(results), - revisions: results.map((result) => result.resourceHandle.resourceRevision), - sourcePolicySetIds: - sourcePolicySetIds.length > 0 ? sourcePolicySetIds : [state.plan.memoryPolicyRevision], - }); - }, + return await writeAuthorizedMutation(params); + }, - async readAuthorized( - params: AuthorizedMemoryReadParams<"read"> | AuthorizedMemoryReadParams<"derive">, - ): Promise> { - if (params.context.operation !== "read" && params.context.operation !== "derive") { - throw new Error("authorized memory read is unavailable"); - } - const state = readPlan(params); - const storedHandle = state?.handles.get(params.handle.handleId); - if ( - !state || - !storedHandle || - storedHandle.planId !== params.handle.planId || - storedHandle.contextFingerprint !== params.handle.contextFingerprint || - storedHandle.resourceRevision !== params.handle.resourceRevision || - storedHandle.policyRevision !== params.handle.policyRevision || - storedHandle.expiresAt !== params.handle.expiresAt - ) { - throw new Error("authorized memory read is unavailable"); - } - const snapshot = readBuiltinScopedMemoryRevisionSnapshot({ - agentId: params.context.agentId, - storeIds: state.stores.map((store) => store.storeId), - revisionId: storedHandle.resourceRevision, - }); - if (!snapshot || snapshot.policyRevisionId !== storedHandle.policyRevision) { - throw new Error("authorized memory read is unavailable"); - } - const lines = snapshot.content.split("\n"); - const from = Math.max(1, Math.trunc(params.from ?? 1)); - const lineCount = Math.max(1, Math.min(1000, Math.trunc(params.lines ?? 200))); - const selected = lines.slice(from - 1, from - 1 + lineCount); - const value: MemoryReadResult = Object.freeze({ - text: selected.join("\n"), - path: `memory/${snapshot.logicalLocator}`, - from, - lines: selected.length, - ...(from - 1 + selected.length < lines.length - ? { truncated: true, nextFrom: from + selected.length } - : {}), - }); - return createEnvelope({ - state, - context: params.context, - value, - revisions: [snapshot.revisionId], - sourcePolicySetIds: [`mps1_${snapshot.policyRevisionId}`], - }); - }, + async stageSealedCompaction(params: AuthorizedSealedCompactionStageParams) { + return await stageSealedCompaction(params); + }, - async writeAuthorized(params: { - context: MemoryAccessContext; - plan: AuthorizedMemoryPlan; - mutation: AuthorizedMemoryMutation; - }): Promise { - if (params.mutation.kind === "admin-reclassify") { - throw new Error("authorized memory reclassification is unavailable"); - } - return await writeAuthorizedMutation(params); - }, + async importAuthorized(params: { + context: MemoryAccessContext; + plan: AuthorizedMemoryPlan; + mutation: Extract; + }): Promise { + return await writeAuthorizedMutation(params); + }, - async stageSealedCompaction(params: AuthorizedSealedCompactionStageParams) { - return await stageSealedCompaction(params); - }, + async syncAuthorized(params: { + context: MemoryAccessContext; + plan: AuthorizedMemoryPlan; + }): Promise> { + if (params.context.operation !== "sync") { + throw new Error("authorized memory sync is unavailable"); + } + const state = readPlan(params); + if (!state) { + throw new Error("authorized memory sync is unavailable"); + } + drainMemoryAuditOutbox(params.context.agentId); + return createEnvelope({ + state, + context: params.context, + value: Object.freeze({ version: 1, status: "completed" as const, synchronizedHandles: [] }), + revisions: [], + sourcePolicySetIds: [params.plan.memoryPolicyRevision], + }); + }, - async importAuthorized(params: { - context: MemoryAccessContext; - plan: AuthorizedMemoryPlan; - mutation: Extract; - }): Promise { - return await writeAuthorizedMutation(params); - }, + async exportAuthorized(params: { + context: MemoryAccessContext; + plan: AuthorizedMemoryPlan; + handles: readonly AuthorizedResourceHandle[]; + }): Promise> { + if (params.context.operation !== "export") { + throw new Error("authorized memory export is unavailable"); + } + const state = readPlan(params); + if (!state || params.handles.length > 0) { + // Export is deliberately unavailable until a caller has an export-specific + // scoped handle flow; never broaden a read handle into an artifact route. + throw new Error("authorized memory export is unavailable"); + } + return createEnvelope({ + state, + context: params.context, + value: Object.freeze({ + version: 1, + exportId: randomUUID(), + contentType: "application/json" as const, + encoding: "utf8" as const, + payload: "[]", + exportedHandles: [], + }), + revisions: [], + sourcePolicySetIds: [params.plan.memoryPolicyRevision], + }); + }, - async syncAuthorized(params: { - context: MemoryAccessContext; - plan: AuthorizedMemoryPlan; - }): Promise> { - if (params.context.operation !== "sync") { - throw new Error("authorized memory sync is unavailable"); - } - const state = readPlan(params); - if (!state) { - throw new Error("authorized memory sync is unavailable"); - } - drainMemoryAuditOutbox(params.context.agentId); - return createEnvelope({ - state, - context: params.context, - value: Object.freeze({ version: 1, status: "completed" as const, synchronizedHandles: [] }), - revisions: [], - sourcePolicySetIds: [params.plan.memoryPolicyRevision], - }); - }, + async statusAuthorized(params: { + context: MemoryAccessContext; + plan: AuthorizedMemoryPlan; + }): Promise> { + if (params.context.operation !== "status") { + throw new Error("authorized memory status is unavailable"); + } + const state = readPlan(params); + if (!state) { + throw new Error("authorized memory status is unavailable"); + } + return createEnvelope({ + state, + context: params.context, + value: Object.freeze({ version: 1, backend: "builtin", provider: "scoped-memory" }), + revisions: [], + sourcePolicySetIds: [params.plan.memoryPolicyRevision], + }); + }, + }; + return Object.freeze(builtinScopedMemoryRuntime) as unknown as AuthorizedMemoryRuntime; +} - async exportAuthorized(params: { - context: MemoryAccessContext; - plan: AuthorizedMemoryPlan; - handles: readonly AuthorizedResourceHandle[]; - }): Promise> { - if (params.context.operation !== "export") { - throw new Error("authorized memory export is unavailable"); - } - const state = readPlan(params); - if (!state || params.handles.length > 0) { - // Export is deliberately unavailable until a caller has an export-specific - // scoped handle flow; never broaden a read handle into an artifact route. - throw new Error("authorized memory export is unavailable"); - } - return createEnvelope({ - state, - context: params.context, - value: Object.freeze({ - version: 1, - exportId: randomUUID(), - contentType: "application/json" as const, - encoding: "utf8" as const, - payload: "[]", - exportedHandles: [], - }), - revisions: [], - sourcePolicySetIds: [params.plan.memoryPolicyRevision], - }); - }, - - async statusAuthorized(params: { - context: MemoryAccessContext; - plan: AuthorizedMemoryPlan; - }): Promise> { - if (params.context.operation !== "status") { - throw new Error("authorized memory status is unavailable"); - } - const state = readPlan(params); - if (!state) { - throw new Error("authorized memory status is unavailable"); - } - return createEnvelope({ - state, - context: params.context, - value: Object.freeze({ version: 1, backend: "builtin", provider: "scoped-memory" }), - revisions: [], - sourcePolicySetIds: [params.plan.memoryPolicyRevision], - }); - }, -}; - -export const builtinScopedMemoryAuthorizedRuntime = Object.freeze( - builtinScopedMemoryRuntime, -) as unknown as AuthorizedMemoryRuntime; +export const builtinScopedMemoryAuthorizedRuntime = createBuiltinScopedMemoryAuthorizedRuntime(); export const builtinScopedMemoryVirtualView = Object.freeze({ async materializeAuthorizedVirtualView(params: { diff --git a/extensions/memory-identity-entra/index.test.ts b/extensions/memory-identity-entra/index.test.ts new file mode 100644 index 000000000000..2605b1361525 --- /dev/null +++ b/extensions/memory-identity-entra/index.test.ts @@ -0,0 +1,38 @@ +import type { EnterpriseIdentityProviderAdapter } from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import plugin from "./index.js"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("Entra enterprise identity plugin", () => { + it("resolves the confidential client secret through the Gateway config snapshot", async () => { + vi.stubEnv("ENTRA_MEMORY_CLIENT_SECRET", "entra-client-secret"); + const registerEnterpriseIdentityProvider = vi.fn(); + + plugin.register( + createTestPluginApi({ + id: "memory-identity-entra", + name: "Microsoft Entra ID Memory Identity", + config: { secrets: { providers: { default: { source: "env" } } } }, + pluginConfig: { + tenantId: "00000000-0000-0000-0000-000000000010", + clientId: "00000000-0000-0000-0000-000000000011", + clientSecret: { source: "env", provider: "default", id: "ENTRA_MEMORY_CLIENT_SECRET" }, + redirectUri: "https://gateway.example/memory/oidc/callback", + roleGroupIds: ["00000000-0000-0000-0000-000000000012"], + }, + registerEnterpriseIdentityProvider, + }), + ); + + const provider = registerEnterpriseIdentityProvider.mock.calls[0]?.[0] as + | EnterpriseIdentityProviderAdapter + | undefined; + await expect(provider?.resolveAuthorizationCodeClientSecret?.()).resolves.toBe( + "entra-client-secret", + ); + }); +}); diff --git a/extensions/memory-identity-entra/index.ts b/extensions/memory-identity-entra/index.ts new file mode 100644 index 000000000000..cb2609c94765 --- /dev/null +++ b/extensions/memory-identity-entra/index.ts @@ -0,0 +1,24 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; +import { createEntraEnterpriseIdentityProvider } from "./src/adapter.js"; + +export default definePluginEntry({ + id: "memory-identity-entra", + name: "Microsoft Entra ID Memory Identity", + description: "Verified Microsoft Entra ID authority for scoped memory access", + register(api) { + api.registerEnterpriseIdentityProvider( + createEntraEnterpriseIdentityProvider(api.pluginConfig, { + resolveClientSecret: async (value) => + ( + await resolveConfiguredSecretInputString({ + config: api.config, + env: process.env, + value, + path: "plugins.entries.memory-identity-entra.config.clientSecret", + }) + ).value, + }), + ); + }, +}); diff --git a/extensions/memory-identity-entra/openclaw.plugin.json b/extensions/memory-identity-entra/openclaw.plugin.json new file mode 100644 index 000000000000..34d3ab02ce45 --- /dev/null +++ b/extensions/memory-identity-entra/openclaw.plugin.json @@ -0,0 +1,41 @@ +{ + "id": "memory-identity-entra", + "name": "Microsoft Entra ID Memory Identity", + "activation": { "onStartup": false }, + "contracts": { "enterpriseIdentityProviders": ["entra"] }, + "configContracts": { + "secretInputs": { + "paths": [{ "path": "clientSecret", "expected": "string" }] + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "required": ["tenantId", "clientId", "clientSecret", "redirectUri", "roleGroupIds"], + "properties": { + "tenantId": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" }, + "clientId": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" }, + "clientSecret": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "provider", "id"], + "properties": { + "source": { "enum": ["env", "file", "exec", "store"] }, + "provider": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + } + } + ] + }, + "redirectUri": { "type": "string", "format": "uri" }, + "roleGroupIds": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" } }, + "maxAuthenticationAgeMs": { "type": "integer", "minimum": 1000, "maximum": 86400000, "default": 3600000 }, + "maxSnapshotAgeMs": { "type": "integer", "minimum": 1000, "maximum": 86400000, "default": 3600000 }, + "acceptedAcrValues": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "requiredAmrValues": { "type": "array", "items": { "type": "string", "minLength": 1 } } + } + } +} diff --git a/extensions/memory-identity-entra/package.json b/extensions/memory-identity-entra/package.json new file mode 100644 index 000000000000..f1f3665459af --- /dev/null +++ b/extensions/memory-identity-entra/package.json @@ -0,0 +1,25 @@ +{ + "name": "@openclaw/memory-identity-entra", + "version": "2026.8.1", + "description": "OpenClaw Microsoft Entra ID memory identity plugin.", + "repository": { "type": "git", "url": "https://github.com/openclaw/openclaw" }, + "type": "module", + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*", + "openclaw": "workspace:*" + }, + "peerDependencies": { "openclaw": ">=2026.8.1" }, + "peerDependenciesMeta": { "openclaw": { "optional": true } }, + "openclaw": { + "extensions": ["./index.ts"], + "install": { + "clawhubSpec": "clawhub:@openclaw/memory-identity-entra", + "npmSpec": "@openclaw/memory-identity-entra", + "defaultChoice": "npm", + "minHostVersion": ">=2026.8.1" + }, + "compat": { "pluginApi": ">=2026.8.1" }, + "build": { "openclawVersion": "2026.8.1", "bundledDist": false }, + "release": { "publishToClawHub": true, "publishToNpm": true } + } +} diff --git a/extensions/memory-identity-entra/src/adapter.test.ts b/extensions/memory-identity-entra/src/adapter.test.ts new file mode 100644 index 000000000000..90bb5c64a2f1 --- /dev/null +++ b/extensions/memory-identity-entra/src/adapter.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { createEntraEnterpriseIdentityProvider } from "./adapter.js"; + +describe("Entra enterprise identity adapter", () => { + it("derives the exact tenant-bound authority and fail-closed overage signals", async () => { + const adapter = createEntraEnterpriseIdentityProvider({ + tenantId: "00000000-0000-0000-0000-000000000010", + clientId: "00000000-0000-0000-0000-000000000011", + clientSecret: "test-client-secret", + redirectUri: "https://gateway.example/memory/oidc/callback", + roleGroupIds: [ + "00000000-0000-0000-0000-000000000012", + "00000000-0000-0000-0000-000000000013", + ], + }); + await expect(adapter.resolveAuthorizationCodeClientSecret?.()).resolves.toBe( + "test-client-secret", + ); + const authority = adapter.authorities[0]!; + + expect(authority).toMatchObject({ + issuer: "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000010/v2.0", + audiences: ["00000000-0000-0000-0000-000000000011"], + tenantBinding: { + kind: "claim", + claim: "tid", + value: "00000000-0000-0000-0000-000000000010", + }, + membership: { + claim: "groups", + roleGroupIds: [ + "00000000-0000-0000-0000-000000000012", + "00000000-0000-0000-0000-000000000013", + ], + incompleteIndicators: [ + { kind: "truthy-claim", claim: "hasgroups" }, + { kind: "nested-key", claim: "_claim_names", key: "groups" }, + ], + }, + }); + }); + + it("rejects mutable labels and malformed Entra object IDs before provider registration", () => { + expect(() => + createEntraEnterpriseIdentityProvider({ + tenantId: "tenant-name", + clientId: "00000000-0000-0000-0000-000000000011", + clientSecret: "test-client-secret", + roleGroupIds: ["00000000-0000-0000-0000-000000000012"], + }), + ).toThrow("tenantId"); + expect(() => + createEntraEnterpriseIdentityProvider({ + tenantId: "00000000-0000-0000-0000-000000000010", + clientId: "00000000-0000-0000-0000-000000000011", + clientSecret: "test-client-secret", + roleGroupIds: ["memory-writers"], + }), + ).toThrow("roleGroupIds"); + }); +}); diff --git a/extensions/memory-identity-entra/src/adapter.ts b/extensions/memory-identity-entra/src/adapter.ts new file mode 100644 index 000000000000..a885988cce18 --- /dev/null +++ b/extensions/memory-identity-entra/src/adapter.ts @@ -0,0 +1,117 @@ +import type { EnterpriseIdentityProviderAdapter } from "openclaw/plugin-sdk/plugin-entry"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; + +type EntraConfig = Readonly<{ + tenantId?: unknown; + clientId?: unknown; + clientSecret?: unknown; + redirectUri?: unknown; + roleGroupIds?: unknown; + maxAuthenticationAgeMs?: unknown; + maxSnapshotAgeMs?: unknown; + acceptedAcrValues?: unknown; + requiredAmrValues?: unknown; +}>; + +type ClientSecretResolver = (value: unknown) => Promise; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +function strings(value: unknown): readonly string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.trim()) + ? [...new Set(value.map((entry) => entry.trim()))].toSorted() + : []; +} + +function text(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function requiredUuid(value: unknown, field: string): string { + const normalized = text(value); + if (!UUID_PATTERN.test(normalized)) { + throw new Error(`memory-identity-entra requires ${field} to be an immutable Entra object ID`); + } + return normalized; +} + +function requiredUuidList(value: unknown, field: string): readonly string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`memory-identity-entra requires at least one ${field}`); + } + const ids = value.map((entry) => requiredUuid(entry, field)); + if (new Set(ids).size !== ids.length) { + throw new Error(`memory-identity-entra requires unique ${field}`); + } + return ids.toSorted(); +} + +function duration(value: unknown, fallback: number): number { + if (value === undefined) { + return fallback; + } + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error("memory-identity-entra requires a positive integer duration"); + } + return value; +} + +/** Builds static Entra authority metadata; core validates every returned ID token. */ +export function createEntraEnterpriseIdentityProvider( + rawConfig: EntraConfig | undefined, + options: Readonly<{ resolveClientSecret?: ClientSecretResolver }> = {}, +): EnterpriseIdentityProviderAdapter { + const config = rawConfig ?? {}; + const tenantId = requiredUuid(config.tenantId, "tenantId"); + const clientId = requiredUuid(config.clientId, "clientId"); + const issuer = `https://login.microsoftonline.com/${tenantId}/v2.0`; + const roleGroupIds = requiredUuidList(config.roleGroupIds, "roleGroupIds"); + return { + providerPrefix: "entra", + authorities: [ + { + issuer, + tenantId, + audiences: [clientId], + jwksUri: `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`, + algorithm: "RS256", + tenantBinding: { kind: "claim", claim: "tid", value: tenantId }, + assurance: { + maxAuthenticationAgeMs: duration(config.maxAuthenticationAgeMs, 60 * 60_000), + ...(strings(config.acceptedAcrValues).length > 0 + ? { acceptedAcrValues: strings(config.acceptedAcrValues) } + : {}), + ...(strings(config.requiredAmrValues).length > 0 + ? { requiredAmrValues: strings(config.requiredAmrValues) } + : {}), + }, + authorizationCodeFlow: { + clientId, + authorizationEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`, + tokenEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, + redirectUri: text(config.redirectUri), + scopes: ["openid", "profile", "email"], + }, + membership: { + kind: "oidc-claim", + claim: "groups", + required: true, + roleGroupIds, + maxGroups: 200, + incompleteIndicators: [ + { kind: "truthy-claim", claim: "hasgroups" }, + { kind: "nested-key", claim: "_claim_names", key: "groups" }, + ], + }, + maxSnapshotAgeMs: duration(config.maxSnapshotAgeMs, 60 * 60_000), + }, + ], + resolveAuthorizationCodeClientSecret: async () => + options.resolveClientSecret + ? await options.resolveClientSecret(config.clientSecret) + : normalizeResolvedSecretInputString({ + value: config.clientSecret, + path: "plugins.entries.memory-identity-entra.config.clientSecret", + }), + }; +} diff --git a/extensions/memory-identity-google-workspace/index.test.ts b/extensions/memory-identity-google-workspace/index.test.ts new file mode 100644 index 000000000000..d080cf0f1049 --- /dev/null +++ b/extensions/memory-identity-google-workspace/index.test.ts @@ -0,0 +1,71 @@ +import type { EnterpriseIdentityProviderAdapter } from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const getAccessToken = vi.hoisted(() => vi.fn()); + +vi.mock("google-auth-library", () => ({ + JWT: class { + getAccessToken = getAccessToken; + }, +})); + +import plugin from "./index.js"; + +const directoryServiceAccount = JSON.stringify({ + client_email: "directory-reader@example.iam.gserviceaccount.com", + private_key: "test-private-key", +}); + +afterEach(() => { + getAccessToken.mockReset(); + vi.unstubAllEnvs(); +}); + +describe("Google Workspace enterprise identity plugin", () => { + it("resolves env SecretRefs through the plugin entry before code and directory exchanges", async () => { + vi.stubEnv("GOOGLE_WORKSPACE_DIRECTORY_SERVICE_ACCOUNT", directoryServiceAccount); + vi.stubEnv("GOOGLE_WORKSPACE_CLIENT_SECRET", "workspace-client-secret"); + getAccessToken.mockResolvedValue({ token: "directory-access-token" }); + const registerEnterpriseIdentityProvider = vi.fn(); + + plugin.register( + createTestPluginApi({ + id: "memory-identity-google-workspace", + name: "Google Workspace Memory Identity", + config: { secrets: { providers: { default: { source: "env" } } } }, + pluginConfig: { + hostedDomain: "example.com", + clientId: "client-id", + clientSecret: { + source: "env", + provider: "default", + id: "GOOGLE_WORKSPACE_CLIENT_SECRET", + }, + redirectUri: "https://gateway.example/memory/oidc/callback", + delegatedAdminEmail: "admin@example.com", + directoryServiceAccount: { + source: "env", + provider: "default", + id: "GOOGLE_WORKSPACE_DIRECTORY_SERVICE_ACCOUNT", + }, + roleGroupResourceNames: ["groups/writers"], + }, + registerEnterpriseIdentityProvider, + }), + ); + + const provider = registerEnterpriseIdentityProvider.mock.calls[0]?.[0] as + | EnterpriseIdentityProviderAdapter + | undefined; + expect(provider).toBeDefined(); + await expect(provider?.resolveAuthorizationCodeClientSecret?.()).resolves.toBe( + "workspace-client-secret", + ); + await expect(provider?.acquireDirectoryAccessToken?.()).resolves.toEqual({ + kind: "available", + accessToken: "directory-access-token", + }); + expect(getAccessToken).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/memory-identity-google-workspace/index.ts b/extensions/memory-identity-google-workspace/index.ts new file mode 100644 index 000000000000..9fb321548803 --- /dev/null +++ b/extensions/memory-identity-google-workspace/index.ts @@ -0,0 +1,33 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; +import { createGoogleWorkspaceEnterpriseIdentityProvider } from "./src/adapter.js"; + +export default definePluginEntry({ + id: "memory-identity-google-workspace", + name: "Google Workspace Memory Identity", + description: "Verified Google Workspace authority for scoped memory access", + register(api) { + api.registerEnterpriseIdentityProvider( + createGoogleWorkspaceEnterpriseIdentityProvider(api.pluginConfig, { + resolveDirectoryServiceAccount: async (value) => + ( + await resolveConfiguredSecretInputString({ + config: api.config, + env: process.env, + value, + path: "plugins.entries.memory-identity-google-workspace.config.directoryServiceAccount", + }) + ).value, + resolveClientSecret: async (value) => + ( + await resolveConfiguredSecretInputString({ + config: api.config, + env: process.env, + value, + path: "plugins.entries.memory-identity-google-workspace.config.clientSecret", + }) + ).value, + }), + ); + }, +}); diff --git a/extensions/memory-identity-google-workspace/openclaw.plugin.json b/extensions/memory-identity-google-workspace/openclaw.plugin.json new file mode 100644 index 000000000000..f4efcca9c7f1 --- /dev/null +++ b/extensions/memory-identity-google-workspace/openclaw.plugin.json @@ -0,0 +1,59 @@ +{ + "id": "memory-identity-google-workspace", + "name": "Google Workspace Memory Identity", + "activation": { "onStartup": false }, + "contracts": { "enterpriseIdentityProviders": ["google-workspace"] }, + "configContracts": { + "secretInputs": { + "paths": [ + { "path": "clientSecret", "expected": "string" }, + { "path": "directoryServiceAccount", "expected": "string" } + ] + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "required": ["hostedDomain", "clientId", "clientSecret", "redirectUri", "delegatedAdminEmail", "directoryServiceAccount", "roleGroupResourceNames"], + "properties": { + "hostedDomain": { "type": "string", "minLength": 1 }, + "clientId": { "type": "string", "minLength": 1 }, + "clientSecret": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "provider", "id"], + "properties": { + "source": { "enum": ["env", "file", "exec", "store"] }, + "provider": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + } + } + ] + }, + "redirectUri": { "type": "string", "format": "uri" }, + "delegatedAdminEmail": { "type": "string", "minLength": 1 }, + "directoryServiceAccount": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "provider", "id"], + "properties": { + "source": { "enum": ["env", "file", "exec", "store"] }, + "provider": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + } + } + ] + }, + "roleGroupResourceNames": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^groups/[^/\\s]+$" } }, + "customerId": { "type": "string", "pattern": "^C[\\w-]+$" }, + "maxAuthenticationAgeMs": { "type": "integer", "minimum": 1000, "maximum": 86400000, "default": 3600000 }, + "maxSnapshotAgeMs": { "type": "integer", "minimum": 1000, "maximum": 86400000, "default": 3600000 } + } + } +} diff --git a/extensions/memory-identity-google-workspace/package.json b/extensions/memory-identity-google-workspace/package.json new file mode 100644 index 000000000000..768b04c38313 --- /dev/null +++ b/extensions/memory-identity-google-workspace/package.json @@ -0,0 +1,26 @@ +{ + "name": "@openclaw/memory-identity-google-workspace", + "version": "2026.8.1", + "description": "OpenClaw Google Workspace memory identity plugin.", + "repository": { "type": "git", "url": "https://github.com/openclaw/openclaw" }, + "type": "module", + "dependencies": { "google-auth-library": "10.9.1" }, + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*", + "openclaw": "workspace:*" + }, + "peerDependencies": { "openclaw": ">=2026.8.1" }, + "peerDependenciesMeta": { "openclaw": { "optional": true } }, + "openclaw": { + "extensions": ["./index.ts"], + "install": { + "clawhubSpec": "clawhub:@openclaw/memory-identity-google-workspace", + "npmSpec": "@openclaw/memory-identity-google-workspace", + "defaultChoice": "npm", + "minHostVersion": ">=2026.8.1" + }, + "compat": { "pluginApi": ">=2026.8.1" }, + "build": { "openclawVersion": "2026.8.1", "bundledDist": false }, + "release": { "publishToClawHub": true, "publishToNpm": true } + } +} diff --git a/extensions/memory-identity-google-workspace/src/adapter.test.ts b/extensions/memory-identity-google-workspace/src/adapter.test.ts new file mode 100644 index 000000000000..af8c5bf48987 --- /dev/null +++ b/extensions/memory-identity-google-workspace/src/adapter.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { createGoogleWorkspaceEnterpriseIdentityProvider } from "./adapter.js"; + +describe("Google Workspace enterprise identity adapter", () => { + it("returns only static OIDC policy and an ephemeral-directory-token callback", async () => { + const adapter = createGoogleWorkspaceEnterpriseIdentityProvider({ + hostedDomain: "example.com", + clientId: "client-id", + clientSecret: "test-client-secret", + redirectUri: "https://gateway.example/memory/oidc/callback", + delegatedAdminEmail: "admin@example.com", + roleGroupResourceNames: ["groups/writers"], + }); + await expect(adapter.resolveAuthorizationCodeClientSecret?.()).resolves.toBe( + "test-client-secret", + ); + const authority = adapter.authorities[0]!; + + expect(authority).toMatchObject({ + issuer: "https://accounts.google.com", + requiredClaims: [ + { claim: "email_verified", value: true }, + { claim: "hd", value: "example.com" }, + ], + membership: { + kind: "google-workspace-directory", + roleGroupResourceNames: ["groups/writers"], + }, + }); + await expect(adapter.acquireDirectoryAccessToken?.()).resolves.toEqual({ + kind: "unavailable", + reason: "directory credentials are unavailable", + }); + }); + + it("rejects ambiguous tenant and group configuration before the directory token is acquired", () => { + expect(() => + createGoogleWorkspaceEnterpriseIdentityProvider({ + hostedDomain: "not a domain", + clientId: "client-id", + clientSecret: "test-client-secret", + delegatedAdminEmail: "admin@example.com", + roleGroupResourceNames: ["groups/writers"], + }), + ).toThrow("hostedDomain"); + expect(() => + createGoogleWorkspaceEnterpriseIdentityProvider({ + hostedDomain: "example.com", + clientId: "client-id", + clientSecret: "test-client-secret", + delegatedAdminEmail: "admin@other.example", + roleGroupResourceNames: ["writers"], + }), + ).toThrow("delegatedAdminEmail"); + }); + + it("defers a configured SecretRef to the Gateway-owned resolver", async () => { + const resolveDirectoryServiceAccount = vi.fn(async () => undefined); + const adapter = createGoogleWorkspaceEnterpriseIdentityProvider( + { + hostedDomain: "example.com", + clientId: "client-id", + clientSecret: { source: "env", provider: "default", id: "GOOGLE_CLIENT_SECRET" }, + redirectUri: "https://gateway.example/memory/oidc/callback", + delegatedAdminEmail: "admin@example.com", + directoryServiceAccount: { source: "env", provider: "default", id: "GOOGLE_DWD_JSON" }, + roleGroupResourceNames: ["groups/writers"], + }, + { + resolveDirectoryServiceAccount, + resolveClientSecret: vi.fn(async () => "test-client-secret"), + }, + ); + + await expect(adapter.acquireDirectoryAccessToken?.()).resolves.toEqual({ + kind: "unavailable", + reason: "directory credentials are unavailable", + }); + expect(resolveDirectoryServiceAccount).toHaveBeenCalledWith({ + source: "env", + provider: "default", + id: "GOOGLE_DWD_JSON", + }); + await expect(adapter.resolveAuthorizationCodeClientSecret?.()).resolves.toBe( + "test-client-secret", + ); + }); +}); diff --git a/extensions/memory-identity-google-workspace/src/adapter.ts b/extensions/memory-identity-google-workspace/src/adapter.ts new file mode 100644 index 000000000000..ebe8beb45e8f --- /dev/null +++ b/extensions/memory-identity-google-workspace/src/adapter.ts @@ -0,0 +1,174 @@ +import { JWT } from "google-auth-library"; +import type { EnterpriseIdentityProviderAdapter } from "openclaw/plugin-sdk/plugin-entry"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; + +const CLOUD_IDENTITY_GROUPS_READ_SCOPE = + "https://www.googleapis.com/auth/cloud-identity.groups.readonly"; + +type GoogleWorkspaceConfig = Readonly<{ + hostedDomain?: unknown; + clientId?: unknown; + clientSecret?: unknown; + redirectUri?: unknown; + delegatedAdminEmail?: unknown; + directoryServiceAccount?: unknown; + roleGroupResourceNames?: unknown; + customerId?: unknown; + maxAuthenticationAgeMs?: unknown; + maxSnapshotAgeMs?: unknown; +}>; + +type DirectoryServiceAccountResolver = (value: unknown) => Promise; +type ClientSecretResolver = (value: unknown) => Promise; + +function text(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function requiredHostedDomain(value: unknown): string { + const domain = text(value).toLowerCase(); + if (!/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u.test(domain)) { + throw new Error("memory-identity-google-workspace requires a registered hostedDomain"); + } + return domain; +} + +function requiredDelegatedAdmin(value: unknown, hostedDomain: string): string { + const email = text(value).toLowerCase(); + if (!/^[^@\s]+@[^@\s]+$/u.test(email) || !email.endsWith(`@${hostedDomain}`)) { + throw new Error( + "memory-identity-google-workspace requires delegatedAdminEmail in hostedDomain", + ); + } + return email; +} + +function groups(value: unknown): readonly string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error("memory-identity-google-workspace requires roleGroupResourceNames"); + } + const resourceNames = value.map((entry) => text(entry)); + if ( + resourceNames.some((resourceName) => !/^groups\/[^/\s]+$/u.test(resourceName)) || + new Set(resourceNames).size !== resourceNames.length + ) { + throw new Error("memory-identity-google-workspace requires unique groups/ resource names"); + } + return resourceNames.toSorted(); +} + +function duration(value: unknown, fallback: number): number { + if (value === undefined) { + return fallback; + } + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error("memory-identity-google-workspace requires a positive integer duration"); + } + return value; +} + +async function acquireDirectoryAccessToken( + config: GoogleWorkspaceConfig, + resolveDirectoryServiceAccount?: DirectoryServiceAccountResolver, +) { + try { + const serializedCredentials = resolveDirectoryServiceAccount + ? await resolveDirectoryServiceAccount(config.directoryServiceAccount) + : normalizeResolvedSecretInputString({ + value: config.directoryServiceAccount, + path: "plugins.entries.memory-identity-google-workspace.config.directoryServiceAccount", + }); + const delegatedAdminEmail = text(config.delegatedAdminEmail); + if (!serializedCredentials || !delegatedAdminEmail) { + return { kind: "unavailable" as const, reason: "directory credentials are unavailable" }; + } + const credentials: unknown = JSON.parse(serializedCredentials); + if (!credentials || typeof credentials !== "object" || Array.isArray(credentials)) { + return { kind: "unavailable" as const, reason: "directory credentials are malformed" }; + } + const clientEmail = text((credentials as Record).client_email); + const privateKey = text((credentials as Record).private_key); + if (!clientEmail || !privateKey) { + return { kind: "unavailable" as const, reason: "directory credentials are malformed" }; + } + const auth = new JWT({ + email: clientEmail, + key: privateKey, + subject: delegatedAdminEmail, + scopes: [CLOUD_IDENTITY_GROUPS_READ_SCOPE], + }); + const token = await auth.getAccessToken(); + const accessToken = token.token; + return accessToken + ? { kind: "available" as const, accessToken } + : { kind: "unavailable" as const, reason: "directory token is unavailable" }; + } catch { + return { kind: "unavailable" as const, reason: "directory token request failed" }; + } +} + +/** The adapter obtains only an ephemeral DWD token; core performs membership lookup and persistence. */ +export function createGoogleWorkspaceEnterpriseIdentityProvider( + rawConfig: GoogleWorkspaceConfig | undefined, + options: Readonly<{ + resolveDirectoryServiceAccount?: DirectoryServiceAccountResolver; + resolveClientSecret?: ClientSecretResolver; + }> = {}, +): EnterpriseIdentityProviderAdapter { + const config = rawConfig ?? {}; + const hostedDomain = requiredHostedDomain(config.hostedDomain); + const delegatedAdminEmail = requiredDelegatedAdmin(config.delegatedAdminEmail, hostedDomain); + const roleGroupResourceNames = groups(config.roleGroupResourceNames); + const customerId = text(config.customerId); + if (customerId && !/^C[\w-]+$/u.test(customerId)) { + throw new Error( + "memory-identity-google-workspace requires customerId in the canonical C form", + ); + } + return { + providerPrefix: "google-workspace", + authorities: [ + { + issuer: "https://accounts.google.com", + acceptedIssuerAliases: ["accounts.google.com"], + tenantId: hostedDomain, + audiences: [text(config.clientId)], + jwksUri: "https://www.googleapis.com/oauth2/v3/certs", + algorithm: "RS256", + tenantBinding: { kind: "issuer", tenantId: hostedDomain }, + assurance: { maxAuthenticationAgeMs: duration(config.maxAuthenticationAgeMs, 60 * 60_000) }, + authorizationCodeFlow: { + clientId: text(config.clientId), + authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + tokenEndpoint: "https://oauth2.googleapis.com/token", + redirectUri: text(config.redirectUri), + scopes: ["openid", "email", "profile"], + }, + requiredClaims: [ + { claim: "email_verified", value: true }, + { claim: "hd", value: hostedDomain }, + ], + membership: { + kind: "google-workspace-directory", + verifiedEmailClaim: "email", + roleGroupResourceNames, + ...(customerId ? { customerId } : {}), + maxGroups: roleGroupResourceNames.length, + }, + maxSnapshotAgeMs: duration(config.maxSnapshotAgeMs, 60 * 60_000), + }, + ], + resolveAuthorizationCodeClientSecret: async () => + options.resolveClientSecret + ? await options.resolveClientSecret(config.clientSecret) + : normalizeResolvedSecretInputString({ + value: config.clientSecret, + path: "plugins.entries.memory-identity-google-workspace.config.clientSecret", + }), + acquireDirectoryAccessToken: () => + acquireDirectoryAccessToken( + { ...config, delegatedAdminEmail }, + options.resolveDirectoryServiceAccount, + ), + }; +} diff --git a/extensions/memory-identity-okta/index.test.ts b/extensions/memory-identity-okta/index.test.ts new file mode 100644 index 000000000000..3984030b2009 --- /dev/null +++ b/extensions/memory-identity-okta/index.test.ts @@ -0,0 +1,39 @@ +import type { EnterpriseIdentityProviderAdapter } from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import plugin from "./index.js"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("Okta enterprise identity plugin", () => { + it("resolves the confidential client secret through the Gateway config snapshot", async () => { + vi.stubEnv("OKTA_MEMORY_CLIENT_SECRET", "okta-client-secret"); + const registerEnterpriseIdentityProvider = vi.fn(); + + plugin.register( + createTestPluginApi({ + id: "memory-identity-okta", + name: "Okta Memory Identity", + config: { secrets: { providers: { default: { source: "env" } } } }, + pluginConfig: { + issuer: "https://example.okta.com/oauth2/memory", + clientId: "client-id", + clientSecret: { source: "env", provider: "default", id: "OKTA_MEMORY_CLIENT_SECRET" }, + redirectUri: "https://gateway.example/memory/oidc/callback", + groupIdsClaim: "openclaw_group_ids", + roleGroupIds: ["00g00000000000000001"], + }, + registerEnterpriseIdentityProvider, + }), + ); + + const provider = registerEnterpriseIdentityProvider.mock.calls[0]?.[0] as + | EnterpriseIdentityProviderAdapter + | undefined; + await expect(provider?.resolveAuthorizationCodeClientSecret?.()).resolves.toBe( + "okta-client-secret", + ); + }); +}); diff --git a/extensions/memory-identity-okta/index.ts b/extensions/memory-identity-okta/index.ts new file mode 100644 index 000000000000..c1f06f04c014 --- /dev/null +++ b/extensions/memory-identity-okta/index.ts @@ -0,0 +1,24 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; +import { createOktaEnterpriseIdentityProvider } from "./src/adapter.js"; + +export default definePluginEntry({ + id: "memory-identity-okta", + name: "Okta Memory Identity", + description: "Verified Okta custom-authorization-server authority for scoped memory access", + register(api) { + api.registerEnterpriseIdentityProvider( + createOktaEnterpriseIdentityProvider(api.pluginConfig, { + resolveClientSecret: async (value) => + ( + await resolveConfiguredSecretInputString({ + config: api.config, + env: process.env, + value, + path: "plugins.entries.memory-identity-okta.config.clientSecret", + }) + ).value, + }), + ); + }, +}); diff --git a/extensions/memory-identity-okta/openclaw.plugin.json b/extensions/memory-identity-okta/openclaw.plugin.json new file mode 100644 index 000000000000..fe88e49d580c --- /dev/null +++ b/extensions/memory-identity-okta/openclaw.plugin.json @@ -0,0 +1,42 @@ +{ + "id": "memory-identity-okta", + "name": "Okta Memory Identity", + "activation": { "onStartup": false }, + "contracts": { "enterpriseIdentityProviders": ["okta"] }, + "configContracts": { + "secretInputs": { + "paths": [{ "path": "clientSecret", "expected": "string" }] + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "required": ["issuer", "clientId", "clientSecret", "redirectUri", "groupIdsClaim", "roleGroupIds"], + "properties": { + "issuer": { "type": "string", "pattern": "^https://[^/?#]+/oauth2/[^/?#]+$" }, + "clientId": { "type": "string", "minLength": 1 }, + "clientSecret": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "provider", "id"], + "properties": { + "source": { "enum": ["env", "file", "exec", "store"] }, + "provider": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + } + } + ] + }, + "redirectUri": { "type": "string", "format": "uri" }, + "groupIdsClaim": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,63}$" }, + "roleGroupIds": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^00g[0-9A-Za-z]{17}$" } }, + "maxAuthenticationAgeMs": { "type": "integer", "minimum": 1000, "maximum": 86400000, "default": 3600000 }, + "maxSnapshotAgeMs": { "type": "integer", "minimum": 1000, "maximum": 86400000, "default": 3600000 }, + "acceptedAcrValues": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "requiredAmrValues": { "type": "array", "items": { "type": "string", "minLength": 1 } } + } + } +} diff --git a/extensions/memory-identity-okta/package.json b/extensions/memory-identity-okta/package.json new file mode 100644 index 000000000000..c7b78dbdda6a --- /dev/null +++ b/extensions/memory-identity-okta/package.json @@ -0,0 +1,25 @@ +{ + "name": "@openclaw/memory-identity-okta", + "version": "2026.8.1", + "description": "OpenClaw Okta memory identity plugin.", + "repository": { "type": "git", "url": "https://github.com/openclaw/openclaw" }, + "type": "module", + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*", + "openclaw": "workspace:*" + }, + "peerDependencies": { "openclaw": ">=2026.8.1" }, + "peerDependenciesMeta": { "openclaw": { "optional": true } }, + "openclaw": { + "extensions": ["./index.ts"], + "install": { + "clawhubSpec": "clawhub:@openclaw/memory-identity-okta", + "npmSpec": "@openclaw/memory-identity-okta", + "defaultChoice": "npm", + "minHostVersion": ">=2026.8.1" + }, + "compat": { "pluginApi": ">=2026.8.1" }, + "build": { "openclawVersion": "2026.8.1", "bundledDist": false }, + "release": { "publishToClawHub": true, "publishToNpm": true } + } +} diff --git a/extensions/memory-identity-okta/src/adapter.test.ts b/extensions/memory-identity-okta/src/adapter.test.ts new file mode 100644 index 000000000000..11d8212aa90b --- /dev/null +++ b/extensions/memory-identity-okta/src/adapter.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { createOktaEnterpriseIdentityProvider } from "./adapter.js"; + +describe("Okta enterprise identity adapter", () => { + it("uses the exact custom authorization-server issuer as the tenant boundary", async () => { + const adapter = createOktaEnterpriseIdentityProvider({ + issuer: "https://example.okta.com/oauth2/memory", + clientId: "client-id", + clientSecret: "test-client-secret", + redirectUri: "https://gateway.example/memory/oidc/callback", + groupIdsClaim: "openclaw_group_ids", + roleGroupIds: ["00g00000000000000001"], + }); + await expect(adapter.resolveAuthorizationCodeClientSecret?.()).resolves.toBe( + "test-client-secret", + ); + const authority = adapter.authorities[0]!; + + expect(authority).toMatchObject({ + issuer: "https://example.okta.com/oauth2/memory", + jwksUri: "https://example.okta.com/oauth2/memory/v1/keys", + tenantBinding: { kind: "issuer", tenantId: "https://example.okta.com/oauth2/memory" }, + membership: { claim: "openclaw_group_ids", roleGroupIds: ["00g00000000000000001"] }, + }); + }); + + it("rejects an org authorization server and mutable group labels", () => { + expect(() => + createOktaEnterpriseIdentityProvider({ + issuer: "https://example.okta.com", + clientId: "client-id", + clientSecret: "test-client-secret", + groupIdsClaim: "openclaw_group_ids", + roleGroupIds: ["00g00000000000000001"], + }), + ).toThrow("custom authorization-server issuer"); + expect(() => + createOktaEnterpriseIdentityProvider({ + issuer: "https://example.okta.com/oauth2/memory", + clientId: "client-id", + clientSecret: "test-client-secret", + groupIdsClaim: "groups", + roleGroupIds: ["memory-writers"], + }), + ).toThrow("immutable Okta group IDs"); + }); +}); diff --git a/extensions/memory-identity-okta/src/adapter.ts b/extensions/memory-identity-okta/src/adapter.ts new file mode 100644 index 000000000000..44e64f6a41b8 --- /dev/null +++ b/extensions/memory-identity-okta/src/adapter.ts @@ -0,0 +1,133 @@ +import type { EnterpriseIdentityProviderAdapter } from "openclaw/plugin-sdk/plugin-entry"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; + +type OktaConfig = Readonly<{ + issuer?: unknown; + clientId?: unknown; + clientSecret?: unknown; + redirectUri?: unknown; + groupIdsClaim?: unknown; + roleGroupIds?: unknown; + maxAuthenticationAgeMs?: unknown; + maxSnapshotAgeMs?: unknown; + acceptedAcrValues?: unknown; + requiredAmrValues?: unknown; +}>; + +type ClientSecretResolver = (value: unknown) => Promise; + +const OKTA_GROUP_ID_PATTERN = /^00g[0-9a-z]{17}$/iu; + +function text(value: unknown): string { + return typeof value === "string" ? value.trim().replace(/\/$/u, "") : ""; +} + +function strings(value: unknown): readonly string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.trim()) + ? [...new Set(value.map((entry) => entry.trim()))].toSorted() + : []; +} + +function requireCustomAuthorizationServerIssuer(value: unknown): string { + const issuer = text(value); + let parsed: URL; + try { + parsed = new URL(issuer); + } catch { + throw new Error("memory-identity-okta requires an HTTPS custom authorization-server issuer"); + } + if ( + parsed.protocol !== "https:" || + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + !/^\/oauth2\/[^/]+$/u.test(parsed.pathname) + ) { + throw new Error("memory-identity-okta requires an HTTPS custom authorization-server issuer"); + } + return parsed.toString().replace(/\/$/u, ""); +} + +function requiredGroupIds(value: unknown): readonly string[] { + const ids = strings(value); + if (ids.length === 0 || ids.some((id) => !OKTA_GROUP_ID_PATTERN.test(id))) { + throw new Error("memory-identity-okta requires immutable Okta group IDs"); + } + return ids; +} + +function requiredClaim(value: unknown): string { + const claim = text(value); + if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/u.test(claim)) { + throw new Error( + "memory-identity-okta requires groupIdsClaim to name an ID-valued custom claim", + ); + } + return claim; +} + +function duration(value: unknown, fallback: number): number { + if (value === undefined) { + return fallback; + } + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error("memory-identity-okta requires a positive integer duration"); + } + return value; +} + +/** Okta's exact verified custom authorization-server issuer is its tenant boundary. */ +export function createOktaEnterpriseIdentityProvider( + rawConfig: OktaConfig | undefined, + options: Readonly<{ resolveClientSecret?: ClientSecretResolver }> = {}, +): EnterpriseIdentityProviderAdapter { + const config = rawConfig ?? {}; + const issuer = requireCustomAuthorizationServerIssuer(config.issuer); + const groupIdsClaim = requiredClaim(config.groupIdsClaim); + const roleGroupIds = requiredGroupIds(config.roleGroupIds); + return { + providerPrefix: "okta", + authorities: [ + { + issuer, + tenantId: issuer, + audiences: [text(config.clientId)], + jwksUri: `${issuer}/v1/keys`, + algorithm: "RS256", + tenantBinding: { kind: "issuer", tenantId: issuer }, + assurance: { + maxAuthenticationAgeMs: duration(config.maxAuthenticationAgeMs, 60 * 60_000), + ...(strings(config.acceptedAcrValues).length > 0 + ? { acceptedAcrValues: strings(config.acceptedAcrValues) } + : {}), + ...(strings(config.requiredAmrValues).length > 0 + ? { requiredAmrValues: strings(config.requiredAmrValues) } + : {}), + }, + authorizationCodeFlow: { + clientId: text(config.clientId), + authorizationEndpoint: `${issuer}/v1/authorize`, + tokenEndpoint: `${issuer}/v1/token`, + redirectUri: text(config.redirectUri), + scopes: ["openid", "profile", "email", "groups"], + }, + membership: { + kind: "oidc-claim", + claim: groupIdsClaim, + required: true, + roleGroupIds, + maxGroups: 100, + }, + maxSnapshotAgeMs: duration(config.maxSnapshotAgeMs, 60 * 60_000), + }, + ], + resolveAuthorizationCodeClientSecret: async () => + options.resolveClientSecret + ? await options.resolveClientSecret(config.clientSecret) + : normalizeResolvedSecretInputString({ + value: config.clientSecret, + path: "plugins.entries.memory-identity-okta.config.clientSecret", + }), + }; +} diff --git a/package.json b/package.json index 41fe04e65776..b77ed61335d0 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "!dist/plugin-sdk/llm.d.ts", "!dist/plugin-sdk/markdown-table-runtime.d.ts", "!dist/plugin-sdk/media-generation-runtime.d.ts", + "!dist/plugin-sdk/memory-enterprise-audit-runtime.d.ts", "!dist/plugin-sdk/memory-postbox-runtime.d.ts", "!dist/plugin-sdk/memory-sharing-control-runtime.d.ts", "!dist/plugin-sdk/memory-core-host-embedding-registry.d.ts", @@ -301,6 +302,9 @@ "!dist/extensions/longcat/**", "!dist/extensions/mattermost/**", "!dist/extensions/memory-lancedb/**", + "!dist/extensions/memory-identity-entra/**", + "!dist/extensions/memory-identity-google-workspace/**", + "!dist/extensions/memory-identity-okta/**", "!dist/extensions/meta/**", "!dist/extensions/matrix/**", "!dist/extensions/mistral/**", @@ -1238,6 +1242,9 @@ "types": "./dist/plugin-sdk/memory-authorization-conformance.d.ts", "default": "./dist/plugin-sdk/memory-authorization-conformance.js" }, + "./plugin-sdk/memory-enterprise-audit-runtime": { + "default": "./dist/plugin-sdk/memory-enterprise-audit-runtime.js" + }, "./plugin-sdk/memory-postbox-runtime": { "default": "./dist/plugin-sdk/memory-postbox-runtime.js" }, diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 4b81f97ae94f..2b1a366e11b0 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -439,6 +439,22 @@ export { ChannelsPairingApproveResultSchema, ChannelsPairingDismissParamsSchema, ChannelsPairingDismissResultSchema, + MemoryEnterpriseIdentityAuthorizationStartParamsSchema, + MemoryEnterpriseIdentityAuthorizationStartResultSchema, + MemoryEnterpriseIdentityAuthorizationCompleteParamsSchema, + MemoryEnterpriseIdentityAuthorizationCompleteResultSchema, + MemoryEnterpriseIdentityAccessAuditListParamsSchema, + MemoryEnterpriseIdentityAccessAuditListResultSchema, + MemoryEnterpriseIdentityPolicyDriftAlertListParamsSchema, + MemoryEnterpriseIdentityPolicyDriftAlertListResultSchema, + MemoryEnterpriseIdentityEvidenceTransitionListParamsSchema, + MemoryEnterpriseIdentityEvidenceTransitionListResultSchema, + MemoryEnterpriseIdentityAccessAuditExportParamsSchema, + MemoryEnterpriseIdentityAccessAuditExportResultSchema, + MemoryEnterpriseIdentityUnlinkParamsSchema, + MemoryEnterpriseIdentityUnlinkResultSchema, + MemoryEnterpriseIdentityEvidenceRevokeParamsSchema, + MemoryEnterpriseIdentityEvidenceRevokeResultSchema, ChannelsStartParamsSchema, ChannelsStopParamsSchema, ChannelsLogoutParamsSchema, diff --git a/packages/gateway-protocol/src/schema-modules.ts b/packages/gateway-protocol/src/schema-modules.ts index fca55b44597b..46342747bf38 100644 --- a/packages/gateway-protocol/src/schema-modules.ts +++ b/packages/gateway-protocol/src/schema-modules.ts @@ -27,6 +27,7 @@ export * from "./schema/fs.js"; export * from "./schema/gateway-suspend.js"; export * from "./schema/hooks.js"; export * from "./schema/logs-chat.js"; +export * from "./schema/memory-enterprise-identity.js"; export * from "./schema/migrations.js"; export * from "./schema/nodes.js"; export * from "./schema/push.js"; diff --git a/packages/gateway-protocol/src/schema/memory-enterprise-identity.test.ts b/packages/gateway-protocol/src/schema/memory-enterprise-identity.test.ts new file mode 100644 index 000000000000..67ada8ba017d --- /dev/null +++ b/packages/gateway-protocol/src/schema/memory-enterprise-identity.test.ts @@ -0,0 +1,206 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { + validateMemoryEnterpriseIdentityAccessAuditListParams, + validateMemoryEnterpriseIdentityAccessAuditExportParams, + validateMemoryEnterpriseIdentityEvidenceRevokeParams, + validateMemoryEnterpriseIdentityEvidenceTransitionListParams, + validateMemoryEnterpriseIdentityPolicyDriftAlertListParams, + validateMemoryEnterpriseIdentityUnlinkParams, + validateMemoryEnterpriseIdentityAuthorizationCompleteParams, + validateMemoryEnterpriseIdentityAuthorizationStartParams, +} from "../validator-registry.js"; +import { + MemoryEnterpriseIdentityAccessAuditExportResultSchema, + MemoryEnterpriseIdentityEvidenceRevokeResultSchema, + MemoryEnterpriseIdentityUnlinkResultSchema, +} from "./memory-enterprise-identity.js"; + +describe("memory enterprise identity authorization protocol", () => { + it("accepts only the provider selected from sealed policy when starting", () => { + expect( + validateMemoryEnterpriseIdentityAuthorizationStartParams({ providerPrefix: "entra" }), + ).toBe(true); + expect(validateMemoryEnterpriseIdentityAuthorizationStartParams({ providerPrefix: "" })).toBe( + false, + ); + expect( + validateMemoryEnterpriseIdentityAuthorizationStartParams({ + providerPrefix: "entra", + targetProfileId: "profile-bob", + }), + ).toBe(false); + }); + + it("accepts only the one-use receipt and authorization code when completing", () => { + expect( + validateMemoryEnterpriseIdentityAuthorizationCompleteParams({ + providerPrefix: "entra", + state: "gateway-issued-state", + code: "authorization-code", + }), + ).toBe(true); + expect( + validateMemoryEnterpriseIdentityAuthorizationCompleteParams({ + providerPrefix: "entra", + state: "gateway-issued-state", + code: "authorization-code", + idToken: "bearer-token", + }), + ).toBe(false); + }); + + it("accepts bounded redacted audit lookup parameters only", () => { + expect( + validateMemoryEnterpriseIdentityAccessAuditListParams({ + userProfileId: "profile-alice", + providerId: "entra", + limit: 25, + }), + ).toBe(true); + expect( + validateMemoryEnterpriseIdentityAccessAuditListParams({ + userProfileId: "profile-alice", + subjectPrincipalId: "principal:alice", + }), + ).toBe(false); + expect( + validateMemoryEnterpriseIdentityAccessAuditListParams({ + userProfileId: "profile-alice", + limit: 101, + }), + ).toBe(false); + }); + + it("uses the same bounded redacted query shape for policy-drift alerts", () => { + expect( + validateMemoryEnterpriseIdentityPolicyDriftAlertListParams({ + userProfileId: "profile-alice", + providerId: "entra", + limit: 25, + }), + ).toBe(true); + expect( + validateMemoryEnterpriseIdentityPolicyDriftAlertListParams({ + userProfileId: "profile-alice", + policyId: "policy:secret", + }), + ).toBe(false); + }); + + it("uses the same bounded redacted query shape for evidence transitions", () => { + expect( + validateMemoryEnterpriseIdentityEvidenceTransitionListParams({ + userProfileId: "profile-alice", + providerId: "entra", + limit: 25, + }), + ).toBe(true); + expect( + validateMemoryEnterpriseIdentityEvidenceTransitionListParams({ + userProfileId: "profile-alice", + transitionId: "transition:private", + }), + ).toBe(false); + }); + + it("accepts only bounded redacted audit export parameters", () => { + expect( + validateMemoryEnterpriseIdentityAccessAuditExportParams({ + userProfileId: "profile-alice", + providerId: "entra", + limit: 25, + }), + ).toBe(true); + expect( + validateMemoryEnterpriseIdentityAccessAuditExportParams({ + userProfileId: "profile-alice", + actionLedger: true, + }), + ).toBe(false); + }); + + it("keeps the export to the existing redacted audit projections", () => { + const exportRecord = { + decisions: [ + { + eventId: "event:one", + providerId: "entra", + tenantRef: "hmac:tenant", + actorPrincipalId: "principal:operator", + subjectPrincipalId: "principal:alice", + operation: "memory.read", + decision: "allowed", + reasonCode: "membership-current", + ruleRef: "hmac:rule", + policyRevision: "policy:v1", + principalEvidenceRevision: "principal:v1", + membershipEvidenceRevision: "membership:v1", + occurredAt: 1, + receivedAt: 2, + storeKind: "role", + collaboration: "not-applicable", + }, + ], + alerts: [], + transitions: [ + { + providerId: "entra", + kind: "revoke", + revokedAt: 3, + snapshotCount: 1, + exposureCount: 0, + complete: true, + }, + ], + }; + + expect(Value.Check(MemoryEnterpriseIdentityAccessAuditExportResultSchema, exportRecord)).toBe( + true, + ); + expect( + Value.Check(MemoryEnterpriseIdentityAccessAuditExportResultSchema, { + ...exportRecord, + actionLedger: [], + }), + ).toBe(false); + expect( + Value.Check(MemoryEnterpriseIdentityAccessAuditExportResultSchema, { + ...exportRecord, + decisions: [{ ...exportRecord.decisions[0], resourceId: "memory:private" }], + }), + ).toBe(false); + }); + + it.each([ + { + name: "unlink", + validate: validateMemoryEnterpriseIdentityUnlinkParams, + result: MemoryEnterpriseIdentityUnlinkResultSchema, + kind: "unlinked", + }, + { + name: "evidence revocation", + validate: validateMemoryEnterpriseIdentityEvidenceRevokeParams, + result: MemoryEnterpriseIdentityEvidenceRevokeResultSchema, + kind: "revoked", + }, + ])("accepts only redacted $name mutation records", ({ validate, result, kind }) => { + expect(validate({ userProfileId: "profile-alice", providerId: "entra" })).toBe(true); + expect(validate({ userProfileId: "profile-alice" })).toBe(false); + expect( + validate({ userProfileId: "profile-alice", providerId: "entra", snapshotId: "private" }), + ).toBe(false); + + const mutation = { + kind, + providerId: "entra", + affectedIdentityCount: 1, + affectedSnapshotCount: 2, + occurredAt: 3, + }; + expect(Value.Check(result, mutation)).toBe(true); + expect(Value.Check(result, { ...mutation, identityId: "private" })).toBe(false); + expect(Value.Check(result, { ...mutation, snapshotIds: ["private"] })).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/memory-enterprise-identity.ts b/packages/gateway-protocol/src/schema/memory-enterprise-identity.ts new file mode 100644 index 000000000000..92eabd6a2cf4 --- /dev/null +++ b/packages/gateway-protocol/src/schema/memory-enterprise-identity.ts @@ -0,0 +1,226 @@ +// Gateway Protocol schemas for a user linking their own verified enterprise identity. +import type { Static } from "typebox"; +import { Type } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { NonEmptyString } from "./primitives.js"; + +const EnterpriseProviderPrefixSchema = Type.String({ minLength: 1, maxLength: 128 }); +const EnterpriseAuthorizationStateSchema = Type.String({ minLength: 1, maxLength: 256 }); +const EnterpriseAuthorizationCodeSchema = Type.String({ minLength: 1, maxLength: 8_192 }); +const EnterpriseAuditLimitSchema = Type.Integer({ minimum: 1, maximum: 100 }); + +/** Starts a Gateway-bound OIDC authorization-code + PKCE transaction. */ +export const MemoryEnterpriseIdentityAuthorizationStartParamsSchema = closedObject({ + providerPrefix: EnterpriseProviderPrefixSchema, +}); + +export const MemoryEnterpriseIdentityAuthorizationStartResultSchema = closedObject({ + state: EnterpriseAuthorizationStateSchema, + authorizationUrl: Type.String({ minLength: 1, maxLength: 8_192 }), + expiresAt: NonEmptyString, +}); + +/** Completes only the caller's one-use Gateway-bound authorization transaction. */ +export const MemoryEnterpriseIdentityAuthorizationCompleteParamsSchema = closedObject({ + providerPrefix: EnterpriseProviderPrefixSchema, + state: EnterpriseAuthorizationStateSchema, + code: EnterpriseAuthorizationCodeSchema, +}); + +export const MemoryEnterpriseIdentityAuthorizationCompleteResultSchema = Type.Union([ + closedObject({ + kind: Type.Literal("linked"), + providerId: NonEmptyString, + expiresAt: NonEmptyString, + }), + closedObject({ + kind: Type.Literal("denied"), + reason: Type.String({ + enum: ["transaction-invalid", "provider-unavailable", "identity-verification-failed"], + }), + }), +]); + +export type MemoryEnterpriseIdentityAuthorizationStartParams = Static< + typeof MemoryEnterpriseIdentityAuthorizationStartParamsSchema +>; +export type MemoryEnterpriseIdentityAuthorizationStartResult = Static< + typeof MemoryEnterpriseIdentityAuthorizationStartResultSchema +>; +export type MemoryEnterpriseIdentityAuthorizationCompleteParams = Static< + typeof MemoryEnterpriseIdentityAuthorizationCompleteParamsSchema +>; +export type MemoryEnterpriseIdentityAuthorizationCompleteResult = Static< + typeof MemoryEnterpriseIdentityAuthorizationCompleteResultSchema +>; + +/** + * Redacted explanation for a Gateway profile's enterprise role decisions. + * The Gateway admits only the named profile or an `operator.admin` caller. + */ +export const MemoryEnterpriseIdentityAccessAuditListParamsSchema = closedObject({ + userProfileId: Type.String({ minLength: 1, maxLength: 256 }), + providerId: Type.Optional(EnterpriseProviderPrefixSchema), + limit: Type.Optional(EnterpriseAuditLimitSchema), +}); + +const MemoryEnterpriseIdentityAccessAuditEntrySchema = closedObject({ + eventId: NonEmptyString, + providerId: NonEmptyString, + tenantRef: NonEmptyString, + actorPrincipalId: NonEmptyString, + subjectPrincipalId: NonEmptyString, + operation: NonEmptyString, + decision: Type.Union([ + Type.Literal("allowed"), + Type.Literal("denied"), + Type.Literal("unavailable"), + ]), + reasonCode: NonEmptyString, + ruleRef: NonEmptyString, + policyRevision: NonEmptyString, + principalEvidenceRevision: NonEmptyString, + membershipEvidenceRevision: Type.Union([NonEmptyString, Type.Null()]), + occurredAt: Type.Integer({ minimum: 0 }), + receivedAt: Type.Integer({ minimum: 0 }), + // Enterprise evidence currently authorizes only role stores, never a + // Gateway collaboration session. Keep this explicit in the operator view. + storeKind: Type.Literal("role"), + collaboration: Type.Literal("not-applicable"), +}); + +export const MemoryEnterpriseIdentityAccessAuditListResultSchema = closedObject({ + decisions: Type.Array(MemoryEnterpriseIdentityAccessAuditEntrySchema, { maxItems: 100 }), +}); + +/** Redacted policy changes, with the same owner-or-operator.admin boundary. */ +export const MemoryEnterpriseIdentityPolicyDriftAlertListParamsSchema = + MemoryEnterpriseIdentityAccessAuditListParamsSchema; + +const MemoryEnterpriseIdentityPolicyDriftAlertSchema = closedObject({ + alertId: NonEmptyString, + providerId: NonEmptyString, + tenantRef: NonEmptyString, + subjectPrincipalId: NonEmptyString, + ruleRef: NonEmptyString, + policyId: NonEmptyString, + operation: NonEmptyString, + previousPolicyRevision: NonEmptyString, + previousDecision: Type.Union([Type.Literal("allowed"), Type.Literal("denied")]), + policyRevision: NonEmptyString, + decision: Type.Union([Type.Literal("allowed"), Type.Literal("denied")]), + detectedAt: Type.Integer({ minimum: 0 }), + storeKind: Type.Literal("role"), + collaboration: Type.Literal("not-applicable"), +}); + +export const MemoryEnterpriseIdentityPolicyDriftAlertListResultSchema = closedObject({ + alerts: Type.Array(MemoryEnterpriseIdentityPolicyDriftAlertSchema, { maxItems: 100 }), +}); + +/** Redacted refresh/removal history for the same owner-or-operator.admin boundary. */ +export const MemoryEnterpriseIdentityEvidenceTransitionListParamsSchema = + MemoryEnterpriseIdentityAccessAuditListParamsSchema; + +const MemoryEnterpriseIdentityEvidenceTransitionSchema = closedObject({ + providerId: NonEmptyString, + kind: Type.Union([Type.Literal("refresh"), Type.Literal("revoke")]), + revokedAt: Type.Integer({ minimum: 0 }), + // This count supports lifecycle review without revealing groups, snapshot + // IDs, resources, sessions, or historical run/exposure identifiers. + snapshotCount: Type.Integer({ minimum: 1, maximum: 1_000 }), + // Count-only impact preserves the historical revocation signal without + // turning the audit API into a resource, run, or exposure enumerator. + exposureCount: Type.Integer({ minimum: 0 }), + // A missing, incompatible, or unreadable registered agent DB cannot be + // treated as zero historical exposure. + complete: Type.Boolean(), +}); + +export const MemoryEnterpriseIdentityEvidenceTransitionListResultSchema = closedObject({ + transitions: Type.Array(MemoryEnterpriseIdentityEvidenceTransitionSchema, { maxItems: 100 }), +}); + +/** + * Bounded, redacted export of one enterprise identity audit record. The Gateway + * admits only the profile owner or an `operator.admin` caller. + */ +export const MemoryEnterpriseIdentityAccessAuditExportParamsSchema = + MemoryEnterpriseIdentityAccessAuditListParamsSchema; + +export const MemoryEnterpriseIdentityAccessAuditExportResultSchema = closedObject({ + decisions: Type.Array(MemoryEnterpriseIdentityAccessAuditEntrySchema, { maxItems: 100 }), + alerts: Type.Array(MemoryEnterpriseIdentityPolicyDriftAlertSchema, { maxItems: 100 }), + transitions: Type.Array(MemoryEnterpriseIdentityEvidenceTransitionSchema, { maxItems: 100 }), +}); + +const MemoryEnterpriseIdentityMutationParamsSchema = closedObject({ + userProfileId: Type.String({ minLength: 1, maxLength: 256 }), + providerId: EnterpriseProviderPrefixSchema, +}); + +const MemoryEnterpriseIdentityMutationCountSchema = Type.Integer({ + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, +}); + +/** Removes one provider link without exposing the affected enterprise identity. */ +export const MemoryEnterpriseIdentityUnlinkParamsSchema = + MemoryEnterpriseIdentityMutationParamsSchema; + +export const MemoryEnterpriseIdentityUnlinkResultSchema = closedObject({ + kind: Type.Literal("unlinked"), + providerId: NonEmptyString, + affectedIdentityCount: MemoryEnterpriseIdentityMutationCountSchema, + affectedSnapshotCount: MemoryEnterpriseIdentityMutationCountSchema, + occurredAt: Type.Integer({ minimum: 0 }), +}); + +/** Revokes one provider's evidence without exposing snapshot or transition identifiers. */ +export const MemoryEnterpriseIdentityEvidenceRevokeParamsSchema = + MemoryEnterpriseIdentityMutationParamsSchema; + +export const MemoryEnterpriseIdentityEvidenceRevokeResultSchema = closedObject({ + kind: Type.Literal("revoked"), + providerId: NonEmptyString, + affectedIdentityCount: MemoryEnterpriseIdentityMutationCountSchema, + affectedSnapshotCount: MemoryEnterpriseIdentityMutationCountSchema, + occurredAt: Type.Integer({ minimum: 0 }), +}); + +export type MemoryEnterpriseIdentityAccessAuditListParams = Static< + typeof MemoryEnterpriseIdentityAccessAuditListParamsSchema +>; +export type MemoryEnterpriseIdentityAccessAuditListResult = Static< + typeof MemoryEnterpriseIdentityAccessAuditListResultSchema +>; +export type MemoryEnterpriseIdentityPolicyDriftAlertListParams = Static< + typeof MemoryEnterpriseIdentityPolicyDriftAlertListParamsSchema +>; +export type MemoryEnterpriseIdentityPolicyDriftAlertListResult = Static< + typeof MemoryEnterpriseIdentityPolicyDriftAlertListResultSchema +>; +export type MemoryEnterpriseIdentityEvidenceTransitionListParams = Static< + typeof MemoryEnterpriseIdentityEvidenceTransitionListParamsSchema +>; +export type MemoryEnterpriseIdentityEvidenceTransitionListResult = Static< + typeof MemoryEnterpriseIdentityEvidenceTransitionListResultSchema +>; +export type MemoryEnterpriseIdentityAccessAuditExportParams = Static< + typeof MemoryEnterpriseIdentityAccessAuditExportParamsSchema +>; +export type MemoryEnterpriseIdentityAccessAuditExportResult = Static< + typeof MemoryEnterpriseIdentityAccessAuditExportResultSchema +>; +export type MemoryEnterpriseIdentityUnlinkParams = Static< + typeof MemoryEnterpriseIdentityUnlinkParamsSchema +>; +export type MemoryEnterpriseIdentityUnlinkResult = Static< + typeof MemoryEnterpriseIdentityUnlinkResultSchema +>; +export type MemoryEnterpriseIdentityEvidenceRevokeParams = Static< + typeof MemoryEnterpriseIdentityEvidenceRevokeParamsSchema +>; +export type MemoryEnterpriseIdentityEvidenceRevokeResult = Static< + typeof MemoryEnterpriseIdentityEvidenceRevokeResultSchema +>; diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts index 20a29d854fac..4eba87161692 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts @@ -2,6 +2,7 @@ import * as auditActivity from "./audit-activity.js"; import * as auditRun from "./audit-run.js"; import * as audit from "./audit.js"; import * as config from "./config.js"; +import * as memoryEnterpriseIdentity from "./memory-enterprise-identity.js"; import * as openclaw from "./openclaw.js"; import * as taskSuggestions from "./task-suggestions.js"; import * as tasks from "./tasks.js"; @@ -55,6 +56,38 @@ export const OperationsProtocolSchemas = { ConfigSchemaLookupParams: config.ConfigSchemaLookupParamsSchema, ConfigSchemaResponse: config.ConfigSchemaResponseSchema, ConfigSchemaLookupResult: config.ConfigSchemaLookupResultSchema, + MemoryEnterpriseIdentityAuthorizationStartParams: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityAuthorizationStartParamsSchema, + MemoryEnterpriseIdentityAuthorizationStartResult: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityAuthorizationStartResultSchema, + MemoryEnterpriseIdentityAuthorizationCompleteParams: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityAuthorizationCompleteParamsSchema, + MemoryEnterpriseIdentityAuthorizationCompleteResult: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityAuthorizationCompleteResultSchema, + MemoryEnterpriseIdentityAccessAuditListParams: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityAccessAuditListParamsSchema, + MemoryEnterpriseIdentityAccessAuditListResult: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityAccessAuditListResultSchema, + MemoryEnterpriseIdentityPolicyDriftAlertListParams: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityPolicyDriftAlertListParamsSchema, + MemoryEnterpriseIdentityPolicyDriftAlertListResult: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityPolicyDriftAlertListResultSchema, + MemoryEnterpriseIdentityEvidenceTransitionListParams: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityEvidenceTransitionListParamsSchema, + MemoryEnterpriseIdentityEvidenceTransitionListResult: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityEvidenceTransitionListResultSchema, + MemoryEnterpriseIdentityAccessAuditExportParams: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityAccessAuditExportParamsSchema, + MemoryEnterpriseIdentityAccessAuditExportResult: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityAccessAuditExportResultSchema, + MemoryEnterpriseIdentityUnlinkParams: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityUnlinkParamsSchema, + MemoryEnterpriseIdentityUnlinkResult: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityUnlinkResultSchema, + MemoryEnterpriseIdentityEvidenceRevokeParams: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityEvidenceRevokeParamsSchema, + MemoryEnterpriseIdentityEvidenceRevokeResult: + memoryEnterpriseIdentity.MemoryEnterpriseIdentityEvidenceRevokeResultSchema, SystemAgentChatParams: openclaw.SystemAgentChatParamsSchema, SystemAgentChatResult: openclaw.SystemAgentChatResultSchema, SystemAgentChatHistoryParams: openclaw.SystemAgentChatHistoryParamsSchema, diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 06ddd4ca1fb5..386517700bd6 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -327,6 +327,30 @@ export const validateChannelsStatusParams = compile(S.ChannelsStatusParamsSchema export const validateChannelsPairingListParams = compile(S.ChannelsPairingListParamsSchema); export const validateChannelsPairingApproveParams = compile(S.ChannelsPairingApproveParamsSchema); export const validateChannelsPairingDismissParams = compile(S.ChannelsPairingDismissParamsSchema); +export const validateMemoryEnterpriseIdentityAuthorizationStartParams = compile( + S.MemoryEnterpriseIdentityAuthorizationStartParamsSchema, +); +export const validateMemoryEnterpriseIdentityAuthorizationCompleteParams = compile( + S.MemoryEnterpriseIdentityAuthorizationCompleteParamsSchema, +); +export const validateMemoryEnterpriseIdentityAccessAuditListParams = compile( + S.MemoryEnterpriseIdentityAccessAuditListParamsSchema, +); +export const validateMemoryEnterpriseIdentityPolicyDriftAlertListParams = compile( + S.MemoryEnterpriseIdentityPolicyDriftAlertListParamsSchema, +); +export const validateMemoryEnterpriseIdentityEvidenceTransitionListParams = compile( + S.MemoryEnterpriseIdentityEvidenceTransitionListParamsSchema, +); +export const validateMemoryEnterpriseIdentityAccessAuditExportParams = compile( + S.MemoryEnterpriseIdentityAccessAuditExportParamsSchema, +); +export const validateMemoryEnterpriseIdentityUnlinkParams = compile( + S.MemoryEnterpriseIdentityUnlinkParamsSchema, +); +export const validateMemoryEnterpriseIdentityEvidenceRevokeParams = compile( + S.MemoryEnterpriseIdentityEvidenceRevokeParamsSchema, +); export const validateChannelsStartParams = compile(S.ChannelsStartParamsSchema); export const validateChannelsStopParams = compile(S.ChannelsStopParamsSchema); export const validateChannelsLogoutParams = compile(S.ChannelsLogoutParamsSchema); diff --git a/packages/memory-host-sdk/src/host/authorization.ts b/packages/memory-host-sdk/src/host/authorization.ts index cdc18ea0fc87..f8d1b543f747 100644 --- a/packages/memory-host-sdk/src/host/authorization.ts +++ b/packages/memory-host-sdk/src/host/authorization.ts @@ -89,10 +89,18 @@ export type MemoryActorEvidence = }>; export type MemoryVerifiedMembership = Readonly<{ + /** Immutable provider-evidence row used to authorize this membership. */ + snapshotId: string; + /** The Gateway user who is the subject of the role decision. */ principalId: string; + /** The separately verified enterprise principal that supplied the group proof. */ + sourcePrincipalId: string; groupId: string; provider: string; + /** Current evidence revision of sourcePrincipalId. */ evidenceRevision: string; + /** Current explicit link between sourcePrincipalId and principalId. */ + profileLinkRevision: string; observedAt: string; expiresAt: string; }>; @@ -428,11 +436,13 @@ export type AuthorizedTranscriptDerivationPurpose = "flush" | "compaction"; */ export type AuthorizedSealedCompactionArtifact = Readonly<{ resourceRevisionId: string; - commitInTransaction(params: Readonly<{ - database: DatabaseSync; - compactionPolicyId: string; - eventSeq: number; - }>): void; + commitInTransaction( + params: Readonly<{ + database: DatabaseSync; + compactionPolicyId: string; + eventSeq: number; + }>, + ): void; }>; export type AuthorizedSealedCompactionStageParams = Readonly<{ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11b5a8da69f0..5dad37e5f3ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1255,6 +1255,37 @@ importers: specifier: workspace:* version: link:../.. + extensions/memory-identity-entra: + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + openclaw: + specifier: workspace:* + version: link:../.. + + extensions/memory-identity-google-workspace: + dependencies: + google-auth-library: + specifier: 10.9.1 + version: 10.9.1(supports-color@10.2.2) + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + openclaw: + specifier: workspace:* + version: link:../.. + + extensions/memory-identity-okta: + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + openclaw: + specifier: workspace:* + version: link:../.. + extensions/memory-lancedb: dependencies: '@lancedb/lancedb': diff --git a/scripts/lib/official-external-plugin-catalog.json b/scripts/lib/official-external-plugin-catalog.json index 039772347599..40f96f1ddf5a 100644 --- a/scripts/lib/official-external-plugin-catalog.json +++ b/scripts/lib/official-external-plugin-catalog.json @@ -419,6 +419,60 @@ } } }, + { + "name": "@openclaw/memory-identity-entra", + "description": "OpenClaw Microsoft Entra ID memory identity plugin", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "memory-identity-entra", + "label": "Microsoft Entra ID Memory Identity" + }, + "install": { + "clawhubSpec": "clawhub:@openclaw/memory-identity-entra", + "npmSpec": "@openclaw/memory-identity-entra", + "defaultChoice": "npm", + "minHostVersion": ">=2026.8.1" + } + } + }, + { + "name": "@openclaw/memory-identity-google-workspace", + "description": "OpenClaw Google Workspace memory identity plugin", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "memory-identity-google-workspace", + "label": "Google Workspace Memory Identity" + }, + "install": { + "clawhubSpec": "clawhub:@openclaw/memory-identity-google-workspace", + "npmSpec": "@openclaw/memory-identity-google-workspace", + "defaultChoice": "npm", + "minHostVersion": ">=2026.8.1" + } + } + }, + { + "name": "@openclaw/memory-identity-okta", + "description": "OpenClaw Okta memory identity plugin", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "memory-identity-okta", + "label": "Okta Memory Identity" + }, + "install": { + "clawhubSpec": "clawhub:@openclaw/memory-identity-okta", + "npmSpec": "@openclaw/memory-identity-okta", + "defaultChoice": "npm", + "minHostVersion": ">=2026.8.1" + } + } + }, { "name": "@openclaw/memory-lancedb", "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 958943fa744f..b17c22ecb33c 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -260,6 +260,7 @@ "qa-runner-runtime", "memory-authorization", "memory-authorization-conformance", + "memory-enterprise-audit-runtime", "memory-postbox-runtime", "memory-sharing-control-runtime", "memory-core-host-embedding-registry", diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index a79a0cbceb18..df496ece8175 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -63,6 +63,7 @@ "llm", "markdown-table-runtime", "media-generation-runtime", + "memory-enterprise-audit-runtime", "memory-postbox-runtime", "memory-sharing-control-runtime", "memory-core-host-embedding-registry", diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index 6b143a34b0e4..2a174291b092 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -273,7 +273,9 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +57: versioned serializable memory-authorization contract and reusable backend-conformance // types, capability declarations, and helpers. // +7: reconcile the existing public memory-authorization contract surface with this ratchet. - 4930, + // +4: manifest-bound enterprise identity adapter contracts required for independently + // packaged, operator-allowlisted verification plugins. + 4934, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/memory-authorized-read-host.ts b/src/agents/memory-authorized-read-host.ts index 90b4c8dcdeaa..13df9e39cf69 100644 --- a/src/agents/memory-authorized-read-host.ts +++ b/src/agents/memory-authorized-read-host.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { readAuthorizedTranscriptDerivation } from "../config/sessions/session-transcript-memory-policy.js"; import type { AuthorizedMemoryVirtualView, AuthorizedSealedCompactionArtifact, @@ -7,7 +8,6 @@ import type { MemoryAccessContext, MemoryActorEvidence, } from "../memory-host-sdk/host/authorization.js"; -import { readAuthorizedTranscriptDerivation } from "../config/sessions/session-transcript-memory-policy.js"; import { isMemoryIsolationCutoverAgent } from "../plugins/memory-cutover.js"; import { MEMORY_INVOCATION_UNAVAILABLE, @@ -28,6 +28,7 @@ import { createTrustedMemoryAccessContext, type TrustedMemoryAccessContext, } from "../state/memory-access-context.js"; +import { readCurrentEnterpriseMemoryFactsForUser } from "../state/memory-enterprise-admission.js"; import { recheckMemoryIdentityBinding } from "../state/memory-identity.js"; import { createCurrentMemorySessionContext, @@ -50,7 +51,9 @@ export type AuthorizedMemoryVirtualFileBroker = Readonly<{ /** Core-private sealed compaction capability; plugins never receive this host. */ export type AuthorizedSealedCompactionHost = Readonly<{ source: AuthorizedTranscriptDerivationSource; - stage: (content: string) => Promise; + stage: ( + content: string, + ) => Promise; }>; type AuthorizedMemoryReadHostWithVirtualBroker = AuthorizedMemoryReadHost & @@ -96,6 +99,37 @@ function deliveryFacts(params: { ); } +function hasCurrentEnterpriseMemoryFacts(params: { + userPrincipalId: string; + verifiedPrincipals: ReadonlyArray; + verifiedMemberships: ReadonlyArray; +}): boolean { + const current = readCurrentEnterpriseMemoryFactsForUser({ + userPrincipalId: params.userPrincipalId, + }); + const currentPrincipals = new Set( + current.verifiedPrincipals.map( + (principal) => `${principal.principalId}\u0000${principal.evidenceRevision}`, + ), + ); + const currentMemberships = new Set( + current.verifiedMemberships.map( + (membership) => + `${membership.snapshotId}\u0000${membership.principalId}\u0000${membership.sourcePrincipalId}\u0000${membership.groupId}\u0000${membership.provider}\u0000${membership.evidenceRevision}\u0000${membership.profileLinkRevision}`, + ), + ); + return ( + params.verifiedPrincipals.every((principal) => + currentPrincipals.has(`${principal.principalId}\u0000${principal.evidenceRevision}`), + ) && + params.verifiedMemberships.every((membership) => + currentMemberships.has( + `${membership.snapshotId}\u0000${membership.principalId}\u0000${membership.sourcePrincipalId}\u0000${membership.groupId}\u0000${membership.provider}\u0000${membership.evidenceRevision}\u0000${membership.profileLinkRevision}`, + ), + ) + ); +} + /** * Builds the sole tool-facing handle for a cut-over run. Session identity and delivery facts are * reread from their owners; sender IDs, `toolsBySender`, paths, and model parameters never name a @@ -141,12 +175,7 @@ function createTrustedMemoryHostContext( return undefined; } let actor: MemoryActorEvidence; - let verifiedPrincipals: Array<{ - principalId: string; - assurance: "gateway-profile" | "service"; - evidenceRevision: string; - expiresAt?: string; - }> = []; + let verifiedPrincipals: Array = []; if (context.subject.kind === "user" && context.bindingId) { const binding = recheckMemoryIdentityBinding({ bindingId: context.bindingId }); if (binding.kind !== "current" || binding.binding.principalId !== context.principalId) { @@ -172,6 +201,45 @@ function createTrustedMemoryHostContext( ...(expiresAt ? { expiresAt } : {}), }, ]; + const enterprise = readCurrentEnterpriseMemoryFactsForUser({ + userPrincipalId: context.principalId, + }); + verifiedPrincipals = [...verifiedPrincipals, ...enterprise.verifiedPrincipals]; + const verifiedMemberships = enterprise.verifiedMemberships; + const facts = captureTrustedMemoryAccessFacts({ + requestId: randomUUID(), + runId: params.runId?.trim() || `session:${context.sessionId}`, + actor, + verifiedPrincipals, + collaboration: { kind: "not-applicable" }, + verifiedMemberships, + recheck: () => + hasCurrentEnterpriseMemoryFacts({ + userPrincipalId: context.principalId, + verifiedPrincipals: enterprise.verifiedPrincipals, + verifiedMemberships, + }), + delivery, + operation: params.operation, + hostFactsRevision: `mhf1_${hash({ + session: context.fingerprint, + delivery: delivery.routeRevision, + egress: delivery.egressRegistryRevision, + memberships: verifiedMemberships.map((membership) => [ + membership.snapshotId, + membership.sourcePrincipalId, + membership.evidenceRevision, + membership.profileLinkRevision, + ]), + })}`, + }); + const trusted = createTrustedMemoryAccessContext({ + sessionKey: context.sessionKey, + sessionId: context.sessionId, + options: { agentId: context.agentId }, + facts, + }); + return trusted.kind === "current" ? trusted.context : undefined; } else if (context.subject.kind === "conversation") { actor = { kind: "unattributed" as const, @@ -206,8 +274,8 @@ function createTrustedMemoryHostContext( actor, verifiedPrincipals, collaboration: { kind: "not-applicable" }, - // Role membership is intentionally absent until a trusted membership resolver exists. This - // keeps a group actor from selecting a role store merely because they sent the latest message. + // Only Gateway-user contexts can receive independently rechecked enterprise memberships. + // Group actors can never select a role store merely because they sent the latest message. verifiedMemberships: [], delivery, operation: params.operation, @@ -330,7 +398,8 @@ export async function admitAuthorizedMemoryDerivation( * substitute a session, event list, policy set, or delivery audience. */ export async function prepareAuthorizedTranscriptDerivationHost( - params: AuthorizedMemoryHostParams & Readonly<{ derivationPurpose?: AuthorizedTranscriptDerivationPurpose }>, + params: AuthorizedMemoryHostParams & + Readonly<{ derivationPurpose?: AuthorizedTranscriptDerivationPurpose }>, ): Promise { const sessionId = params.sessionId?.trim(); const trusted = createTrustedMemoryHostContext({ ...params, operation: "derive" }); @@ -399,12 +468,12 @@ export async function prepareAuthorizedSealedCompactionHost( return undefined; } const sealedSource = Object.freeze({ - kind: "transcript", - sessionId, - eventSeqs: transcriptSource.eventSeqs, - sourcePolicySetId: transcriptSource.sourcePolicySetId, - deliveryAudiencesJson: transcriptSource.deliveryAudiencesJson, - }); + kind: "transcript", + sessionId, + eventSeqs: transcriptSource.eventSeqs, + sourcePolicySetId: transcriptSource.sourcePolicySetId, + deliveryAudiencesJson: transcriptSource.deliveryAudiencesJson, + }); return Object.freeze({ source: sealedSource, async stage(content) { diff --git a/src/config/schema.help.agents.ts b/src/config/schema.help.agents.ts index ca2b6ae21f1a..fb072a9ae227 100644 --- a/src/config/schema.help.agents.ts +++ b/src/config/schema.help.agents.ts @@ -24,6 +24,10 @@ export const AGENT_FIELD_HELP: Record = { "Plugin loader configuration group for specifying filesystem paths where plugins are discovered. Keep load paths explicit and reviewed to avoid accidental untrusted extension loading.", "plugins.load.paths": "Additional plugin files or directories scanned by the loader beyond built-in defaults. Use dedicated extension directories and avoid broad paths with unrelated executable content.", + "plugins.enterpriseIdentityProviders": + "Operator-owned startup policy for plugins that contribute enterprise identity verification material. This policy is separate from the plugin-id allowlist and is retained across ordinary plugin reloads.", + "plugins.enterpriseIdentityProviders.allow": + "Exact provider prefixes that may register enterprise identity verification material at Gateway startup. Omit or leave empty to deny all providers. Restart the Gateway after changing this list; plugin reload does not reopen the sealed authority snapshot.", "plugins.slots": "Selects which plugins own exclusive runtime slots such as memory so only one plugin provides that capability. Use explicit slot ownership to avoid overlapping providers with conflicting behavior.", "plugins.slots.memory": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 4c252022bc03..8a3ffa53de5b 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -921,6 +921,8 @@ export const FIELD_LABELS: Record = { "plugins.deny": "Plugin Denylist", "plugins.load": "Plugin Loader", "plugins.load.paths": "Plugin Load Paths", + "plugins.enterpriseIdentityProviders": "Enterprise Identity Providers", + "plugins.enterpriseIdentityProviders.allow": "Enterprise Identity Provider Allowlist", "plugins.slots": "Plugin Slots", "plugins.slots.memory": "Memory Plugin", "plugins.slots.contextEngine": "Context Engine Plugin", diff --git a/src/config/sessions/session-transcript-memory-policy.test.ts b/src/config/sessions/session-transcript-memory-policy.test.ts index a89b10fcdbd5..2543cc6a7a9c 100644 --- a/src/config/sessions/session-transcript-memory-policy.test.ts +++ b/src/config/sessions/session-transcript-memory-policy.test.ts @@ -170,6 +170,7 @@ function recordExposure(params: { exposedResourceRevisions: ["resource-revision-1"], exposureReceiptIds: ["exposure-receipt-1"], egressReceiptIds: ["egress-receipt-1"], + enterpriseMembershipSnapshotIds: [], deliveryAudiences: [{ kind: "user", id: "alice" }], deliveryRevision: "delivery-revision-1", egressRegistryRevision: "egress-registry-revision-1", @@ -344,9 +345,7 @@ describe("transcript memory policy companions", () => { ), ).toThrow("source policy is unavailable"); expect( - database.db - .prepare("SELECT count(*) AS count FROM memory_compaction_policies") - .get(), + database.db.prepare("SELECT count(*) AS count FROM memory_compaction_policies").get(), ).toEqual({ count: 1 }); }); @@ -425,7 +424,10 @@ describe("transcript memory policy companions", () => { await expect(commit()).resolves.toMatchObject({ compactionPolicy: { compactionPolicyId: "sealed-compaction-policy" }, }); - expect(committed).toEqual({ eventSeq: expect.any(Number), policyId: "sealed-compaction-policy" }); + expect(committed).toEqual({ + eventSeq: expect.any(Number), + policyId: "sealed-compaction-policy", + }); const committedEventSeq = expectDefined( committed?.eventSeq, "committed sealed compaction event sequence", @@ -436,9 +438,7 @@ describe("transcript memory policy companions", () => { ), ).toEqual(["sealed-compaction-checkpoint"]); expect( - database.db - .prepare("SELECT count(*) AS count FROM memory_compaction_policies") - .get(), + database.db.prepare("SELECT count(*) AS count FROM memory_compaction_policies").get(), ).toEqual({ count: 1 }); expect( database.db @@ -513,9 +513,7 @@ describe("transcript memory policy companions", () => { ), ).rejects.toThrow("derived state failed"); expect( - database.db - .prepare("SELECT count(*) AS count FROM memory_compaction_policies") - .get(), + database.db.prepare("SELECT count(*) AS count FROM memory_compaction_policies").get(), ).toEqual({ count: 1 }); expect( loadSqliteTranscriptEventsSync(scope(env)).some( @@ -584,12 +582,12 @@ describe("transcript memory policy companions", () => { expect(readSessionEntryRow(database, SESSION_KEY)?.entry.compactionCheckpoints).toHaveLength( 25, ); - expect(readSessionEntryRow(database, SESSION_KEY)?.entry.compactionCheckpoints?.[0]).toMatchObject( - { checkpointId: "retained-checkpoint-1" }, - ); - expect(readSessionEntryRow(database, SESSION_KEY)?.entry.compactionCheckpoints?.at(-1)).toMatchObject( - { checkpointId: "checkpoint-cap-newest" }, - ); + expect( + readSessionEntryRow(database, SESSION_KEY)?.entry.compactionCheckpoints?.[0], + ).toMatchObject({ checkpointId: "retained-checkpoint-1" }); + expect( + readSessionEntryRow(database, SESSION_KEY)?.entry.compactionCheckpoints?.at(-1), + ).toMatchObject({ checkpointId: "checkpoint-cap-newest" }); }); it("enforces Doctor shadow-read-only companion persistence for only its bound subject", async () => { @@ -660,6 +658,7 @@ describe("transcript memory policy companions", () => { exposedResourceRevisions: ["resource-revision-1"], exposureReceiptIds: ["exposure-receipt-1"], egressReceiptIds: ["egress-receipt-1"], + enterpriseMembershipSnapshotIds: [], deliveryAudiences: [{ kind: "agent", id: aliceContext.context.principalId }], deliveryRevision: "delivery-revision-1", egressRegistryRevision: "egress-registry-revision-1", diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 9c293f0fc8dc..c3af86385a2b 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -58,6 +58,11 @@ export type PluginsLoadConfig = { paths?: string[]; }; +export type EnterpriseIdentityProvidersConfig = { + /** Provider prefixes that may register enterprise identity verification material at startup. */ + allow?: string[]; +}; + export type PluginInstallRecord = Omit & { source: InstallRecordBase["source"] | "marketplace"; marketplaceName?: string; @@ -73,6 +78,8 @@ export type PluginsConfig = { /** Optional plugin denylist (plugin ids). */ deny?: string[]; load?: PluginsLoadConfig; + /** Operator-owned enterprise identity provider allowlist. Empty or unset denies all providers. */ + enterpriseIdentityProviders?: EnterpriseIdentityProvidersConfig; slots?: PluginSlotsConfig; entries?: Record; /** diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index 74d2b74c7a11..de0b9f4912ce 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -480,6 +480,11 @@ export const OpenClawSchemaShape = { paths: z.array(z.string()).optional(), }) .optional(), + enterpriseIdentityProviders: z + .strictObject({ + allow: z.array(z.string().min(1)).optional(), + }) + .optional(), slots: z .strictObject({ memory: z.string().optional(), diff --git a/src/gateway/memory-enterprise-oidc-callback-http.test.ts b/src/gateway/memory-enterprise-oidc-callback-http.test.ts new file mode 100644 index 000000000000..90317879f3e6 --- /dev/null +++ b/src/gateway/memory-enterprise-oidc-callback-http.test.ts @@ -0,0 +1,103 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { handleMemoryEnterpriseOidcCallbackHttpRequest } from "./memory-enterprise-oidc-callback-http.js"; +import { createResponse } from "./server-http.test-harness.js"; + +const state = "s".repeat(43); + +function request(url: string, method = "GET"): IncomingMessage { + return { url, method } as IncomingMessage; +} + +describe("memory enterprise OIDC callback HTTP handler", () => { + it("accepts a one-use receipt without browser authentication and redacts the completion result", async () => { + const complete = vi.fn(async () => ({ + kind: "linked" as const, + providerId: "entra", + expiresAt: "2026-08-14T00:00:00.000Z", + })); + const response = createResponse(); + + await expect( + handleMemoryEnterpriseOidcCallbackHttpRequest( + request(`/memory/oidc/callback?state=${state}&code=provider-code`), + response.res, + { complete }, + ), + ).resolves.toBe(true); + + expect(complete).toHaveBeenCalledExactlyOnceWith({ + state, + code: "provider-code", + }); + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toContain("Sign-in completed"); + expect(response.getBody()).not.toContain("entra"); + expect(response.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store, max-age=0"); + expect(response.setHeader).toHaveBeenCalledWith( + "Content-Security-Policy", + "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + ); + }); + + it("rejects duplicate or oversized redirect parameters without exchanging a code", async () => { + const complete = vi.fn(); + for (const url of [ + `/memory/oidc/callback?state=${state}&state=${state}&code=provider-code`, + "/memory/oidc/callback?state=not-a-random-receipt&code=provider-code", + `/memory/oidc/callback?state=${state}&code=${"a".repeat(8_193)}`, + ]) { + const response = createResponse(); + await expect( + handleMemoryEnterpriseOidcCallbackHttpRequest(request(url), response.res, { complete }), + ).resolves.toBe(true); + expect(response.res.statusCode, url).toBe(400); + } + expect(complete).not.toHaveBeenCalled(); + }); + + it("consumes a valid state when the provider returns no code and keeps denial details private", async () => { + const complete = vi.fn(async () => ({ + kind: "denied" as const, + reason: "identity-verification-failed" as const, + })); + const response = createResponse(); + + await handleMemoryEnterpriseOidcCallbackHttpRequest( + request(`/memory/oidc/callback?state=${state}&error=access_denied`), + response.res, + { complete }, + ); + + expect(complete).toHaveBeenCalledExactlyOnceWith({ state, code: "" }); + expect(response.res.statusCode).toBe(400); + expect(response.getBody()).toContain("Sign-in could not be completed"); + expect(response.getBody()).not.toContain("identity-verification-failed"); + }); + + it("returns a cache-busting method error for non-GET callbacks", async () => { + const response = createResponse(); + + await expect( + handleMemoryEnterpriseOidcCallbackHttpRequest( + request(`/memory/oidc/callback?state=${state}&code=provider-code`, "POST"), + response.res, + ), + ).resolves.toBe(true); + + expect(response.res.statusCode).toBe(405); + expect(response.setHeader).toHaveBeenCalledWith("Allow", "GET"); + expect(response.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store, max-age=0"); + }); + + it("does not claim unrelated paths", async () => { + const response = createResponse(); + await expect( + handleMemoryEnterpriseOidcCallbackHttpRequest( + request(`/memory/oidc/not-callback?state=${state}&code=provider-code`), + response.res as ServerResponse, + ), + ).resolves.toBe(false); + expect(response.end).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/memory-enterprise-oidc-callback-http.ts b/src/gateway/memory-enterprise-oidc-callback-http.ts new file mode 100644 index 000000000000..3b01f84b4981 --- /dev/null +++ b/src/gateway/memory-enterprise-oidc-callback-http.ts @@ -0,0 +1,124 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + completeGatewayEnterpriseIdentityAuthorizationCallback, + type GatewayEnterpriseIdentityAuthorizationResult, +} from "./memory-enterprise-oidc-transaction.js"; + +export const MEMORY_ENTERPRISE_OIDC_CALLBACK_PATH = "/memory/oidc/callback"; + +const MAX_CODE_LENGTH = 8_192; + +type CompleteAuthorization = (params: { + state: string; + code: string; +}) => Promise; + +function hasOneBoundedParameter( + params: URLSearchParams, + name: string, + maxLength: number, +): string | undefined { + const values = params.getAll(name); + if (values.length !== 1) { + return undefined; + } + const value = values[0]!; + return value.length > 0 && value.length <= maxLength ? value : undefined; +} + +function hasOneTransactionState(params: URLSearchParams): string | undefined { + const values = params.getAll("state"); + if (values.length !== 1) { + return undefined; + } + // Transactions use 32 random bytes encoded as unpadded base64url. Keeping + // this exact wire shape prevents the public route from becoming an oracle + // over arbitrary identifiers while preserving a fixed receipt capability. + return /^[A-Za-z0-9_-]{43}$/u.test(values[0]!) ? values[0] : undefined; +} + +function writeCallbackPage(res: ServerResponse, statusCode: number, message: string): void { + const body = `OpenClaw

${message}

`; + res.statusCode = statusCode; + res.setHeader("Cache-Control", "no-store, max-age=0"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader( + "Content-Security-Policy", + "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + ); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Content-Length", String(Buffer.byteLength(body))); + res.end(body); +} + +/** + * Handles the public redirect endpoint for a receipt-bound OIDC login. It does + * not authenticate the browser: the opaque, one-use state receipt is the sole + * capability and completion returns no provider, account, or profile details. + */ +export async function handleMemoryEnterpriseOidcCallbackHttpRequest( + req: IncomingMessage, + res: ServerResponse, + options: { complete?: CompleteAuthorization } = {}, +): Promise { + let callbackUrl: URL; + try { + callbackUrl = new URL(req.url ?? "/", "http://localhost"); + } catch { + return false; + } + if (callbackUrl.pathname !== MEMORY_ENTERPRISE_OIDC_CALLBACK_PATH) { + return false; + } + if ((req.method ?? "GET").toUpperCase() !== "GET") { + res.setHeader("Allow", "GET"); + writeCallbackPage(res, 405, "This sign-in callback only accepts GET requests."); + return true; + } + + const state = hasOneTransactionState(callbackUrl.searchParams); + const hasCodeParameter = callbackUrl.searchParams.has("code"); + const code = hasOneBoundedParameter(callbackUrl.searchParams, "code", MAX_CODE_LENGTH); + if (!state || (hasCodeParameter && !code)) { + writeCallbackPage( + res, + 400, + "Sign-in could not be completed. Return to OpenClaw and try again.", + ); + return true; + } + if (!code) { + // A provider-denied redirect can contain an otherwise-valid receipt but no + // code. Consume it so that an interrupted login cannot be resumed later. + if (state) { + await (options.complete ?? completeGatewayEnterpriseIdentityAuthorizationCallback)({ + state, + code: "", + }); + } + writeCallbackPage( + res, + 400, + "Sign-in could not be completed. Return to OpenClaw and try again.", + ); + return true; + } + + const result = await (options.complete ?? completeGatewayEnterpriseIdentityAuthorizationCallback)( + { + state, + code, + }, + ); + if (result.kind !== "linked") { + writeCallbackPage( + res, + 400, + "Sign-in could not be completed. Return to OpenClaw and try again.", + ); + return true; + } + writeCallbackPage(res, 200, "Sign-in completed. You can return to OpenClaw."); + return true; +} diff --git a/src/gateway/memory-enterprise-oidc-transaction.test.ts b/src/gateway/memory-enterprise-oidc-transaction.test.ts new file mode 100644 index 000000000000..16fc3931c836 --- /dev/null +++ b/src/gateway/memory-enterprise-oidc-transaction.test.ts @@ -0,0 +1,342 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEnterpriseIdentityProviderAuthorityRegistry } from "../plugins/enterprise-identity-provider-authority-registry.js"; +import type { EnterpriseIdentityProviderAdapter } from "../plugins/enterprise-identity-provider-types.js"; +import { clearEnterpriseOidcJwksCacheForTest } from "../state/memory-enterprise-verifier.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { ensureProfileForEmail } from "../state/user-profiles.js"; +import { + clearGatewayEnterpriseOidcTransactionsForTest, + completeGatewayEnterpriseIdentityAuthorizationCallback, + completeGatewayEnterpriseIdentityAuthorization, + startGatewayEnterpriseIdentityAuthorization, +} from "./memory-enterprise-oidc-transaction.js"; +import type { GatewayClient } from "./server-methods/types.js"; + +const now = 1_000_000; +const pair = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const publicJwk = pair.publicKey.export({ format: "jwk" }); +const roots: string[] = []; + +const adapter: EnterpriseIdentityProviderAdapter = { + providerPrefix: "entra", + resolveAuthorizationCodeClientSecret: async () => "test-client-secret", + authorities: [ + { + issuer: "https://login.example/tenant-a/v2.0", + tenantId: "tenant-a", + audiences: ["openclaw-memory"], + jwksUri: "https://login.example/tenant-a/keys", + algorithm: "RS256", + tenantBinding: { kind: "claim", claim: "tid", value: "tenant-a" }, + assurance: { maxAuthenticationAgeMs: 60_000 }, + authorizationCodeFlow: { + clientId: "openclaw-memory", + authorizationEndpoint: "https://login.example/tenant-a/authorize", + tokenEndpoint: "https://login.example/tenant-a/token", + redirectUri: "https://gateway.example/memory/oidc/callback", + scopes: ["openid"], + }, + membership: { + kind: "oidc-claim", + claim: "groups", + required: true, + roleGroupIds: ["writers"], + maxGroups: 200, + }, + maxSnapshotAgeMs: 60_000, + }, + ], +}; + +function fixture() { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-memory-enterprise-oidc-")); + roots.push(root); + return { env: { ...process.env, OPENCLAW_STATE_DIR: root } }; +} + +function client(profileId: string): GatewayClient { + return { + authenticatedUserProfile: { profileId }, + connect: { scopes: [] }, + } as unknown as GatewayClient; +} + +function registry() { + const authorityRegistry = createEnterpriseIdentityProviderAuthorityRegistry({ + operatorAllowlist: ["entra"], + }); + authorityRegistry.seal([ + { pluginId: "memory-identity-entra", provider: adapter, source: "test" }, + ]); + return authorityRegistry; +} + +function addUser(email: string, principalId: string, env: NodeJS.ProcessEnv): string { + const profile = ensureProfileForEmail(email, { env }); + openOpenClawStateDatabase({ env }) + .db.prepare( + `INSERT INTO memory_principals + (principal_id, principal_kind, user_profile_id, principal_lookup_hmac, state, revision, created_at, revoked_at) + VALUES (?, 'user', ?, NULL, 'active', ?, ?, NULL)`, + ) + .run(principalId, profile.id, `revision:${principalId}`, now); + return profile.id; +} + +function idToken(nonce: string): string { + const header = Buffer.from(JSON.stringify({ alg: "RS256", kid: "key-a", typ: "JWT" })).toString( + "base64url", + ); + const payload = Buffer.from( + JSON.stringify({ + iss: "https://login.example/tenant-a/v2.0", + aud: "openclaw-memory", + tid: "tenant-a", + sub: "enterprise-alice", + nonce, + groups: ["writers"], + auth_time: Math.floor((now - 2_000) / 1_000), + iat: Math.floor((now - 1_000) / 1_000), + exp: Math.floor((now + 30_000) / 1_000), + }), + ).toString("base64url"); + const input = `${header}.${payload}`; + return `${input}.${sign("RSA-SHA256", Buffer.from(input), pair.privateKey).toString("base64url")}`; +} + +afterEach(() => { + clearGatewayEnterpriseOidcTransactionsForTest(); + clearEnterpriseOidcJwksCacheForTest(); + closeOpenClawStateDatabaseForTest(); + vi.unstubAllGlobals(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("Gateway enterprise OIDC transaction", () => { + it("bounds pending browser transactions per profile and globally", async () => { + const authorityRegistry = registry(); + for (let index = 0; index < 16; index += 1) { + await expect( + startGatewayEnterpriseIdentityAuthorization({ + client: client("profile:one"), + providerPrefix: "entra", + authorityRegistry, + now, + }), + ).resolves.toMatchObject({ authorizationUrl: expect.any(String) }); + } + await expect( + startGatewayEnterpriseIdentityAuthorization({ + client: client("profile:one"), + providerPrefix: "entra", + authorityRegistry, + now, + }), + ).rejects.toThrow("too many pending requests"); + + clearGatewayEnterpriseOidcTransactionsForTest(); + for (let index = 0; index < 256; index += 1) { + await startGatewayEnterpriseIdentityAuthorization({ + client: client(`profile:${index}`), + providerPrefix: "entra", + authorityRegistry, + now, + }); + } + await expect( + startGatewayEnterpriseIdentityAuthorization({ + client: client("profile:overflow"), + providerPrefix: "entra", + authorityRegistry, + now, + }), + ).rejects.toThrow("temporarily busy"); + }); + + it("binds code, nonce, and resulting link to the initiating Gateway profile exactly once", async () => { + const { env } = fixture(); + const aliceProfileId = addUser("alice@example.com", "principal:alice", env); + const authorityRegistry = registry(); + const start = await startGatewayEnterpriseIdentityAuthorization({ + client: client(aliceProfileId), + providerPrefix: "entra", + authorityRegistry, + now, + }); + const nonce = new URL(start.authorizationUrl).searchParams.get("nonce"); + expect(nonce).toBeTruthy(); + expect(start.authorizationUrl).toContain("code_challenge_method=S256"); + expect(new URL(start.authorizationUrl).searchParams.get("max_age")).toBe("60"); + const fetch = vi.fn(async (input: URL | string, _init?: RequestInit) => { + if (String(input) === "https://login.example/tenant-a/token") { + return { ok: true, json: async () => ({ id_token: idToken(nonce!) }) }; + } + return { + ok: true, + json: async () => ({ keys: [{ ...publicJwk, kid: "key-a", use: "sig" }] }), + }; + }); + vi.stubGlobal("fetch", fetch); + + await expect( + completeGatewayEnterpriseIdentityAuthorization({ + client: client(aliceProfileId), + providerPrefix: "entra", + state: start.state, + code: "authorization-code", + authorityRegistry, + options: { env }, + now, + }), + ).resolves.toMatchObject({ kind: "linked", providerId: "entra" }); + const tokenCall = fetch.mock.calls.find( + ([input]) => String(input) === "https://login.example/tenant-a/token", + ); + expect(tokenCall?.[1]).toMatchObject({ + headers: { + authorization: "Basic b3BlbmNsYXctbWVtb3J5OnRlc3QtY2xpZW50LXNlY3JldA==", + }, + }); + expect( + new URLSearchParams(String((tokenCall?.[1] as RequestInit | undefined)?.body)).has( + "client_id", + ), + ).toBe(false); + await expect( + completeGatewayEnterpriseIdentityAuthorization({ + client: client(aliceProfileId), + providerPrefix: "entra", + state: start.state, + code: "authorization-code", + authorityRegistry, + options: { env }, + now, + }), + ).resolves.toEqual({ kind: "denied", reason: "transaction-invalid" }); + }); + + it("rejects a receipt replayed by a different Gateway profile before token exchange", async () => { + const { env } = fixture(); + const aliceProfileId = addUser("alice@example.com", "principal:alice", env); + const bobProfileId = addUser("bob@example.com", "principal:bob", env); + const authorityRegistry = registry(); + const start = await startGatewayEnterpriseIdentityAuthorization({ + client: client(aliceProfileId), + providerPrefix: "entra", + authorityRegistry, + now, + }); + await expect( + completeGatewayEnterpriseIdentityAuthorization({ + client: client(bobProfileId), + providerPrefix: "entra", + state: start.state, + code: "authorization-code", + authorityRegistry, + now, + }), + ).resolves.toEqual({ kind: "denied", reason: "transaction-invalid" }); + }); + + it("expires a receipt before code exchange and does not contact the provider", async () => { + const authorityRegistry = registry(); + const start = await startGatewayEnterpriseIdentityAuthorization({ + client: client("profile:expired"), + providerPrefix: "entra", + authorityRegistry, + now, + }); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + await expect( + completeGatewayEnterpriseIdentityAuthorization({ + client: client("profile:expired"), + providerPrefix: "entra", + state: start.state, + code: "authorization-code", + authorityRegistry, + now: now + 5 * 60_000 + 1, + }), + ).resolves.toEqual({ kind: "denied", reason: "transaction-invalid" }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("refuses to start a confidential callback when its client secret is unavailable", async () => { + const authorityRegistry = createEnterpriseIdentityProviderAuthorityRegistry({ + operatorAllowlist: ["entra"], + }); + authorityRegistry.seal([ + { + pluginId: "memory-identity-entra", + provider: { ...adapter, resolveAuthorizationCodeClientSecret: async () => undefined }, + source: "test", + }, + ]); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + await expect( + startGatewayEnterpriseIdentityAuthorization({ + client: client("profile:missing-secret"), + providerPrefix: "entra", + authorityRegistry, + now, + }), + ).rejects.toThrow("client authentication is unavailable"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("completes a browser redirect from its receipt without caller-selected profile or provider", async () => { + const { env } = fixture(); + const aliceProfileId = addUser("alice@example.com", "principal:alice", env); + const authorityRegistry = registry(); + const start = await startGatewayEnterpriseIdentityAuthorization({ + client: client(aliceProfileId), + providerPrefix: "entra", + authorityRegistry, + now, + }); + const nonce = new URL(start.authorizationUrl).searchParams.get("nonce"); + vi.stubGlobal( + "fetch", + vi.fn(async (input: URL | string) => { + if (String(input) === "https://login.example/tenant-a/token") { + return { ok: true, json: async () => ({ id_token: idToken(nonce!) }) }; + } + return { + ok: true, + json: async () => ({ keys: [{ ...publicJwk, kid: "key-a", use: "sig" }] }), + }; + }), + ); + + await expect( + completeGatewayEnterpriseIdentityAuthorizationCallback({ + state: start.state, + code: "authorization-code", + authorityRegistry, + options: { env }, + now, + }), + ).resolves.toMatchObject({ kind: "linked", providerId: "entra" }); + await expect( + completeGatewayEnterpriseIdentityAuthorizationCallback({ + state: start.state, + code: "authorization-code", + authorityRegistry, + options: { env }, + now, + }), + ).resolves.toEqual({ kind: "denied", reason: "transaction-invalid" }); + }); +}); diff --git a/src/gateway/memory-enterprise-oidc-transaction.ts b/src/gateway/memory-enterprise-oidc-transaction.ts new file mode 100644 index 000000000000..2ae7201e59eb --- /dev/null +++ b/src/gateway/memory-enterprise-oidc-transaction.ts @@ -0,0 +1,358 @@ +import { createHash, randomBytes } from "node:crypto"; +import { + getProcessEnterpriseIdentityProviderAuthorityRegistry, + type EnterpriseIdentityProviderAuthorityRegistry, + type EnterpriseIdentityProviderRegistration, +} from "../plugins/enterprise-identity-provider-authority-registry.js"; +import { admitVerifiedEnterpriseIdentityForMemory } from "../state/memory-enterprise-admission.js"; +import { linkMemoryEnterpriseProfile } from "../state/memory-enterprise-identity.js"; +import { + persistVerifiedEnterpriseOidcIdentity, + verifyEnterpriseOidcIdentity, +} from "../state/memory-enterprise-verifier.js"; +import { resolveMemoryPrincipalForUserProfile } from "../state/memory-identity.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import type { GatewayClient } from "./server-methods/types.js"; + +const TRANSACTION_TTL_MS = 5 * 60_000; +const MAX_TRANSACTIONS = 256; +const MAX_TRANSACTIONS_PER_PROFILE = 16; + +type EnterpriseOidcTransaction = Readonly<{ + providerPrefix: string; + userProfileId: string; + nonce: string; + codeVerifier: string; + expiresAt: number; +}>; + +const transactions = new Map(); + +export type GatewayEnterpriseIdentityAuthorizationStart = Readonly<{ + state: string; + authorizationUrl: string; + expiresAt: string; +}>; + +export type GatewayEnterpriseIdentityAuthorizationResult = + | Readonly<{ kind: "linked"; providerId: string; expiresAt: string }> + | Readonly<{ + kind: "denied"; + reason: "transaction-invalid" | "provider-unavailable" | "identity-verification-failed"; + }>; + +type EnterpriseAuthorizationCompletion = Readonly<{ + state: string; + code: string; + /** The authenticated RPC caller is an additional binding; browser callbacks have no caller. */ + expectedUserProfileId?: string; + /** RPC callers name the selected provider; browser callbacks recover it from the receipt. */ + expectedProviderPrefix?: string; + authorityRegistry?: EnterpriseIdentityProviderAuthorityRegistry; + options?: OpenClawStateDatabaseOptions; + now?: number; +}>; + +function randomBase64Url(bytes: number): string { + return randomBytes(bytes).toString("base64url"); +} + +function codeChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +function requireAuthenticatedGatewayProfile(client: GatewayClient): string { + const profileId = client.authenticatedUserProfile?.profileId; + if (!profileId) { + throw new Error("enterprise identity authorization requires an authenticated Gateway profile"); + } + return profileId; +} + +function resolveRegistration(params: { + providerPrefix: string; + authorityRegistry?: EnterpriseIdentityProviderAuthorityRegistry; +}): EnterpriseIdentityProviderRegistration | undefined { + const registry = + params.authorityRegistry ?? getProcessEnterpriseIdentityProviderAuthorityRegistry(); + if (!registry?.isSealed()) { + throw new Error( + "enterprise identity authorization requires the sealed Gateway provider registry", + ); + } + return registry.providers.find( + (candidate) => candidate.provider.providerPrefix === params.providerPrefix, + ); +} + +async function resolveAuthorizationCodeClientSecret( + registration: EnterpriseIdentityProviderRegistration, +): Promise { + try { + return await registration.provider.resolveAuthorizationCodeClientSecret?.(); + } catch { + return undefined; + } +} + +function cleanupExpiredTransactions(now: number): void { + for (const [state, transaction] of transactions) { + if (transaction.expiresAt <= now) { + transactions.delete(state); + } + } +} + +/** Test lifecycle hook; authorization code verifiers and nonces never survive a process restart. */ +export function clearGatewayEnterpriseOidcTransactionsForTest(): void { + transactions.clear(); +} + +/** + * Starts a self-service public-client authorization-code flow. The browser only + * receives opaque state and a PKCE challenge; the verifier and nonce remain in + * the Gateway process and are consumed exactly once by completion. + */ +export async function startGatewayEnterpriseIdentityAuthorization(params: { + client: GatewayClient; + providerPrefix: string; + authorityRegistry?: EnterpriseIdentityProviderAuthorityRegistry; + now?: number; +}): Promise { + const userProfileId = requireAuthenticatedGatewayProfile(params.client); + const registration = resolveRegistration(params); + if (!registration || registration.provider.authorities.length !== 1) { + throw new Error( + "enterprise identity provider is not enabled for a single configured authority", + ); + } + const availability = await registration.provider.checkServiceAvailability?.(); + if (availability && !availability.available) { + throw new Error("enterprise identity provider is unavailable"); + } + if (!(await resolveAuthorizationCodeClientSecret(registration))) { + throw new Error("enterprise identity provider client authentication is unavailable"); + } + const now = params.now ?? Date.now(); + cleanupExpiredTransactions(now); + if (transactions.size >= MAX_TRANSACTIONS) { + throw new Error( + "enterprise identity authorization is temporarily busy; complete or retry shortly", + ); + } + const profileTransactionCount = [...transactions.values()].filter( + (transaction) => transaction.userProfileId === userProfileId, + ).length; + if (profileTransactionCount >= MAX_TRANSACTIONS_PER_PROFILE) { + throw new Error( + "enterprise identity authorization already has too many pending requests for this profile", + ); + } + const state = randomBase64Url(32); + const nonce = randomBase64Url(32); + const verifier = randomBase64Url(48); + const authority = registration.provider.authorities[0]!; + const url = new URL(authority.authorizationCodeFlow.authorizationEndpoint); + url.searchParams.set("client_id", authority.authorizationCodeFlow.clientId); + url.searchParams.set("redirect_uri", authority.authorizationCodeFlow.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", authority.authorizationCodeFlow.scopes.join(" ")); + url.searchParams.set("state", state); + url.searchParams.set("nonce", nonce); + url.searchParams.set("code_challenge", codeChallenge(verifier)); + url.searchParams.set("code_challenge_method", "S256"); + // OIDC providers emit auth_time for a max_age-bound transaction. Core then + // verifies it against the same sealed assurance policy before linking memory. + url.searchParams.set( + "max_age", + String(Math.max(1, Math.floor(authority.assurance.maxAuthenticationAgeMs / 1_000))), + ); + const expiresAt = now + TRANSACTION_TTL_MS; + transactions.set( + state, + Object.freeze({ + providerPrefix: registration.provider.providerPrefix, + userProfileId, + nonce, + codeVerifier: verifier, + expiresAt, + }), + ); + return Object.freeze({ + state, + authorizationUrl: url.toString(), + expiresAt: new Date(expiresAt).toISOString(), + }); +} + +async function exchangeAuthorizationCode(params: { + registration: EnterpriseIdentityProviderRegistration; + code: string; + transaction: EnterpriseOidcTransaction; +}): Promise { + const authority = params.registration.provider.authorities[0]; + if (!authority) { + return undefined; + } + const clientSecret = await resolveAuthorizationCodeClientSecret(params.registration); + if (!clientSecret) { + return undefined; + } + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: params.code, + redirect_uri: authority.authorizationCodeFlow.redirectUri, + code_verifier: params.transaction.codeVerifier, + }); + try { + const response = await fetch(authority.authorizationCodeFlow.tokenEndpoint, { + method: "POST", + // HTTPS Gateway callbacks are confidential web clients. Never silently + // retry this exchange as a public client if the configured secret is absent. + headers: { + accept: "application/json", + authorization: `Basic ${Buffer.from( + `${authority.authorizationCodeFlow.clientId}:${clientSecret}`, + "utf8", + ).toString("base64")}`, + "content-type": "application/x-www-form-urlencoded", + }, + body, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + return undefined; + } + const payload: unknown = await response.json(); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return undefined; + } + const idToken = (payload as Record).id_token; + return typeof idToken === "string" && idToken ? idToken : undefined; + } catch { + return undefined; + } +} + +/** + * Consumes a Gateway-created state receipt. There is no bearer-token linking + * or caller-selected target profile: the verified result can link only the + * authenticated profile that started this exact PKCE+nonce transaction. + */ +async function completeEnterpriseIdentityAuthorization( + params: EnterpriseAuthorizationCompletion, +): Promise { + const now = params.now ?? Date.now(); + cleanupExpiredTransactions(now); + const transaction = transactions.get(params.state); + // Consume before exchange: any replay, changed user, provider mismatch, or + // failed exchange requires a new authorization transaction. + transactions.delete(params.state); + if ( + !transaction || + transaction.expiresAt <= now || + (params.expectedUserProfileId !== undefined && + transaction.userProfileId !== params.expectedUserProfileId) || + (params.expectedProviderPrefix !== undefined && + transaction.providerPrefix !== params.expectedProviderPrefix) || + !params.code.trim() + ) { + return { kind: "denied", reason: "transaction-invalid" }; + } + const registration = resolveRegistration({ + providerPrefix: transaction.providerPrefix, + authorityRegistry: params.authorityRegistry, + }); + if (!registration) { + return { kind: "denied", reason: "transaction-invalid" }; + } + const availability = await registration.provider.checkServiceAvailability?.(); + if (availability && !availability.available) { + return { kind: "denied", reason: "provider-unavailable" }; + } + const idToken = await exchangeAuthorizationCode({ + registration, + code: params.code.trim(), + transaction, + }); + if (!idToken) { + return { kind: "denied", reason: "provider-unavailable" }; + } + const verification = await verifyEnterpriseOidcIdentity({ + adapter: registration.provider, + token: idToken, + expectedNonce: transaction.nonce, + now, + }); + if (verification.kind !== "verified") { + return { + kind: "denied", + reason: + verification.reason === "provider-unavailable" + ? "provider-unavailable" + : "identity-verification-failed", + }; + } + const userPrincipal = resolveMemoryPrincipalForUserProfile({ + userProfileId: transaction.userProfileId, + options: params.options, + }); + if (!userPrincipal) { + throw new Error("enterprise identity authorization requires an active Gateway user principal"); + } + const persisted = persistVerifiedEnterpriseOidcIdentity({ + identity: verification.identity, + options: params.options, + }); + const profileLink = linkMemoryEnterpriseProfile({ + enterprisePrincipalId: persisted.principal.principalId, + providerId: verification.identity.providerId, + userPrincipalId: userPrincipal.principalId, + createdByPrincipalId: userPrincipal.principalId, + options: params.options, + now, + }); + admitVerifiedEnterpriseIdentityForMemory({ + userPrincipalId: userPrincipal.principalId, + principal: persisted.principal, + profileLink, + identity: verification.identity, + }); + return { + kind: "linked", + providerId: verification.identity.providerId, + expiresAt: new Date(verification.identity.expiresAt).toISOString(), + }; +} + +export async function completeGatewayEnterpriseIdentityAuthorization(params: { + client: GatewayClient; + providerPrefix: string; + state: string; + code: string; + authorityRegistry?: EnterpriseIdentityProviderAuthorityRegistry; + options?: OpenClawStateDatabaseOptions; + now?: number; +}): Promise { + const userProfileId = requireAuthenticatedGatewayProfile(params.client); + return await completeEnterpriseIdentityAuthorization({ + ...params, + expectedUserProfileId: userProfileId, + expectedProviderPrefix: params.providerPrefix, + }); +} + +/** + * Completes a public OIDC redirect. The consumed receipt supplies both target + * profile and provider; request parameters never select either authority. + */ +export async function completeGatewayEnterpriseIdentityAuthorizationCallback(params: { + state: string; + code: string; + authorityRegistry?: EnterpriseIdentityProviderAuthorityRegistry; + options?: OpenClawStateDatabaseOptions; + now?: number; +}): Promise { + return await completeEnterpriseIdentityAuthorization(params); +} diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 6956d1838840..ab6e17a6c2e0 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -502,6 +502,55 @@ const CORE_GATEWAY_METHOD_SPECS = [ ["secrets.store.list", null, "operator.admin", "2026.8"], ["secrets.store.set", null, "operator.admin", "2026.8", { controlPlaneWrite: true }], ["secrets.store.delete", null, "operator.admin", "2026.8", { controlPlaneWrite: true }], + // Enterprise identity links always bind to the authenticated initiating profile. + // Append so every existing advertised method index remains stable for older clients. + [ + "memory.enterpriseIdentity.authorization.start", + "memory-enterprise-identity", + "operator.write", + "2026.8", + ], + [ + "memory.enterpriseIdentity.authorization.complete", + "memory-enterprise-identity", + "operator.write", + "2026.8", + ], + [ + "memory.enterpriseIdentity.accessAudit.list", + "memory-enterprise-identity", + "operator.read", + "2026.8", + ], + [ + "memory.enterpriseIdentity.policyDriftAlerts.list", + "memory-enterprise-identity", + "operator.read", + "2026.8", + ], + // Lifecycle history is bounded and redacted; object-level ownership is + // enforced in the handler before an operator can select another profile. + [ + "memory.enterpriseIdentity.evidenceTransitions.list", + "memory-enterprise-identity", + "operator.read", + "2026.8", + ], + // Export and lifecycle mutations require write scope before the handler + // applies profile ownership or the stronger operator.admin override. + [ + "memory.enterpriseIdentity.accessAudit.export", + "memory-enterprise-identity", + "operator.write", + "2026.8", + ], + ["memory.enterpriseIdentity.unlink", "memory-enterprise-identity", "operator.write", "2026.8"], + [ + "memory.enterpriseIdentity.evidence.revoke", + "memory-enterprise-identity", + "operator.write", + "2026.8", + ], ] as const satisfies readonly CoreGatewayMethodSpecRow[]; export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>; diff --git a/src/gateway/server-http.memory-enterprise-oidc-callback.test.ts b/src/gateway/server-http.memory-enterprise-oidc-callback.test.ts new file mode 100644 index 000000000000..88bfc5189067 --- /dev/null +++ b/src/gateway/server-http.memory-enterprise-oidc-callback.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + AUTH_TOKEN, + createRequest, + createResponse, + dispatchRequest, + withGatewayServer, +} from "./server-http.test-harness.js"; + +describe("Gateway enterprise OIDC callback route", () => { + it("is public but state-bound, ahead of plugin routing, and never returns a Gateway auth challenge", async () => { + await withGatewayServer({ + prefix: "memory-enterprise-oidc-callback-route", + resolvedAuth: AUTH_TOKEN, + overrides: { + handlePluginRequest: async () => { + throw new Error("the core OIDC callback must run before plugin routing"); + }, + }, + run: async (server) => { + const response = createResponse(); + await dispatchRequest( + server, + createRequest({ + path: `/memory/oidc/callback?state=${"s".repeat(43)}&code=provider-code`, + method: "GET", + }), + response.res, + ); + + expect(response.res.statusCode).toBe(400); + expect(response.getBody()).toContain("Sign-in could not be completed"); + expect(response.getBody()).not.toContain("Unauthorized"); + expect(response.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store, max-age=0"); + }, + }); + }); +}); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 1ad86491b3b5..d1f3ca70e127 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -110,6 +110,9 @@ const getEmbeddingsHttpModule = createLazyRuntimeModule(() => import("./embeddin const getManagedMediaAttachmentsModule = createLazyRuntimeModule( () => import("./managed-image-attachments.js"), ); +const getMemoryEnterpriseOidcCallbackHttpModule = createLazyRuntimeModule( + () => import("./memory-enterprise-oidc-callback-http.js"), +); const getMcpAppStandaloneModule = createLazyRuntimeModule(() => import("./mcp-app-standalone.js")); const getPluginIconHttpModule = createLazyRuntimeModule(() => import("./plugin-icon-http.js")); const getModelsHttpModule = createLazyRuntimeModule(() => import("./models-http.js")); @@ -487,6 +490,18 @@ export function createGatewayHttpServer(opts: { run: GatewayHttpRequestStage["run"], ) => addRequestStage(name, enabled, run, true); + // This public endpoint is receipt-bound, not browser-authenticated. It + // must run before plugins so a provider redirect cannot be claimed by a + // mutable plugin route or expose its opaque authorization parameters. + addAdmittedStage( + "memory-enterprise-oidc-callback", + scopedRequestPath === "/memory/oidc/callback", + async () => + ( + await getMemoryEnterpriseOidcCallbackHttpModule() + ).handleMemoryEnterpriseOidcCallbackHttpRequest(req, res), + ); + addAdmittedStage( "watch-node", Boolean(opts.handleWatchNodeRequest) && scopedRequestPath.startsWith("/api/nodes/watch/"), diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 1d09492e3d5d..699eae541cec 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -103,6 +103,10 @@ const CORE_GATEWAY_HANDLER_MODULES = { logs: () => import("./server-methods/logs.js").then((module) => module.logsHandlers), "memory-search": () => import("./server-methods/memory-search.js").then((module) => module.memorySearchHandlers), + "memory-enterprise-identity": () => + import("./server-methods/memory-enterprise-identity.js").then( + (module) => module.memoryEnterpriseIdentityHandlers, + ), terminal: () => import("./server-methods/terminal.js").then((module) => module.terminalHandlers), "ui-command": () => import("./server-methods/ui-command.js").then((module) => module.uiCommandHandlers), diff --git a/src/gateway/server-methods/memory-enterprise-identity.test.ts b/src/gateway/server-methods/memory-enterprise-identity.test.ts new file mode 100644 index 000000000000..b6d2485a783d --- /dev/null +++ b/src/gateway/server-methods/memory-enterprise-identity.test.ts @@ -0,0 +1,486 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + complete: vi.fn(), + start: vi.fn(), + listAudit: vi.fn(), + listPolicyDriftAlerts: vi.fn(), + listEvidenceTransitionImpacts: vi.fn(), + revokeProfileEvidence: vi.fn(), + unlinkProfile: vi.fn(), + resolvePrincipal: vi.fn(), + resolveProfileId: vi.fn((profileId: string) => profileId), +})); + +vi.mock("../memory-enterprise-oidc-transaction.js", () => ({ + completeGatewayEnterpriseIdentityAuthorization: mocks.complete, + startGatewayEnterpriseIdentityAuthorization: mocks.start, +})); + +vi.mock("../../state/memory-enterprise-access-audit.js", () => ({ + listMemoryEnterpriseAccessDecisionAudit: mocks.listAudit, + listMemoryEnterprisePolicyDriftAlerts: mocks.listPolicyDriftAlerts, +})); + +vi.mock("../../state/memory-enterprise-revocation-impact.js", () => ({ + listMemoryEnterpriseEvidenceTransitionImpactsForUserPrincipal: + mocks.listEvidenceTransitionImpacts, +})); + +vi.mock("../../state/memory-enterprise-identity.js", () => ({ + revokeMemoryEnterpriseProfileEvidence: mocks.revokeProfileEvidence, + unlinkMemoryEnterpriseProfile: mocks.unlinkProfile, +})); + +vi.mock("../../state/memory-identity.js", () => ({ + resolveMemoryPrincipalForUserProfile: mocks.resolvePrincipal, +})); + +vi.mock("../../state/user-profiles.js", () => ({ + resolveUserProfileId: mocks.resolveProfileId, +})); + +import { memoryEnterpriseIdentityHandlers } from "./memory-enterprise-identity.js"; + +async function invoke( + method: keyof typeof memoryEnterpriseIdentityHandlers, + params: Record, + client: Record | null = { + authenticatedUserProfile: { profileId: "profile-alice" }, + }, +) { + const respond = vi.fn(); + const handler = expectDefined(memoryEnterpriseIdentityHandlers[method], "handler test invariant"); + await handler({ params, client, respond } as unknown as Parameters[0]); + return respond; +} + +describe("memory enterprise identity Gateway methods", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveProfileId.mockImplementation((profileId: string) => profileId); + }); + + it("starts only a caller-bound provider transaction", async () => { + mocks.start.mockResolvedValue({ + state: "state", + authorizationUrl: "https://issuer.example/authorize?state=state", + expiresAt: "2026-08-14T00:00:00.000Z", + }); + + const respond = await invoke("memory.enterpriseIdentity.authorization.start", { + providerPrefix: "entra", + }); + + expect(mocks.start).toHaveBeenCalledWith({ + client: { authenticatedUserProfile: { profileId: "profile-alice" } }, + providerPrefix: "entra", + }); + expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({ state: "state" })); + }); + + it("rejects profile selection and raw-token input before dispatch", async () => { + const start = await invoke("memory.enterpriseIdentity.authorization.start", { + providerPrefix: "entra", + targetProfileId: "profile-bob", + }); + const complete = await invoke("memory.enterpriseIdentity.authorization.complete", { + providerPrefix: "entra", + state: "state", + code: "code", + idToken: "bearer-token", + }); + + expect(mocks.start).not.toHaveBeenCalled(); + expect(mocks.complete).not.toHaveBeenCalled(); + expect(start.mock.calls[0]?.[0]).toBe(false); + expect(complete.mock.calls[0]?.[0]).toBe(false); + }); + + it("does not dispatch an unauthenticated completion", async () => { + const respond = await invoke( + "memory.enterpriseIdentity.authorization.complete", + { providerPrefix: "entra", state: "state", code: "code" }, + null, + ); + + expect(mocks.complete).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + + it("returns only a redacted operator audit page for the selected Gateway profile", async () => { + mocks.resolvePrincipal.mockReturnValue({ principalId: "principal:alice" }); + mocks.listAudit.mockReturnValue([ + { eventId: "event:one", tenantRef: "hmac:tenant", ruleRef: "hmac:role" }, + ]); + + const respond = await invoke("memory.enterpriseIdentity.accessAudit.list", { + userProfileId: "profile-alice", + providerId: "entra", + limit: 10, + }); + + expect(mocks.resolvePrincipal).toHaveBeenCalledWith({ userProfileId: "profile-alice" }); + expect(mocks.listAudit).toHaveBeenCalledWith({ + subjectPrincipalId: "principal:alice", + providerId: "entra", + limit: 10, + }); + expect(respond).toHaveBeenCalledWith(true, { + decisions: [ + { + eventId: "event:one", + tenantRef: "hmac:tenant", + ruleRef: "hmac:role", + storeKind: "role", + collaboration: "not-applicable", + }, + ], + }); + }); + + it("permits a caller whose profile reference and selected reference have the same merge head", async () => { + mocks.resolveProfileId.mockReturnValue("profile-canonical"); + mocks.resolvePrincipal.mockReturnValue({ principalId: "principal:alice" }); + mocks.listAudit.mockReturnValue([]); + + const respond = await invoke("memory.enterpriseIdentity.accessAudit.list", { + userProfileId: "profile-alice-old", + }); + + expect(mocks.resolveProfileId).toHaveBeenNthCalledWith(1, "profile-alice"); + expect(mocks.resolveProfileId).toHaveBeenNthCalledWith(2, "profile-alice-old"); + expect(mocks.resolvePrincipal).toHaveBeenCalledWith({ userProfileId: "profile-alice-old" }); + expect(respond).toHaveBeenCalledWith(true, { decisions: [] }); + }); + + it("rejects a cross-profile audit lookup unless the caller has operator.admin", async () => { + vi.clearAllMocks(); + const respond = await invoke("memory.enterpriseIdentity.accessAudit.list", { + userProfileId: "profile-bob", + }); + + expect(mocks.resolvePrincipal).not.toHaveBeenCalled(); + expect(mocks.listAudit).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + + it("allows an operator.admin to inspect another profile's redacted audit page", async () => { + mocks.resolvePrincipal.mockReturnValue({ principalId: "principal:bob" }); + mocks.listAudit.mockReturnValue([]); + + const respond = await invoke( + "memory.enterpriseIdentity.accessAudit.list", + { userProfileId: "profile-bob" }, + { connect: { scopes: ["operator.admin"] } }, + ); + + expect(mocks.resolvePrincipal).toHaveBeenCalledWith({ userProfileId: "profile-bob" }); + expect(mocks.listAudit).toHaveBeenCalledWith({ subjectPrincipalId: "principal:bob" }); + expect(respond).toHaveBeenCalledWith(true, { decisions: [] }); + }); + + it("exports one bounded redacted audit record for its owning profile", async () => { + mocks.resolvePrincipal.mockReturnValue({ principalId: "principal:alice" }); + mocks.listAudit.mockReturnValue([ + { eventId: "event:one", tenantRef: "hmac:tenant", ruleRef: "hmac:role" }, + ]); + mocks.listPolicyDriftAlerts.mockReturnValue([ + { alertId: "alert:one", tenantRef: "hmac:tenant", ruleRef: "hmac:role" }, + ]); + mocks.listEvidenceTransitionImpacts.mockReturnValue([ + { + providerId: "entra", + kind: "revoke", + revokedAt: 1_000, + snapshotCount: 2, + exposureCount: 3, + complete: true, + }, + ]); + + const respond = await invoke("memory.enterpriseIdentity.accessAudit.export", { + userProfileId: "profile-alice", + providerId: "entra", + limit: 10, + }); + + expect(mocks.listAudit).toHaveBeenCalledWith({ + subjectPrincipalId: "principal:alice", + providerId: "entra", + limit: 10, + }); + expect(mocks.listPolicyDriftAlerts).toHaveBeenCalledWith({ + subjectPrincipalId: "principal:alice", + providerId: "entra", + limit: 10, + }); + expect(mocks.listEvidenceTransitionImpacts).toHaveBeenCalledWith({ + userPrincipalId: "principal:alice", + providerId: "entra", + limit: 10, + }); + expect(respond).toHaveBeenCalledWith(true, { + decisions: [ + { + eventId: "event:one", + tenantRef: "hmac:tenant", + ruleRef: "hmac:role", + storeKind: "role", + collaboration: "not-applicable", + }, + ], + alerts: [ + { + alertId: "alert:one", + tenantRef: "hmac:tenant", + ruleRef: "hmac:role", + storeKind: "role", + collaboration: "not-applicable", + }, + ], + transitions: [ + { + providerId: "entra", + kind: "revoke", + revokedAt: 1_000, + snapshotCount: 2, + exposureCount: 3, + complete: true, + }, + ], + }); + }); + + it("allows an operator.admin to export another profile's redacted audit record", async () => { + mocks.resolvePrincipal.mockReturnValue({ principalId: "principal:bob" }); + mocks.listAudit.mockReturnValue([]); + mocks.listPolicyDriftAlerts.mockReturnValue([]); + mocks.listEvidenceTransitionImpacts.mockReturnValue([]); + + const respond = await invoke( + "memory.enterpriseIdentity.accessAudit.export", + { userProfileId: "profile-bob" }, + { connect: { scopes: ["operator.admin"] } }, + ); + + expect(mocks.listAudit).toHaveBeenCalledWith({ subjectPrincipalId: "principal:bob" }); + expect(mocks.listPolicyDriftAlerts).toHaveBeenCalledWith({ + subjectPrincipalId: "principal:bob", + }); + expect(mocks.listEvidenceTransitionImpacts).toHaveBeenCalledWith({ + userPrincipalId: "principal:bob", + }); + expect(respond).toHaveBeenCalledWith(true, { decisions: [], alerts: [], transitions: [] }); + }); + + it("lets an owner unlink only their own enterprise identity without exposing IDs", async () => { + mocks.resolvePrincipal.mockReturnValue({ principalId: "principal:alice" }); + mocks.unlinkProfile.mockReturnValue({ + providerId: "entra", + kind: "unlink", + affectedIdentityCount: 1, + affectedSnapshotCount: 0, + }); + + const respond = await invoke("memory.enterpriseIdentity.unlink", { + userProfileId: "profile-alice", + providerId: "entra", + }); + + expect(mocks.unlinkProfile).toHaveBeenCalledWith( + expect.objectContaining({ + userPrincipalId: "principal:alice", + actorPrincipalId: "principal:alice", + providerId: "entra", + }), + ); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + kind: "unlinked", + providerId: "entra", + affectedIdentityCount: 1, + affectedSnapshotCount: 0, + }), + ); + }); + + it("lets an attributed operator.admin revoke another profile's enterprise evidence", async () => { + mocks.resolvePrincipal.mockImplementation(({ userProfileId }: { userProfileId: string }) => + userProfileId === "profile-admin" + ? { principalId: "principal:admin" } + : { principalId: "principal:bob" }, + ); + mocks.revokeProfileEvidence.mockReturnValue({ + providerId: "entra", + kind: "revoke", + affectedIdentityCount: 1, + affectedSnapshotCount: 2, + }); + + const respond = await invoke( + "memory.enterpriseIdentity.evidence.revoke", + { userProfileId: "profile-bob", providerId: "entra" }, + { + connect: { scopes: ["operator.admin"] }, + authenticatedUserProfile: { profileId: "profile-admin" }, + }, + ); + + expect(mocks.revokeProfileEvidence).toHaveBeenCalledWith( + expect.objectContaining({ + userPrincipalId: "principal:bob", + actorPrincipalId: "principal:admin", + providerId: "entra", + }), + ); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + kind: "revoked", + providerId: "entra", + affectedIdentityCount: 1, + affectedSnapshotCount: 2, + }), + ); + }); + + it("rejects profile-less administrators before an enterprise mutation can be attributed", async () => { + const respond = await invoke( + "memory.enterpriseIdentity.unlink", + { userProfileId: "profile-bob", providerId: "entra" }, + { connect: { scopes: ["operator.admin"] } }, + ); + + expect(mocks.unlinkProfile).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + + it("returns only redacted selected-policy drift alerts for the selected Gateway profile", async () => { + mocks.resolvePrincipal.mockReturnValue({ principalId: "principal:alice" }); + mocks.listPolicyDriftAlerts.mockReturnValue([ + { alertId: "alert:one", tenantRef: "hmac:tenant", ruleRef: "hmac:role" }, + ]); + + const respond = await invoke("memory.enterpriseIdentity.policyDriftAlerts.list", { + userProfileId: "profile-alice", + providerId: "entra", + limit: 10, + }); + + expect(mocks.listPolicyDriftAlerts).toHaveBeenCalledWith({ + subjectPrincipalId: "principal:alice", + providerId: "entra", + limit: 10, + }); + expect(respond).toHaveBeenCalledWith(true, { + alerts: [ + { + alertId: "alert:one", + tenantRef: "hmac:tenant", + ruleRef: "hmac:role", + storeKind: "role", + collaboration: "not-applicable", + }, + ], + }); + }); + + it("applies the same cross-profile boundary to policy-drift alerts", async () => { + vi.clearAllMocks(); + const respond = await invoke("memory.enterpriseIdentity.policyDriftAlerts.list", { + userProfileId: "profile-bob", + }); + + expect(mocks.resolvePrincipal).not.toHaveBeenCalled(); + expect(mocks.listPolicyDriftAlerts).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + + it("allows an operator.admin to inspect another profile's redacted policy-drift alerts", async () => { + mocks.resolvePrincipal.mockReturnValue({ principalId: "principal:bob" }); + mocks.listPolicyDriftAlerts.mockReturnValue([]); + + const respond = await invoke( + "memory.enterpriseIdentity.policyDriftAlerts.list", + { userProfileId: "profile-bob" }, + { connect: { scopes: ["operator.admin"] } }, + ); + + expect(mocks.resolvePrincipal).toHaveBeenCalledWith({ userProfileId: "profile-bob" }); + expect(mocks.listPolicyDriftAlerts).toHaveBeenCalledWith({ + subjectPrincipalId: "principal:bob", + }); + expect(respond).toHaveBeenCalledWith(true, { alerts: [] }); + }); + + it("returns only bounded redacted evidence transition counts for the selected profile", async () => { + mocks.resolvePrincipal.mockReturnValue({ principalId: "principal:alice" }); + mocks.listEvidenceTransitionImpacts.mockReturnValue([ + { + providerId: "entra", + kind: "refresh", + revokedAt: 1_000, + snapshotCount: 2, + exposureCount: 3, + complete: true, + }, + ]); + + const respond = await invoke("memory.enterpriseIdentity.evidenceTransitions.list", { + userProfileId: "profile-alice", + providerId: "entra", + limit: 10, + }); + + expect(mocks.listEvidenceTransitionImpacts).toHaveBeenCalledWith({ + userPrincipalId: "principal:alice", + providerId: "entra", + limit: 10, + }); + expect(respond).toHaveBeenCalledWith(true, { + transitions: [ + { + providerId: "entra", + kind: "refresh", + revokedAt: 1_000, + snapshotCount: 2, + exposureCount: 3, + complete: true, + }, + ], + }); + }); + + it("applies the same cross-profile boundary to evidence transition history", async () => { + const respond = await invoke("memory.enterpriseIdentity.evidenceTransitions.list", { + userProfileId: "profile-bob", + }); + + expect(mocks.resolvePrincipal).not.toHaveBeenCalled(); + expect(mocks.listEvidenceTransitionImpacts).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); +}); diff --git a/src/gateway/server-methods/memory-enterprise-identity.ts b/src/gateway/server-methods/memory-enterprise-identity.ts new file mode 100644 index 000000000000..e4a58f5e19fb --- /dev/null +++ b/src/gateway/server-methods/memory-enterprise-identity.ts @@ -0,0 +1,421 @@ +// Gateway methods for linking the authenticated user's verified enterprise identity. +import { + ErrorCodes, + errorShape, + validateMemoryEnterpriseIdentityAccessAuditExportParams, + validateMemoryEnterpriseIdentityAccessAuditListParams, + validateMemoryEnterpriseIdentityEvidenceRevokeParams, + validateMemoryEnterpriseIdentityEvidenceTransitionListParams, + validateMemoryEnterpriseIdentityUnlinkParams, + validateMemoryEnterpriseIdentityPolicyDriftAlertListParams, + validateMemoryEnterpriseIdentityAuthorizationCompleteParams, + validateMemoryEnterpriseIdentityAuthorizationStartParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { + listMemoryEnterpriseAccessDecisionAudit, + listMemoryEnterprisePolicyDriftAlerts, +} from "../../state/memory-enterprise-access-audit.js"; +import { + revokeMemoryEnterpriseProfileEvidence, + unlinkMemoryEnterpriseProfile, +} from "../../state/memory-enterprise-identity.js"; +import { listMemoryEnterpriseEvidenceTransitionImpactsForUserPrincipal } from "../../state/memory-enterprise-revocation-impact.js"; +import { resolveMemoryPrincipalForUserProfile } from "../../state/memory-identity.js"; +import { resolveUserProfileId } from "../../state/user-profiles.js"; +import { + completeGatewayEnterpriseIdentityAuthorization, + startGatewayEnterpriseIdentityAuthorization, +} from "../memory-enterprise-oidc-transaction.js"; +import { ADMIN_SCOPE } from "../operator-scopes.js"; +import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +function authorizationError(error: unknown) { + const message = + error instanceof Error ? error.message : "enterprise identity authorization is unavailable"; + if (message.includes("authenticated Gateway profile")) { + return errorShape(ErrorCodes.FORBIDDEN, message); + } + return errorShape(ErrorCodes.UNAVAILABLE, message); +} + +function requireEnterpriseAuditAccess(params: { + client: GatewayRequestHandlerOptions["client"]; + userProfileId: string; + respond: GatewayRequestHandlerOptions["respond"]; +}): boolean { + if (params.client?.connect?.scopes?.includes(ADMIN_SCOPE)) { + return true; + } + const callerProfileId = params.client?.authenticatedUserProfile?.profileId; + const canonicalCallerProfileId = callerProfileId + ? resolveUserProfileId(callerProfileId) + : undefined; + const canonicalTargetProfileId = resolveUserProfileId(params.userProfileId); + if ( + // Two unknown profile references must not compare equal and turn into an + // owner grant; both sides must resolve to the current durable profile. + canonicalCallerProfileId !== undefined && + canonicalTargetProfileId !== undefined && + canonicalCallerProfileId === canonicalTargetProfileId + ) { + return true; + } + // Decision records can reveal tenant, role-rule, and timing metadata. A read + // scope alone cannot select another person's profile; only its owner or an + // explicit administrator may inspect it. + params.respond( + false, + undefined, + errorShape( + ErrorCodes.FORBIDDEN, + "enterprise memory audit requires the owning profile or operator.admin", + ), + ); + return false; +} + +function resolveEnterpriseActionPrincipals(params: { + client: GatewayRequestHandlerOptions["client"]; + userProfileId: string; + respond: GatewayRequestHandlerOptions["respond"]; +}): Readonly<{ actorPrincipalId: string; targetPrincipalId: string }> | undefined { + if (!requireEnterpriseAuditAccess(params)) { + return undefined; + } + const actorProfileId = params.client?.authenticatedUserProfile?.profileId; + if (!actorProfileId) { + params.respond( + false, + undefined, + errorShape( + ErrorCodes.FORBIDDEN, + "enterprise memory changes require an authenticated Gateway profile for audit attribution", + ), + ); + return undefined; + } + const actor = resolveMemoryPrincipalForUserProfile({ userProfileId: actorProfileId }); + const target = resolveMemoryPrincipalForUserProfile({ userProfileId: params.userProfileId }); + if (!actor || !target) { + params.respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "enterprise memory changes require active memory principals for the actor and target profiles", + ), + ); + return undefined; + } + return Object.freeze({ + actorPrincipalId: actor.principalId, + targetPrincipalId: target.principalId, + }); +} + +export const memoryEnterpriseIdentityHandlers: GatewayRequestHandlers = { + "memory.enterpriseIdentity.authorization.start": async ({ client, params, respond }) => { + if ( + !assertValidParams( + params, + validateMemoryEnterpriseIdentityAuthorizationStartParams, + "memory.enterpriseIdentity.authorization.start", + respond, + ) + ) { + return; + } + if (!client) { + respond( + false, + undefined, + errorShape( + ErrorCodes.FORBIDDEN, + "enterprise identity authorization requires an authenticated Gateway profile", + ), + ); + return; + } + try { + // There is deliberately no profile parameter: Gateway binds the receipt to + // the caller, so another user cannot link or replace this user's evidence. + respond( + true, + await startGatewayEnterpriseIdentityAuthorization({ + client, + providerPrefix: params.providerPrefix, + }), + ); + } catch (error) { + respond(false, undefined, authorizationError(error)); + } + }, + "memory.enterpriseIdentity.authorization.complete": async ({ client, params, respond }) => { + if ( + !assertValidParams( + params, + validateMemoryEnterpriseIdentityAuthorizationCompleteParams, + "memory.enterpriseIdentity.authorization.complete", + respond, + ) + ) { + return; + } + if (!client) { + respond( + false, + undefined, + errorShape( + ErrorCodes.FORBIDDEN, + "enterprise identity authorization requires an authenticated Gateway profile", + ), + ); + return; + } + try { + respond( + true, + await completeGatewayEnterpriseIdentityAuthorization({ + client, + providerPrefix: params.providerPrefix, + state: params.state, + code: params.code, + }), + ); + } catch (error) { + respond(false, undefined, authorizationError(error)); + } + }, + "memory.enterpriseIdentity.accessAudit.list": async ({ client, params, respond }) => { + if ( + !assertValidParams( + params, + validateMemoryEnterpriseIdentityAccessAuditListParams, + "memory.enterpriseIdentity.accessAudit.list", + respond, + ) + ) { + return; + } + if (!requireEnterpriseAuditAccess({ client, userProfileId: params.userProfileId, respond })) { + return; + } + const principal = resolveMemoryPrincipalForUserProfile({ userProfileId: params.userProfileId }); + respond( + true, + Object.freeze({ + decisions: principal + ? listMemoryEnterpriseAccessDecisionAudit({ + subjectPrincipalId: principal.principalId, + ...(params.providerId ? { providerId: params.providerId } : {}), + ...(params.limit ? { limit: params.limit } : {}), + }).map((decision) => + Object.freeze({ + ...decision, + // Enterprise group evidence remains distinct from Gateway + // session_members, so it can explain only a role-store decision. + storeKind: "role" as const, + collaboration: "not-applicable" as const, + }), + ) + : [], + }), + ); + }, + "memory.enterpriseIdentity.accessAudit.export": async ({ client, params, respond }) => { + if ( + !assertValidParams( + params, + validateMemoryEnterpriseIdentityAccessAuditExportParams, + "memory.enterpriseIdentity.accessAudit.export", + respond, + ) + ) { + return; + } + if (!requireEnterpriseAuditAccess({ client, userProfileId: params.userProfileId, respond })) { + return; + } + const principal = resolveMemoryPrincipalForUserProfile({ userProfileId: params.userProfileId }); + const query = { + ...(params.providerId ? { providerId: params.providerId } : {}), + ...(params.limit ? { limit: params.limit } : {}), + }; + respond( + true, + Object.freeze({ + decisions: principal + ? listMemoryEnterpriseAccessDecisionAudit({ + subjectPrincipalId: principal.principalId, + ...query, + }).map((decision) => + Object.freeze({ + ...decision, + storeKind: "role" as const, + collaboration: "not-applicable" as const, + }), + ) + : [], + alerts: principal + ? listMemoryEnterprisePolicyDriftAlerts({ + subjectPrincipalId: principal.principalId, + ...query, + }).map((alert) => + Object.freeze({ + ...alert, + storeKind: "role" as const, + collaboration: "not-applicable" as const, + }), + ) + : [], + transitions: principal + ? listMemoryEnterpriseEvidenceTransitionImpactsForUserPrincipal({ + userPrincipalId: principal.principalId, + ...query, + }) + : [], + }), + ); + }, + "memory.enterpriseIdentity.unlink": async ({ client, params, respond }) => { + if ( + !assertValidParams( + params, + validateMemoryEnterpriseIdentityUnlinkParams, + "memory.enterpriseIdentity.unlink", + respond, + ) + ) { + return; + } + const principals = resolveEnterpriseActionPrincipals({ + client, + userProfileId: params.userProfileId, + respond, + }); + if (!principals) { + return; + } + try { + const occurredAt = Date.now(); + const action = unlinkMemoryEnterpriseProfile({ + userPrincipalId: principals.targetPrincipalId, + providerId: params.providerId, + actorPrincipalId: principals.actorPrincipalId, + now: occurredAt, + }); + respond( + true, + Object.freeze({ + ...action, + kind: "unlinked" as const, + occurredAt, + }), + ); + } catch (error) { + respond(false, undefined, authorizationError(error)); + } + }, + "memory.enterpriseIdentity.evidence.revoke": async ({ client, params, respond }) => { + if ( + !assertValidParams( + params, + validateMemoryEnterpriseIdentityEvidenceRevokeParams, + "memory.enterpriseIdentity.evidence.revoke", + respond, + ) + ) { + return; + } + const principals = resolveEnterpriseActionPrincipals({ + client, + userProfileId: params.userProfileId, + respond, + }); + if (!principals) { + return; + } + try { + const occurredAt = Date.now(); + const action = revokeMemoryEnterpriseProfileEvidence({ + userPrincipalId: principals.targetPrincipalId, + providerId: params.providerId, + actorPrincipalId: principals.actorPrincipalId, + now: occurredAt, + }); + respond( + true, + Object.freeze({ + ...action, + kind: "revoked" as const, + occurredAt, + }), + ); + } catch (error) { + respond(false, undefined, authorizationError(error)); + } + }, + "memory.enterpriseIdentity.policyDriftAlerts.list": async ({ client, params, respond }) => { + if ( + !assertValidParams( + params, + validateMemoryEnterpriseIdentityPolicyDriftAlertListParams, + "memory.enterpriseIdentity.policyDriftAlerts.list", + respond, + ) + ) { + return; + } + if (!requireEnterpriseAuditAccess({ client, userProfileId: params.userProfileId, respond })) { + return; + } + const principal = resolveMemoryPrincipalForUserProfile({ userProfileId: params.userProfileId }); + respond( + true, + Object.freeze({ + alerts: principal + ? listMemoryEnterprisePolicyDriftAlerts({ + subjectPrincipalId: principal.principalId, + ...(params.providerId ? { providerId: params.providerId } : {}), + ...(params.limit ? { limit: params.limit } : {}), + }).map((alert) => + Object.freeze({ + ...alert, + storeKind: "role" as const, + collaboration: "not-applicable" as const, + }), + ) + : [], + }), + ); + }, + "memory.enterpriseIdentity.evidenceTransitions.list": async ({ client, params, respond }) => { + if ( + !assertValidParams( + params, + validateMemoryEnterpriseIdentityEvidenceTransitionListParams, + "memory.enterpriseIdentity.evidenceTransitions.list", + respond, + ) + ) { + return; + } + if (!requireEnterpriseAuditAccess({ client, userProfileId: params.userProfileId, respond })) { + return; + } + const principal = resolveMemoryPrincipalForUserProfile({ userProfileId: params.userProfileId }); + respond( + true, + Object.freeze({ + transitions: principal + ? listMemoryEnterpriseEvidenceTransitionImpactsForUserPrincipal({ + userPrincipalId: principal.principalId, + ...(params.providerId ? { providerId: params.providerId } : {}), + ...(params.limit ? { limit: params.limit } : {}), + }) + : [], + }), + ); + }, +}; diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 1236214f25fe..149c86c57645 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -534,6 +534,7 @@ export function loadGatewayPlugins(params: { const loaderStatsBefore = getPluginModuleLoaderStats(); const gatewayRuntimeBindings = getGatewayPluginRuntimeBindings(); const pluginRegistry = loadAndActivateRootPluginRegistry({ + enterpriseIdentityAuthorityStartup: true, config: resolvedConfig, activationSourceConfig: params.activationSourceConfig ?? params.cfg, autoEnabledReasons: autoEnabled.autoEnabledReasons, diff --git a/src/plugin-sdk/memory-enterprise-audit-runtime.ts b/src/plugin-sdk/memory-enterprise-audit-runtime.ts new file mode 100644 index 000000000000..d89ae598fe20 --- /dev/null +++ b/src/plugin-sdk/memory-enterprise-audit-runtime.ts @@ -0,0 +1,9 @@ +/** + * This resolver has no writer of its own. The registry binds a reporter to the + * selected memory plugin's API object, so importing this module cannot grant + * another plugin enterprise-audit authority. + */ +export { + resolveMemoryEnterpriseAccessAuditReporter, + type MemoryEnterpriseAccessAuditReporter, +} from "../plugins/memory-enterprise-access-audit-reporter.js"; diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index f85750cc9528..fcb090f4f0c2 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -41,6 +41,10 @@ export type { OpenClawPluginServiceContext, OpenClawPluginToolContext, OpenClawPluginToolFactory, + EnterpriseIdentityDirectoryAccessTokenResult, + EnterpriseIdentityMembershipSource, + EnterpriseIdentityProviderAdapter, + EnterpriseIdentityProviderAuthority, PluginAgentEventEmitParams, PluginAgentEventEmitResult, PluginAgentEventSubscriptionRegistration, diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index d60168d087ec..a413972d8f78 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -84,6 +84,7 @@ type BuildPluginApiParams = { | "registerMemoryPromptPreparation" | "registerMemoryCorpusSupplement" | "registerMemoryEmbeddingProvider" + | "registerEnterpriseIdentityProvider" | "on" > >; @@ -182,6 +183,8 @@ const noopRegisterMemoryCorpusSupplement: OpenClawPluginApi["registerMemoryCorpu () => {}; const noopRegisterMemoryEmbeddingProvider: OpenClawPluginApi["registerMemoryEmbeddingProvider"] = () => {}; +const noopRegisterEnterpriseIdentityProvider: OpenClawPluginApi["registerEnterpriseIdentityProvider"] = + () => {}; const noopOn: OpenClawPluginApi["on"] = () => {}; export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi { @@ -294,6 +297,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi handlers.registerMemoryCorpusSupplement ?? noopRegisterMemoryCorpusSupplement, registerMemoryEmbeddingProvider: handlers.registerMemoryEmbeddingProvider ?? noopRegisterMemoryEmbeddingProvider, + registerEnterpriseIdentityProvider: + handlers.registerEnterpriseIdentityProvider ?? noopRegisterEnterpriseIdentityProvider, resolvePath: params.resolvePath, on: handlers.on ?? noopOn, }; diff --git a/src/plugins/enterprise-identity-provider-authority-registry.ts b/src/plugins/enterprise-identity-provider-authority-registry.ts new file mode 100644 index 000000000000..716a7248e05c --- /dev/null +++ b/src/plugins/enterprise-identity-provider-authority-registry.ts @@ -0,0 +1,130 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { + EnterpriseIdentityMembershipSource, + EnterpriseIdentityProviderAdapter, + EnterpriseIdentityProviderAuthority, +} from "./enterprise-identity-provider-types.js"; + +export type EnterpriseIdentityProviderRegistration = { + pluginId: string; + pluginName?: string; + provider: EnterpriseIdentityProviderAdapter; + source: string; + rootDir?: string; +}; + +/** + * Core-owned startup authority snapshot. It deliberately outlives a replaceable + * plugin registry so a plugin reload cannot reopen enterprise registration. + */ +export type EnterpriseIdentityProviderAuthorityRegistry = { + readonly providers: readonly EnterpriseIdentityProviderRegistration[]; + readonly operatorAllowlist: ReadonlySet; + isSealed: () => boolean; + /** Publish an immutable copy only after complete registry activation succeeds. */ + seal: (providers?: readonly EnterpriseIdentityProviderRegistration[]) => void; +}; + +function freezeMembershipSource( + source: EnterpriseIdentityMembershipSource, +): EnterpriseIdentityMembershipSource { + if (source.kind === "google-workspace-directory") { + return Object.freeze({ + ...source, + roleGroupResourceNames: Object.freeze([...source.roleGroupResourceNames]), + }); + } + return Object.freeze({ + ...source, + roleGroupIds: Object.freeze([...source.roleGroupIds]), + incompleteIndicators: Object.freeze( + (source.incompleteIndicators ?? []).map((indicator) => Object.freeze({ ...indicator })), + ), + }); +} + +function freezeAuthority( + authority: EnterpriseIdentityProviderAuthority, +): EnterpriseIdentityProviderAuthority { + return Object.freeze({ + ...authority, + audiences: Object.freeze([...authority.audiences]), + acceptedIssuerAliases: Object.freeze([...(authority.acceptedIssuerAliases ?? [])]), + tenantBinding: Object.freeze({ ...authority.tenantBinding }), + assurance: Object.freeze({ + ...authority.assurance, + acceptedAcrValues: Object.freeze([...(authority.assurance.acceptedAcrValues ?? [])]), + requiredAmrValues: Object.freeze([...(authority.assurance.requiredAmrValues ?? [])]), + }), + authorizationCodeFlow: Object.freeze({ + ...authority.authorizationCodeFlow, + scopes: Object.freeze([...authority.authorizationCodeFlow.scopes]), + }), + requiredClaims: Object.freeze( + (authority.requiredClaims ?? []).map((claim) => Object.freeze({ ...claim })), + ), + membership: freezeMembershipSource(authority.membership), + }); +} + +export function createEnterpriseIdentityProviderAuthorityRegistry(params?: { + operatorAllowlist?: readonly string[]; +}): EnterpriseIdentityProviderAuthorityRegistry { + let providers: readonly EnterpriseIdentityProviderRegistration[] = []; + const operatorAllowlist = new Set(params?.operatorAllowlist ?? []); + let sealed = false; + return { + get providers() { + return providers; + }, + operatorAllowlist, + isSealed: () => sealed, + seal: (registrations = []) => { + if (sealed) { + return; + } + // A generic plugin registry remains reloadable. This snapshot does not: + // publication copies records after activation so rollback cannot splice + // enterprise authorities out of a registry that is already serving users. + providers = Object.freeze( + registrations.map((registration) => + Object.freeze({ + ...registration, + provider: Object.freeze({ + ...registration.provider, + authorities: Object.freeze(registration.provider.authorities.map(freezeAuthority)), + }), + }), + ), + ); + sealed = true; + }, + }; +} + +/** Read the operator-owned config surface once while preparing the startup snapshot. */ +export function resolveEnterpriseIdentityProviderAllowlist( + config: OpenClawConfig | undefined, +): readonly string[] { + return config?.plugins?.enterpriseIdentityProviders?.allow ?? []; +} + +let processAuthorityRegistry: EnterpriseIdentityProviderAuthorityRegistry | undefined; + +/** Read the published Gateway snapshot without creating one from a secondary runtime. */ +export function getProcessEnterpriseIdentityProviderAuthorityRegistry(): + | EnterpriseIdentityProviderAuthorityRegistry + | undefined { + return processAuthorityRegistry; +} + +/** + * Establish the process-owned enterprise authority snapshot once, at startup. + * Later plugin-registry replacements reuse this sealed snapshot unchanged. + */ +export function getOrCreateProcessEnterpriseIdentityProviderAuthorityRegistry(params: { + operatorAllowlist?: readonly string[]; +}): EnterpriseIdentityProviderAuthorityRegistry { + processAuthorityRegistry ??= createEnterpriseIdentityProviderAuthorityRegistry(params); + return processAuthorityRegistry; +} diff --git a/src/plugins/enterprise-identity-provider-registry.test.ts b/src/plugins/enterprise-identity-provider-registry.test.ts new file mode 100644 index 000000000000..403119fb559e --- /dev/null +++ b/src/plugins/enterprise-identity-provider-registry.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + createEnterpriseIdentityProviderAuthorityRegistry, + resolveEnterpriseIdentityProviderAllowlist, +} from "./enterprise-identity-provider-authority-registry.js"; +import type { EnterpriseIdentityProviderAdapter } from "./enterprise-identity-provider-types.js"; +import { createPluginRecord } from "./loader-records.js"; +import { createPluginRegistry } from "./registry.js"; +import type { PluginRuntime } from "./runtime/types.js"; + +function createTestRegistry(enterpriseIdentityProviderAllowlist: readonly string[]) { + const enterpriseIdentityProviderAuthorityRegistry = + createEnterpriseIdentityProviderAuthorityRegistry({ + operatorAllowlist: enterpriseIdentityProviderAllowlist, + }); + return createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: false, + enterpriseIdentityProviderAuthorityRegistry, + }); +} + +function createRecord(id: string, enterpriseIdentityProviders?: readonly string[]) { + return createPluginRecord({ + id, + source: `/plugins/${id}/index.ts`, + origin: "global", + enabled: true, + configSchema: false, + contracts: enterpriseIdentityProviders + ? { enterpriseIdentityProviders: [...enterpriseIdentityProviders] } + : undefined, + }); +} + +function createProvider( + providerPrefix: string, + authority = { + issuer: `https://${providerPrefix}.example`, + tenantId: `${providerPrefix}-tenant`, + }, +): EnterpriseIdentityProviderAdapter { + return { + providerPrefix, + resolveAuthorizationCodeClientSecret: async () => "test-client-secret", + authorities: [ + { + ...authority, + audiences: ["openclaw-memory-test"], + jwksUri: `https://${providerPrefix}.example/keys`, + algorithm: "RS256", + tenantBinding: { kind: "claim", claim: "tid", value: authority.tenantId }, + assurance: { maxAuthenticationAgeMs: 60_000 }, + authorizationCodeFlow: { + clientId: "openclaw-memory-test", + authorizationEndpoint: `https://${providerPrefix}.example/authorize`, + tokenEndpoint: `https://${providerPrefix}.example/token`, + redirectUri: "https://gateway.example/memory/oidc/callback", + scopes: ["openid"], + }, + membership: { + kind: "oidc-claim", + claim: "groups", + required: true, + roleGroupIds: ["writers"], + maxGroups: 200, + }, + maxSnapshotAgeMs: 60_000, + }, + ], + }; +} + +describe("enterprise identity provider registry", () => { + it("reads the dedicated operator allowlist from configuration", () => { + expect( + resolveEnterpriseIdentityProviderAllowlist({ + plugins: { enterpriseIdentityProviders: { allow: ["entra"] } }, + }), + ).toEqual(["entra"]); + expect(resolveEnterpriseIdentityProviderAllowlist({ plugins: {} })).toEqual([]); + }); + + it("requires the exact manifest declaration and operator allowlist", () => { + const registry = createTestRegistry(["entra"]); + + registry.registerEnterpriseIdentityProvider( + createRecord("undeclared"), + createProvider("entra"), + ); + registry.registerEnterpriseIdentityProvider( + createRecord("mismatch", ["okta"]), + createProvider("entra"), + ); + registry.registerEnterpriseIdentityProvider( + createRecord("unlisted", ["okta"]), + createProvider("okta"), + ); + + expect(registry.registry.enterpriseIdentityProviders).toEqual([]); + expect(registry.registry.diagnostics.map((entry) => entry.message)).toEqual([ + "plugin must declare contracts.enterpriseIdentityProviders for provider: entra", + "plugin must declare contracts.enterpriseIdentityProviders for provider: entra", + "enterprise identity provider is not operator-allowlisted: okta", + ]); + }); + + it("accepts only HTTPS endpoints and the Gateway-owned OIDC callback path", () => { + const registry = createTestRegistry(["entra"]); + const provider = createProvider("entra"); + const authority = provider.authorities[0]!; + const invalidCallbackProvider: EnterpriseIdentityProviderAdapter = { + ...provider, + authorities: [ + { + ...authority, + authorizationCodeFlow: { + ...authority.authorizationCodeFlow, + redirectUri: "https://gateway.example/other/callback", + }, + }, + ], + }; + + registry.registerEnterpriseIdentityProvider( + createRecord("entra-plugin", ["entra"]), + invalidCallbackProvider, + ); + + expect(registry.registry.enterpriseIdentityProviders).toEqual([]); + expect(registry.registry.diagnostics.at(-1)?.message).toBe( + "enterprise identity provider has an invalid issuer or tenant authority: entra", + ); + }); + + it("rejects providers that cannot authenticate the confidential code exchange", () => { + const registry = createTestRegistry(["entra"]); + registry.registerEnterpriseIdentityProvider(createRecord("entra-plugin", ["entra"]), { + ...createProvider("entra"), + resolveAuthorizationCodeClientSecret: undefined, + }); + + expect(registry.registry.enterpriseIdentityProviders).toEqual([]); + expect(registry.registry.diagnostics.at(-1)?.message).toBe( + "enterprise identity provider has an invalid issuer or tenant authority: entra", + ); + }); + + it("retains provenance while refusing duplicate prefixes and issuer-tenant authorities", () => { + const registry = createTestRegistry(["entra", "okta"]); + const first = createRecord("entra-plugin", ["entra"]); + registry.registerEnterpriseIdentityProvider(first, createProvider("entra")); + + registry.registerEnterpriseIdentityProvider( + createRecord("duplicate-prefix", ["entra"]), + createProvider("entra", { issuer: "https://other.example", tenantId: "other" }), + ); + registry.registerEnterpriseIdentityProvider( + createRecord("duplicate-authority", ["okta"]), + createProvider("okta", { issuer: "https://entra.example", tenantId: "entra-tenant" }), + ); + + expect(registry.registry.enterpriseIdentityProviders).toHaveLength(1); + expect(registry.registry.enterpriseIdentityProviders[0]).toMatchObject({ + pluginId: "entra-plugin", + source: "/plugins/entra-plugin/index.ts", + provider: { providerPrefix: "entra" }, + }); + expect(registry.registry.diagnostics.map((entry) => entry.message)).toEqual([ + "enterprise identity provider already registered: entra", + "enterprise identity authority already registered: https://entra.example (entra-tenant) by entra-plugin", + ]); + }); + + it("is exposed through the plugin API, but seals before a later registration", () => { + const registry = createTestRegistry(["entra", "okta"]); + const record = createRecord("entra-plugin", ["entra"]); + registry + .createApi(record, { config: {} as OpenClawConfig }) + .registerEnterpriseIdentityProvider(createProvider("entra")); + registry.sealEnterpriseIdentityProviderRegistry(); + registry.registerEnterpriseIdentityProvider( + createRecord("okta-plugin", ["okta"]), + createProvider("okta"), + ); + + expect(registry.registry.enterpriseIdentityProviders).toHaveLength(1); + expect(registry.registry.enterpriseIdentityProviders[0]?.provider).not.toHaveProperty( + "createPrincipal", + ); + expect(registry.registry.enterpriseIdentityProviderAuthorityRegistry.isSealed()).toBe(true); + expect(registry.registry.diagnostics.at(-1)?.message).toBe( + "enterprise identity provider registry is sealed after startup", + ); + }); + + it("keeps the core-owned authority snapshot sealed across registry replacement", () => { + const authorityRegistry = createEnterpriseIdentityProviderAuthorityRegistry({ + operatorAllowlist: ["entra"], + }); + const previous = createPluginRegistry({ + logger: { info() {}, warn() {}, error() {}, debug() {} }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: false, + enterpriseIdentityProviderAuthorityRegistry: authorityRegistry, + }); + previous.registerEnterpriseIdentityProvider( + createRecord("entra-plugin", ["entra"]), + createProvider("entra"), + ); + previous.sealEnterpriseIdentityProviderRegistry(); + + const replacement = createPluginRegistry({ + logger: { info() {}, warn() {}, error() {}, debug() {} }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: false, + enterpriseIdentityProviderAuthorityRegistry: authorityRegistry, + }); + replacement.registerEnterpriseIdentityProvider( + createRecord("replacement-entra-plugin", ["entra"]), + createProvider("entra"), + ); + + expect(previous.registry.enterpriseIdentityProviders).toHaveLength(1); + expect(replacement.registry.enterpriseIdentityProviders).toHaveLength(1); + expect(replacement.registry.enterpriseIdentityProviders[0]?.pluginId).toBe("entra-plugin"); + expect(replacement.registry.diagnostics.at(-1)?.message).toBe( + "enterprise identity provider registry is sealed after startup", + ); + }); + + it("publishes a frozen copy so generic reload rollback cannot erase authority", () => { + const authorityRegistry = createEnterpriseIdentityProviderAuthorityRegistry({ + operatorAllowlist: ["entra"], + }); + const registry = createPluginRegistry({ + logger: { info() {}, warn() {}, error() {}, debug() {} }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: false, + enterpriseIdentityProviderAuthorityRegistry: authorityRegistry, + }); + registry.registerEnterpriseIdentityProvider( + createRecord("entra-plugin", ["entra"]), + createProvider("entra"), + ); + registry.sealEnterpriseIdentityProviderRegistry(); + registry.registry.enterpriseIdentityProviders.splice(0); + + expect(authorityRegistry.providers).toHaveLength(1); + expect(Object.isFrozen(authorityRegistry.providers)).toBe(true); + }); + + it("deep-copies nested authority policy so a plugin cannot mutate the sealed trust boundary", () => { + const authorityRegistry = createEnterpriseIdentityProviderAuthorityRegistry({ + operatorAllowlist: ["entra"], + }); + const registry = createPluginRegistry({ + logger: { info() {}, warn() {}, error() {}, debug() {} }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: false, + enterpriseIdentityProviderAuthorityRegistry: authorityRegistry, + }); + const provider = createProvider("entra"); + registry.registerEnterpriseIdentityProvider(createRecord("entra-plugin", ["entra"]), provider); + registry.sealEnterpriseIdentityProviderRegistry(); + + (provider.authorities[0]!.authorizationCodeFlow.scopes as string[]).push("profile"); + const sealed = authorityRegistry.providers[0]!.provider.authorities[0]!; + expect(sealed.authorizationCodeFlow.scopes).toEqual(["openid"]); + expect(Object.isFrozen(sealed.authorizationCodeFlow.scopes)).toBe(true); + expect(Object.isFrozen(sealed.membership)).toBe(true); + }); +}); diff --git a/src/plugins/enterprise-identity-provider-types.ts b/src/plugins/enterprise-identity-provider-types.ts new file mode 100644 index 000000000000..704bd4bd7925 --- /dev/null +++ b/src/plugins/enterprise-identity-provider-types.ts @@ -0,0 +1,116 @@ +/** + * Static authority material that identifies an enterprise identity provider + * boundary. Core validates this material before it constructs any principal. + */ +export type EnterpriseIdentityTenantBinding = + | Readonly<{ kind: "claim"; claim: string; value: string }> + /** The exact verified issuer is the tenant boundary for providers without a tenant claim. */ + | Readonly<{ kind: "issuer"; tenantId: string }>; + +export type EnterpriseIdentityAssurancePolicy = Readonly<{ + /** ID tokens must prove a recent interactive authentication, not merely recent issuance. */ + maxAuthenticationAgeMs: number; + /** When set, the provider must issue one of these exact assurance-class values. */ + acceptedAcrValues?: readonly string[]; + /** Every listed authentication-method reference must be present in the token. */ + requiredAmrValues?: readonly string[]; +}>; + +export type EnterpriseIdentityMembershipClaim = Readonly<{ + kind: "oidc-claim"; + claim: string; + /** Missing claims are never silently converted to an empty complete snapshot. */ + required: boolean; + /** Only these configured immutable role groups become durable memory evidence. */ + roleGroupIds: readonly string[]; + maxGroups: number; + /** Provider-specific signals that mean the token omitted a complete group snapshot. */ + incompleteIndicators?: readonly ( + | Readonly<{ kind: "truthy-claim"; claim: string }> + | Readonly<{ kind: "nested-key"; claim: string; key: string }> + )[]; +}>; + +/** + * Google Workspace ID tokens prove the person, but not group membership. The + * adapter may obtain an ephemeral read-only token; core owns the pinned Cloud + * Identity request, response validation, and resulting group snapshot. + */ +export type EnterpriseIdentityGoogleWorkspaceDirectoryMembership = Readonly<{ + kind: "google-workspace-directory"; + verifiedEmailClaim: string; + roleGroupResourceNames: readonly string[]; + customerId?: string; + maxGroups: number; +}>; + +export type EnterpriseIdentityMembershipSource = + | EnterpriseIdentityMembershipClaim + | EnterpriseIdentityGoogleWorkspaceDirectoryMembership; + +export type EnterpriseIdentityDirectoryAccessTokenResult = + | Readonly<{ kind: "available"; accessToken: string }> + | Readonly<{ kind: "unavailable"; reason: string }>; + +export type EnterpriseIdentityRequiredClaim = Readonly<{ + claim: string; + value: string | boolean; +}>; + +export type EnterpriseIdentityAuthorizationCodeFlow = Readonly<{ + clientId: string; + authorizationEndpoint: string; + tokenEndpoint: string; + redirectUri: string; + scopes: readonly string[]; +}>; + +export type EnterpriseIdentityProviderAuthority = { + issuer: string; + /** Additional exact issuer spellings accepted for this same published authority. */ + acceptedIssuerAliases?: readonly string[]; + tenantId: string; + /** Exact OIDC audiences accepted for this tenant and issuer. */ + audiences: readonly string[]; + /** HTTPS JWKS endpoint owned by the declared issuer. */ + jwksUri: string; + /** Enterprise providers in this first rollout all publish RSA-SHA256 ID tokens. */ + algorithm: "RS256"; + /** Exact configured tenant binding; a provider never supplies this policy. */ + tenantBinding: EnterpriseIdentityTenantBinding; + /** OIDC authentication assurance that core enforces after signature verification. */ + assurance: EnterpriseIdentityAssurancePolicy; + /** Public-client OIDC code+PKCE endpoints. Core owns state, nonce, and exchange. */ + authorizationCodeFlow: EnterpriseIdentityAuthorizationCodeFlow; + /** Provider-specific claims that must match exactly, such as Google hd/email_verified. */ + requiredClaims?: readonly EnterpriseIdentityRequiredClaim[]; + /** Complete bounded role evidence carried by this signed token. */ + membership: EnterpriseIdentityMembershipSource; + /** Maximum accepted age from token issue to protected-memory authorization. */ + maxSnapshotAgeMs: number; +}; + +export type EnterpriseIdentityProviderServiceAvailability = + | { available: true } + | { available: false; reason: string }; + +/** + * A plugin-owned source of enterprise identity verification material. + * + * This deliberately has no principal construction or membership assertion + * method. An adapter may report that its own service is unavailable, but the + * core identity boundary remains the only place that can construct principals. + */ +export type EnterpriseIdentityProviderAdapter = { + providerPrefix: string; + authorities: readonly EnterpriseIdentityProviderAuthority[]; + checkServiceAvailability?: () => + | EnterpriseIdentityProviderServiceAvailability + | Promise; + /** Resolves the confidential web client's secret only when core redeems a code. */ + resolveAuthorizationCodeClientSecret?: () => Promise; + /** Only relevant to google-workspace-directory membership. It never returns groups or identity facts. */ + acquireDirectoryAccessToken?: () => + | EnterpriseIdentityDirectoryAccessTokenResult + | Promise; +}; diff --git a/src/plugins/gateway-startup-plugin-config.ts b/src/plugins/gateway-startup-plugin-config.ts index 588b5c11d132..07e19498e841 100644 --- a/src/plugins/gateway-startup-plugin-config.ts +++ b/src/plugins/gateway-startup-plugin-config.ts @@ -242,6 +242,22 @@ export function shouldConsiderForGatewayStartup(params: { return params.memorySlotStartupPluginId === params.plugin.pluginId; } +/** + * Enterprise identity material is loaded only when its provider prefix is an + * explicit operator choice. This keeps adapters out of normal startup while + * ensuring a selected provider registers before the Gateway snapshot seals. + */ +export function declaresAllowedEnterpriseIdentityProvider(params: { + manifest: PluginManifestRecord | undefined; + operatorAllowlist: ReadonlySet; +}): boolean { + return Boolean( + params.manifest?.contracts?.enterpriseIdentityProviders?.some((prefix) => + params.operatorAllowlist.has(prefix), + ), + ); +} + export function hasConfiguredStartupChannel(params: { plugin: InstalledPluginIndexRecord; manifestLookup: ManifestRegistryLookup; diff --git a/src/plugins/gateway-startup-plugin-metadata.ts b/src/plugins/gateway-startup-plugin-metadata.ts index 87d68bf0c3e7..c0b4c76878be 100644 --- a/src/plugins/gateway-startup-plugin-metadata.ts +++ b/src/plugins/gateway-startup-plugin-metadata.ts @@ -59,6 +59,19 @@ export function resolveGatewayStartupMetadataPluginIds(params: { const scope = new Set([...pluginsConfig.allow, ...activationSourcePlugins.allow]); addPluginConfigEntryIds(scope, pluginsConfig); addPluginConfigEntryIds(scope, activationSourcePlugins); + const enterpriseIdentityProviderAllowlist = new Set( + params.config.plugins?.enterpriseIdentityProviders?.allow ?? [], + ); + for (const plugin of params.index.plugins) { + if ( + (pluginsConfig.allow.length === 0 || pluginsConfig.allow.includes(plugin.pluginId)) && + plugin.contributions?.contracts.enterpriseIdentityProviders?.some((prefix) => + enterpriseIdentityProviderAllowlist.has(prefix), + ) + ) { + scope.add(plugin.pluginId); + } + } const memorySlotStartupPluginId = resolveMemorySlotStartupPluginId({ activationSourceConfig, diff --git a/src/plugins/gateway-startup-plugin-plan.ts b/src/plugins/gateway-startup-plugin-plan.ts index 4542a0576d69..31f15fbfb0aa 100644 --- a/src/plugins/gateway-startup-plugin-plan.ts +++ b/src/plugins/gateway-startup-plugin-plan.ts @@ -14,6 +14,7 @@ import { } from "./gateway-startup-plugin-activation.js"; import { hasConfiguredStartupChannel, + declaresAllowedEnterpriseIdentityProvider, listPotentialEnabledChannelIds, resolveAuthorizedGatewayStartupDreamingPluginIds, resolveContextEngineSlotStartupPluginId, @@ -126,6 +127,9 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { activationSourcePlugins, normalizePluginId, }); + const enterpriseIdentityProviderAllowlist = new Set( + params.config.plugins?.enterpriseIdentityProviders?.allow ?? [], + ); const pluginIds: string[] = []; for (const plugin of params.index.plugins) { const manifest = findManifestPlugin(manifestLookup, plugin.pluginId); @@ -187,6 +191,31 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { pluginIds.push(plugin.pluginId); continue; } + if ( + declaresAllowedEnterpriseIdentityProvider({ + manifest, + operatorAllowlist: enterpriseIdentityProviderAllowlist, + }) + ) { + const isSourceExternalPlugin = + plugin.origin === "bundled" && plugin.packageBuild?.bundledDist === false; + const startupPolicyOrigin = isSourceExternalPlugin ? "workspace" : plugin.origin; + const activationState = resolveEffectivePluginActivationState({ + id: plugin.pluginId, + origin: startupPolicyOrigin, + config: pluginsConfig, + rootConfig: params.config, + enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin, params.platform), + activationSource, + }); + if ( + activationState.enabled && + (startupPolicyOrigin === "bundled" || activationState.explicitlyEnabled) + ) { + pluginIds.push(plugin.pluginId); + } + continue; + } if ( !shouldConsiderForGatewayStartup({ plugin, diff --git a/src/plugins/loader-load-context.ts b/src/plugins/loader-load-context.ts index fd54d7d922d3..d273dfa7a42d 100644 --- a/src/plugins/loader-load-context.ts +++ b/src/plugins/loader-load-context.ts @@ -190,6 +190,7 @@ function buildCacheKey(params: { pluginSdkResolution?: PluginSdkResolutionPreference; coreGatewayMethodNames?: string[]; activate?: boolean; + enterpriseIdentityAuthorityStartup?: boolean; }): string { const discoveryContext = resolvePluginDiscoveryContext({ workspaceDir: params.workspaceDir, @@ -238,7 +239,7 @@ function buildCacheKey(params: { loadPaths, activationMetadataKey: params.activationMetadataKey ?? "", }, - )}::${serializePluginIdScope(params.onlyPluginIds)}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${params.channelPluginLoadIntent}::${bundledArtifactMode}::${rawConfigEnvMode}::${moduleLoadMode}::${discoveryMode}::${params.runtimeSubagentMode ?? "default"}::${params.runtimeBindingIdentity ?? "{}"}::${params.pluginSdkResolution ?? "auto"}::${JSON.stringify(params.coreGatewayMethodNames ?? [])}::${activationMode}`; + )}::${serializePluginIdScope(params.onlyPluginIds)}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${params.channelPluginLoadIntent}::${bundledArtifactMode}::${rawConfigEnvMode}::${moduleLoadMode}::${discoveryMode}::${params.runtimeSubagentMode ?? "default"}::${params.runtimeBindingIdentity ?? "{}"}::${params.pluginSdkResolution ?? "auto"}::${JSON.stringify(params.coreGatewayMethodNames ?? [])}::${activationMode}::${params.enterpriseIdentityAuthorityStartup === true ? "enterprise-startup" : "enterprise-consumer"}`; return createHash("sha256").update(cacheIdentity).digest("hex"); } @@ -388,6 +389,7 @@ export function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { pluginSdkResolution: options.pluginSdkResolution, coreGatewayMethodNames, activate: options.activate, + enterpriseIdentityAuthorityStartup: options.enterpriseIdentityAuthorityStartup, }); return { env, diff --git a/src/plugins/loader-runtime-load.ts b/src/plugins/loader-runtime-load.ts index dc30d34f6bbe..8074fb44da34 100644 --- a/src/plugins/loader-runtime-load.ts +++ b/src/plugins/loader-runtime-load.ts @@ -2,6 +2,12 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { normalizeAgentToolResultMiddlewareRuntimeIds } from "./agent-tool-result-middleware.js"; import { resolveEffectivePluginActivationState } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; +import { + createEnterpriseIdentityProviderAuthorityRegistry, + getProcessEnterpriseIdentityProviderAuthorityRegistry, + getOrCreateProcessEnterpriseIdentityProviderAuthorityRegistry, + resolveEnterpriseIdentityProviderAllowlist, +} from "./enterprise-identity-provider-authority-registry.js"; import { getReusableCachedPluginRegistry, pluginLoaderCacheState, @@ -153,6 +159,18 @@ function loadOpenClawPluginsInternal( coreGatewayMethodNames: options.coreGatewayMethodNames, }), ...(options.hostServices !== undefined && { hostServices: options.hostServices }), + enterpriseIdentityProviderAuthorityRegistry: + options.enterpriseIdentityAuthorityStartup === true + ? getOrCreateProcessEnterpriseIdentityProviderAuthorityRegistry({ + operatorAllowlist: resolveEnterpriseIdentityProviderAllowlist(options.config), + }) + : (getProcessEnterpriseIdentityProviderAuthorityRegistry() ?? + // A CLI, tool-discovery, or agent runtime must not become the first + // enterprise authority. It can consume an already-published Gateway + // snapshot, otherwise every registration is refused by this empty + // allowlist. + createEnterpriseIdentityProviderAuthorityRegistry()), + enterpriseIdentityAuthorityStartup: options.enterpriseIdentityAuthorityStartup === true, activateGlobalSideEffects: context.shouldActivate, }); const { registry } = registryBuilder; @@ -255,7 +273,17 @@ function loadOpenClawPluginsInternal( logger, env: context.env, }); - maybeThrowOnPluginLoadError(registry, options.throwOnLoadError); + const enterpriseRegistrationFailure = + options.enterpriseIdentityAuthorityStartup === true && + registry.plugins.some( + (plugin) => + plugin.status === "error" && + (plugin.contracts?.enterpriseIdentityProviders?.length ?? 0) > 0, + ); + maybeThrowOnPluginLoadError( + registry, + options.throwOnLoadError || enterpriseRegistrationFailure, + ); if (context.shouldActivate && options.mode !== "validate") { const failedPlugins = registry.plugins.filter((plugin) => plugin.failedAt != null); if (failedPlugins.length > 0) { diff --git a/src/plugins/loader-shared.ts b/src/plugins/loader-shared.ts index 2ce142e21aac..76fb878344eb 100644 --- a/src/plugins/loader-shared.ts +++ b/src/plugins/loader-shared.ts @@ -30,7 +30,11 @@ import { createPluginRecord } from "./loader-records.js"; import type { PluginLoadOptions, PluginRuntimeSubagentMode } from "./loader-types.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; import type { PluginDiagnostic } from "./manifest-types.js"; -import type { PluginRecord, PluginRegistry } from "./registry.js"; +import { + sealEnterpriseIdentityProviderRegistry, + type PluginRecord, + type PluginRegistry, +} from "./registry.js"; import { captureActivePluginRegistrySnapshot, commitStagedPluginRegistry, @@ -358,6 +362,9 @@ export function activatePluginRegistry( initializeGlobalHookRunner(registry); activateContextEngineRegistrations(registry); commitStagedPluginRegistry(activeSnapshot.activeRegistry, registry); + // Publish the enterprise authority snapshot only after all activation work + // has succeeded; a failed activation must not leave a usable authority. + sealEnterpriseIdentityProviderRegistry(registry); } catch (error) { restoreActivePluginRegistrySnapshot(activeSnapshot); if (previousHookRegistry) { diff --git a/src/plugins/loader-types.ts b/src/plugins/loader-types.ts index bc85ed0adcc8..9b8869483221 100644 --- a/src/plugins/loader-types.ts +++ b/src/plugins/loader-types.ts @@ -43,6 +43,8 @@ export type PluginLoadOptions = { preferBuiltPluginArtifacts?: boolean; toolDiscovery?: boolean; activate?: boolean; + /** Only the Gateway composition root may establish enterprise identity authority. */ + enterpriseIdentityAuthorityStartup?: boolean; loadModules?: boolean; throwOnLoadError?: boolean; manifestRegistry?: PluginManifestRegistry; diff --git a/src/plugins/manifest-capability-normalizers.ts b/src/plugins/manifest-capability-normalizers.ts index af0366b7d862..8ad510f29ed4 100644 --- a/src/plugins/manifest-capability-normalizers.ts +++ b/src/plugins/manifest-capability-normalizers.ts @@ -347,6 +347,7 @@ const MANIFEST_CONTRACT_KEYS = [ "externalAuthProviders", "embeddingProviders", "memoryEmbeddingProviders", + "enterpriseIdentityProviders", "speechProviders", "realtimeTranscriptionProviders", "realtimeVoiceProviders", diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 24613b7bd378..66def09f9cac 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -2537,6 +2537,27 @@ describe("loadPluginManifestRegistry", () => { }); }); + it("preserves enterprise identity provider contracts from plugin manifests", () => { + const dir = makeTempDir(); + writeManifest(dir, { + id: "enterprise-identity-fixture", + contracts: { + enterpriseIdentityProviders: [" entra ", "", "okta"], + }, + configSchema: { type: "object" }, + }); + + const registry = loadSingleCandidateRegistry({ + idHint: "enterprise-identity-fixture", + rootDir: dir, + origin: "workspace", + }); + + expect(registry.plugins[0]?.contracts).toEqual({ + enterpriseIdentityProviders: ["entra", "okta"], + }); + }); + it("preserves qa runner descriptors from plugin manifests", () => { const dir = makeTempDir(); writeManifest(dir, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 48d16338ba15..59a07d650a01 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -416,6 +416,7 @@ function mergeManifestContracts( "externalAuthProviders", "embeddingProviders", "memoryEmbeddingProviders", + "enterpriseIdentityProviders", "speechProviders", "realtimeTranscriptionProviders", "realtimeVoiceProviders", diff --git a/src/plugins/manifest-types.ts b/src/plugins/manifest-types.ts index b4f121c7663d..e650919a4b7a 100644 --- a/src/plugins/manifest-types.ts +++ b/src/plugins/manifest-types.ts @@ -450,6 +450,8 @@ export type PluginManifestContracts = { externalAuthProviders?: string[]; embeddingProviders?: string[]; memoryEmbeddingProviders?: string[]; + /** Provider prefixes allowed to register enterprise identity material. */ + enterpriseIdentityProviders?: string[]; speechProviders?: string[]; realtimeTranscriptionProviders?: string[]; realtimeVoiceProviders?: string[]; diff --git a/src/plugins/memory-enterprise-access-audit-reporter.ts b/src/plugins/memory-enterprise-access-audit-reporter.ts new file mode 100644 index 000000000000..aa8630ef703a --- /dev/null +++ b/src/plugins/memory-enterprise-access-audit-reporter.ts @@ -0,0 +1,29 @@ +import type { MemoryAccessContext } from "../memory-host-sdk/host/authorization.js"; +import type { MemoryEnterpriseRoleAccessDecision } from "../state/memory-enterprise-access-audit.js"; +import type { OpenClawPluginApi } from "./types.js"; + +/** A host-issued closure for the selected memory plugin's redacted audit writes. */ +export type MemoryEnterpriseAccessAuditReporter = Readonly<{ + recordRoleAccessDecisions: (params: { + context: MemoryAccessContext; + decisions: readonly MemoryEnterpriseRoleAccessDecision[]; + now?: number; + }) => void; +}>; + +const reporters = new WeakMap(); + +/** Bind the durable writer to one API object after the selected slot is known. */ +export function issueMemoryEnterpriseAccessAuditReporter( + api: OpenClawPluginApi, + reporter: MemoryEnterpriseAccessAuditReporter, +): void { + reporters.set(api, reporter); +} + +/** Resolve only the reporter that the registry issued to this plugin API object. */ +export function resolveMemoryEnterpriseAccessAuditReporter( + api: OpenClawPluginApi, +): MemoryEnterpriseAccessAuditReporter | undefined { + return reporters.get(api); +} diff --git a/src/plugins/memory-run-exposure-ledger.test.ts b/src/plugins/memory-run-exposure-ledger.test.ts index 5cd2b50a8939..b2b617118049 100644 --- a/src/plugins/memory-run-exposure-ledger.test.ts +++ b/src/plugins/memory-run-exposure-ledger.test.ts @@ -56,7 +56,7 @@ afterEach(() => { database = undefined; }); -function prepare(sessionId: string) { +function prepare(sessionId: string, enterpriseMembershipSnapshotIds: readonly string[] = []) { return prepareMemoryRunExposure({ agentId: "main", sessionId, @@ -69,6 +69,7 @@ function prepare(sessionId: string) { exposedResourceRevisions: ["revision-1"], exposureReceiptIds: ["exposure-1"], egressReceiptIds: ["egress-1"], + enterpriseMembershipSnapshotIds, deliveryAudiences: [{ kind: "user", id: "alice" }], deliveryRevision: "delivery-1", egressRegistryRevision: "egress-1", @@ -149,6 +150,33 @@ describe("memory pre-output exposure ledger", () => { ]); }); + it("persists opaque enterprise snapshot-to-exposure joins before content release", () => { + const snapshot = prepare("session-a", ["snapshot-z", "snapshot-a", "snapshot-z"]); + expect(snapshot.enterpriseMembershipSnapshotIds).toEqual(["snapshot-a", "snapshot-z"]); + expect(persistMemoryRunExposureBeforeContent(snapshot)).toBe(true); + + const rows = database + ?.prepare( + `SELECT snapshot_id, created_at + FROM memory_preoutput_exposure_enterprise_memberships + WHERE exposure_set_id = ? + ORDER BY snapshot_id`, + ) + .all(snapshot.exposureSetId); + expect(rows).toEqual([ + { snapshot_id: "snapshot-a", created_at: snapshot.createdAt }, + { snapshot_id: "snapshot-z", created_at: snapshot.createdAt }, + ]); + clearMemoryRunExposureForTest(); + expect( + readDurableMemoryRunExposure({ + database: mocks.database as never, + sessionId: "session-a", + runId: "shared-run-id", + }), + ).toMatchObject({ enterpriseMembershipSnapshotIds: ["snapshot-a", "snapshot-z"] }); + }); + it("persists token-free delegated facts and rehydrates them after restart", () => { const base = prepare("session-a"); const snapshot = { @@ -239,6 +267,23 @@ describe("memory pre-output exposure ledger", () => { ).toEqual({ kind: "unavailable" }); }); + it("fails closed when an immutable enterprise membership mapping is missing", () => { + const snapshot = prepare("session-a", ["snapshot-a"]); + expect(persistMemoryRunExposureBeforeContent(snapshot)).toBe(true); + database?.exec(/* sqlite-allow-raw: test corrupts immutable proof to assert fail-closed read. */ ` + DROP TRIGGER memory_preoutput_exposure_enterprise_memberships_no_delete; + DELETE FROM memory_preoutput_exposure_enterprise_memberships + WHERE exposure_set_id = '${snapshot.exposureSetId}'; + `); + expect( + readLatestDurableMemoryRunExposure({ + agentId: "main", + sessionId: "session-a", + runId: "shared-run-id", + }), + ).toEqual({ kind: "unavailable" }); + }); + it("rolls back both ledger rows when durable authorization-fact persistence fails", () => { database?.exec(/* sqlite-allow-raw: test-only atomicity fault injection. */ ` CREATE TRIGGER reject_exposure_authorization_facts_for_test @@ -259,6 +304,28 @@ describe("memory pre-output exposure ledger", () => { } }); + it("rolls back the exposure and authorization facts when membership mapping persistence fails", () => { + database?.exec(/* sqlite-allow-raw: test-only atomicity fault injection. */ ` + CREATE TRIGGER reject_exposure_enterprise_memberships_for_test + BEFORE INSERT ON memory_preoutput_exposure_enterprise_memberships + BEGIN + SELECT RAISE(ABORT, 'test enterprise membership failure'); + END; + `); + + expect(persistMemoryRunExposureBeforeContent(prepare("session-a", ["snapshot-a"]))).toBe(false); + for (const table of [ + "memory_preoutput_exposure_ledger", + "memory_preoutput_exposure_authorization_facts", + "memory_preoutput_exposure_enterprise_membership_sets", + "memory_preoutput_exposure_enterprise_memberships", + ]) { + expect(database?.prepare(`SELECT count(*) AS count FROM ${table}`).get()).toEqual({ + count: 0, + }); + } + }); + it("fails closed on a duplicate revision without adding a partial row", () => { const snapshot = prepare("session-a"); diff --git a/src/plugins/memory-run-exposure-ledger.ts b/src/plugins/memory-run-exposure-ledger.ts index bc3c4679d11c..d674d034f196 100644 --- a/src/plugins/memory-run-exposure-ledger.ts +++ b/src/plugins/memory-run-exposure-ledger.ts @@ -49,6 +49,16 @@ type MemoryPreoutputExposureLedgerDatabase = { host_facts_revision: string; created_at: number; }; + memory_preoutput_exposure_enterprise_membership_sets: { + exposure_set_id: string; + snapshot_count: number; + created_at: number; + }; + memory_preoutput_exposure_enterprise_memberships: { + exposure_set_id: string; + snapshot_id: string; + created_at: number; + }; }; type MemoryExposureLedgerDiagnostic = "hydrate-failed" | "persist-failed"; @@ -335,10 +345,17 @@ function persistMemoryRunExposureInTransaction(params: { exposureReceiptIdsJson: string; egressReceiptIdsJson: string; deliveryAudiencesJson: string; + enterpriseMembershipSnapshotIdsJson: string; actorEvidenceJson: string; delegationSnapshotJson: string; }): void { const { database, snapshot } = params; + if ( + canonicalStrings(snapshot.enterpriseMembershipSnapshotIds) !== + params.enterpriseMembershipSnapshotIdsJson + ) { + throw new Error("memory exposure enterprise memberships are not canonical"); + } ensureMemoryPreoutputExposureLedgerSchemaInTransaction(database.db); const db = getNodeSqliteKysely(database.db); const inserted = executeSqliteQuerySync( @@ -390,6 +407,38 @@ function persistMemoryRunExposureInTransaction(params: { if (factsInserted.numAffectedRows !== 1n) { throw new Error("memory exposure revision already has durable authorization facts"); } + const membershipSetInserted = executeSqliteQuerySync( + database.db, + db + .insertInto("memory_preoutput_exposure_enterprise_membership_sets") + .values({ + exposure_set_id: snapshot.exposureSetId, + snapshot_count: snapshot.enterpriseMembershipSnapshotIds.length, + created_at: snapshot.createdAt, + }) + .onConflict((conflict) => conflict.column("exposure_set_id").doNothing()), + ); + if (membershipSetInserted.numAffectedRows !== 1n) { + throw new Error("memory exposure revision already has durable enterprise membership facts"); + } + if (snapshot.enterpriseMembershipSnapshotIds.length > 0) { + const membershipsInserted = executeSqliteQuerySync( + database.db, + db.insertInto("memory_preoutput_exposure_enterprise_memberships").values( + snapshot.enterpriseMembershipSnapshotIds.map((snapshotId) => ({ + exposure_set_id: snapshot.exposureSetId, + snapshot_id: snapshotId, + created_at: snapshot.createdAt, + })), + ), + ); + if ( + membershipsInserted.numAffectedRows !== + BigInt(snapshot.enterpriseMembershipSnapshotIds.length) + ) { + throw new Error("memory exposure revision already has durable enterprise memberships"); + } + } } /** @@ -403,6 +452,9 @@ export function persistMemoryRunExposureBeforeContent( const exposedResourceRevisionsJson = canonicalStrings(snapshot.exposedResourceRevisions); const exposureReceiptIdsJson = canonicalStrings(snapshot.exposureReceiptIds); const egressReceiptIdsJson = canonicalStrings(snapshot.egressReceiptIds); + const enterpriseMembershipSnapshotIdsJson = canonicalStrings( + snapshot.enterpriseMembershipSnapshotIds, + ); const deliveryAudiencesJson = canonicalAudiences(snapshot); const actorEvidenceJson = canonicalActorEvidence(snapshot.actorEvidence); const delegationSnapshotJson = canonicalDelegationSnapshot(snapshot.delegationSnapshot); @@ -412,6 +464,7 @@ export function persistMemoryRunExposureBeforeContent( !exposedResourceRevisionsJson || !exposureReceiptIdsJson || !egressReceiptIdsJson || + !enterpriseMembershipSnapshotIdsJson || !deliveryAudiencesJson || !actorEvidenceJson || !delegationSnapshotJson @@ -439,6 +492,9 @@ export function persistMemoryRunExposureBeforeContentInDatabase(params: { const exposedResourceRevisionsJson = canonicalStrings(snapshot.exposedResourceRevisions); const exposureReceiptIdsJson = canonicalStrings(snapshot.exposureReceiptIds); const egressReceiptIdsJson = canonicalStrings(snapshot.egressReceiptIds); + const enterpriseMembershipSnapshotIdsJson = canonicalStrings( + snapshot.enterpriseMembershipSnapshotIds, + ); const deliveryAudiencesJson = canonicalAudiences(snapshot); const actorEvidenceJson = canonicalActorEvidence(snapshot.actorEvidence); const delegationSnapshotJson = canonicalDelegationSnapshot(snapshot.delegationSnapshot); @@ -449,6 +505,7 @@ export function persistMemoryRunExposureBeforeContentInDatabase(params: { !exposedResourceRevisionsJson || !exposureReceiptIdsJson || !egressReceiptIdsJson || + !enterpriseMembershipSnapshotIdsJson || !deliveryAudiencesJson || !actorEvidenceJson || !delegationSnapshotJson @@ -465,6 +522,7 @@ export function persistMemoryRunExposureBeforeContentInDatabase(params: { exposureReceiptIdsJson, egressReceiptIdsJson, deliveryAudiencesJson, + enterpriseMembershipSnapshotIdsJson, actorEvidenceJson, delegationSnapshotJson, }); @@ -541,6 +599,26 @@ function readDurableMemoryRunExposureOrThrow(params: { const exposureReceiptIds = parseCanonicalStrings(row.exposure_receipt_ids_json); const egressReceiptIds = parseCanonicalStrings(row.egress_receipt_ids_json); const deliveryAudiences = parseCanonicalAudiences(row.delivery_audiences_json); + const enterpriseMembershipRows = executeSqliteQuerySync( + params.database.db, + db + .selectFrom("memory_preoutput_exposure_enterprise_memberships") + .select(["snapshot_id", "created_at"]) + .where("exposure_set_id", "=", row.exposure_set_id) + .orderBy("snapshot_id", "asc"), + ).rows; + const enterpriseMembershipSnapshotIds = enterpriseMembershipRows.map( + (membership) => membership.snapshot_id, + ); + const enterpriseMembershipSnapshotIdsJson = canonicalStrings(enterpriseMembershipSnapshotIds); + const enterpriseMembershipSet = executeSqliteQueryTakeFirstSync( + params.database.db, + db + .selectFrom("memory_preoutput_exposure_enterprise_membership_sets") + .select(["snapshot_count", "created_at"]) + .where("exposure_set_id", "=", row.exposure_set_id) + .limit(1), + ); const facts = executeSqliteQueryTakeFirstSync( params.database.db, db @@ -562,6 +640,11 @@ function readDurableMemoryRunExposureOrThrow(params: { !exposedResourceRevisions || !exposureReceiptIds || !egressReceiptIds || + !enterpriseMembershipSnapshotIdsJson || + !enterpriseMembershipSet || + enterpriseMembershipSet.snapshot_count !== enterpriseMembershipSnapshotIds.length || + enterpriseMembershipSet.created_at !== row.created_at || + enterpriseMembershipRows.some((membership) => membership.created_at !== row.created_at) || !deliveryAudiences || !actorEvidence || !delegationSnapshot || @@ -600,6 +683,7 @@ function readDurableMemoryRunExposureOrThrow(params: { exposedResourceRevisions, exposureReceiptIds, egressReceiptIds, + enterpriseMembershipSnapshotIds: Object.freeze(enterpriseMembershipSnapshotIds), deliveryAudiences, deliveryRevision: row.delivery_revision, egressRegistryRevision: row.egress_registry_revision, diff --git a/src/plugins/memory-run-exposure.ts b/src/plugins/memory-run-exposure.ts index 18a54cfe7ba8..809868c1fd87 100644 --- a/src/plugins/memory-run-exposure.ts +++ b/src/plugins/memory-run-exposure.ts @@ -60,6 +60,7 @@ export type MemoryRunExposureSnapshot = Readonly<{ exposedResourceRevisions: readonly string[]; exposureReceiptIds: readonly string[]; egressReceiptIds: readonly string[]; + enterpriseMembershipSnapshotIds: readonly string[]; deliveryAudiences: readonly AudienceRef[]; deliveryRevision: string; egressRegistryRevision: string; @@ -192,11 +193,15 @@ function captureDelegation( export function captureDurableMemoryAuthorizationFacts(context: MemoryAccessContext): Readonly<{ actorEvidence: DurableMemoryActorEvidence; delegationSnapshot: DurableMemoryDelegationSnapshot; + enterpriseMembershipSnapshotIds: readonly string[]; hostFactsRevision: string; }> { return Object.freeze({ actorEvidence: captureActorEvidence(context.actor), delegationSnapshot: captureDelegation(context.delegation), + enterpriseMembershipSnapshotIds: sortedUnique( + context.verifiedMemberships.map((membership) => membership.snapshotId), + ), hostFactsRevision: requireText(context.hostFactsRevision, "hostFactsRevision"), }); } @@ -215,6 +220,7 @@ export function prepareMemoryRunExposure(facts: MemoryRunExposureFacts): MemoryR exposedResourceRevisions: sortedUnique(facts.exposedResourceRevisions), exposureReceiptIds: sortedUnique(facts.exposureReceiptIds), egressReceiptIds: sortedUnique(facts.egressReceiptIds), + enterpriseMembershipSnapshotIds: sortedUnique(facts.enterpriseMembershipSnapshotIds), deliveryAudiences: sortedAudiences(facts.deliveryAudiences), createdAt: Date.now(), }) satisfies MemoryRunExposureSnapshot; diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 819a6741b057..9060ace5beba 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -270,6 +270,24 @@ describe("official external plugin catalog", () => { }); }); + it("publishes every enterprise memory identity provider through the official catalog", () => { + const providers = [ + ["memory-identity-entra", "@openclaw/memory-identity-entra"], + ["memory-identity-google-workspace", "@openclaw/memory-identity-google-workspace"], + ["memory-identity-okta", "@openclaw/memory-identity-okta"], + ] as const; + + for (const [id, npmSpec] of providers) { + const entry = expectCatalogEntry(id); + expect(resolveOfficialExternalPluginInstall(entry)).toEqual({ + clawhubSpec: `clawhub:${npmSpec}`, + npmSpec, + defaultChoice: "npm", + minHostVersion: ">=2026.8.1", + }); + } + }); + it("keeps Fish Audio's legacy id migration-only across npm and ClawHub routes", () => { const entry = getOfficialExternalPluginCatalogEntryForPackage("@openclaw/fish-audio-speech"); expect(entry).toBeDefined(); diff --git a/src/plugins/plugin-api.types.ts b/src/plugins/plugin-api.types.ts index e31f5704c093..9ca326bb4c29 100644 --- a/src/plugins/plugin-api.types.ts +++ b/src/plugins/plugin-api.types.ts @@ -23,6 +23,7 @@ import type { import type { CliBackendPlugin, PluginTextTransforms } from "./cli-backend.types.js"; import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import type { PluginConversationBindingResolvedEvent } from "./conversation-binding.types.js"; +import type { EnterpriseIdentityProviderAdapter } from "./enterprise-identity-provider-types.js"; import type { PluginHookHandlerMap, PluginHookName, @@ -448,6 +449,8 @@ export type OpenClawPluginApi = { * while existing memory providers migrate. */ registerMemoryEmbeddingProvider: (adapter: MemoryEmbeddingProviderAdapter) => void; + /** Register manifest-declared enterprise identity verification material. */ + registerEnterpriseIdentityProvider: (adapter: EnterpriseIdentityProviderAdapter) => void; resolvePath: (input: string) => string; /** Register a lifecycle hook handler */ on: ( diff --git a/src/plugins/plugin-lookup-table.test.ts b/src/plugins/plugin-lookup-table.test.ts index b3710f3251d0..7b19de25377b 100644 --- a/src/plugins/plugin-lookup-table.test.ts +++ b/src/plugins/plugin-lookup-table.test.ts @@ -91,6 +91,21 @@ function createIndex( agentHarnesses: [], configPaths: plugin.activation?.onConfigPaths ?? [], }, + ...(plugin.contracts + ? { + contributions: { + channels: [], + channelConfigs: [], + providers: [], + modelCatalogProviders: [], + modelSupportPrefixes: [], + modelSupportPatterns: [], + autoEnableProviderIds: [], + commandAliases: [], + contracts: plugin.contracts, + }, + } + : {}), compat: [], })), }; @@ -431,6 +446,52 @@ describe("loadPluginLookUpTable", () => { expect(table.startup.pluginIds).toEqual(["openai"]); }); + it("loads an operator-allowlisted enterprise identity adapter before Gateway sealing", async () => { + const plugins = [ + createManifestRecord({ + id: "memory-identity-entra", + origin: "bundled", + enabledByDefault: true, + activation: { onStartup: false }, + contracts: { enterpriseIdentityProviders: ["entra"] }, + }), + createManifestRecord({ + id: "memory-identity-okta", + origin: "bundled", + enabledByDefault: true, + activation: { onStartup: false }, + contracts: { enterpriseIdentityProviders: ["okta"] }, + }), + ]; + const index = createIndex(plugins); + loadPluginManifestRegistryForInstalledIndex.mockImplementation( + (params: { pluginIds?: readonly string[] }) => ({ + plugins: params.pluginIds + ? plugins.filter((plugin) => params.pluginIds?.includes(plugin.id)) + : plugins, + diagnostics: [], + }), + ); + const { loadPluginLookUpTable } = await import("./plugin-lookup-table.js"); + + const table = loadPluginLookUpTable({ + config: { + plugins: { + allow: ["memory-identity-entra"], + enterpriseIdentityProviders: { allow: ["entra"] }, + slots: { memory: "none" }, + }, + } as OpenClawConfig, + env: {}, + index, + }); + + expect(loadPluginManifestRegistryForInstalledIndex.mock.calls[0]?.[0]).toMatchObject({ + pluginIds: ["memory-identity-entra"], + }); + expect(table.startup.pluginIds).toEqual(["memory-identity-entra"]); + }); + it("keeps config-path startup activation owners in scoped manifest reconstruction", async () => { const plugins = [ createManifestRecord({ diff --git a/src/plugins/registry-api.ts b/src/plugins/registry-api.ts index cd0294628edb..46c3d9807267 100644 --- a/src/plugins/registry-api.ts +++ b/src/plugins/registry-api.ts @@ -15,6 +15,7 @@ import { unschedulePluginSessionTurnsByTag, } from "./host-hook-scheduled-turns.js"; import { enqueuePluginNextTurnInjection } from "./host-hook-state.js"; +import { issueMemoryEnterpriseAccessAuditReporter } from "./memory-enterprise-access-audit-reporter.js"; import { isPluginRegistryActivated, isPluginRegistryRetired } from "./registry-lifecycle.js"; import type { PluginRegistrars } from "./registry-registrars.js"; import type { PluginRuntimeResolver } from "./registry-runtime.js"; @@ -105,6 +106,8 @@ export function createPluginApiFactory( registerMemoryPromptPreparation, registerMemoryCorpusSupplement, registerMemoryEmbeddingProvider, + registerEnterpriseIdentityProvider, + createMemoryEnterpriseAccessAuditReporter, registerCli, registerChannel, } = registrars; @@ -159,7 +162,7 @@ export function createPluginApiFactory( !isPluginRegistryRetired(registry) && (isActivatingLoadedRecord() || (isPluginRegistryActivated(registry) && isLoadedRecordInRegistry())); - return buildPluginApi({ + const api = buildPluginApi({ id: record.id, name: record.name, version: record.version, @@ -369,6 +372,8 @@ export function createPluginApiFactory( registerMemoryCorpusSupplement(record, supplement), registerMemoryEmbeddingProvider: (adapter) => registerMemoryEmbeddingProvider(record, adapter), + registerEnterpriseIdentityProvider: (adapter) => + registerEnterpriseIdentityProvider(record, adapter), on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts, params.hookPolicy), } @@ -387,6 +392,11 @@ export function createPluginApiFactory( registerChannel: (registration) => registerChannel(record, registration, registrationMode), }, }); + const enterpriseAccessAuditReporter = createMemoryEnterpriseAccessAuditReporter(record); + if (enterpriseAccessAuditReporter) { + issueMemoryEnterpriseAccessAuditReporter(api, enterpriseAccessAuditReporter); + } + return api; }; return { createApi, deactivatePluginSideEffectGuards }; diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index 03ac792939c0..3cfe24a7f90a 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -1,7 +1,10 @@ +import { createEnterpriseIdentityProviderAuthorityRegistry } from "./enterprise-identity-provider-authority-registry.js"; // Provides the empty plugin registry used before discovery completes. import type { PluginRegistry } from "./registry-types.js"; export function createEmptyPluginRegistry(): PluginRegistry { + const enterpriseIdentityProviderAuthorityRegistry = + createEnterpriseIdentityProviderAuthorityRegistry(); return { plugins: [], tools: [], @@ -31,6 +34,10 @@ export function createEmptyPluginRegistry(): PluginRegistry { agentToolResultMiddlewareOwners: [], agentToolResultMiddlewares: [], memoryEmbeddingProviders: [], + // Keep the generic registry reloadable. The authority owner publishes an + // immutable copy when activation succeeds, rather than sharing this array. + enterpriseIdentityProviders: [...enterpriseIdentityProviderAuthorityRegistry.providers], + enterpriseIdentityProviderAuthorityRegistry, agentHarnesses: [], pluginRuntimeArtifacts: new Map(), compactionProviders: [], diff --git a/src/plugins/registry-registrars-memory.ts b/src/plugins/registry-registrars-memory.ts index 550f63521c07..d7bf6da21b08 100644 --- a/src/plugins/registry-registrars-memory.ts +++ b/src/plugins/registry-registrars-memory.ts @@ -1,10 +1,140 @@ +import { recordMemoryEnterpriseRoleAccessDecisions } from "../state/memory-enterprise-access-audit.js"; +import type { EnterpriseIdentityMembershipSource } from "./enterprise-identity-provider-types.js"; +import type { MemoryEnterpriseAccessAuditReporter } from "./memory-enterprise-access-audit-reporter.js"; import type { PluginRegistryState } from "./registry-state.js"; import type { PluginRecord } from "./registry-types.js"; import { hasKind } from "./slots.js"; import type { OpenClawPluginApi } from "./types.js"; +const MAX_ENTERPRISE_EVIDENCE_AGE_MS = 24 * 60 * 60_000; + +function isNormalizedText(value: unknown): value is string { + return typeof value === "string" && Boolean(value) && value.trim() === value; +} + +function isNormalizedTextList(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every(isNormalizedText); +} + +function isHttpsUrl(value: unknown): value is string { + if (!isNormalizedText(value)) { + return false; + } + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} + +function isEnterpriseOidcCallbackUrl(value: unknown): boolean { + if (!isHttpsUrl(value)) { + return false; + } + const url = new URL(value); + return ( + url.pathname === "/memory/oidc/callback" && + !url.search && + !url.hash && + !url.username && + !url.password + ); +} + +function hasValidEnterpriseMembershipSource(value: EnterpriseIdentityMembershipSource): boolean { + if (!Number.isSafeInteger(value.maxGroups) || value.maxGroups < 1 || value.maxGroups > 1_000) { + return false; + } + if (value.kind === "google-workspace-directory") { + return ( + isNormalizedText(value.verifiedEmailClaim) && + isNormalizedTextList(value.roleGroupResourceNames) && + value.roleGroupResourceNames.length > 0 && + value.roleGroupResourceNames.every((group) => /^groups\/[^/\s]+$/u.test(group)) && + (value.customerId === undefined || /^C[\w-]+$/u.test(value.customerId)) + ); + } + return ( + isNormalizedText(value.claim) && + typeof value.required === "boolean" && + isNormalizedTextList(value.roleGroupIds) && + value.roleGroupIds.length > 0 && + (value.incompleteIndicators ?? []).every( + (indicator) => + isNormalizedText(indicator.claim) && + (indicator.kind === "truthy-claim" || + (indicator.kind === "nested-key" && isNormalizedText(indicator.key))), + ) + ); +} + +function hasValidEnterpriseAuthority( + provider: Parameters[0], +): boolean { + if (typeof provider.resolveAuthorizationCodeClientSecret !== "function") { + return false; + } + return provider.authorities.every((authority) => { + if ( + !isHttpsUrl(authority.issuer) || + !isNormalizedTextList(authority.acceptedIssuerAliases ?? []) || + !isNormalizedText(authority.tenantId) || + !isNormalizedTextList(authority.audiences) || + authority.audiences.length === 0 || + !isHttpsUrl(authority.jwksUri) || + authority.algorithm !== "RS256" || + !Number.isSafeInteger(authority.maxSnapshotAgeMs) || + authority.maxSnapshotAgeMs <= 0 || + authority.maxSnapshotAgeMs > MAX_ENTERPRISE_EVIDENCE_AGE_MS || + !Number.isSafeInteger(authority.assurance.maxAuthenticationAgeMs) || + authority.assurance.maxAuthenticationAgeMs <= 0 || + authority.assurance.maxAuthenticationAgeMs > MAX_ENTERPRISE_EVIDENCE_AGE_MS || + !isNormalizedTextList(authority.assurance.acceptedAcrValues ?? []) || + !isNormalizedTextList(authority.assurance.requiredAmrValues ?? []) || + !isNormalizedText(authority.authorizationCodeFlow.clientId) || + !authority.audiences.includes(authority.authorizationCodeFlow.clientId) || + !isHttpsUrl(authority.authorizationCodeFlow.authorizationEndpoint) || + !isHttpsUrl(authority.authorizationCodeFlow.tokenEndpoint) || + !isEnterpriseOidcCallbackUrl(authority.authorizationCodeFlow.redirectUri) || + !isNormalizedTextList(authority.authorizationCodeFlow.scopes) || + authority.authorizationCodeFlow.scopes.length === 0 || + !Array.isArray(authority.requiredClaims ?? []) || + !(authority.requiredClaims ?? []).every( + (claim) => + isNormalizedText(claim.claim) && + (typeof claim.value === "string" + ? isNormalizedText(claim.value) + : typeof claim.value === "boolean"), + ) || + !hasValidEnterpriseMembershipSource(authority.membership) + ) { + return false; + } + return authority.tenantBinding.kind === "issuer" + ? isNormalizedText(authority.tenantBinding.tenantId) + : isNormalizedText(authority.tenantBinding.claim) && + isNormalizedText(authority.tenantBinding.value); + }); +} + export function createMemoryRegistrars(state: PluginRegistryState) { - const { registry, pushDiagnostic } = state; + const { registry, registryParams, enterpriseIdentityProviderAuthorityRegistry, pushDiagnostic } = + state; + + const rejectEnterpriseIdentityProvider = (record: PluginRecord, message: string): void => { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message, + }); + if (registryParams.enterpriseIdentityAuthorityStartup === true) { + record.status = "error"; + record.error = message; + record.failurePhase = "register"; + record.failedAt = new Date(); + } + }; const requireMemorySlot = (record: PluginRecord, surface: string): boolean => { if (!hasKind(record.kind, "memory")) { @@ -118,11 +248,133 @@ export function createMemoryRegistrars(state: PluginRegistryState) { }); }; + const registerEnterpriseIdentityProvider = ( + record: PluginRecord, + provider: Parameters[0], + ) => { + if (enterpriseIdentityProviderAuthorityRegistry.isSealed()) { + const existing = registry.enterpriseIdentityProviders.find( + (entry) => + entry.pluginId === record.id && + entry.provider.providerPrefix === provider?.providerPrefix, + ); + if (existing) { + // A normal plugin reload replays the already-published authority. It + // is not a new registration and cannot replace its sealed snapshot. + return; + } + rejectEnterpriseIdentityProvider( + record, + "enterprise identity provider registry is sealed after startup", + ); + return; + } + const providerPrefix = provider?.providerPrefix; + if ( + typeof providerPrefix !== "string" || + !providerPrefix || + providerPrefix.trim() !== providerPrefix + ) { + rejectEnterpriseIdentityProvider( + record, + "enterprise identity provider registration missing a normalized providerPrefix", + ); + return; + } + if (!(record.contracts?.enterpriseIdentityProviders ?? []).includes(providerPrefix)) { + rejectEnterpriseIdentityProvider( + record, + `plugin must declare contracts.enterpriseIdentityProviders for provider: ${providerPrefix}`, + ); + return; + } + if (!enterpriseIdentityProviderAuthorityRegistry.operatorAllowlist.has(providerPrefix)) { + rejectEnterpriseIdentityProvider( + record, + `enterprise identity provider is not operator-allowlisted: ${providerPrefix}`, + ); + return; + } + if ( + registry.enterpriseIdentityProviders.some( + (entry) => entry.provider.providerPrefix === providerPrefix, + ) + ) { + rejectEnterpriseIdentityProvider( + record, + `enterprise identity provider already registered: ${providerPrefix}`, + ); + return; + } + if (!Array.isArray(provider.authorities) || provider.authorities.length === 0) { + rejectEnterpriseIdentityProvider( + record, + `enterprise identity provider requires at least one issuer and tenant authority: ${providerPrefix}`, + ); + return; + } + if (!hasValidEnterpriseAuthority(provider)) { + rejectEnterpriseIdentityProvider( + record, + `enterprise identity provider has an invalid issuer or tenant authority: ${providerPrefix}`, + ); + return; + } + const authorityKeys = new Set(); + for (const authority of provider.authorities) { + const authorityKey = `${authority.issuer}\u0000${authority.tenantId}`; + if (authorityKeys.has(authorityKey)) { + rejectEnterpriseIdentityProvider( + record, + `enterprise identity provider repeats issuer and tenant authority: ${providerPrefix}`, + ); + return; + } + authorityKeys.add(authorityKey); + const existing = registry.enterpriseIdentityProviders.find((entry) => + entry.provider.authorities.some( + (existingAuthority) => + existingAuthority.issuer === authority.issuer && + existingAuthority.tenantId === authority.tenantId, + ), + ); + if (existing) { + rejectEnterpriseIdentityProvider( + record, + `enterprise identity authority already registered: ${authority.issuer} (${authority.tenantId}) by ${existing.pluginId}`, + ); + return; + } + } + registry.enterpriseIdentityProviders.push({ + pluginId: record.id, + pluginName: record.name, + provider, + source: record.source, + rootDir: record.rootDir, + }); + }; + + const createMemoryEnterpriseAccessAuditReporter = ( + record: PluginRecord, + ): MemoryEnterpriseAccessAuditReporter | undefined => { + // Unlike memory capability registration, this closure grants a durable + // write authority. Only the explicitly selected slot owner may receive it. + if (!hasKind(record.kind, "memory") || record.memorySlotSelected !== true) { + return undefined; + } + return Object.freeze({ + recordRoleAccessDecisions: recordMemoryEnterpriseRoleAccessDecisions, + }); + }; + return { registerMemoryCapability, registerMemoryPromptSupplement, registerMemoryPromptPreparation, registerMemoryCorpusSupplement, registerMemoryEmbeddingProvider, + registerEnterpriseIdentityProvider, + createMemoryEnterpriseAccessAuditReporter, }; } diff --git a/src/plugins/registry-state.ts b/src/plugins/registry-state.ts index d689666c44d8..2c1fce84f134 100644 --- a/src/plugins/registry-state.ts +++ b/src/plugins/registry-state.ts @@ -1,3 +1,4 @@ +import { createEnterpriseIdentityProviderAuthorityRegistry } from "./enterprise-identity-provider-authority-registry.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import { createModelCatalogRegistrationHandlers } from "./model-catalog-registration.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; @@ -58,6 +59,12 @@ export function resolveTypedHookTimeoutMs(params: { export function createPluginRegistryState(registryParams: PluginRegistryParams) { const registry = createEmptyPluginRegistry(); + const enterpriseIdentityProviderAuthorityRegistry = + registryParams.enterpriseIdentityProviderAuthorityRegistry ?? + createEnterpriseIdentityProviderAuthorityRegistry(); + registry.enterpriseIdentityProviderAuthorityRegistry = + enterpriseIdentityProviderAuthorityRegistry; + registry.enterpriseIdentityProviders = [...enterpriseIdentityProviderAuthorityRegistry.providers]; bindPluginRegistryRuntime(registry, registryParams.runtime); const coreGatewayMethodNames = Array.from( new Set([ @@ -78,6 +85,7 @@ export function createPluginRegistryState(registryParams: PluginRegistryParams) return { registry, registryParams, + enterpriseIdentityProviderAuthorityRegistry, coreGatewayMethods: new Set(coreGatewayMethodNames), getHostCronService: () => registryParams.hostServices?.cron, pluginsWithChannelRegistrationConflict: new Set(), diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 8d4b0f5a58fa..81659c04e2c7 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -15,6 +15,10 @@ import type { CodexAppServerExtensionFactory } from "./codex-app-server-extensio import type { PluginCompatCode } from "./compat/registry.js"; import type { PluginActivationSource } from "./config-state.js"; import type { EmbeddingProviderAdapter } from "./embedding-provider-types.js"; +import type { + EnterpriseIdentityProviderAuthorityRegistry, + EnterpriseIdentityProviderRegistration, +} from "./enterprise-identity-provider-authority-registry.js"; import type { PluginAgentEventSubscriptionRegistration, PluginControlUiDescriptor, @@ -539,6 +543,9 @@ export type PluginRegistry = { agentToolResultMiddlewareOwners: PluginAgentToolResultMiddlewareOwner[]; agentToolResultMiddlewares: PluginAgentToolResultMiddlewareRegistration[]; memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[]; + /** Core-owned startup snapshot; it is not reset by a plugin registry reload. */ + enterpriseIdentityProviders: EnterpriseIdentityProviderRegistration[]; + enterpriseIdentityProviderAuthorityRegistry: EnterpriseIdentityProviderAuthorityRegistry; agentHarnesses: PluginAgentHarnessRegistration[]; pluginRuntimeArtifacts: Map; compactionProviders: RegisteredCompactionProvider[]; @@ -589,4 +596,8 @@ export type PluginRegistryParams = { cron?: import("../cron/service-contract.js").CronServiceContract; }; activateGlobalSideEffects?: boolean; + /** Core-owned startup snapshot; normal plugin registry replacement must reuse it. */ + enterpriseIdentityProviderAuthorityRegistry?: EnterpriseIdentityProviderAuthorityRegistry; + /** Only the Gateway startup registry may make enterprise registration failures boot-fatal. */ + enterpriseIdentityAuthorityStartup?: boolean; }; diff --git a/src/plugins/registry.dual-kind-memory-gate.test.ts b/src/plugins/registry.dual-kind-memory-gate.test.ts index 96a512a9bcdc..0bb8a6fae77d 100644 --- a/src/plugins/registry.dual-kind-memory-gate.test.ts +++ b/src/plugins/registry.dual-kind-memory-gate.test.ts @@ -6,6 +6,7 @@ import { } from "openclaw/plugin-sdk/plugin-test-contracts"; import { describe, expect, it } from "vitest"; import { LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES } from "../plugin-sdk/memory-authorization.js"; +import { resolveMemoryEnterpriseAccessAuditReporter } from "../plugin-sdk/memory-enterprise-audit-runtime.js"; import { resolveMemoryCapabilityRegistration, resolveSelectedMemoryCapabilityRegistration, @@ -34,6 +35,39 @@ function requireMemoryRuntime( } describe("dual-kind memory registration gate", () => { + it("issues the enterprise audit reporter only to the selected memory-slot owner", () => { + const { config, registry } = createPluginRegistryFixture(); + const selected = createPluginRecord({ + id: "selected-memory", + name: "Selected Memory", + kind: "memory", + memorySlotSelected: true, + }); + const unselected = createPluginRecord({ + id: "unselected-memory", + name: "Unselected Memory", + kind: "memory", + }); + const nonMemory = createPluginRecord({ + id: "not-memory", + name: "Not Memory", + kind: "context-engine", + }); + + const selectedReporter = resolveMemoryEnterpriseAccessAuditReporter( + registry.createApi(selected, { config }), + ); + + expect(selectedReporter).toBeDefined(); + expect(Object.isFrozen(selectedReporter)).toBe(true); + expect( + resolveMemoryEnterpriseAccessAuditReporter(registry.createApi(unselected, { config })), + ).toBeUndefined(); + expect( + resolveMemoryEnterpriseAccessAuditReporter(registry.createApi(nonMemory, { config })), + ).toBeUndefined(); + }); + it("blocks memory runtime registration for dual-kind plugins not selected for memory slot", () => { const { config, registry } = createPluginRegistryFixture(); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index b403c349a317..0a2c57f38971 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -18,6 +18,13 @@ export type PluginHttpRouteRegistration = RegistryTypesPluginHttpRouteRegistrati export type { PluginRecord, PluginRegistry } from "./registry-types.js"; export { createEmptyPluginRegistry } from "./registry-empty.js"; +/** Seal enterprise identity registrations once the startup registry is complete. */ +export function sealEnterpriseIdentityProviderRegistry( + registry: import("./registry-types.js").PluginRegistry, +): void { + registry.enterpriseIdentityProviderAuthorityRegistry.seal(registry.enterpriseIdentityProviders); +} + function clonePluginRecord(record: RegistryPluginRecord): RegistryPluginRecord { return Object.fromEntries( Object.entries(record).map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]), @@ -157,5 +164,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerSessionAction: registrars.registerSessionAction, registerHook: registrars.registerHook, registerTypedHook: registrars.registerTypedHook, + registerEnterpriseIdentityProvider: registrars.registerEnterpriseIdentityProvider, + sealEnterpriseIdentityProviderRegistry: () => + sealEnterpriseIdentityProviderRegistry(state.registry), }; } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index bd40d4d90773..3393e555249d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -115,6 +115,7 @@ export type * from "./types.mcp-connection.js"; export { WorkerProviderError } from "./capability-provider.types.js"; export type * from "./capability-provider.types.js"; +export type * from "./enterprise-identity-provider-types.js"; export type * from "./migration-provider.types.js"; export type * from "./plugin-api.types.js"; export { AGENT_PROMPT_SURFACE_KINDS } from "./plugin-command.types.js"; diff --git a/src/state/memory-access-context.ts b/src/state/memory-access-context.ts index 33f1fd7635e7..968b75ad26dc 100644 --- a/src/state/memory-access-context.ts +++ b/src/state/memory-access-context.ts @@ -30,6 +30,7 @@ type StoredFacts = Readonly<{ verifiedMemberships: readonly MemoryVerifiedMembership[]; delivery: MemoryAccessContext["delivery"]; delegation?: MemoryAccessContext["delegation"]; + recheck?: () => boolean; operation: MemoryOperation; hostFactsRevision: string; }>; @@ -190,10 +191,13 @@ function normalizeMemberships( const unique = new Map(); for (const value of values) { const membership = Object.freeze({ + snapshotId: requireText(value.snapshotId, "membership.snapshotId"), principalId: requireText(value.principalId, "membership.principalId"), + sourcePrincipalId: requireText(value.sourcePrincipalId, "membership.sourcePrincipalId"), groupId: requireText(value.groupId, "membership.groupId"), provider: requireText(value.provider, "membership.provider"), evidenceRevision: requireText(value.evidenceRevision, "membership.evidenceRevision"), + profileLinkRevision: requireText(value.profileLinkRevision, "membership.profileLinkRevision"), observedAt: requireText(value.observedAt, "membership.observedAt"), expiresAt: requireText(value.expiresAt, "membership.expiresAt"), }) satisfies MemoryVerifiedMembership; @@ -204,14 +208,14 @@ function normalizeMemberships( throw new TypeError("membership timestamps must be ISO dates"); } unique.set( - `${membership.principalId}\u0000${membership.groupId}\u0000${membership.provider}`, + `${membership.snapshotId}\u0000${membership.principalId}\u0000${membership.sourcePrincipalId}\u0000${membership.groupId}\u0000${membership.provider}`, membership, ); } return Object.freeze( [...unique.values()].toSorted((left, right) => - `${left.principalId}\u0000${left.groupId}\u0000${left.provider}`.localeCompare( - `${right.principalId}\u0000${right.groupId}\u0000${right.provider}`, + `${left.snapshotId}\u0000${left.principalId}\u0000${left.sourcePrincipalId}\u0000${left.groupId}\u0000${left.provider}`.localeCompare( + `${right.snapshotId}\u0000${right.principalId}\u0000${right.sourcePrincipalId}\u0000${right.groupId}\u0000${right.provider}`, ), ), ); @@ -395,6 +399,8 @@ export function captureTrustedMemoryAccessFacts(params: { egressRegistryRevision: string; }; delegation?: MemoryAccessContext["delegation"]; + /** Core-owned current-state check for facts whose owner is outside the session database. */ + recheck?: () => boolean; operation: MemoryOperation; hostFactsRevision: string; }): TrustedMemoryAccessFacts { @@ -430,6 +436,7 @@ export function captureTrustedMemoryAccessFacts(params: { deliveryRevision: requireText(params.delivery.routeRevision, "delivery.routeRevision"), }), ...(params.delegation ? { delegation: normalizeDelegation(params.delegation) } : {}), + ...(params.recheck ? { recheck: params.recheck } : {}), operation: params.operation, hostFactsRevision: requireText(params.hostFactsRevision, "hostFactsRevision"), }); @@ -529,6 +536,13 @@ export function materializeTrustedMemoryAccessContext( if (!facts || !source || !session) { return undefined; } + try { + if (facts.recheck && !facts.recheck()) { + return undefined; + } + } catch { + return undefined; + } const subject = readSessionMemorySubject({ session, facts, options: source.options }); if (!subject) { return undefined; diff --git a/src/state/memory-enterprise-access-audit.test.ts b/src/state/memory-enterprise-access-audit.test.ts new file mode 100644 index 000000000000..32cef93e0f95 --- /dev/null +++ b/src/state/memory-enterprise-access-audit.test.ts @@ -0,0 +1,251 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { generateSecureUuid } from "../infra/secure-random.js"; +import type { MemoryAccessContext } from "../memory-host-sdk/host/authorization.js"; +import { + listMemoryEnterpriseAccessDecisionAudit, + listMemoryEnterprisePolicyDriftAlerts, + recordMemoryEnterpriseRoleAccessDecisions, + writeMemoryEnterpriseAccessDecisionAudit, +} from "./memory-enterprise-access-audit.js"; +import { + ensureMemoryEnterprisePrincipal, + writeMemoryEnterpriseMembershipSnapshot, +} from "./memory-enterprise-identity.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "./openclaw-state-db.js"; + +const roots: string[] = []; + +function fixture() { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-memory-enterprise-audit-")); + roots.push(root); + return { env: { ...process.env, OPENCLAW_STATE_DIR: root } }; +} + +function entry( + subjectPrincipalId: string, + overrides: Partial[0]> = {}, +) { + return { + eventId: generateSecureUuid(), + providerId: "entra", + tenantRef: "hmac-sha256:v1:tenant-a", + actorPrincipalId: "principal:operator", + subjectPrincipalId, + operation: "memory.read", + decision: "allowed" as const, + reasonCode: "membership-current", + ruleRef: "hmac-sha256:v1:rule-a", + policyRevision: "policy:v4", + principalEvidenceRevision: "principal:v9", + membershipEvidenceRevision: "membership:v7", + occurredAt: 1_000, + receivedAt: 1_001, + ...overrides, + }; +} + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); + for (const root of roots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +describe("enterprise memory access decision audit", () => { + it("stores only redacted ids, revisions, reasons, and opaque policy references", () => { + const { env } = fixture(); + const subjectPrincipalId = "principal:alice"; + const recorded = entry(subjectPrincipalId); + writeMemoryEnterpriseAccessDecisionAudit(recorded, { env }); + + const db = openOpenClawStateDatabase({ env }).db; + const row = db + .prepare("SELECT * FROM memory_enterprise_access_decisions WHERE event_id = ?") + .get(recorded.eventId) as Record; + expect(row).toEqual({ + event_id: recorded.eventId, + provider_id: "entra", + tenant_ref: "hmac-sha256:v1:tenant-a", + actor_principal_id: "principal:operator", + subject_principal_id: subjectPrincipalId, + operation: "memory.read", + decision: "allowed", + reason_code: "membership-current", + rule_ref: "hmac-sha256:v1:rule-a", + policy_revision: "policy:v4", + principal_evidence_revision: "principal:v9", + membership_evidence_revision: "membership:v7", + occurred_at: 1_000, + received_at: 1_001, + }); + expect(Object.keys(row)).not.toContain("claims"); + expect(Object.keys(row)).not.toContain("group_id"); + expect(Object.keys(row)).not.toContain("content"); + }); + + it("deduplicates an event and exposes only a bounded subject-scoped page", () => { + const { env } = fixture(); + const subjectPrincipalId = "principal:subject"; + const first = entry(subjectPrincipalId, { occurredAt: 10 }); + const second = entry(subjectPrincipalId, { occurredAt: 20 }); + writeMemoryEnterpriseAccessDecisionAudit(first, { env }); + writeMemoryEnterpriseAccessDecisionAudit(first, { env }); + writeMemoryEnterpriseAccessDecisionAudit(second, { env }); + writeMemoryEnterpriseAccessDecisionAudit(entry("principal:other"), { env }); + + expect( + listMemoryEnterpriseAccessDecisionAudit({ subjectPrincipalId, limit: 1 }, { env }), + ).toEqual([expect.objectContaining({ eventId: second.eventId, subjectPrincipalId })]); + expect( + listMemoryEnterpriseAccessDecisionAudit( + { subjectPrincipalId, providerId: "other-provider" }, + { env }, + ), + ).toEqual([]); + expect(() => + listMemoryEnterpriseAccessDecisionAudit({ subjectPrincipalId, limit: 0 }, { env }), + ).toThrow("limit must be a positive integer"); + }); + + it("derives role decision audit evidence from current reduced membership, never backend input", () => { + const { env } = fixture(); + const enterprise = ensureMemoryEnterprisePrincipal( + { + providerId: "entra", + issuer: "https://login.microsoftonline.com/tenant-raw/v2.0", + tenant: "tenant-raw", + subject: "alice-raw", + evidenceRevision: "evidence:v1", + observedAt: 1_000, + expiresAt: 3_000, + }, + { env }, + ); + const membership = writeMemoryEnterpriseMembershipSnapshot( + { + principalId: enterprise.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "engineering-raw", + evidenceRevision: "evidence:v1", + observedAt: 1_000, + expiresAt: 3_000, + }, + { env }, + ); + const context = { + requestId: "request:one", + operation: "read", + actor: { kind: "principal", principalId: "principal:user" }, + subject: { kind: "user", principalId: "principal:user" }, + verifiedMemberships: [ + { + snapshotId: membership.snapshotId, + principalId: "principal:user", + sourcePrincipalId: enterprise.principalId, + provider: "entra", + groupId: "engineering-raw", + evidenceRevision: "evidence:v1", + profileLinkRevision: "profile:v1", + observedAt: new Date(1_000).toISOString(), + expiresAt: new Date(3_000).toISOString(), + }, + ], + } as unknown as MemoryAccessContext; + + recordMemoryEnterpriseRoleAccessDecisions({ + context, + decisions: [ + { + groupId: "engineering-raw", + policyId: "policy:role-engineering", + decision: "allowed", + reasonCode: "allowed", + policyRevision: "policy:v4", + }, + ], + now: 1_500, + options: { env }, + }); + for (const decision of [ + { policyRevision: "policy:v4", decision: "allowed" as const }, + { policyRevision: "policy:v5", decision: "denied" as const }, + { policyRevision: "policy:v5", decision: "denied" as const }, + { policyRevision: "policy:v6", decision: "denied" as const }, + ]) { + recordMemoryEnterpriseRoleAccessDecisions({ + context, + decisions: [ + { + groupId: "engineering-raw", + policyId: "policy:role-engineering", + decision: decision.decision, + reasonCode: decision.decision === "allowed" ? "allowed" : "policy-denied", + policyRevision: decision.policyRevision, + }, + ], + now: 1_500, + options: { env }, + }); + } + + const auditEntries = listMemoryEnterpriseAccessDecisionAudit( + { subjectPrincipalId: "principal:user" }, + { env }, + ); + expect(auditEntries).toHaveLength(3); + expect(auditEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + providerId: "entra", + tenantRef: membership.tenantRef, + ruleRef: membership.groupRef, + decision: "allowed", + policyRevision: "policy:v4", + }), + expect.objectContaining({ decision: "denied", policyRevision: "policy:v5" }), + expect.objectContaining({ decision: "denied", policyRevision: "policy:v6" }), + ]), + ); + const stored = JSON.stringify( + openOpenClawStateDatabase({ env }) + .db.prepare("SELECT * FROM memory_enterprise_access_decisions") + .all(), + ); + expect(stored).not.toContain("tenant-raw"); + expect(stored).not.toContain("engineering-raw"); + const alerts = openOpenClawStateDatabase({ env }) + .db.prepare("SELECT * FROM memory_enterprise_policy_drift_alerts") + .all() as Array>; + expect(alerts).toEqual([ + expect.objectContaining({ + provider_id: "entra", + tenant_ref: membership.tenantRef, + subject_principal_id: "principal:user", + rule_ref: membership.groupRef, + policy_id: "policy:role-engineering", + previous_policy_revision: "policy:v4", + previous_decision: "allowed", + policy_revision: "policy:v5", + decision: "denied", + }), + ]); + expect(JSON.stringify(alerts)).not.toContain("engineering-raw"); + expect( + listMemoryEnterprisePolicyDriftAlerts({ subjectPrincipalId: "principal:user" }, { env }), + ).toEqual([ + expect.objectContaining({ + previousPolicyRevision: "policy:v4", + previousDecision: "allowed", + policyRevision: "policy:v5", + decision: "denied", + }), + ]); + }); +}); diff --git a/src/state/memory-enterprise-access-audit.ts b/src/state/memory-enterprise-access-audit.ts new file mode 100644 index 000000000000..695629fa720e --- /dev/null +++ b/src/state/memory-enterprise-access-audit.ts @@ -0,0 +1,543 @@ +import { createHash } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; +import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js"; +import type { MemoryAccessContext } from "../memory-host-sdk/host/authorization.js"; +import { readCurrentMemoryEnterpriseMembershipForAudit } from "./memory-enterprise-identity.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "./openclaw-state-db.js"; +import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; + +const SCHEMA_START = "CREATE TABLE IF NOT EXISTS memory_enterprise_access_decisions ("; +const SCHEMA_END = "CREATE TABLE IF NOT EXISTS session_state_events ("; +const MAX_AUDIT_DECISIONS = 100; +const ensuredDatabases = new WeakSet(); + +function extractSchema(): string { + const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(SCHEMA_START); + const end = OPENCLAW_STATE_SCHEMA_SQL.indexOf(SCHEMA_END, start); + if (start < 0 || end <= start) { + throw new Error("canonical enterprise memory access audit schema markers are missing"); + } + return OPENCLAW_STATE_SCHEMA_SQL.slice(start, end).trim(); +} + +function requireText(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new TypeError(`${label} must not be empty`); + } + return normalized; +} + +function boundedLimit(limit: number | undefined): number { + if (limit === undefined) { + return MAX_AUDIT_DECISIONS; + } + if (!Number.isInteger(limit) || limit < 1) { + throw new TypeError("limit must be a positive integer"); + } + return Math.min(limit, MAX_AUDIT_DECISIONS); +} + +/** Canonical lazy shared schema for redacted enterprise memory decisions. */ +export const MEMORY_ENTERPRISE_ACCESS_AUDIT_SCHEMA_SQL = extractSchema(); + +/** + * An audit event contains only canonical ids and versioned opaque references. + * Verifiers must HMAC tenant and rule material before calling this boundary. + */ +export type MemoryEnterpriseAccessDecisionAuditEntry = Readonly<{ + eventId: string; + providerId: string; + tenantRef: string; + actorPrincipalId: string; + subjectPrincipalId: string; + operation: string; + decision: "allowed" | "denied" | "unavailable"; + reasonCode: string; + ruleRef: string; + policyRevision: string; + principalEvidenceRevision: string; + membershipEvidenceRevision: string | null; + occurredAt: number; + receivedAt: number; +}>; + +export type MemoryEnterpriseAccessDecisionAuditQuery = Readonly<{ + providerId?: string; + tenantRef?: string; + subjectPrincipalId?: string; + limit?: number; +}>; + +export type MemoryEnterprisePolicyDriftAlert = Readonly<{ + alertId: string; + providerId: string; + tenantRef: string; + subjectPrincipalId: string; + ruleRef: string; + policyId: string; + operation: string; + previousPolicyRevision: string; + previousDecision: "allowed" | "denied"; + policyRevision: string; + decision: "allowed" | "denied"; + detectedAt: number; +}>; + +export type MemoryEnterprisePolicyDriftAlertQuery = Readonly<{ + providerId?: string; + subjectPrincipalId?: string; + limit?: number; +}>; + +/** The memory backend may report only redacted role-store policy outcomes. */ +export type MemoryEnterpriseRoleAccessDecision = Readonly<{ + groupId: string; + policyId: string; + decision: "allowed" | "denied" | "unavailable"; + reasonCode: string; + policyRevision: string; +}>; + +type MemoryEnterpriseAccessAuditDatabase = { + memory_enterprise_access_decisions: { + event_id: string; + provider_id: string; + tenant_ref: string; + actor_principal_id: string; + subject_principal_id: string; + operation: string; + decision: "allowed" | "denied" | "unavailable"; + reason_code: string; + rule_ref: string; + policy_revision: string; + principal_evidence_revision: string; + membership_evidence_revision: string | null; + occurred_at: number; + received_at: number; + }; + memory_enterprise_role_policy_observations: { + provider_id: string; + tenant_ref: string; + subject_principal_id: string; + rule_ref: string; + policy_id: string; + operation: string; + policy_revision: string; + decision: "allowed" | "denied" | "unavailable"; + observed_at: number; + }; + memory_enterprise_policy_drift_alerts: { + alert_id: string; + provider_id: string; + tenant_ref: string; + subject_principal_id: string; + rule_ref: string; + policy_id: string; + operation: string; + previous_policy_revision: string; + previous_decision: "allowed" | "denied"; + policy_revision: string; + decision: "allowed" | "denied"; + detected_at: number; + }; +}; + +function toEntry( + row: MemoryEnterpriseAccessAuditDatabase["memory_enterprise_access_decisions"], +): MemoryEnterpriseAccessDecisionAuditEntry { + return Object.freeze({ + eventId: row.event_id, + providerId: row.provider_id, + tenantRef: row.tenant_ref, + actorPrincipalId: row.actor_principal_id, + subjectPrincipalId: row.subject_principal_id, + operation: row.operation, + decision: row.decision, + reasonCode: row.reason_code, + ruleRef: row.rule_ref, + policyRevision: row.policy_revision, + principalEvidenceRevision: row.principal_evidence_revision, + membershipEvidenceRevision: row.membership_evidence_revision, + occurredAt: row.occurred_at, + receivedAt: row.received_at, + }); +} + +function toPolicyDriftAlert( + row: MemoryEnterpriseAccessAuditDatabase["memory_enterprise_policy_drift_alerts"], +): MemoryEnterprisePolicyDriftAlert { + return Object.freeze({ + alertId: row.alert_id, + providerId: row.provider_id, + tenantRef: row.tenant_ref, + subjectPrincipalId: row.subject_principal_id, + ruleRef: row.rule_ref, + policyId: row.policy_id, + operation: row.operation, + previousPolicyRevision: row.previous_policy_revision, + previousDecision: row.previous_decision, + policyRevision: row.policy_revision, + decision: row.decision, + detectedAt: row.detected_at, + }); +} + +/** Install the additive audit ledger only when an enterprise decision is retained. */ +export function ensureMemoryEnterpriseAccessAuditSchema(database: DatabaseSync): void { + if (ensuredDatabases.has(database)) { + return; + } + const ensure = () => { + database.exec(MEMORY_ENTERPRISE_ACCESS_AUDIT_SCHEMA_SQL); // sqlite-allow-raw -- canonical additive DDL. + }; + if (database.isTransaction) { + ensure(); + } else { + runSqliteImmediateTransactionSync(database, ensure); + } + ensuredDatabases.add(database); +} + +/** Idempotently retain a redacted decision after policy evaluation completes. */ +export function writeMemoryEnterpriseAccessDecisionAudit( + entry: MemoryEnterpriseAccessDecisionAuditEntry, + options: OpenClawStateDatabaseOptions = {}, +): void { + const checked = { + eventId: requireText(entry.eventId, "eventId"), + providerId: requireText(entry.providerId, "providerId"), + tenantRef: requireText(entry.tenantRef, "tenantRef"), + actorPrincipalId: requireText(entry.actorPrincipalId, "actorPrincipalId"), + subjectPrincipalId: requireText(entry.subjectPrincipalId, "subjectPrincipalId"), + operation: requireText(entry.operation, "operation"), + reasonCode: requireText(entry.reasonCode, "reasonCode"), + ruleRef: requireText(entry.ruleRef, "ruleRef"), + policyRevision: requireText(entry.policyRevision, "policyRevision"), + principalEvidenceRevision: requireText( + entry.principalEvidenceRevision, + "principalEvidenceRevision", + ), + membershipEvidenceRevision: + entry.membershipEvidenceRevision === null + ? null + : requireText(entry.membershipEvidenceRevision, "membershipEvidenceRevision"), + }; + runOpenClawStateWriteTransaction( + ({ db: database }) => { + ensureMemoryEnterpriseAccessAuditSchema(database); + insertMemoryEnterpriseAccessDecisionAuditInTransaction( + database, + Object.freeze({ + ...checked, + decision: entry.decision, + occurredAt: entry.occurredAt, + receivedAt: entry.receivedAt, + }), + ); + }, + options, + { operationLabel: "memory-enterprise-audit.decision.write" }, + ); +} + +function auditEventId(parts: readonly string[]): string { + return `mea1_${createHash("sha256").update(parts.join("\0")).digest("base64url")}`; +} + +function insertMemoryEnterpriseAccessDecisionAuditInTransaction( + database: DatabaseSync, + entry: MemoryEnterpriseAccessDecisionAuditEntry, +): void { + const db = getNodeSqliteKysely(database); + executeSqliteQuerySync( + database, + db + .insertInto("memory_enterprise_access_decisions") + .values({ + event_id: entry.eventId, + provider_id: entry.providerId, + tenant_ref: entry.tenantRef, + actor_principal_id: entry.actorPrincipalId, + subject_principal_id: entry.subjectPrincipalId, + operation: entry.operation, + decision: entry.decision, + reason_code: entry.reasonCode, + rule_ref: entry.ruleRef, + policy_revision: entry.policyRevision, + principal_evidence_revision: entry.principalEvidenceRevision, + membership_evidence_revision: entry.membershipEvidenceRevision, + occurred_at: entry.occurredAt, + received_at: entry.receivedAt, + }) + .onConflict((conflict) => conflict.column("event_id").doNothing()), + ); +} + +// The selected memory plugin owns policy evaluation. This ledger only compares +// the plugin-reported opaque revision/decision pairs so alert suppression stays +// atomic with redacted audit persistence; it cannot select a policy or store. +function recordMemoryEnterprisePolicyDriftInTransaction(params: { + database: DatabaseSync; + entry: MemoryEnterpriseAccessDecisionAuditEntry; + policyId: string; +}): void { + const { database, entry } = params; + const db = getNodeSqliteKysely(database); + const policyId = requireText(params.policyId, "policyId"); + const baseline = executeSqliteQuerySync( + database, + db + .selectFrom("memory_enterprise_role_policy_observations") + .select(["policy_revision", "decision"]) + .where("provider_id", "=", entry.providerId) + .where("tenant_ref", "=", entry.tenantRef) + .where("subject_principal_id", "=", entry.subjectPrincipalId) + .where("rule_ref", "=", entry.ruleRef) + .where("policy_id", "=", policyId) + .where("operation", "=", entry.operation) + .limit(1), + ).rows[0]; + if (!baseline) { + executeSqliteQuerySync( + database, + db.insertInto("memory_enterprise_role_policy_observations").values({ + provider_id: entry.providerId, + tenant_ref: entry.tenantRef, + subject_principal_id: entry.subjectPrincipalId, + rule_ref: entry.ruleRef, + policy_id: policyId, + operation: entry.operation, + policy_revision: entry.policyRevision, + decision: entry.decision, + observed_at: entry.occurredAt, + }), + ); + return; + } + if (baseline.policy_revision === entry.policyRevision) { + return; + } + if ( + (baseline.decision === "allowed" || baseline.decision === "denied") && + (entry.decision === "allowed" || entry.decision === "denied") && + baseline.decision !== entry.decision + ) { + executeSqliteQuerySync( + database, + db + .insertInto("memory_enterprise_policy_drift_alerts") + .values({ + alert_id: auditEventId([ + "policy-drift", + entry.providerId, + entry.tenantRef, + entry.subjectPrincipalId, + entry.ruleRef, + policyId, + entry.operation, + baseline.policy_revision, + baseline.decision, + entry.policyRevision, + entry.decision, + ]), + provider_id: entry.providerId, + tenant_ref: entry.tenantRef, + subject_principal_id: entry.subjectPrincipalId, + rule_ref: entry.ruleRef, + policy_id: policyId, + operation: entry.operation, + previous_policy_revision: baseline.policy_revision, + previous_decision: baseline.decision, + policy_revision: entry.policyRevision, + decision: entry.decision, + detected_at: entry.occurredAt, + }) + .onConflict((conflict) => conflict.column("alert_id").doNothing()), + ); + } + executeSqliteQuerySync( + database, + db + .updateTable("memory_enterprise_role_policy_observations") + .set({ + policy_revision: entry.policyRevision, + decision: entry.decision, + observed_at: entry.occurredAt, + }) + .where("provider_id", "=", entry.providerId) + .where("tenant_ref", "=", entry.tenantRef) + .where("subject_principal_id", "=", entry.subjectPrincipalId) + .where("rule_ref", "=", entry.ruleRef) + .where("policy_id", "=", policyId) + .where("operation", "=", entry.operation), + ); +} + +/** + * Core derives every durable field from the current verified context and + * identity store. A backend can report a role-policy outcome but cannot name + * a tenant, subject, or redacted audit reference of its choosing. + */ +export function recordMemoryEnterpriseRoleAccessDecisions(params: { + context: MemoryAccessContext; + decisions: readonly MemoryEnterpriseRoleAccessDecision[]; + now?: number; + options?: OpenClawStateDatabaseOptions; +}): void { + if (params.context.subject.kind !== "user" || params.decisions.length === 0) { + return; + } + const now = params.now ?? Date.now(); + const actorPrincipalId = + params.context.actor.kind === "principal" + ? params.context.actor.principalId + : params.context.subject.principalId; + const unique = new Map(); + for (const decision of params.decisions) { + const groupId = requireText(decision.groupId, "decision.groupId"); + const policyId = requireText(decision.policyId, "decision.policyId"); + const reasonCode = requireText(decision.reasonCode, "decision.reasonCode"); + const policyRevision = requireText(decision.policyRevision, "decision.policyRevision"); + unique.set( + `${groupId}\0${policyId}\0${decision.decision}\0${reasonCode}\0${policyRevision}`, + Object.freeze({ groupId, policyId, decision: decision.decision, reasonCode, policyRevision }), + ); + } + const entries: Array< + Readonly<{ entry: MemoryEnterpriseAccessDecisionAuditEntry; policyId: string }> + > = []; + for (const decision of unique.values()) { + for (const membership of params.context.verifiedMemberships) { + if ( + membership.principalId !== params.context.subject.principalId || + membership.groupId !== decision.groupId || + Date.parse(membership.observedAt) > now || + Date.parse(membership.expiresAt) <= now + ) { + continue; + } + const snapshot = readCurrentMemoryEnterpriseMembershipForAudit({ + principalId: membership.sourcePrincipalId, + providerId: membership.provider, + group: membership.groupId, + now, + options: params.options, + }); + if ( + !snapshot || + snapshot.evidenceRevision !== membership.evidenceRevision || + snapshot.expiresAt <= now + ) { + continue; + } + entries.push( + Object.freeze({ + entry: Object.freeze({ + eventId: auditEventId([ + params.context.requestId, + params.context.operation, + membership.sourcePrincipalId, + membership.provider, + snapshot.groupRef, + decision.decision, + decision.reasonCode, + decision.policyId, + decision.policyRevision, + ]), + providerId: membership.provider, + tenantRef: snapshot.tenantRef, + actorPrincipalId, + subjectPrincipalId: params.context.subject.principalId, + operation: params.context.operation, + decision: decision.decision, + reasonCode: decision.reasonCode, + // The reduced group ref identifies the authorization rule without + // retaining a role display name or any memory resource identifier. + ruleRef: snapshot.groupRef, + policyRevision: decision.policyRevision, + principalEvidenceRevision: membership.evidenceRevision, + membershipEvidenceRevision: snapshot.evidenceRevision, + occurredAt: now, + receivedAt: Date.now(), + }), + policyId: decision.policyId, + }), + ); + } + } + if (entries.length === 0) { + return; + } + runOpenClawStateWriteTransaction( + ({ db: database }) => { + ensureMemoryEnterpriseAccessAuditSchema(database); + for (const { entry, policyId } of entries) { + insertMemoryEnterpriseAccessDecisionAuditInTransaction(database, entry); + recordMemoryEnterprisePolicyDriftInTransaction({ database, entry, policyId }); + } + }, + params.options ?? {}, + { operationLabel: "memory-enterprise-audit.role-decision.write" }, + ); +} + +/** List at most one small page of redacted decision evidence for one subject. */ +export function listMemoryEnterpriseAccessDecisionAudit( + query: MemoryEnterpriseAccessDecisionAuditQuery, + options: OpenClawStateDatabaseOptions = {}, +): readonly MemoryEnterpriseAccessDecisionAuditEntry[] { + const subjectPrincipalId = requireText(query.subjectPrincipalId ?? "", "subjectPrincipalId"); + const database = openOpenClawStateDatabase(options).db; + ensureMemoryEnterpriseAccessAuditSchema(database); + const db = getNodeSqliteKysely(database); + let statement = db + .selectFrom("memory_enterprise_access_decisions") + .selectAll() + .where("subject_principal_id", "=", subjectPrincipalId); + if (query.providerId !== undefined) { + statement = statement.where("provider_id", "=", requireText(query.providerId, "providerId")); + } + if (query.tenantRef !== undefined) { + statement = statement.where("tenant_ref", "=", requireText(query.tenantRef, "tenantRef")); + } + const rows = executeSqliteQuerySync( + database, + statement + .orderBy("occurred_at", "desc") + .orderBy("event_id", "desc") + .limit(boundedLimit(query.limit)), + ); + return Object.freeze(rows.rows.map(toEntry)); +} + +/** List a bounded redacted page of actual selected-plugin allow/deny policy flips. */ +export function listMemoryEnterprisePolicyDriftAlerts( + query: MemoryEnterprisePolicyDriftAlertQuery, + options: OpenClawStateDatabaseOptions = {}, +): readonly MemoryEnterprisePolicyDriftAlert[] { + const subjectPrincipalId = requireText(query.subjectPrincipalId ?? "", "subjectPrincipalId"); + const database = openOpenClawStateDatabase(options).db; + ensureMemoryEnterpriseAccessAuditSchema(database); + const db = getNodeSqliteKysely(database); + let statement = db + .selectFrom("memory_enterprise_policy_drift_alerts") + .selectAll() + .where("subject_principal_id", "=", subjectPrincipalId); + if (query.providerId !== undefined) { + statement = statement.where("provider_id", "=", requireText(query.providerId, "providerId")); + } + const rows = executeSqliteQuerySync( + database, + statement + .orderBy("detected_at", "desc") + .orderBy("alert_id", "desc") + .limit(boundedLimit(query.limit)), + ); + return Object.freeze(rows.rows.map(toPolicyDriftAlert)); +} diff --git a/src/state/memory-enterprise-admission.test.ts b/src/state/memory-enterprise-admission.test.ts new file mode 100644 index 000000000000..2ea878b8cd62 --- /dev/null +++ b/src/state/memory-enterprise-admission.test.ts @@ -0,0 +1,219 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + admitVerifiedEnterpriseIdentityForMemory, + clearMemoryEnterpriseAdmissionsForTest, + readCurrentEnterpriseMemoryFactsForUser, +} from "./memory-enterprise-admission.js"; +import { + ensureMemoryEnterprisePrincipal, + linkMemoryEnterpriseProfile, + persistMemoryEnterpriseIdentity, + revokeMemoryEnterpriseMembershipSnapshot, + writeMemoryEnterpriseMembershipSnapshot, +} from "./memory-enterprise-identity.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "./openclaw-state-db.js"; + +const roots: string[] = []; + +function fixture() { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-memory-enterprise-admission-")); + roots.push(root); + return { env: { ...process.env, OPENCLAW_STATE_DIR: root } }; +} + +function createUserPrincipal(params: { + principalId: string; + profileId: string; + env: NodeJS.ProcessEnv; +}) { + openOpenClawStateDatabase({ env: params.env }) + .db.prepare( + `INSERT INTO memory_principals + (principal_id, principal_kind, user_profile_id, principal_lookup_hmac, state, revision, created_at, revoked_at) + VALUES (?, 'user', ?, NULL, 'active', ?, ?, NULL)`, + ) + .run(params.principalId, params.profileId, `revision:${params.principalId}`, 1_000); +} + +afterEach(() => { + clearMemoryEnterpriseAdmissionsForTest(); + closeOpenClawStateDatabaseForTest(); + for (const root of roots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +describe("enterprise memory admission", () => { + it("projects current linked enterprise groups onto the Gateway user, not the enterprise principal", () => { + const { env } = fixture(); + const now = Date.now(); + const principal = ensureMemoryEnterprisePrincipal( + { + providerId: "entra", + issuer: "https://login.microsoftonline.com/tenant-a/v2.0", + tenant: "tenant-a", + subject: "enterprise-alice", + evidenceRevision: "oidc-evidence-1", + observedAt: now, + expiresAt: now + 60_000, + }, + { env }, + ); + const membership = writeMemoryEnterpriseMembershipSnapshot( + { + principalId: principal.principalId, + providerId: "entra", + tenant: "tenant-a", + group: "writers", + evidenceRevision: "oidc-evidence-1", + observedAt: now, + expiresAt: now + 60_000, + }, + { env }, + ); + createUserPrincipal({ principalId: "user-alice", profileId: "profile-alice", env }); + const link = linkMemoryEnterpriseProfile({ + enterprisePrincipalId: principal.principalId, + providerId: "entra", + userPrincipalId: "user-alice", + createdByPrincipalId: "user-alice", + options: { env }, + }); + admitVerifiedEnterpriseIdentityForMemory({ + userPrincipalId: "user-alice", + principal, + profileLink: link, + identity: { + providerId: "entra", + issuer: "https://login.microsoftonline.com/tenant-a/v2.0", + tenant: "tenant-a", + subject: "enterprise-alice", + groups: ["writers"], + evidenceRevision: "oidc-evidence-1", + observedAt: now, + expiresAt: now + 60_000, + }, + }); + + expect( + readCurrentEnterpriseMemoryFactsForUser({ + userPrincipalId: "user-alice", + now: now + 2_000, + options: { env }, + }), + ).toMatchObject({ + verifiedPrincipals: [ + { + principalId: principal.principalId, + assurance: "oidc", + evidenceRevision: "oidc-evidence-1", + }, + ], + verifiedMemberships: [ + { + principalId: "user-alice", + sourcePrincipalId: principal.principalId, + groupId: "writers", + profileLinkRevision: link.revision, + }, + ], + }); + expect( + readCurrentEnterpriseMemoryFactsForUser({ + userPrincipalId: "user-bob", + now: now + 2_000, + options: { env }, + }), + ).toEqual({ verifiedPrincipals: [], verifiedMemberships: [] }); + + revokeMemoryEnterpriseMembershipSnapshot({ + snapshotId: membership.snapshotId, + revokedAt: now + 2_001, + options: { env }, + }); + expect( + readCurrentEnterpriseMemoryFactsForUser({ + userPrincipalId: "user-alice", + now: now + 2_002, + options: { env }, + }), + ).toEqual({ verifiedPrincipals: [], verifiedMemberships: [] }); + }); + + it("removes an old admission immediately when a verified refresh replaces its membership evidence", () => { + const { env } = fixture(); + const now = Date.now(); + const initialIdentity = { + providerId: "entra", + issuer: "https://login.microsoftonline.com/tenant-a/v2.0", + tenant: "tenant-a", + subject: "enterprise-alice", + groups: ["writers"], + evidenceRevision: "oidc-evidence-1", + observedAt: now, + expiresAt: now + 60_000, + }; + const initial = persistMemoryEnterpriseIdentity({ + verified: initialIdentity, + groups: initialIdentity.groups, + options: { env }, + }); + createUserPrincipal({ principalId: "user-alice", profileId: "profile-alice", env }); + const link = linkMemoryEnterpriseProfile({ + enterprisePrincipalId: initial.principal.principalId, + providerId: "entra", + userPrincipalId: "user-alice", + createdByPrincipalId: "user-alice", + options: { env }, + now, + }); + admitVerifiedEnterpriseIdentityForMemory({ + userPrincipalId: "user-alice", + principal: initial.principal, + profileLink: link, + identity: initialIdentity, + }); + + const refreshedIdentity = { + ...initialIdentity, + groups: ["reviewers"], + evidenceRevision: "oidc-evidence-2", + observedAt: now + 1_000, + expiresAt: now + 61_000, + }; + const refreshed = persistMemoryEnterpriseIdentity({ + verified: refreshedIdentity, + groups: refreshedIdentity.groups, + options: { env }, + }); + expect( + readCurrentEnterpriseMemoryFactsForUser({ + userPrincipalId: "user-alice", + now: now + 2_000, + options: { env }, + }), + ).toEqual({ verifiedPrincipals: [], verifiedMemberships: [] }); + + admitVerifiedEnterpriseIdentityForMemory({ + userPrincipalId: "user-alice", + principal: refreshed.principal, + profileLink: link, + identity: refreshedIdentity, + }); + expect( + readCurrentEnterpriseMemoryFactsForUser({ + userPrincipalId: "user-alice", + now: now + 2_000, + options: { env }, + }), + ).toMatchObject({ + verifiedMemberships: [{ groupId: "reviewers", evidenceRevision: "oidc-evidence-2" }], + }); + }); +}); diff --git a/src/state/memory-enterprise-admission.ts b/src/state/memory-enterprise-admission.ts new file mode 100644 index 000000000000..69b5607194c3 --- /dev/null +++ b/src/state/memory-enterprise-admission.ts @@ -0,0 +1,162 @@ +import type { + MemoryVerifiedMembership, + VerifiedPrincipalRef, +} from "../memory-host-sdk/host/authorization.js"; +import { + readCurrentMemoryEnterpriseMembership, + recheckMemoryEnterprisePrincipal, + recheckMemoryEnterpriseProfileLink, + type MemoryEnterprisePrincipal, + type MemoryEnterpriseProfileLink, +} from "./memory-enterprise-identity.js"; +import type { VerifiedEnterpriseOidcIdentity } from "./memory-enterprise-verifier.js"; +import type { OpenClawStateDatabaseOptions } from "./openclaw-state-db.js"; + +type EnterpriseAdmission = Readonly<{ + userPrincipalId: string; + enterprisePrincipalId: string; + providerId: string; + tenant: string; + groups: readonly string[]; + evidenceRevision: string; + observedAt: number; + expiresAt: number; + profileLinkRevision: string; +}>; + +export type CurrentEnterpriseMemoryFacts = Readonly<{ + verifiedPrincipals: readonly VerifiedPrincipalRef[]; + verifiedMemberships: readonly MemoryVerifiedMembership[]; +}>; + +// JWT group values remain process-local admission material. SQLite retains only +// HMAC-reduced group refs, so a restart fails closed until Gateway admits fresh +// provider evidence instead of reconstructing a role from durable raw claims. +const admissionsByUser = new Map(); + +function admissionKey(userPrincipalId: string, providerId: string): string { + return `${userPrincipalId}\u0000${providerId}`; +} + +function iso(value: number): string { + return new Date(value).toISOString(); +} + +/** Test lifecycle hook; production admissions are bounded by provider evidence expiry. */ +export function clearMemoryEnterpriseAdmissionsForTest(): void { + admissionsByUser.clear(); +} + +/** + * Gateway calls this only after core verification, persistence, and the explicit + * profile link have succeeded. It retains no bearer token and cannot be replayed + * for another Gateway user because every later read rechecks that exact link. + */ +export function admitVerifiedEnterpriseIdentityForMemory(params: { + userPrincipalId: string; + principal: MemoryEnterprisePrincipal; + profileLink: MemoryEnterpriseProfileLink; + identity: VerifiedEnterpriseOidcIdentity; +}): void { + if ( + params.profileLink.enterprisePrincipalId !== params.principal.principalId || + params.profileLink.userPrincipalId !== params.userPrincipalId || + params.identity.providerId !== params.principal.providerId || + params.identity.evidenceRevision !== params.principal.evidenceRevision + ) { + throw new Error("enterprise memory admission requires one current verified profile link"); + } + admissionsByUser.set( + admissionKey(params.userPrincipalId, params.identity.providerId), + Object.freeze({ + userPrincipalId: params.userPrincipalId, + enterprisePrincipalId: params.principal.principalId, + providerId: params.identity.providerId, + tenant: params.identity.tenant, + groups: Object.freeze([...params.identity.groups]), + evidenceRevision: params.identity.evidenceRevision, + observedAt: params.identity.observedAt, + expiresAt: params.identity.expiresAt, + profileLinkRevision: params.profileLink.revision, + }), + ); +} + +/** + * Materialize only current, linked enterprise facts for a Gateway user. Any + * revocation, evidence refresh, expiry, process restart, or profile reassignment + * removes the facts before the memory host can select a role audience. + */ +export function readCurrentEnterpriseMemoryFactsForUser(params: { + userPrincipalId: string; + now?: number; + options?: OpenClawStateDatabaseOptions; +}): CurrentEnterpriseMemoryFacts { + const now = params.now ?? Date.now(); + const verifiedPrincipals: VerifiedPrincipalRef[] = []; + const verifiedMemberships: MemoryVerifiedMembership[] = []; + for (const admission of admissionsByUser.values()) { + if (admission.userPrincipalId !== params.userPrincipalId || admission.expiresAt <= now) { + continue; + } + const link = recheckMemoryEnterpriseProfileLink({ + enterprisePrincipalId: admission.enterprisePrincipalId, + userPrincipalId: admission.userPrincipalId, + providerId: admission.providerId, + now, + options: params.options, + }); + if (!link || link.revision !== admission.profileLinkRevision) { + continue; + } + const principal = recheckMemoryEnterprisePrincipal({ + principalId: admission.enterprisePrincipalId, + providerId: admission.providerId, + now, + options: params.options, + }); + if (!principal || principal.evidenceRevision !== admission.evidenceRevision) { + continue; + } + const memberships = admission.groups.flatMap((group) => { + const snapshot = readCurrentMemoryEnterpriseMembership({ + principalId: admission.enterprisePrincipalId, + providerId: admission.providerId, + tenant: admission.tenant, + group, + now, + options: params.options, + }); + if (!snapshot || snapshot.evidenceRevision !== admission.evidenceRevision) { + return []; + } + return [ + Object.freeze({ + snapshotId: snapshot.snapshotId, + principalId: admission.userPrincipalId, + sourcePrincipalId: admission.enterprisePrincipalId, + groupId: group, + provider: admission.providerId, + evidenceRevision: admission.evidenceRevision, + profileLinkRevision: link.revision, + observedAt: iso(snapshot.observedAt), + expiresAt: iso(snapshot.expiresAt), + }) satisfies MemoryVerifiedMembership, + ]; + }); + if (memberships.length === 0) { + continue; + } + verifiedPrincipals.push({ + principalId: principal.principalId, + assurance: "oidc", + evidenceRevision: principal.evidenceRevision, + expiresAt: iso(principal.expiresAt), + }); + verifiedMemberships.push(...memberships); + } + return Object.freeze({ + verifiedPrincipals: Object.freeze(verifiedPrincipals), + verifiedMemberships: Object.freeze(verifiedMemberships), + }); +} diff --git a/src/state/memory-enterprise-identity.test.ts b/src/state/memory-enterprise-identity.test.ts new file mode 100644 index 000000000000..64a58f875a43 --- /dev/null +++ b/src/state/memory-enterprise-identity.test.ts @@ -0,0 +1,797 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { generateSecureUuid } from "../infra/secure-random.js"; +import { + ensureMemoryEnterprisePrincipal, + listMemoryEnterpriseEvidenceTransitionsForUserPrincipal, + persistMemoryEnterpriseIdentity, + readCurrentMemoryEnterpriseMembership, + recheckMemoryEnterprisePrincipal, + recheckMemoryEnterpriseProfileLink, + revokeMemoryEnterpriseProfileEvidence, + revokeMemoryEnterpriseMembershipSnapshot, + linkMemoryEnterpriseProfile, + unlinkMemoryEnterpriseProfile, + writeMemoryEnterpriseMembershipSnapshot, +} from "./memory-enterprise-identity.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "./openclaw-state-db.js"; + +const roots: string[] = []; + +function fixture() { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-memory-enterprise-identity-")); + roots.push(root); + return { env: { ...process.env, OPENCLAW_STATE_DIR: root } }; +} + +function verifiedPrincipal( + overrides: Partial[0]> = {}, +) { + return { + providerId: "entra", + issuer: "https://login.microsoftonline.com/tenant-raw/v2.0", + tenant: "tenant-raw", + subject: "subject-alice-raw", + evidenceRevision: "principal-revision-1", + observedAt: 1_000, + expiresAt: 2_000, + ...overrides, + }; +} + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); + for (const root of roots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +describe("enterprise memory identity state", () => { + it("returns only bounded redacted refresh and revocation lifecycle counts for a linked user", () => { + const { env } = fixture(); + const now = Date.now(); + const first = persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal({ observedAt: now, expiresAt: now + 60_000 }), + groups: ["writers", "reviewers"], + options: { env }, + }); + const db = openOpenClawStateDatabase({ env }).db; + for (const principalId of ["principal:user", "principal:operator"]) { + db.prepare( + `INSERT INTO memory_principals + (principal_id, principal_kind, user_profile_id, principal_lookup_hmac, state, revision, created_at, revoked_at) + VALUES (?, 'user', ?, NULL, 'active', ?, ?, NULL)`, + ).run(principalId, `${principalId}:profile`, generateSecureUuid(), now); + } + linkMemoryEnterpriseProfile({ + enterprisePrincipalId: first.principal.principalId, + providerId: "entra", + userPrincipalId: "principal:user", + createdByPrincipalId: "principal:operator", + now, + options: { env }, + }); + persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal({ + evidenceRevision: "principal-revision-2", + observedAt: now + 1_000, + expiresAt: now + 61_000, + }), + groups: ["writers"], + options: { env }, + }); + + expect( + listMemoryEnterpriseEvidenceTransitionsForUserPrincipal({ + userPrincipalId: "principal:user", + providerId: "entra", + limit: 10, + options: { env }, + }), + ).toEqual([ + { + providerId: "entra", + kind: "refresh", + revokedAt: now + 1_000, + snapshotCount: 2, + }, + ]); + expect( + listMemoryEnterpriseEvidenceTransitionsForUserPrincipal({ + userPrincipalId: "principal:operator", + options: { env }, + }), + ).toEqual([]); + }); + + it("keeps lifecycle evidence with the profile linked when the event occurred", () => { + const { env } = fixture(); + const now = Date.now(); + const first = persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal({ observedAt: now, expiresAt: now + 60_000 }), + groups: ["writers"], + options: { env }, + }); + const db = openOpenClawStateDatabase({ env }).db; + for (const principalId of ["principal:user-a", "principal:user-b", "principal:operator"]) { + db.prepare( + `INSERT INTO memory_principals + (principal_id, principal_kind, user_profile_id, principal_lookup_hmac, state, revision, created_at, revoked_at) + VALUES (?, 'user', ?, NULL, 'active', ?, ?, NULL)`, + ).run(principalId, `${principalId}:profile`, generateSecureUuid(), now); + } + linkMemoryEnterpriseProfile({ + enterprisePrincipalId: first.principal.principalId, + providerId: "entra", + userPrincipalId: "principal:user-a", + createdByPrincipalId: "principal:operator", + now, + options: { env }, + }); + persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal({ + evidenceRevision: "principal-revision-2", + observedAt: now + 1_000, + expiresAt: now + 61_000, + }), + groups: ["writers"], + options: { env }, + }); + linkMemoryEnterpriseProfile({ + enterprisePrincipalId: first.principal.principalId, + providerId: "entra", + userPrincipalId: "principal:user-b", + createdByPrincipalId: "principal:operator", + now: now + 2_000, + options: { env }, + }); + + expect( + listMemoryEnterpriseEvidenceTransitionsForUserPrincipal({ + userPrincipalId: "principal:user-a", + options: { env }, + }), + ).toEqual([ + { + providerId: "entra", + kind: "refresh", + revokedAt: now + 1_000, + snapshotCount: 1, + }, + ]); + expect( + listMemoryEnterpriseEvidenceTransitionsForUserPrincipal({ + userPrincipalId: "principal:user-b", + options: { env }, + }), + ).toEqual([]); + }); + + it("atomically replaces a verified group snapshot and durably revokes every superseded membership", () => { + const { env } = fixture(); + const first = persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal(), + groups: ["writers", "reviewers"], + options: { env }, + }); + const refreshed = persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal({ + evidenceRevision: "principal-revision-2", + observedAt: 1_100, + expiresAt: 2_100, + }), + groups: ["writers", "operators"], + options: { env }, + }); + + expect(refreshed.principal).toMatchObject({ + principalId: first.principal.principalId, + evidenceRevision: "principal-revision-2", + }); + expect(refreshed.memberships.map((membership) => membership.evidenceRevision)).toEqual([ + "principal-revision-2", + "principal-revision-2", + ]); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: first.principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "reviewers", + now: 1_200, + options: { env }, + }), + ).toBeUndefined(); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: first.principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "writers", + now: 1_200, + options: { env }, + }), + ).toMatchObject({ evidenceRevision: "principal-revision-2" }); + const db = openOpenClawStateDatabase({ env }).db; + const prior = db + .prepare( + "SELECT snapshot_id, evidence_revision, revoked_at FROM memory_enterprise_membership_snapshots WHERE evidence_revision = ? ORDER BY snapshot_id", + ) + .all("principal-revision-1") as Array<{ + snapshot_id: string; + evidence_revision: string; + revoked_at: number | null; + }>; + expect(prior).toHaveLength(2); + expect(prior.every((snapshot) => snapshot.evidence_revision === "principal-revision-1")).toBe( + true, + ); + expect(prior.every((snapshot) => snapshot.revoked_at === 1_100)).toBe(true); + const refreshTransition = db + .prepare( + `SELECT transition_id, principal_id, provider_id, kind, revoked_at + FROM memory_enterprise_evidence_transitions + WHERE kind = 'refresh'`, + ) + .get() as { + transition_id: string; + principal_id: string; + provider_id: string; + kind: string; + revoked_at: number; + }; + expect(refreshTransition).toMatchObject({ + principal_id: first.principal.principalId, + provider_id: "entra", + kind: "refresh", + revoked_at: 1_100, + }); + expect( + db + .prepare( + `SELECT snapshot_id FROM memory_enterprise_evidence_transition_memberships + WHERE transition_id = ? ORDER BY snapshot_id`, + ) + .all(refreshTransition.transition_id), + ).toEqual(prior.map((snapshot) => ({ snapshot_id: snapshot.snapshot_id }))); + + const removed = persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal({ + evidenceRevision: "principal-revision-3", + observedAt: 1_200, + expiresAt: 2_200, + }), + groups: [], + options: { env }, + }); + expect(removed.memberships).toEqual([]); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: first.principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "writers", + now: 1_300, + options: { env }, + }), + ).toBeUndefined(); + }); + + it("rolls back the principal refresh and prior-membership revocation if the replacement snapshot cannot persist", () => { + const { env } = fixture(); + const first = persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal(), + groups: ["writers", "reviewers"], + options: { env }, + }); + const db = openOpenClawStateDatabase({ env }).db; + db.exec(` + CREATE TRIGGER reject_enterprise_refresh + BEFORE INSERT ON memory_enterprise_membership_snapshots + WHEN NEW.evidence_revision = 'principal-revision-2' + BEGIN SELECT RAISE(ABORT, 'test-only refresh rejection'); END; + `); + + expect(() => + persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal({ + evidenceRevision: "principal-revision-2", + observedAt: 1_100, + expiresAt: 2_100, + }), + groups: ["writers", "operators"], + options: { env }, + }), + ).toThrow("test-only refresh rejection"); + expect( + recheckMemoryEnterprisePrincipal({ + principalId: first.principal.principalId, + providerId: "entra", + now: 1_200, + options: { env }, + }), + ).toMatchObject({ evidenceRevision: "principal-revision-1" }); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: first.principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "writers", + now: 1_200, + options: { env }, + }), + ).toMatchObject({ evidenceRevision: "principal-revision-1" }); + expect( + db + .prepare( + "SELECT COUNT(*) AS count FROM memory_enterprise_membership_snapshots WHERE revoked_at IS NULL", + ) + .get(), + ).toEqual({ count: 2 }); + expect( + db.prepare("SELECT COUNT(*) AS count FROM memory_enterprise_evidence_transitions").get(), + ).toEqual({ count: 0 }); + expect( + db + .prepare("SELECT COUNT(*) AS count FROM memory_enterprise_evidence_transition_memberships") + .get(), + ).toEqual({ count: 0 }); + }); + + it("canonicalizes a verified enterprise principal without persisting upstream identifiers", () => { + const { env } = fixture(); + const first = ensureMemoryEnterprisePrincipal(verifiedPrincipal(), { env }); + const refreshed = ensureMemoryEnterprisePrincipal( + verifiedPrincipal({ + evidenceRevision: "principal-revision-2", + observedAt: 1_100, + expiresAt: 2_100, + }), + { env }, + ); + + expect(refreshed).toMatchObject({ + principalId: first.principalId, + evidenceRevision: "principal-revision-2", + }); + expect( + ensureMemoryEnterprisePrincipal( + verifiedPrincipal({ tenant: "tenant-other", subject: "subject-alice-raw" }), + { env }, + ).principalId, + ).not.toBe(first.principalId); + const db = openOpenClawStateDatabase({ env }).db; + const stored = JSON.stringify( + db.prepare("SELECT * FROM memory_enterprise_principal_evidence").all(), + ); + expect(stored).not.toContain("tenant-raw"); + expect(stored).not.toContain("subject-alice-raw"); + expect(stored).not.toContain("login.microsoftonline.com"); + }); + + it("refuses conflicting active canonical evidence and leaves unknown principals unbound", () => { + const { env } = fixture(); + const principal = ensureMemoryEnterprisePrincipal(verifiedPrincipal(), { env }); + expect( + recheckMemoryEnterprisePrincipal({ + principalId: "principal:unknown", + providerId: "entra", + now: 1_100, + options: { env }, + }), + ).toBeUndefined(); + + const db = openOpenClawStateDatabase({ env }).db; + const evidence = db + .prepare( + `SELECT provider_id, issuer_ref, tenant_ref, subject_ref + FROM memory_enterprise_principal_evidence + WHERE principal_id = ?`, + ) + .get(principal.principalId) as { + provider_id: string; + issuer_ref: string; + tenant_ref: string; + subject_ref: string; + }; + db.exec("DROP INDEX idx_memory_enterprise_principal_evidence_active_subject"); + db.prepare( + `INSERT INTO memory_principals + (principal_id, principal_kind, user_profile_id, principal_lookup_hmac, state, revision, created_at, revoked_at) + VALUES (?, 'enterprise', NULL, ?, 'active', ?, ?, NULL)`, + ).run("principal:conflict", "hmac-sha256:v1:conflicting-principal", "revision:conflict", 1_100); + db.prepare( + `INSERT INTO memory_enterprise_principal_evidence + (principal_id, provider_id, issuer_ref, tenant_ref, subject_ref, assurance, evidence_revision, observed_at, expires_at, revoked_at) + VALUES (?, ?, ?, ?, ?, 'oidc', ?, ?, ?, NULL)`, + ).run( + "principal:conflict", + evidence.provider_id, + evidence.issuer_ref, + evidence.tenant_ref, + evidence.subject_ref, + "principal-revision-conflict", + 1_100, + 2_100, + ); + + expect(() => ensureMemoryEnterprisePrincipal(verifiedPrincipal(), { env })).toThrow( + "enterprise principal evidence has conflicting active canonical bindings", + ); + }); + + it("reads only current membership evidence and fails closed for expiry, revocation, and selector mismatches", () => { + const { env } = fixture(); + const principal = ensureMemoryEnterprisePrincipal(verifiedPrincipal(), { env }); + const snapshot = writeMemoryEnterpriseMembershipSnapshot( + { + principalId: principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "engineering-raw", + evidenceRevision: "principal-revision-1", + observedAt: 1_100, + expiresAt: 1_500, + }, + { env }, + ); + + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "engineering-raw", + now: 1_200, + options: { env }, + }), + ).toMatchObject({ snapshotId: snapshot.snapshotId, evidenceRevision: "principal-revision-1" }); + const storedMembership = JSON.stringify( + openOpenClawStateDatabase({ env }) + .db.prepare("SELECT * FROM memory_enterprise_membership_snapshots WHERE snapshot_id = ?") + .get(snapshot.snapshotId), + ); + expect(storedMembership).not.toContain("tenant-raw"); + expect(storedMembership).not.toContain("engineering-raw"); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: principal.principalId, + providerId: "other", + tenant: "tenant-raw", + group: "engineering-raw", + now: 1_200, + options: { env }, + }), + ).toBeUndefined(); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: principal.principalId, + providerId: "entra", + tenant: "tenant-other", + group: "engineering-raw", + now: 1_200, + options: { env }, + }), + ).toBeUndefined(); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "engineering-raw", + now: 1_500, + options: { env }, + }), + ).toBeUndefined(); + revokeMemoryEnterpriseMembershipSnapshot({ + snapshotId: snapshot.snapshotId, + revokedAt: 1_250, + options: { env }, + }); + const db = openOpenClawStateDatabase({ env }).db; + const revocation = db + .prepare( + `SELECT transition_id, kind, principal_id, provider_id, revoked_at + FROM memory_enterprise_evidence_transitions`, + ) + .get() as { + transition_id: string; + kind: string; + principal_id: string; + provider_id: string; + revoked_at: number; + }; + expect(revocation).toMatchObject({ + kind: "revoke", + principal_id: principal.principalId, + provider_id: "entra", + revoked_at: 1_250, + }); + expect( + db + .prepare( + "SELECT snapshot_id FROM memory_enterprise_evidence_transition_memberships WHERE transition_id = ?", + ) + .all(revocation.transition_id), + ).toEqual([{ snapshot_id: snapshot.snapshotId }]); + revokeMemoryEnterpriseMembershipSnapshot({ + snapshotId: snapshot.snapshotId, + revokedAt: 1_260, + options: { env }, + }); + expect( + db.prepare("SELECT COUNT(*) AS count FROM memory_enterprise_evidence_transitions").get(), + ).toEqual({ count: 1 }); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "engineering-raw", + now: 1_300, + options: { env }, + }), + ).toBeUndefined(); + expect( + recheckMemoryEnterprisePrincipal({ + principalId: principal.principalId, + providerId: "entra", + now: 2_000, + options: { env }, + }), + ).toBeUndefined(); + }); + + it("links an enterprise principal to one active Gateway user without creating session membership", () => { + const { env } = fixture(); + const now = Date.now(); + const enterprise = ensureMemoryEnterprisePrincipal( + verifiedPrincipal({ observedAt: now, expiresAt: now + 60_000 }), + { env }, + ); + const db = openOpenClawStateDatabase({ env }).db; + for (const principalId of ["principal:user", "principal:operator"]) { + db.prepare( + `INSERT INTO memory_principals + (principal_id, principal_kind, user_profile_id, principal_lookup_hmac, state, revision, created_at, revoked_at) + VALUES (?, 'user', ?, NULL, 'active', ?, ?, NULL)`, + ).run(principalId, `${principalId}:profile`, generateSecureUuid(), now); + } + const link = linkMemoryEnterpriseProfile({ + enterprisePrincipalId: enterprise.principalId, + providerId: "entra", + userPrincipalId: "principal:user", + createdByPrincipalId: "principal:operator", + options: { env }, + }); + + expect( + recheckMemoryEnterpriseProfileLink({ + enterprisePrincipalId: enterprise.principalId, + userPrincipalId: "principal:user", + providerId: "entra", + now: now + 100, + options: { env }, + }), + ).toEqual(link); + db.prepare("UPDATE memory_enterprise_profile_links SET revoked_at = ? WHERE link_id = ?").run( + now + 200, + link.linkId, + ); + expect( + recheckMemoryEnterpriseProfileLink({ + enterprisePrincipalId: enterprise.principalId, + userPrincipalId: "principal:user", + providerId: "entra", + now: now + 300, + options: { env }, + }), + ).toBeUndefined(); + }); + + it("unlinks only the selected profile/provider and records a redacted immutable action", () => { + const { env } = fixture(); + const now = Date.now(); + const enterprise = persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal({ observedAt: now, expiresAt: now + 60_000 }), + groups: ["writers"], + options: { env }, + }); + const db = openOpenClawStateDatabase({ env }).db; + for (const principalId of ["principal:user", "principal:admin"]) { + db.prepare( + `INSERT INTO memory_principals + (principal_id, principal_kind, user_profile_id, principal_lookup_hmac, state, revision, created_at, revoked_at) + VALUES (?, 'user', ?, NULL, 'active', ?, ?, NULL)`, + ).run(principalId, `${principalId}:profile`, generateSecureUuid(), now); + } + const link = linkMemoryEnterpriseProfile({ + enterprisePrincipalId: enterprise.principal.principalId, + providerId: "entra", + userPrincipalId: "principal:user", + createdByPrincipalId: "principal:user", + now, + options: { env }, + }); + + expect( + unlinkMemoryEnterpriseProfile({ + userPrincipalId: "principal:user", + providerId: "entra", + actorPrincipalId: "principal:admin", + now: now + 1, + options: { env }, + }), + ).toEqual({ + providerId: "entra", + kind: "unlink", + affectedIdentityCount: 1, + affectedSnapshotCount: 0, + }); + expect( + recheckMemoryEnterpriseProfileLink({ + enterprisePrincipalId: enterprise.principal.principalId, + providerId: "entra", + userPrincipalId: "principal:user", + now: now + 2, + options: { env }, + }), + ).toBeUndefined(); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: enterprise.principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "writers", + now: now + 2, + options: { env }, + }), + ).toMatchObject({ evidenceRevision: "principal-revision-1" }); + expect( + db + .prepare( + `SELECT target_user_principal_id, actor_principal_id, provider_id, kind, + affected_identity_count, affected_snapshot_count, occurred_at + FROM memory_enterprise_identity_actions`, + ) + .all(), + ).toEqual([ + { + target_user_principal_id: "principal:user", + actor_principal_id: "principal:admin", + provider_id: "entra", + kind: "unlink", + affected_identity_count: 1, + affected_snapshot_count: 0, + occurred_at: now + 1, + }, + ]); + expect(() => + db + .prepare( + "DELETE FROM memory_enterprise_identity_actions WHERE target_user_principal_id = ?", + ) + .run("principal:user"), + ).toThrow("enterprise identity actions cannot be deleted"); + expect( + db + .prepare("SELECT revoked_at FROM memory_enterprise_profile_links WHERE link_id = ?") + .get(link.linkId), + ).toEqual({ revoked_at: now + 1 }); + }); + + it("revokes current evidence before unlinking, preserves lifecycle provenance, and permits later reauthentication", () => { + const { env } = fixture(); + const now = Date.now(); + const enterprise = persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal({ observedAt: now, expiresAt: now + 60_000 }), + groups: ["writers", "reviewers"], + options: { env }, + }); + const db = openOpenClawStateDatabase({ env }).db; + for (const principalId of ["principal:user", "principal:admin"]) { + db.prepare( + `INSERT INTO memory_principals + (principal_id, principal_kind, user_profile_id, principal_lookup_hmac, state, revision, created_at, revoked_at) + VALUES (?, 'user', ?, NULL, 'active', ?, ?, NULL)`, + ).run(principalId, `${principalId}:profile`, generateSecureUuid(), now); + } + linkMemoryEnterpriseProfile({ + enterprisePrincipalId: enterprise.principal.principalId, + providerId: "entra", + userPrincipalId: "principal:user", + createdByPrincipalId: "principal:user", + now, + options: { env }, + }); + + expect( + revokeMemoryEnterpriseProfileEvidence({ + userPrincipalId: "principal:user", + providerId: "entra", + actorPrincipalId: "principal:admin", + now: now + 1, + options: { env }, + }), + ).toEqual({ + providerId: "entra", + kind: "revoke", + affectedIdentityCount: 1, + affectedSnapshotCount: 2, + }); + expect( + listMemoryEnterpriseEvidenceTransitionsForUserPrincipal({ + userPrincipalId: "principal:user", + providerId: "entra", + options: { env }, + }), + ).toEqual([{ providerId: "entra", kind: "revoke", revokedAt: now + 1, snapshotCount: 2 }]); + expect( + db + .prepare( + "SELECT COUNT(*) AS count FROM memory_enterprise_membership_snapshots WHERE revoked_at = ?", + ) + .get(now + 1), + ).toEqual({ count: 2 }); + expect( + db + .prepare( + "SELECT COUNT(*) AS count FROM memory_enterprise_evidence_transition_profile_links", + ) + .get(), + ).toEqual({ count: 1 }); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: enterprise.principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "writers", + now: now + 2, + options: { env }, + }), + ).toBeUndefined(); + expect(() => + linkMemoryEnterpriseProfile({ + enterprisePrincipalId: enterprise.principal.principalId, + providerId: "entra", + userPrincipalId: "principal:user", + createdByPrincipalId: "principal:user", + now: now + 2, + options: { env }, + }), + ).toThrow("enterprise profile link requires current enterprise evidence"); + + const refreshed = persistMemoryEnterpriseIdentity({ + verified: verifiedPrincipal({ + evidenceRevision: "principal-revision-2", + observedAt: now + 3, + expiresAt: now + 60_003, + }), + groups: ["writers"], + options: { env }, + }); + linkMemoryEnterpriseProfile({ + enterprisePrincipalId: refreshed.principal.principalId, + providerId: "entra", + userPrincipalId: "principal:user", + createdByPrincipalId: "principal:user", + now: now + 3, + options: { env }, + }); + expect( + readCurrentMemoryEnterpriseMembership({ + principalId: refreshed.principal.principalId, + providerId: "entra", + tenant: "tenant-raw", + group: "writers", + now: now + 4, + options: { env }, + }), + ).toMatchObject({ evidenceRevision: "principal-revision-2" }); + }); +}); diff --git a/src/state/memory-enterprise-identity.ts b/src/state/memory-enterprise-identity.ts new file mode 100644 index 000000000000..8f962948a64d --- /dev/null +++ b/src/state/memory-enterprise-identity.ts @@ -0,0 +1,1477 @@ +import type { DatabaseSync } from "node:sqlite"; +import { pseudonymizeExecutionIdentityRef } from "../audit/audit-identity.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "../infra/kysely-sync.js"; +import { generateSecureUuid } from "../infra/secure-random.js"; +import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js"; +import { ensureMemoryIdentitySchema } from "./memory-identity.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "./openclaw-state-db.js"; +import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; + +const SCHEMA_START = "CREATE TABLE IF NOT EXISTS memory_enterprise_principal_evidence ("; +const SCHEMA_END = "CREATE TABLE IF NOT EXISTS memory_access_audit ("; +const MAX_EVIDENCE_TRANSITIONS = 100; +const SQLITE_IN_VALUES_LIMIT = 900; +const ensuredDatabases = new WeakSet(); + +type EnterpriseIdentityDatabase = { + audit_identity_keys: { + id: number; + key_id: string; + key: Uint8Array; + created_at: number; + }; + memory_principals: { + principal_id: string; + principal_kind: "user" | "enterprise" | "service" | "agent" | "system" | "conversation"; + user_profile_id: string | null; + principal_lookup_hmac: string | null; + state: "active" | "revoked"; + revision: string; + created_at: number; + revoked_at: number | null; + }; + memory_enterprise_principal_evidence: { + principal_id: string; + provider_id: string; + issuer_ref: string; + tenant_ref: string; + subject_ref: string; + assurance: "oidc"; + evidence_revision: string; + observed_at: number; + expires_at: number; + revoked_at: number | null; + }; + memory_enterprise_membership_snapshots: { + snapshot_id: string; + principal_id: string; + provider_id: string; + tenant_ref: string; + group_ref: string; + evidence_revision: string; + observed_at: number; + expires_at: number; + revoked_at: number | null; + created_at: number; + }; + memory_enterprise_evidence_transitions: { + transition_id: string; + principal_id: string; + provider_id: string; + kind: "refresh" | "revoke"; + revoked_at: number; + created_at: number; + }; + memory_enterprise_evidence_transition_memberships: { + transition_id: string; + snapshot_id: string; + created_at: number; + }; + memory_enterprise_evidence_transition_profile_links: { + transition_id: string; + link_id: string; + user_principal_id: string; + created_at: number; + }; + memory_enterprise_profile_links: { + link_id: string; + enterprise_principal_id: string; + user_principal_id: string; + created_by_principal_id: string; + created_at: number; + revoked_at: number | null; + revision: string; + }; + memory_enterprise_identity_actions: { + action_id: string; + target_user_principal_id: string; + actor_principal_id: string; + provider_id: string; + kind: "unlink" | "revoke"; + affected_identity_count: number; + affected_snapshot_count: number; + occurred_at: number; + }; +}; + +type EnterprisePrincipalEvidenceRow = + EnterpriseIdentityDatabase["memory_enterprise_principal_evidence"] & { + principal_revision: string; + principal_state: "active" | "revoked"; + }; + +function extractSchema(): string { + const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(SCHEMA_START); + const end = OPENCLAW_STATE_SCHEMA_SQL.indexOf(SCHEMA_END, start); + if (start < 0 || end <= start) { + throw new Error("canonical enterprise memory identity schema markers are missing"); + } + return OPENCLAW_STATE_SCHEMA_SQL.slice(start, end).trim(); +} + +function requireText(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new TypeError(`${label} must not be empty`); + } + return normalized; +} + +function requireTimestamp(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${label} must be a non-negative safe integer`); + } + return value; +} + +function boundedEvidenceTransitionLimit(value: number | undefined): number { + if (value === undefined) { + return MAX_EVIDENCE_TRANSITIONS; + } + if (!Number.isInteger(value) || value < 1) { + throw new TypeError("limit must be a positive integer"); + } + return Math.min(value, MAX_EVIDENCE_TRANSITIONS); +} + +function chunks(values: readonly T[], size: number): readonly (readonly T[])[] { + const result: T[][] = []; + for (let offset = 0; offset < values.length; offset += size) { + result.push(values.slice(offset, offset + size)); + } + return result; +} + +function ensureFutureExpiry(observedAt: number, expiresAt: number): void { + if (expiresAt <= observedAt) { + throw new TypeError("expiresAt must be after observedAt"); + } +} + +function validateVerifiedEnterprisePrincipal(verified: VerifiedEnterprisePrincipal): void { + requireText(verified.providerId, "providerId"); + requireText(verified.issuer, "issuer"); + requireText(verified.tenant, "tenant"); + requireText(verified.subject, "subject"); + requireText(verified.evidenceRevision, "evidenceRevision"); + ensureFutureExpiry( + requireTimestamp(verified.observedAt, "observedAt"), + requireTimestamp(verified.expiresAt, "expiresAt"), + ); +} + +function enterpriseRef(db: DatabaseSync, providerId: string, kind: string, value: string): string { + return pseudonymizeExecutionIdentityRef({ + db, + kind: "principal", + scope: `memory-enterprise:${kind}:v1:${providerId}`, + value, + }); +} + +function enterprisePrincipalLookup( + db: DatabaseSync, + providerId: string, + issuer: string, + tenant: string, + subject: string, +): string { + return pseudonymizeExecutionIdentityRef({ + db, + kind: "principal", + scope: `memory-enterprise:principal:v1:${providerId}`, + value: `${issuer}\u0000${tenant}\u0000${subject}`, + }); +} + +function toPrincipal(row: EnterprisePrincipalEvidenceRow): MemoryEnterprisePrincipal { + return Object.freeze({ + principalId: row.principal_id, + providerId: row.provider_id, + evidenceRevision: row.evidence_revision, + revision: row.principal_revision, + observedAt: row.observed_at, + expiresAt: row.expires_at, + }); +} + +function toMembership( + row: EnterpriseIdentityDatabase["memory_enterprise_membership_snapshots"], +): MemoryEnterpriseMembershipSnapshot { + return Object.freeze({ + snapshotId: row.snapshot_id, + principalId: row.principal_id, + providerId: row.provider_id, + tenantRef: row.tenant_ref, + groupRef: row.group_ref, + evidenceRevision: row.evidence_revision, + observedAt: row.observed_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + }); +} + +/** Canonical lazy DDL for enterprise identities and revisioned memberships. */ +export const MEMORY_ENTERPRISE_IDENTITY_SCHEMA_SQL = extractSchema(); + +export type MemoryEnterprisePrincipal = Readonly<{ + principalId: string; + providerId: string; + evidenceRevision: string; + revision: string; + observedAt: number; + expiresAt: number; +}>; + +export type MemoryEnterpriseMembershipSnapshot = Readonly<{ + snapshotId: string; + principalId: string; + providerId: string; + tenantRef: string; + groupRef: string; + evidenceRevision: string; + observedAt: number; + expiresAt: number; + revokedAt: number | null; +}>; + +export type MemoryEnterpriseProfileLink = Readonly<{ + linkId: string; + enterprisePrincipalId: string; + userPrincipalId: string; + revision: string; +}>; + +/** Count-only outcome of an explicit redacted enterprise identity control. */ +export type MemoryEnterpriseIdentityActionResult = Readonly<{ + providerId: string; + kind: "unlink" | "revoke"; + affectedIdentityCount: number; + affectedSnapshotCount: number; +}>; + +/** Redacted lifecycle evidence for an identity link's refresh or removal. */ +export type MemoryEnterpriseEvidenceTransition = Readonly<{ + providerId: string; + kind: "refresh" | "revoke"; + revokedAt: number; + snapshotCount: number; +}>; + +/** Internal transition inputs for the content-free revocation-impact projection. */ +export type MemoryEnterpriseEvidenceTransitionImpactInput = Readonly<{ + transition: MemoryEnterpriseEvidenceTransition; + snapshotIds: readonly string[]; +}>; + +/** + * The future core verifier is the only caller permitted to pass upstream ids. + * This state owner reduces them before any durable write and exposes only refs. + */ +export type VerifiedEnterprisePrincipal = Readonly<{ + providerId: string; + issuer: string; + tenant: string; + subject: string; + evidenceRevision: string; + observedAt: number; + expiresAt: number; + principalId?: string; +}>; + +export type VerifiedEnterpriseMembership = Readonly<{ + principalId: string; + providerId: string; + tenant: string; + group: string; + evidenceRevision: string; + observedAt: number; + expiresAt: number; + snapshotId?: string; +}>; + +export type PersistedMemoryEnterpriseIdentity = Readonly<{ + principal: MemoryEnterprisePrincipal; + memberships: readonly MemoryEnterpriseMembershipSnapshot[]; +}>; + +/** Create the additive enterprise tables only on their first use. */ +export function ensureMemoryEnterpriseIdentitySchema( + options: OpenClawStateDatabaseOptions = {}, +): void { + ensureMemoryIdentitySchema(options); + const database = openOpenClawStateDatabase(options).db; + if (ensuredDatabases.has(database)) { + return; + } + runOpenClawStateWriteTransaction( + ({ db }) => db.exec(MEMORY_ENTERPRISE_IDENTITY_SCHEMA_SQL), + options, + { operationLabel: "memory-enterprise-identity.schema.ensure" }, + ); + ensuredDatabases.add(database); +} + +function ensureSchemaInTransaction(database: DatabaseSync): void { + if (ensuredDatabases.has(database)) { + return; + } + database.exec(MEMORY_ENTERPRISE_IDENTITY_SCHEMA_SQL); // sqlite-allow-raw -- canonical additive DDL. + ensuredDatabases.add(database); +} + +function selectEvidence( + database: DatabaseSync, + providerId: string, + tenantRef: string, + subjectRef: string, +): EnterprisePrincipalEvidenceRow | undefined { + const db = getNodeSqliteKysely(database); + const rows = executeSqliteQuerySync( + database, + db + .selectFrom("memory_enterprise_principal_evidence") + .innerJoin( + "memory_principals", + "memory_principals.principal_id", + "memory_enterprise_principal_evidence.principal_id", + ) + .select([ + "memory_enterprise_principal_evidence.principal_id", + "memory_enterprise_principal_evidence.provider_id", + "memory_enterprise_principal_evidence.issuer_ref", + "memory_enterprise_principal_evidence.tenant_ref", + "memory_enterprise_principal_evidence.subject_ref", + "memory_enterprise_principal_evidence.assurance", + "memory_enterprise_principal_evidence.evidence_revision", + "memory_enterprise_principal_evidence.observed_at", + "memory_enterprise_principal_evidence.expires_at", + "memory_enterprise_principal_evidence.revoked_at", + "memory_principals.revision as principal_revision", + "memory_principals.state as principal_state", + ]) + .where("memory_enterprise_principal_evidence.provider_id", "=", providerId) + .where("memory_enterprise_principal_evidence.tenant_ref", "=", tenantRef) + .where("memory_enterprise_principal_evidence.subject_ref", "=", subjectRef), + ).rows as EnterprisePrincipalEvidenceRow[]; + if (rows.length > 1) { + // The partial unique index prevents this under normal writes. A damaged + // database must not let whichever row SQLite happens to return become authority. + throw new Error("enterprise principal evidence has conflicting active canonical bindings"); + } + return rows[0]; +} + +function selectCurrentPrincipal( + database: DatabaseSync, + principalId: string, + providerId: string, + now: number, +): EnterprisePrincipalEvidenceRow | undefined { + const db = getNodeSqliteKysely(database); + return executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("memory_enterprise_principal_evidence") + .innerJoin( + "memory_principals", + "memory_principals.principal_id", + "memory_enterprise_principal_evidence.principal_id", + ) + .select([ + "memory_enterprise_principal_evidence.principal_id", + "memory_enterprise_principal_evidence.provider_id", + "memory_enterprise_principal_evidence.issuer_ref", + "memory_enterprise_principal_evidence.tenant_ref", + "memory_enterprise_principal_evidence.subject_ref", + "memory_enterprise_principal_evidence.assurance", + "memory_enterprise_principal_evidence.evidence_revision", + "memory_enterprise_principal_evidence.observed_at", + "memory_enterprise_principal_evidence.expires_at", + "memory_enterprise_principal_evidence.revoked_at", + "memory_principals.revision as principal_revision", + "memory_principals.state as principal_state", + ]) + .where("memory_enterprise_principal_evidence.principal_id", "=", principalId) + .where("memory_enterprise_principal_evidence.provider_id", "=", providerId) + .where("memory_enterprise_principal_evidence.revoked_at", "is", null) + .where("memory_principals.state", "=", "active") + .where("memory_enterprise_principal_evidence.observed_at", "<=", now) + .where("memory_enterprise_principal_evidence.expires_at", ">", now), + ) as EnterprisePrincipalEvidenceRow | undefined; +} + +function hasAuditIdentityKey(database: DatabaseSync): boolean { + const db = getNodeSqliteKysely(database); + return Boolean( + executeSqliteQueryTakeFirstSync( + database, + db.selectFrom("audit_identity_keys").select("id").where("id", "=", 1), + ), + ); +} + +function ensureMemoryEnterprisePrincipalInTransaction( + database: DatabaseSync, + verified: VerifiedEnterprisePrincipal, +): MemoryEnterprisePrincipal { + validateVerifiedEnterprisePrincipal(verified); + const providerId = requireText(verified.providerId, "providerId"); + const issuer = requireText(verified.issuer, "issuer"); + const tenant = requireText(verified.tenant, "tenant"); + const subject = requireText(verified.subject, "subject"); + const evidenceRevision = requireText(verified.evidenceRevision, "evidenceRevision"); + const observedAt = requireTimestamp(verified.observedAt, "observedAt"); + const expiresAt = requireTimestamp(verified.expiresAt, "expiresAt"); + ensureFutureExpiry(observedAt, expiresAt); + const db = getNodeSqliteKysely(database); + const issuerRef = enterpriseRef(database, providerId, "issuer", issuer); + const tenantRef = enterpriseRef(database, providerId, "tenant", tenant); + const subjectRef = enterpriseRef(database, providerId, "subject", subject); + const existing = selectEvidence(database, providerId, tenantRef, subjectRef); + if (existing) { + if (existing.issuer_ref !== issuerRef || existing.principal_state !== "active") { + throw new Error("enterprise principal evidence conflicts with the canonical binding"); + } + executeSqliteQuerySync( + database, + db + .updateTable("memory_enterprise_principal_evidence") + .set({ + evidence_revision: evidenceRevision, + observed_at: observedAt, + expires_at: expiresAt, + revoked_at: null, + }) + .where("principal_id", "=", existing.principal_id), + ); + return Object.freeze({ + ...toPrincipal(existing), + evidenceRevision, + observedAt, + expiresAt, + }); + } + const principalId = verified.principalId + ? requireText(verified.principalId, "principalId") + : generateSecureUuid(); + const principal = { + principal_id: principalId, + principal_kind: "enterprise" as const, + user_profile_id: null, + principal_lookup_hmac: enterprisePrincipalLookup(database, providerId, issuer, tenant, subject), + state: "active" as const, + revision: generateSecureUuid(), + created_at: Date.now(), + revoked_at: null, + }; + executeSqliteQuerySync(database, db.insertInto("memory_principals").values(principal)); + executeSqliteQuerySync( + database, + db.insertInto("memory_enterprise_principal_evidence").values({ + principal_id: principalId, + provider_id: providerId, + issuer_ref: issuerRef, + tenant_ref: tenantRef, + subject_ref: subjectRef, + assurance: "oidc", + evidence_revision: evidenceRevision, + observed_at: observedAt, + expires_at: expiresAt, + revoked_at: null, + }), + ); + return Object.freeze({ + principalId, + providerId, + evidenceRevision, + revision: principal.revision, + observedAt, + expiresAt, + }); +} + +function writeMemoryEnterpriseMembershipSnapshotInTransaction( + database: DatabaseSync, + verified: VerifiedEnterpriseMembership, +): MemoryEnterpriseMembershipSnapshot { + const providerId = requireText(verified.providerId, "providerId"); + const observedAt = requireTimestamp(verified.observedAt, "observedAt"); + const expiresAt = requireTimestamp(verified.expiresAt, "expiresAt"); + ensureFutureExpiry(observedAt, expiresAt); + const db = getNodeSqliteKysely(database); + const principalId = requireText(verified.principalId, "principalId"); + const principal = selectCurrentPrincipal(database, principalId, providerId, observedAt); + if (!principal) { + throw new Error("enterprise membership snapshot requires current principal evidence"); + } + if (principal.evidence_revision !== requireText(verified.evidenceRevision, "evidenceRevision")) { + throw new Error( + "enterprise membership snapshot must bind the current principal evidence revision", + ); + } + const tenantRef = enterpriseRef( + database, + providerId, + "tenant", + requireText(verified.tenant, "tenant"), + ); + const groupRef = enterpriseRef( + database, + providerId, + "group", + requireText(verified.group, "group"), + ); + const snapshot = { + snapshot_id: verified.snapshotId + ? requireText(verified.snapshotId, "snapshotId") + : generateSecureUuid(), + principal_id: principalId, + provider_id: providerId, + tenant_ref: tenantRef, + group_ref: groupRef, + evidence_revision: principal.evidence_revision, + observed_at: observedAt, + expires_at: expiresAt, + revoked_at: null, + created_at: Date.now(), + }; + executeSqliteQuerySync( + database, + db + .insertInto("memory_enterprise_membership_snapshots") + .values(snapshot) + .onConflict((conflict) => + conflict + .columns(["principal_id", "provider_id", "tenant_ref", "group_ref", "evidence_revision"]) + .doNothing(), + ), + ); + const stored = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("memory_enterprise_membership_snapshots") + .selectAll() + .where("principal_id", "=", snapshot.principal_id) + .where("provider_id", "=", snapshot.provider_id) + .where("tenant_ref", "=", snapshot.tenant_ref) + .where("group_ref", "=", snapshot.group_ref) + .where("evidence_revision", "=", snapshot.evidence_revision), + ); + if (!stored) { + throw new Error("enterprise membership snapshot could not be persisted"); + } + return toMembership(stored); +} + +function recordMemoryEnterpriseEvidenceTransitionInTransaction(params: { + database: DatabaseSync; + principalId: string; + providerId: string; + kind: "refresh" | "revoke"; + revokedAt: number; + snapshotIds: readonly string[]; +}): void { + const snapshotIds = [ + ...new Set(params.snapshotIds.map((snapshotId) => requireText(snapshotId, "snapshotId"))), + ].toSorted(); + if (snapshotIds.length === 0) { + return; + } + const transition = { + transition_id: generateSecureUuid(), + principal_id: requireText(params.principalId, "principalId"), + provider_id: requireText(params.providerId, "providerId"), + kind: params.kind, + revoked_at: requireTimestamp(params.revokedAt, "revokedAt"), + created_at: params.revokedAt, + }; + const db = getNodeSqliteKysely(params.database); + executeSqliteQuerySync( + params.database, + db.insertInto("memory_enterprise_evidence_transitions").values(transition), + ); + executeSqliteQuerySync( + params.database, + db.insertInto("memory_enterprise_evidence_transition_memberships").values( + snapshotIds.map((snapshotId) => ({ + transition_id: transition.transition_id, + snapshot_id: snapshotId, + created_at: transition.created_at, + })), + ), + ); + const profileLink = executeSqliteQueryTakeFirstSync( + params.database, + db + .selectFrom("memory_enterprise_profile_links") + .select(["link_id", "user_principal_id"]) + .where("enterprise_principal_id", "=", transition.principal_id) + .where("revoked_at", "is", null), + ); + if (profileLink) { + // Preserve the link that owned this event. Querying the current link would + // disclose historic lifecycle metadata after a profile relink. + executeSqliteQuerySync( + params.database, + db.insertInto("memory_enterprise_evidence_transition_profile_links").values({ + transition_id: transition.transition_id, + link_id: profileLink.link_id, + user_principal_id: profileLink.user_principal_id, + created_at: transition.created_at, + }), + ); + } +} + +/** Create or refresh one canonical principal from verifier-owned enterprise evidence. */ +export function ensureMemoryEnterprisePrincipal( + verified: VerifiedEnterprisePrincipal, + options: OpenClawStateDatabaseOptions = {}, +): MemoryEnterprisePrincipal { + validateVerifiedEnterprisePrincipal(verified); + ensureMemoryEnterpriseIdentitySchema(options); + return runOpenClawStateWriteTransaction( + ({ db: database }) => { + ensureSchemaInTransaction(database); + return ensureMemoryEnterprisePrincipalInTransaction(database, verified); + }, + options, + { operationLabel: "memory-enterprise-identity.principal.ensure" }, + ); +} + +/** Recheck current principal evidence; unavailable, expired, revoked, and unknown all fail closed. */ +export function recheckMemoryEnterprisePrincipal(params: { + principalId: string; + providerId: string; + now?: number; + options?: OpenClawStateDatabaseOptions; +}): MemoryEnterprisePrincipal | undefined { + const options = params.options ?? {}; + ensureMemoryEnterpriseIdentitySchema(options); + const now = requireTimestamp(params.now ?? Date.now(), "now"); + const database = openOpenClawStateDatabase(options).db; + const db = getNodeSqliteKysely(database); + const row = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("memory_enterprise_principal_evidence") + .innerJoin( + "memory_principals", + "memory_principals.principal_id", + "memory_enterprise_principal_evidence.principal_id", + ) + .select([ + "memory_enterprise_principal_evidence.principal_id", + "memory_enterprise_principal_evidence.provider_id", + "memory_enterprise_principal_evidence.issuer_ref", + "memory_enterprise_principal_evidence.tenant_ref", + "memory_enterprise_principal_evidence.subject_ref", + "memory_enterprise_principal_evidence.assurance", + "memory_enterprise_principal_evidence.evidence_revision", + "memory_enterprise_principal_evidence.observed_at", + "memory_enterprise_principal_evidence.expires_at", + "memory_enterprise_principal_evidence.revoked_at", + "memory_principals.revision as principal_revision", + "memory_principals.state as principal_state", + ]) + .where( + "memory_enterprise_principal_evidence.principal_id", + "=", + requireText(params.principalId, "principalId"), + ) + .where( + "memory_enterprise_principal_evidence.provider_id", + "=", + requireText(params.providerId, "providerId"), + ) + .where("memory_enterprise_principal_evidence.revoked_at", "is", null) + .where("memory_principals.state", "=", "active") + .where("memory_enterprise_principal_evidence.observed_at", "<=", now) + .where("memory_enterprise_principal_evidence.expires_at", ">", now), + ) as EnterprisePrincipalEvidenceRow | undefined; + return row ? toPrincipal(row) : undefined; +} + +/** + * Associate current enterprise evidence with one Gateway user principal. This + * is an explicit operator-owned identity link, never a `session_members` + * write and never a substitute for current provider evidence. + */ +export function linkMemoryEnterpriseProfile(params: { + enterprisePrincipalId: string; + providerId: string; + userPrincipalId: string; + createdByPrincipalId: string; + options?: OpenClawStateDatabaseOptions; + now?: number; +}): MemoryEnterpriseProfileLink { + const options = params.options ?? {}; + ensureMemoryEnterpriseIdentitySchema(options); + const enterprisePrincipalId = requireText(params.enterprisePrincipalId, "enterprisePrincipalId"); + const providerId = requireText(params.providerId, "providerId"); + const userPrincipalId = requireText(params.userPrincipalId, "userPrincipalId"); + const createdByPrincipalId = requireText(params.createdByPrincipalId, "createdByPrincipalId"); + return runOpenClawStateWriteTransaction( + ({ db: database }) => { + ensureSchemaInTransaction(database); + const now = params.now ?? Date.now(); + if (!selectCurrentPrincipal(database, enterprisePrincipalId, providerId, now)) { + throw new Error("enterprise profile link requires current enterprise evidence"); + } + const db = getNodeSqliteKysely(database); + const user = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("memory_principals") + .select(["principal_id", "principal_kind", "state"]) + .where("principal_id", "=", userPrincipalId) + .where("principal_kind", "=", "user") + .where("state", "=", "active"), + ); + const operator = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("memory_principals") + .select(["principal_id", "principal_kind", "state"]) + .where("principal_id", "=", createdByPrincipalId) + .where("principal_kind", "=", "user") + .where("state", "=", "active"), + ); + if (!user || !operator) { + throw new Error("enterprise profile link requires active Gateway user principals"); + } + const existing = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("memory_enterprise_profile_links") + .selectAll() + .where("enterprise_principal_id", "=", enterprisePrincipalId) + .where("revoked_at", "is", null), + ); + if (existing?.user_principal_id === userPrincipalId) { + return Object.freeze({ + linkId: existing.link_id, + enterprisePrincipalId, + userPrincipalId, + revision: existing.revision, + }); + } + if (existing) { + executeSqliteQuerySync( + database, + db + .updateTable("memory_enterprise_profile_links") + .set({ revoked_at: now }) + .where("link_id", "=", existing.link_id) + .where("revoked_at", "is", null), + ); + } + const link = { + link_id: generateSecureUuid(), + enterprise_principal_id: enterprisePrincipalId, + user_principal_id: userPrincipalId, + created_by_principal_id: createdByPrincipalId, + created_at: now, + revoked_at: null, + revision: generateSecureUuid(), + }; + executeSqliteQuerySync( + database, + db.insertInto("memory_enterprise_profile_links").values(link), + ); + return Object.freeze({ + linkId: link.link_id, + enterprisePrincipalId, + userPrincipalId, + revision: link.revision, + }); + }, + options, + { operationLabel: "memory-enterprise-identity.profile-link" }, + ); +} + +/** Recheck the explicit profile association and current provider evidence together. */ +export function recheckMemoryEnterpriseProfileLink(params: { + enterprisePrincipalId: string; + userPrincipalId: string; + providerId: string; + now?: number; + options?: OpenClawStateDatabaseOptions; +}): MemoryEnterpriseProfileLink | undefined { + const options = params.options ?? {}; + const now = requireTimestamp(params.now ?? Date.now(), "now"); + const enterprisePrincipalId = requireText(params.enterprisePrincipalId, "enterprisePrincipalId"); + const userPrincipalId = requireText(params.userPrincipalId, "userPrincipalId"); + const providerId = requireText(params.providerId, "providerId"); + ensureMemoryEnterpriseIdentitySchema(options); + const database = openOpenClawStateDatabase(options).db; + if (!selectCurrentPrincipal(database, enterprisePrincipalId, providerId, now)) { + return undefined; + } + const db = getNodeSqliteKysely(database); + const row = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("memory_enterprise_profile_links") + .innerJoin( + "memory_principals as users", + "users.principal_id", + "memory_enterprise_profile_links.user_principal_id", + ) + .select([ + "memory_enterprise_profile_links.link_id", + "memory_enterprise_profile_links.enterprise_principal_id", + "memory_enterprise_profile_links.user_principal_id", + "memory_enterprise_profile_links.revision", + ]) + .where("memory_enterprise_profile_links.enterprise_principal_id", "=", enterprisePrincipalId) + .where("memory_enterprise_profile_links.user_principal_id", "=", userPrincipalId) + .where("memory_enterprise_profile_links.revoked_at", "is", null) + .where("users.principal_kind", "=", "user") + .where("users.state", "=", "active"), + ); + return row + ? Object.freeze({ + linkId: row.link_id, + enterprisePrincipalId: row.enterprise_principal_id, + userPrincipalId: row.user_principal_id, + revision: row.revision, + }) + : undefined; +} + +/** Persist one immutable membership snapshot from a verified provider response. */ +export function writeMemoryEnterpriseMembershipSnapshot( + verified: VerifiedEnterpriseMembership, + options: OpenClawStateDatabaseOptions = {}, +): MemoryEnterpriseMembershipSnapshot { + requireText(verified.providerId, "providerId"); + ensureFutureExpiry( + requireTimestamp(verified.observedAt, "observedAt"), + requireTimestamp(verified.expiresAt, "expiresAt"), + ); + ensureMemoryEnterpriseIdentitySchema(options); + return runOpenClawStateWriteTransaction( + ({ db: database }) => { + ensureSchemaInTransaction(database); + return writeMemoryEnterpriseMembershipSnapshotInTransaction(database, verified); + }, + options, + { operationLabel: "memory-enterprise-identity.membership.write" }, + ); +} + +/** + * Atomically replace verifier-owned enterprise evidence. Superseded membership + * snapshots are revoked before the new principal revision can become readable. + */ +export function persistMemoryEnterpriseIdentity(params: { + verified: VerifiedEnterprisePrincipal; + groups: readonly string[]; + options?: OpenClawStateDatabaseOptions; +}): PersistedMemoryEnterpriseIdentity { + const options = params.options ?? {}; + const verified = params.verified; + const providerId = requireText(verified.providerId, "providerId"); + const evidenceRevision = requireText(verified.evidenceRevision, "evidenceRevision"); + const observedAt = requireTimestamp(verified.observedAt, "observedAt"); + const expiresAt = requireTimestamp(verified.expiresAt, "expiresAt"); + validateVerifiedEnterprisePrincipal(verified); + const groups = [...new Set(params.groups.map((group) => requireText(group, "group")))].toSorted(); + if (groups.length > 1_000) { + throw new RangeError("enterprise membership snapshot exceeds 1000 groups"); + } + ensureMemoryEnterpriseIdentitySchema(options); + return runOpenClawStateWriteTransaction( + ({ db: database }) => { + ensureSchemaInTransaction(database); + const principal = ensureMemoryEnterprisePrincipalInTransaction(database, verified); + const db = getNodeSqliteKysely(database); + const supersededSnapshotIds = executeSqliteQuerySync( + database, + db + .selectFrom("memory_enterprise_membership_snapshots") + .select("snapshot_id") + .where("principal_id", "=", principal.principalId) + .where("provider_id", "=", providerId) + .where("evidence_revision", "!=", evidenceRevision) + .where("revoked_at", "is", null) + .orderBy("snapshot_id", "asc"), + ).rows.map((snapshot) => snapshot.snapshot_id); + executeSqliteQuerySync( + database, + db + .updateTable("memory_enterprise_membership_snapshots") + .set({ revoked_at: observedAt }) + .where("principal_id", "=", principal.principalId) + .where("provider_id", "=", providerId) + .where("evidence_revision", "!=", evidenceRevision) + .where("revoked_at", "is", null), + ); + recordMemoryEnterpriseEvidenceTransitionInTransaction({ + database, + principalId: principal.principalId, + providerId, + kind: "refresh", + revokedAt: observedAt, + snapshotIds: supersededSnapshotIds, + }); + const memberships = Object.freeze( + groups.map((group) => + writeMemoryEnterpriseMembershipSnapshotInTransaction(database, { + principalId: principal.principalId, + providerId, + tenant: verified.tenant, + group, + evidenceRevision, + observedAt, + expiresAt, + }), + ), + ); + return Object.freeze({ principal, memberships }); + }, + options, + { operationLabel: "memory-enterprise-identity.persist" }, + ); +} + +/** Revoke one snapshot immediately; its immutable evidence remains auditable but unusable. */ +export function revokeMemoryEnterpriseMembershipSnapshot(params: { + snapshotId: string; + revokedAt?: number; + options?: OpenClawStateDatabaseOptions; +}): void { + const options = params.options ?? {}; + ensureMemoryEnterpriseIdentitySchema(options); + runOpenClawStateWriteTransaction( + ({ db: database }) => { + ensureSchemaInTransaction(database); + const db = getNodeSqliteKysely(database); + const snapshotId = requireText(params.snapshotId, "snapshotId"); + const revokedAt = requireTimestamp(params.revokedAt ?? Date.now(), "revokedAt"); + const snapshot = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("memory_enterprise_membership_snapshots") + .select(["snapshot_id", "principal_id", "provider_id"]) + .where("snapshot_id", "=", snapshotId) + .where("revoked_at", "is", null) + .limit(1), + ); + if (!snapshot) { + return; + } + const revoked = executeSqliteQuerySync( + database, + db + .updateTable("memory_enterprise_membership_snapshots") + .set({ revoked_at: revokedAt }) + .where("snapshot_id", "=", snapshot.snapshot_id) + .where("revoked_at", "is", null), + ); + if (revoked.numAffectedRows !== 1n) { + throw new Error("enterprise membership snapshot changed during revocation"); + } + recordMemoryEnterpriseEvidenceTransitionInTransaction({ + database, + principalId: snapshot.principal_id, + providerId: snapshot.provider_id, + kind: "revoke", + revokedAt, + snapshotIds: [snapshot.snapshot_id], + }); + }, + options, + { operationLabel: "memory-enterprise-identity.membership.revoke" }, + ); +} + +function requireEnterpriseIdentityActionPrincipalsInTransaction(params: { + database: DatabaseSync; + userPrincipalId: string; + actorPrincipalId: string; +}): void { + const db = getNodeSqliteKysely(params.database); + const target = executeSqliteQueryTakeFirstSync( + params.database, + db + .selectFrom("memory_principals") + .select(["principal_kind"]) + .where("principal_id", "=", params.userPrincipalId), + ); + if (target?.principal_kind !== "user") { + throw new Error("enterprise identity action requires a Gateway user principal target"); + } + const actor = executeSqliteQueryTakeFirstSync( + params.database, + db + .selectFrom("memory_principals") + .select(["principal_kind", "state"]) + .where("principal_id", "=", params.actorPrincipalId), + ); + if (actor?.principal_kind !== "user" || actor.state !== "active") { + throw new Error("enterprise identity action requires an active Gateway user principal actor"); + } +} + +function listActiveEnterpriseProfileLinksInTransaction(params: { + database: DatabaseSync; + userPrincipalId: string; + providerId: string; +}): readonly Readonly<{ linkId: string; enterprisePrincipalId: string }>[] { + const db = getNodeSqliteKysely(params.database); + return Object.freeze( + executeSqliteQuerySync( + params.database, + db + .selectFrom("memory_enterprise_profile_links as link") + .innerJoin( + "memory_enterprise_principal_evidence as evidence", + "evidence.principal_id", + "link.enterprise_principal_id", + ) + .select(["link.link_id as linkId", "link.enterprise_principal_id as enterprisePrincipalId"]) + .where("link.user_principal_id", "=", params.userPrincipalId) + .where("link.revoked_at", "is", null) + .where("evidence.provider_id", "=", params.providerId) + .orderBy("link.link_id", "asc"), + ).rows, + ); +} + +function writeMemoryEnterpriseIdentityActionInTransaction(params: { + database: DatabaseSync; + userPrincipalId: string; + actorPrincipalId: string; + providerId: string; + kind: "unlink" | "revoke"; + affectedIdentityCount: number; + affectedSnapshotCount: number; + occurredAt: number; +}): void { + const db = getNodeSqliteKysely(params.database); + executeSqliteQuerySync( + params.database, + db.insertInto("memory_enterprise_identity_actions").values({ + action_id: generateSecureUuid(), + target_user_principal_id: params.userPrincipalId, + actor_principal_id: params.actorPrincipalId, + provider_id: params.providerId, + kind: params.kind, + affected_identity_count: params.affectedIdentityCount, + affected_snapshot_count: params.affectedSnapshotCount, + occurred_at: params.occurredAt, + }), + ); +} + +function revokeEnterpriseProfileLinksInTransaction(params: { + database: DatabaseSync; + links: readonly Readonly<{ linkId: string; enterprisePrincipalId: string }>[]; + revokedAt: number; +}): void { + if (params.links.length === 0) { + return; + } + const db = getNodeSqliteKysely(params.database); + for (const linkIds of chunks( + params.links.map((link) => link.linkId), + SQLITE_IN_VALUES_LIMIT, + )) { + const updated = executeSqliteQuerySync( + params.database, + db + .updateTable("memory_enterprise_profile_links") + .set({ revoked_at: params.revokedAt }) + .where("link_id", "in", linkIds) + .where("revoked_at", "is", null), + ); + if (updated.numAffectedRows !== BigInt(linkIds.length)) { + throw new Error("enterprise profile link changed during revocation"); + } + } +} + +function enterpriseIdentityActionResult(params: { + providerId: string; + kind: "unlink" | "revoke"; + affectedIdentityCount: number; + affectedSnapshotCount: number; +}): MemoryEnterpriseIdentityActionResult { + return Object.freeze(params); +} + +/** + * Remove one profile's current association with a provider without erasing + * verified evidence, lifecycle history, access audit, or prior exposure. + */ +export function unlinkMemoryEnterpriseProfile(params: { + userPrincipalId: string; + providerId: string; + actorPrincipalId: string; + options?: OpenClawStateDatabaseOptions; + now?: number; +}): MemoryEnterpriseIdentityActionResult { + const options = params.options ?? {}; + ensureMemoryEnterpriseIdentitySchema(options); + const userPrincipalId = requireText(params.userPrincipalId, "userPrincipalId"); + const providerId = requireText(params.providerId, "providerId"); + const actorPrincipalId = requireText(params.actorPrincipalId, "actorPrincipalId"); + const now = requireTimestamp(params.now ?? Date.now(), "now"); + return runOpenClawStateWriteTransaction( + ({ db: database }) => { + ensureSchemaInTransaction(database); + requireEnterpriseIdentityActionPrincipalsInTransaction({ + database, + userPrincipalId, + actorPrincipalId, + }); + const links = listActiveEnterpriseProfileLinksInTransaction({ + database, + userPrincipalId, + providerId, + }); + revokeEnterpriseProfileLinksInTransaction({ database, links, revokedAt: now }); + const result = enterpriseIdentityActionResult({ + providerId, + kind: "unlink", + affectedIdentityCount: links.length, + affectedSnapshotCount: 0, + }); + writeMemoryEnterpriseIdentityActionInTransaction({ + database, + userPrincipalId, + actorPrincipalId, + ...result, + occurredAt: now, + }); + return result; + }, + options, + { operationLabel: "memory-enterprise-identity.profile.unlink" }, + ); +} + +/** + * Revoke current provider evidence and unlink its profile association. Every + * transition captures its active link before that link is revoked, preserving + * historic lifecycle ownership without retaining any claim or memory content. + */ +export function revokeMemoryEnterpriseProfileEvidence(params: { + userPrincipalId: string; + providerId: string; + actorPrincipalId: string; + options?: OpenClawStateDatabaseOptions; + now?: number; +}): MemoryEnterpriseIdentityActionResult { + const options = params.options ?? {}; + ensureMemoryEnterpriseIdentitySchema(options); + const userPrincipalId = requireText(params.userPrincipalId, "userPrincipalId"); + const providerId = requireText(params.providerId, "providerId"); + const actorPrincipalId = requireText(params.actorPrincipalId, "actorPrincipalId"); + const now = requireTimestamp(params.now ?? Date.now(), "now"); + return runOpenClawStateWriteTransaction( + ({ db: database }) => { + ensureSchemaInTransaction(database); + requireEnterpriseIdentityActionPrincipalsInTransaction({ + database, + userPrincipalId, + actorPrincipalId, + }); + const links = listActiveEnterpriseProfileLinksInTransaction({ + database, + userPrincipalId, + providerId, + }); + const db = getNodeSqliteKysely(database); + const snapshotIdsByPrincipal = new Map(); + for (const principalIds of chunks( + links.map((link) => link.enterprisePrincipalId), + SQLITE_IN_VALUES_LIMIT, + )) { + for (const snapshot of executeSqliteQuerySync( + database, + db + .selectFrom("memory_enterprise_membership_snapshots") + .select(["snapshot_id", "principal_id"]) + .where("principal_id", "in", principalIds) + .where("provider_id", "=", providerId) + .where("revoked_at", "is", null) + .orderBy("principal_id", "asc") + .orderBy("snapshot_id", "asc"), + ).rows) { + const snapshotIds = snapshotIdsByPrincipal.get(snapshot.principal_id) ?? []; + snapshotIds.push(snapshot.snapshot_id); + snapshotIdsByPrincipal.set(snapshot.principal_id, snapshotIds); + } + } + const snapshotIds = [...snapshotIdsByPrincipal.values()].flat(); + for (const link of links) { + // This must precede link revocation: transition provenance is the + // historic profile ownership, not whichever profile may link later. + recordMemoryEnterpriseEvidenceTransitionInTransaction({ + database, + principalId: link.enterprisePrincipalId, + providerId, + kind: "revoke", + revokedAt: now, + snapshotIds: snapshotIdsByPrincipal.get(link.enterprisePrincipalId) ?? [], + }); + } + for (const chunk of chunks(snapshotIds, SQLITE_IN_VALUES_LIMIT)) { + const updated = executeSqliteQuerySync( + database, + db + .updateTable("memory_enterprise_membership_snapshots") + .set({ revoked_at: now }) + .where("snapshot_id", "in", chunk) + .where("revoked_at", "is", null), + ); + if (updated.numAffectedRows !== BigInt(chunk.length)) { + throw new Error( + "enterprise membership snapshot changed during profile evidence revocation", + ); + } + } + for (const principalIds of chunks( + links.map((link) => link.enterprisePrincipalId), + SQLITE_IN_VALUES_LIMIT, + )) { + executeSqliteQuerySync( + database, + db + .updateTable("memory_enterprise_principal_evidence") + .set({ revoked_at: now }) + .where("principal_id", "in", principalIds) + .where("provider_id", "=", providerId) + .where("revoked_at", "is", null), + ); + } + revokeEnterpriseProfileLinksInTransaction({ database, links, revokedAt: now }); + const result = enterpriseIdentityActionResult({ + providerId, + kind: "revoke", + affectedIdentityCount: links.length, + affectedSnapshotCount: snapshotIds.length, + }); + writeMemoryEnterpriseIdentityActionInTransaction({ + database, + userPrincipalId, + actorPrincipalId, + ...result, + occurredAt: now, + }); + return result; + }, + options, + { operationLabel: "memory-enterprise-identity.profile-evidence.revoke" }, + ); +} + +/** + * Return only lifecycle counts owned by the selected user principal when the + * event occurred. Group, snapshot, and transition identifiers stay in the + * state store: even an opaque identifier would let an operator correlate a + * person's enterprise evidence outside this bounded explanation surface. + */ +export function listMemoryEnterpriseEvidenceTransitionsForUserPrincipal(params: { + userPrincipalId: string; + providerId?: string; + limit?: number; + options?: OpenClawStateDatabaseOptions; +}): readonly MemoryEnterpriseEvidenceTransition[] { + return Object.freeze( + listMemoryEnterpriseEvidenceTransitionImpactInputsForUserPrincipal(params).map( + (input) => input.transition, + ), + ); +} + +/** + * Read lifecycle-owned snapshot sets only for a profile that owned each event. + * The caller must project these opaque inputs without returning them to an operator. + */ +export function listMemoryEnterpriseEvidenceTransitionImpactInputsForUserPrincipal(params: { + userPrincipalId: string; + providerId?: string; + limit?: number; + options?: OpenClawStateDatabaseOptions; +}): readonly MemoryEnterpriseEvidenceTransitionImpactInput[] { + const options = params.options ?? {}; + ensureMemoryEnterpriseIdentitySchema(options); + const database = openOpenClawStateDatabase(options).db; + const db = getNodeSqliteKysely(database); + const transitions = executeSqliteQuerySync( + database, + db + .selectFrom("memory_enterprise_evidence_transitions as transition") + .innerJoin( + "memory_enterprise_evidence_transition_profile_links as provenance", + "provenance.transition_id", + "transition.transition_id", + ) + .select([ + "transition.transition_id", + "transition.provider_id", + "transition.kind", + "transition.revoked_at", + ]) + .where( + "provenance.user_principal_id", + "=", + requireText(params.userPrincipalId, "userPrincipalId"), + ) + .$if(params.providerId !== undefined, (query) => + query.where("transition.provider_id", "=", requireText(params.providerId!, "providerId")), + ) + .orderBy("transition.revoked_at", "desc") + .orderBy("transition.transition_id", "desc") + .limit(boundedEvidenceTransitionLimit(params.limit)), + ).rows; + if (transitions.length === 0) { + return Object.freeze([]); + } + const transitionIds = transitions.map((transition) => transition.transition_id); + const memberships = executeSqliteQuerySync( + database, + db + .selectFrom("memory_enterprise_evidence_transition_memberships") + .select(["transition_id", "snapshot_id"]) + .where("transition_id", "in", transitionIds) + .orderBy("transition_id", "asc") + .orderBy("snapshot_id", "asc"), + ).rows; + const snapshotIdsByTransition = new Map(); + for (const membership of memberships) { + const snapshotIds = snapshotIdsByTransition.get(membership.transition_id) ?? []; + snapshotIds.push(membership.snapshot_id); + snapshotIdsByTransition.set(membership.transition_id, snapshotIds); + } + return Object.freeze( + transitions.map((transition) => { + const snapshotIds = snapshotIdsByTransition.get(transition.transition_id) ?? []; + return Object.freeze({ + transition: Object.freeze({ + providerId: transition.provider_id, + kind: transition.kind, + revokedAt: transition.revoked_at, + snapshotCount: snapshotIds.length, + }), + snapshotIds: Object.freeze(snapshotIds), + }); + }), + ); +} + +/** Return current membership only; a provider outage therefore cannot revive stale evidence. */ +export function readCurrentMemoryEnterpriseMembership(params: { + principalId: string; + providerId: string; + tenant: string; + group: string; + now?: number; + options?: OpenClawStateDatabaseOptions; +}): MemoryEnterpriseMembershipSnapshot | undefined { + const options = params.options ?? {}; + ensureMemoryEnterpriseIdentitySchema(options); + const now = requireTimestamp(params.now ?? Date.now(), "now"); + const providerId = requireText(params.providerId, "providerId"); + const database = openOpenClawStateDatabase(options).db; + // A read must never create HMAC key material just to answer an unknown + // lookup. Missing identity material therefore means no current membership. + if (!hasAuditIdentityKey(database)) { + return undefined; + } + const db = getNodeSqliteKysely(database); + const row = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("memory_enterprise_membership_snapshots") + .selectAll() + .where("principal_id", "=", requireText(params.principalId, "principalId")) + .where("provider_id", "=", providerId) + .where( + "tenant_ref", + "=", + enterpriseRef(database, providerId, "tenant", requireText(params.tenant, "tenant")), + ) + .where( + "group_ref", + "=", + enterpriseRef(database, providerId, "group", requireText(params.group, "group")), + ) + .where("revoked_at", "is", null) + .where("observed_at", "<=", now) + .where("expires_at", ">", now) + .orderBy("observed_at", "desc") + .orderBy("snapshot_id", "desc") + .limit(1), + ); + return row ? toMembership(row) : undefined; +} + +/** + * Resolve a current membership from a trusted in-process admission without + * exposing tenant identifiers back to the caller. The returned refs are safe + * for the redacted decision ledger only. + */ +export function readCurrentMemoryEnterpriseMembershipForAudit(params: { + principalId: string; + providerId: string; + group: string; + now?: number; + options?: OpenClawStateDatabaseOptions; +}): MemoryEnterpriseMembershipSnapshot | undefined { + const options = params.options ?? {}; + ensureMemoryEnterpriseIdentitySchema(options); + const now = requireTimestamp(params.now ?? Date.now(), "now"); + const providerId = requireText(params.providerId, "providerId"); + const database = openOpenClawStateDatabase(options).db; + if (!hasAuditIdentityKey(database)) { + return undefined; + } + const principal = selectCurrentPrincipal( + database, + requireText(params.principalId, "principalId"), + providerId, + now, + ); + if (!principal) { + return undefined; + } + const db = getNodeSqliteKysely(database); + const row = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("memory_enterprise_membership_snapshots") + .selectAll() + .where("principal_id", "=", principal.principal_id) + .where("provider_id", "=", providerId) + .where("tenant_ref", "=", principal.tenant_ref) + .where( + "group_ref", + "=", + enterpriseRef(database, providerId, "group", requireText(params.group, "group")), + ) + .where("evidence_revision", "=", principal.evidence_revision) + .where("revoked_at", "is", null) + .where("observed_at", "<=", now) + .where("expires_at", ">", now) + .orderBy("observed_at", "desc") + .orderBy("snapshot_id", "desc") + .limit(1), + ); + return row ? toMembership(row) : undefined; +} diff --git a/src/state/memory-enterprise-revocation-impact.test.ts b/src/state/memory-enterprise-revocation-impact.test.ts new file mode 100644 index 000000000000..8a10239beaf2 --- /dev/null +++ b/src/state/memory-enterprise-revocation-impact.test.ts @@ -0,0 +1,199 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { generateSecureUuid } from "../infra/secure-random.js"; +import { persistMemoryRunExposureBeforeContentInDatabase } from "../plugins/memory-run-exposure-ledger.js"; +import { + clearMemoryRunExposureForTest, + prepareMemoryRunExposure, +} from "../plugins/memory-run-exposure.js"; +import { + linkMemoryEnterpriseProfile, + persistMemoryEnterpriseIdentity, +} from "./memory-enterprise-identity.js"; +import { listMemoryEnterpriseEvidenceTransitionImpactsForUserPrincipal } from "./memory-enterprise-revocation-impact.js"; +import { invalidateRegisteredAgentDatabasesMemo } from "./openclaw-agent-db-registry-listing.js"; +import { + closeOpenClawAgentDatabasesForTest, + openOpenClawAgentDatabase, + OPENCLAW_AGENT_SCHEMA_VERSION, +} from "./openclaw-agent-db.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "./openclaw-state-db.js"; + +const roots: string[] = []; + +function fixture() { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-memory-enterprise-impact-")); + roots.push(root); + return { root, env: { ...process.env, OPENCLAW_STATE_DIR: root } }; +} + +function persistExposure(params: { + agentId: string; + sessionId: string; + snapshotIds: readonly string[]; + env: NodeJS.ProcessEnv; +}) { + const snapshot = prepareMemoryRunExposure({ + agentId: params.agentId, + sessionId: params.sessionId, + sessionKey: `agent:${params.agentId}:direct:${params.sessionId}`, + runId: `run:${params.sessionId}`, + contextFingerprint: `context:${params.sessionId}`, + planId: `plan:${params.sessionId}`, + memoryPolicyRevision: "policy-1", + sourcePolicySetIds: ["policy-set-1"], + exposedResourceRevisions: ["resource-revision-1"], + exposureReceiptIds: ["exposure-receipt-1"], + egressReceiptIds: ["egress-receipt-1"], + enterpriseMembershipSnapshotIds: params.snapshotIds, + deliveryAudiences: [{ kind: "user", id: "alice" }], + deliveryRevision: "delivery-1", + egressRegistryRevision: "egress-1", + sessionIdentityRevision: "identity-1", + subjectRevision: "subject-1", + actorEvidence: { + version: 1, + kind: "principal", + actorKind: "human", + principalId: "principal:user", + assurance: "gateway-profile", + evidenceRevision: "identity-1", + }, + delegationSnapshot: { version: 1, kind: "none" }, + hostFactsRevision: "host-facts-1", + }); + expect( + persistMemoryRunExposureBeforeContentInDatabase({ + database: openOpenClawAgentDatabase({ agentId: params.agentId, env: params.env }), + snapshot, + }), + ).toBe(true); + return snapshot; +} + +afterEach(() => { + clearMemoryRunExposureForTest(); + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + for (const root of roots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +describe("enterprise memory revocation impact", () => { + it("counts historical exposure sets across agent ledgers without exposing identifiers", () => { + const { env, root } = fixture(); + const now = Date.now(); + const first = persistMemoryEnterpriseIdentity({ + verified: { + providerId: "entra", + issuer: "https://login.microsoftonline.com/tenant/v2.0", + tenant: "tenant", + subject: "alice", + evidenceRevision: "revision-1", + observedAt: now, + expiresAt: now + 60_000, + }, + groups: ["writers", "reviewers"], + options: { env }, + }); + const state = openOpenClawStateDatabase({ env }).db; + for (const [principalId, profileId] of [ + ["principal:user", "profile:alice"], + ["principal:operator", "profile:operator"], + ] satisfies readonly (readonly [string, string])[]) { + state + .prepare( + `INSERT INTO memory_principals + (principal_id, principal_kind, user_profile_id, principal_lookup_hmac, state, revision, created_at, revoked_at) + VALUES (?, 'user', ?, NULL, 'active', ?, ?, NULL)`, + ) + .run(principalId, profileId, generateSecureUuid(), now); + } + linkMemoryEnterpriseProfile({ + enterprisePrincipalId: first.principal.principalId, + providerId: "entra", + userPrincipalId: "principal:user", + createdByPrincipalId: "principal:operator", + now, + options: { env }, + }); + persistExposure({ + agentId: "main", + sessionId: "main-session", + snapshotIds: first.memberships.map((membership) => membership.snapshotId), + env, + }); + persistExposure({ + agentId: "worker", + sessionId: "worker-session", + snapshotIds: [first.memberships[0]!.snapshotId], + env, + }); + persistMemoryEnterpriseIdentity({ + verified: { + providerId: "entra", + issuer: "https://login.microsoftonline.com/tenant/v2.0", + tenant: "tenant", + subject: "alice", + evidenceRevision: "revision-2", + observedAt: now + 1_000, + expiresAt: now + 61_000, + }, + groups: ["writers"], + options: { env }, + }); + + expect( + listMemoryEnterpriseEvidenceTransitionImpactsForUserPrincipal({ + userPrincipalId: "principal:user", + providerId: "entra", + options: { env }, + }), + ).toEqual([ + { + providerId: "entra", + kind: "refresh", + revokedAt: now + 1_000, + snapshotCount: 2, + exposureCount: 2, + complete: true, + }, + ]); + + state + .prepare( + `INSERT INTO agent_databases (agent_id, path, schema_version, last_seen_at, size_bytes) + VALUES (?, ?, ?, ?, NULL)`, + ) + .run( + "missing", + path.join(root, "agents", "missing", "agent", "openclaw-agent.sqlite"), + OPENCLAW_AGENT_SCHEMA_VERSION, + now, + ); + invalidateRegisteredAgentDatabasesMemo({ env }); + + const incomplete = listMemoryEnterpriseEvidenceTransitionImpactsForUserPrincipal({ + userPrincipalId: "principal:user", + providerId: "entra", + options: { env }, + }); + expect(incomplete).toEqual([ + { + providerId: "entra", + kind: "refresh", + revokedAt: now + 1_000, + snapshotCount: 2, + exposureCount: 2, + complete: false, + }, + ]); + expect(JSON.stringify(incomplete)).not.toContain(first.memberships[0]!.snapshotId); + }); +}); diff --git a/src/state/memory-enterprise-revocation-impact.ts b/src/state/memory-enterprise-revocation-impact.ts new file mode 100644 index 000000000000..f156afedae39 --- /dev/null +++ b/src/state/memory-enterprise-revocation-impact.ts @@ -0,0 +1,116 @@ +import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; +import { + listMemoryEnterpriseEvidenceTransitionImpactInputsForUserPrincipal, + type MemoryEnterpriseEvidenceTransition, +} from "./memory-enterprise-identity.js"; +import { withOpenClawAgentDatabaseReadOnly } from "./openclaw-agent-db-readonly.js"; +import { listOpenClawRegisteredAgentDatabases } from "./openclaw-agent-db-registry.js"; +import type { OpenClawStateDatabaseOptions } from "./openclaw-state-db.js"; + +const SQLITE_IN_VALUES_LIMIT = 900; + +type MemoryEnterpriseExposureDatabase = { + memory_preoutput_exposure_enterprise_memberships: { + exposure_set_id: string; + snapshot_id: string; + }; +}; + +/** A content-free, best-effort report for one immutable evidence transition. */ +export type MemoryEnterpriseEvidenceTransitionImpact = Readonly< + MemoryEnterpriseEvidenceTransition & { + exposureCount: number; + complete: boolean; + } +>; + +function chunks(values: readonly T[], size: number): readonly (readonly T[])[] { + const result: T[][] = []; + for (let offset = 0; offset < values.length; offset += size) { + result.push(values.slice(offset, offset + size)); + } + return result; +} + +/** + * Join immutable membership transitions to per-agent pre-output ledgers without + * returning any durable identifier. A missing or unreadable registered agent + * database marks the report incomplete instead of producing a false zero. + */ +export function listMemoryEnterpriseEvidenceTransitionImpactsForUserPrincipal(params: { + userPrincipalId: string; + providerId?: string; + limit?: number; + options?: OpenClawStateDatabaseOptions; +}): readonly MemoryEnterpriseEvidenceTransitionImpact[] { + const options = params.options ?? {}; + const inputs = listMemoryEnterpriseEvidenceTransitionImpactInputsForUserPrincipal(params); + if (inputs.length === 0) { + return Object.freeze([]); + } + + const transitionIndexesBySnapshotId = new Map(); + for (const [index, input] of inputs.entries()) { + for (const snapshotId of input.snapshotIds) { + const indexes = transitionIndexesBySnapshotId.get(snapshotId) ?? []; + indexes.push(index); + transitionIndexesBySnapshotId.set(snapshotId, indexes); + } + } + const exposuresByTransition = inputs.map(() => new Set()); + let complete = true; + for (const registered of listOpenClawRegisteredAgentDatabases({ + ...options, + includeIncompatibleSchemaVersions: true, + })) { + try { + const read = withOpenClawAgentDatabaseReadOnly( + ({ db: database }) => { + const exposureIds: Array> = []; + const db = getNodeSqliteKysely(database); + for (const snapshotIds of chunks( + [...transitionIndexesBySnapshotId.keys()], + SQLITE_IN_VALUES_LIMIT, + )) { + for (const row of executeSqliteQuerySync( + database, + db + .selectFrom("memory_preoutput_exposure_enterprise_memberships") + .select(["exposure_set_id", "snapshot_id"]) + .where("snapshot_id", "in", snapshotIds), + ).rows) { + exposureIds.push( + Object.freeze({ + exposureSetId: row.exposure_set_id, + snapshotId: row.snapshot_id, + }), + ); + } + } + return exposureIds; + }, + { agentId: registered.agentId, path: registered.path, env: options.env }, + ); + if (!read.found) { + complete = false; + continue; + } + for (const exposure of read.value) { + for (const index of transitionIndexesBySnapshotId.get(exposure.snapshotId) ?? []) { + exposuresByTransition[index]!.add(exposure.exposureSetId); + } + } + } catch { + complete = false; + } + } + return Object.freeze( + inputs.map((input, index) => + Object.freeze({ + ...input.transition, + exposureCount: exposuresByTransition[index]!.size, + complete, + }), + ), + ); +} diff --git a/src/state/memory-enterprise-verifier.test.ts b/src/state/memory-enterprise-verifier.test.ts new file mode 100644 index 000000000000..8e7e0ad60840 --- /dev/null +++ b/src/state/memory-enterprise-verifier.test.ts @@ -0,0 +1,401 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { EnterpriseIdentityProviderAdapter } from "../plugins/enterprise-identity-provider-types.js"; +import { + clearEnterpriseOidcJwksCacheForTest, + verifyEnterpriseOidcIdentity, +} from "./memory-enterprise-verifier.js"; + +const now = 1_000_000; +const pair = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const publicJwk = pair.publicKey.export({ format: "jwk" }); +const adapter: EnterpriseIdentityProviderAdapter = { + providerPrefix: "entra", + authorities: [ + { + issuer: "https://login.example/tenant-a/v2.0", + tenantId: "tenant-a", + audiences: ["openclaw-memory"], + jwksUri: "https://login.example/tenant-a/keys", + algorithm: "RS256", + tenantBinding: { kind: "claim", claim: "tid", value: "tenant-a" }, + assurance: { maxAuthenticationAgeMs: 60_000, requiredAmrValues: ["mfa"] }, + authorizationCodeFlow: { + clientId: "openclaw-memory", + authorizationEndpoint: "https://login.example/tenant-a/authorize", + tokenEndpoint: "https://login.example/tenant-a/token", + redirectUri: "https://gateway.example/memory/oidc/callback", + scopes: ["openid"], + }, + membership: { + kind: "oidc-claim", + claim: "groups", + required: true, + roleGroupIds: ["writers", "admins"], + maxGroups: 200, + incompleteIndicators: [ + { kind: "truthy-claim", claim: "hasgroups" }, + { kind: "nested-key", claim: "_claim_names", key: "groups" }, + ], + }, + maxSnapshotAgeMs: 60_000, + }, + ], +}; + +const workspaceAdapter: EnterpriseIdentityProviderAdapter = { + providerPrefix: "google-workspace", + authorities: [ + { + issuer: "https://accounts.google.com", + tenantId: "example.com", + audiences: ["workspace-memory-client"], + jwksUri: "https://accounts.google.com/keys", + algorithm: "RS256", + tenantBinding: { kind: "issuer", tenantId: "example.com" }, + assurance: { maxAuthenticationAgeMs: 60_000 }, + authorizationCodeFlow: { + clientId: "workspace-memory-client", + authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + tokenEndpoint: "https://oauth2.googleapis.com/token", + redirectUri: "https://gateway.example/memory/oidc/callback", + scopes: ["openid", "email"], + }, + requiredClaims: [ + { claim: "email_verified", value: true }, + { claim: "hd", value: "example.com" }, + ], + membership: { + kind: "google-workspace-directory", + verifiedEmailClaim: "email", + roleGroupResourceNames: ["groups/role-writers"], + maxGroups: 100, + }, + maxSnapshotAgeMs: 60_000, + }, + ], + acquireDirectoryAccessToken: async () => ({ + kind: "available", + accessToken: "test-only-access-token", + }), +}; + +function token(claims: Record, key = pair.privateKey, keyId = "key-a"): string { + const header = Buffer.from(JSON.stringify({ alg: "RS256", kid: keyId, typ: "JWT" })).toString( + "base64url", + ); + const payload = Buffer.from( + JSON.stringify({ + iss: adapter.authorities[0]!.issuer, + aud: "openclaw-memory", + tid: "tenant-a", + sub: "alice-upstream", + groups: ["writers", "admins"], + nonce: "entra-nonce", + auth_time: Math.floor((now - 2_000) / 1_000), + amr: ["pwd", "mfa"], + iat: Math.floor((now - 1_000) / 1_000), + exp: Math.floor((now + 30_000) / 1_000), + ...claims, + }), + ).toString("base64url"); + const input = `${header}.${payload}`; + return `${input}.${sign("RSA-SHA256", Buffer.from(input), key).toString("base64url")}`; +} + +function verifyToken(value: string) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ keys: [{ ...publicJwk, kid: "key-a", use: "sig" }] }), + })), + ); + return verifyEnterpriseOidcIdentity({ + adapter, + token: value, + expectedNonce: "entra-nonce", + now, + }); +} + +function workspaceToken(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "RS256", kid: "key-a", typ: "JWT" })).toString( + "base64url", + ); + const payload = Buffer.from( + JSON.stringify({ + iss: "https://accounts.google.com", + aud: "workspace-memory-client", + sub: "google-subject", + email: "alice@example.com", + email_verified: true, + hd: "example.com", + nonce: "workspace-nonce", + auth_time: Math.floor((now - 2_000) / 1_000), + iat: Math.floor((now - 1_000) / 1_000), + exp: Math.floor((now + 30_000) / 1_000), + ...claims, + }), + ).toString("base64url"); + const input = `${header}.${payload}`; + return `${input}.${sign("RSA-SHA256", Buffer.from(input), pair.privateKey).toString("base64url")}`; +} + +afterEach(() => { + clearEnterpriseOidcJwksCacheForTest(); + vi.unstubAllGlobals(); +}); + +describe("enterprise OIDC identity verification", () => { + it("constructs facts only after core validates the signed issuer, audience, tenant, expiry, and freshness", async () => { + await expect(verifyToken(token({}))).resolves.toMatchObject({ + kind: "verified", + identity: { + providerId: "entra", + subject: "alice-upstream", + groups: ["admins", "writers"], + expiresAt: now + 30_000, + }, + }); + }); + + it.each([ + ["wrong-issuer", { iss: "https://attacker.example/tenant-a" }, "wrong-issuer"], + ["wrong-audience", { aud: "somewhere-else" }, "wrong-audience"], + ["wrong-tenant", { tid: "tenant-b" }, "wrong-tenant"], + ["expired", { exp: Math.floor((now - 1) / 1_000) }, "expired"], + ["stale", { iat: Math.floor((now - 61_000) / 1_000) }, "stale-snapshot"], + ])("denies %s claims", async (_name, claims, reason) => { + await expect(verifyToken(token(claims))).resolves.toEqual({ kind: "denied", reason }); + }); + + it("rejects a multi-audience token whose authorized party is not the configured client", async () => { + await expect( + verifyToken( + token({ + aud: ["openclaw-memory", "another-client"], + azp: "another-client", + }), + ), + ).resolves.toEqual({ kind: "denied", reason: "wrong-audience" }); + }); + + it("rejects malformed mixed-type audience arrays before authorized-party validation", async () => { + await expect( + verifyToken( + token({ + aud: ["openclaw-memory", 7], + }), + ), + ).resolves.toEqual({ kind: "denied", reason: "wrong-audience" }); + }); + + it("denies a valid-looking token whose signature was not made by the registered key", async () => { + const other = generateKeyPairSync("rsa", { modulusLength: 2048 }); + await expect(verifyToken(token({}, other.privateKey))).resolves.toEqual({ + kind: "denied", + reason: "invalid-signature", + }); + }); + + it("fails closed when the registered provider cannot supply its signing keys", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, json: async () => ({}) })), + ); + await expect( + verifyEnterpriseOidcIdentity({ + adapter, + token: token({}), + expectedNonce: "entra-nonce", + now, + }), + ).resolves.toEqual({ kind: "denied", reason: "provider-unavailable" }); + }); + + it("refreshes cached signing keys once when an issuer rotates to a new key id", async () => { + const rotated = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const rotatedJwk = rotated.publicKey.export({ format: "jwk" }); + const fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ keys: [{ ...publicJwk, kid: "key-a", use: "sig" }] }), + })); + vi.stubGlobal("fetch", fetch); + await expect( + verifyEnterpriseOidcIdentity({ + adapter, + token: token({}), + expectedNonce: "entra-nonce", + now, + }), + ).resolves.toMatchObject({ kind: "verified" }); + + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + keys: [ + { ...publicJwk, kid: "key-a", use: "sig" }, + { ...rotatedJwk, kid: "key-b", use: "sig" }, + ], + }), + }); + await expect( + verifyEnterpriseOidcIdentity({ + adapter, + token: token({}, rotated.privateKey, "key-b"), + expectedNonce: "entra-nonce", + now, + }), + ).resolves.toMatchObject({ kind: "verified" }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("requires the exact one-time Gateway nonce when a transaction binds the token", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ keys: [{ ...publicJwk, kid: "key-a", use: "sig" }] }), + })), + ); + await expect( + verifyEnterpriseOidcIdentity({ + adapter, + token: token({ nonce: "issued-nonce" }), + expectedNonce: "other-nonce", + now, + }), + ).resolves.toEqual({ kind: "denied", reason: "wrong-nonce" }); + // TypeScript callers cannot omit expectedNonce; this guards the JavaScript + // boundary too, so a future direct-token path cannot silently reopen it. + await expect( + verifyEnterpriseOidcIdentity({ adapter, token: token({}), now } as never), + ).resolves.toEqual({ kind: "denied", reason: "wrong-nonce" }); + }); + + it.each([ + ["missing groups", { groups: undefined }, "incomplete-membership-snapshot"], + ["Entra overage flag", { hasgroups: true }, "incomplete-membership-snapshot"], + [ + "Entra distributed groups", + { _claim_names: { groups: "src1" } }, + "incomplete-membership-snapshot", + ], + ["non-array groups", { groups: "src1" }, "incomplete-membership-snapshot"], + ["missing authentication time", { auth_time: undefined }, "wrong-assurance"], + ["stale authentication", { auth_time: Math.floor((now - 61_000) / 1_000) }, "wrong-assurance"], + ["wrong authentication method", { amr: ["pwd"] }, "wrong-assurance"], + ])( + "denies %s rather than admitting an incomplete or weak snapshot", + async (_name, claims, reason) => { + await expect(verifyToken(token(claims))).resolves.toEqual({ kind: "denied", reason }); + }, + ); + + it("uses a core-owned Cloud Identity request for Workspace membership and retains only configured group ids", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: URL | string) => { + if (String(input) === "https://accounts.google.com/keys") { + return { + ok: true, + json: async () => ({ keys: [{ ...publicJwk, kid: "key-a", use: "sig" }] }), + }; + } + expect(String(input)).toContain( + "https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchTransitiveGroups", + ); + return { + ok: true, + json: async () => ({ + memberships: [{ group: "groups/role-writers" }, { group: "groups/unconfigured" }], + }), + }; + }), + ); + + await expect( + verifyEnterpriseOidcIdentity({ + adapter: workspaceAdapter, + token: workspaceToken({}), + expectedNonce: "workspace-nonce", + now, + }), + ).resolves.toMatchObject({ + kind: "verified", + identity: { + providerId: "google-workspace", + subject: "google-subject", + groups: ["groups/role-writers"], + }, + }); + }); + + it("scopes a configured Workspace directory lookup to its canonical customer", async () => { + const workspaceAuthority = workspaceAdapter.authorities[0]!; + if (workspaceAuthority.membership.kind !== "google-workspace-directory") { + throw new Error("workspace test fixture must use a directory membership authority"); + } + const customerScopedAdapter: EnterpriseIdentityProviderAdapter = { + ...workspaceAdapter, + authorities: [ + { + ...workspaceAuthority, + membership: { + ...workspaceAuthority.membership, + customerId: "C012345", + }, + }, + ], + }; + vi.stubGlobal( + "fetch", + vi.fn(async (input: URL | string) => { + if (String(input) === "https://accounts.google.com/keys") { + return { + ok: true, + json: async () => ({ keys: [{ ...publicJwk, kid: "key-a", use: "sig" }] }), + }; + } + const url = new URL(String(input)); + expect(url.searchParams.get("query")).toContain("parent == 'customers/C012345'"); + return { ok: true, json: async () => ({ memberships: [] }) }; + }), + ); + + await expect( + verifyEnterpriseOidcIdentity({ + adapter: customerScopedAdapter, + token: workspaceToken({}), + expectedNonce: "workspace-nonce", + now, + }), + ).resolves.toMatchObject({ kind: "verified", identity: { groups: [] } }); + }); + + it.each([ + ["unverified email", { email_verified: false }, "missing-required-claim"], + ["wrong Workspace domain", { hd: "attacker.example" }, "missing-required-claim"], + ["missing verified email", { email: undefined }, "incomplete-membership-snapshot"], + ])( + "denies Workspace %s before directory membership can be admitted", + async (_name, claims, reason) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ keys: [{ ...publicJwk, kid: "key-a", use: "sig" }] }), + })), + ); + await expect( + verifyEnterpriseOidcIdentity({ + adapter: workspaceAdapter, + token: workspaceToken(claims), + expectedNonce: "workspace-nonce", + now, + }), + ).resolves.toEqual({ kind: "denied", reason }); + }, + ); +}); diff --git a/src/state/memory-enterprise-verifier.ts b/src/state/memory-enterprise-verifier.ts new file mode 100644 index 000000000000..b9acc1c65c4c --- /dev/null +++ b/src/state/memory-enterprise-verifier.ts @@ -0,0 +1,547 @@ +import { createHash, createPublicKey, verify } from "node:crypto"; +import type { + EnterpriseIdentityProviderAdapter, + EnterpriseIdentityProviderAuthority, + EnterpriseIdentityMembershipClaim, + EnterpriseIdentityDirectoryAccessTokenResult, +} from "../plugins/enterprise-identity-provider-types.js"; +import { + persistMemoryEnterpriseIdentity, + type MemoryEnterpriseMembershipSnapshot, + type MemoryEnterprisePrincipal, +} from "./memory-enterprise-identity.js"; +import type { OpenClawStateDatabaseOptions } from "./openclaw-state-db.js"; + +type JsonRecord = Record; +const MAX_ENTERPRISE_GROUPS_PER_SNAPSHOT = 1_000; +const MAX_DIRECTORY_MEMBERSHIP_PAGES = 100; +const JWKS_CACHE_TTL_MS = 5 * 60_000; +const jwksByUri = new Map>(); + +/** Test lifecycle hook; production authority snapshots deliberately keep the bounded JWKS cache. */ +export function clearEnterpriseOidcJwksCacheForTest(): void { + jwksByUri.clear(); +} + +// Node's WebCrypto `JsonWebKey` intentionally omits JOSE's key-id extension; +// the issuer's JWKS contract supplies it and we use it only for key selection. +export type EnterpriseOidcJsonWebKey = JsonWebKey & Readonly<{ kid?: string }>; + +export type EnterpriseOidcJwks = Readonly<{ keys: readonly EnterpriseOidcJsonWebKey[] }>; + +export type VerifiedEnterpriseOidcIdentity = Readonly<{ + providerId: string; + issuer: string; + tenant: string; + subject: string; + groups: readonly string[]; + evidenceRevision: string; + observedAt: number; + expiresAt: number; +}>; + +export type EnterpriseOidcVerification = + | Readonly<{ kind: "verified"; identity: VerifiedEnterpriseOidcIdentity }> + | Readonly<{ + kind: "denied"; + reason: + | "malformed-token" + | "unsupported-algorithm" + | "unknown-key" + | "invalid-signature" + | "wrong-issuer" + | "wrong-audience" + | "wrong-tenant" + | "wrong-nonce" + | "missing-required-claim" + | "wrong-assurance" + | "expired" + | "not-yet-valid" + | "stale-snapshot" + | "missing-subject" + | "invalid-groups" + | "incomplete-membership-snapshot" + | "provider-unavailable"; + }>; + +export type PersistedEnterpriseOidcIdentity = Readonly<{ + principal: MemoryEnterprisePrincipal; + memberships: readonly MemoryEnterpriseMembershipSnapshot[]; +}>; + +function base64urlJson(value: string): JsonRecord | undefined { + try { + const parsed: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as JsonRecord) + : undefined; + } catch { + return undefined; + } +} + +function readText(record: JsonRecord, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.trim() ? value : undefined; +} + +function readTimestamp(record: JsonRecord, key: string): number | undefined { + const seconds = record[key]; + return typeof seconds === "number" && Number.isSafeInteger(seconds) && seconds >= 0 + ? seconds * 1_000 + : undefined; +} + +function readStringArray(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry.trim())) { + return undefined; + } + return Object.freeze([...new Set(value)].toSorted()); +} + +function readAudiences(value: unknown): readonly string[] | undefined { + if (typeof value === "string" && value.trim()) { + return Object.freeze([value]); + } + return readStringArray(value); +} + +function hasAuthorizedParty( + audiences: readonly string[], + claims: JsonRecord, + authority: EnterpriseIdentityProviderAuthority, +): boolean { + const clientId = authority.authorizationCodeFlow.clientId; + if (!audiences.includes(clientId)) { + return false; + } + const azp = readText(claims, "azp"); + if (azp !== undefined && azp !== clientId) { + return false; + } + return audiences.length <= 1 || azp === clientId; +} + +function readGroups( + value: unknown, + maxGroups: number, + roleGroupIds: readonly string[], +): readonly string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + if (value.length > Math.min(maxGroups, MAX_ENTERPRISE_GROUPS_PER_SNAPSHOT)) { + return undefined; + } + const allowedGroups = new Set(roleGroupIds); + const groups = new Set(); + for (const entry of value) { + if (typeof entry !== "string" || !entry.trim()) { + return undefined; + } + if (allowedGroups.has(entry)) { + groups.add(entry); + } + } + return Object.freeze([...groups].toSorted()); +} + +function hasIncompleteMembershipIndicator( + claims: JsonRecord, + membership: EnterpriseIdentityMembershipClaim, +): boolean { + return (membership.incompleteIndicators ?? []).some((indicator) => { + const value = claims[indicator.claim]; + if (indicator.kind === "truthy-claim") { + return Boolean(value); + } + return Boolean( + value && + typeof value === "object" && + !Array.isArray(value) && + (value as JsonRecord)[indicator.key], + ); + }); +} + +type MembershipResolution = + | Readonly<{ kind: "verified"; groups: readonly string[] }> + | Readonly<{ + kind: "denied"; + reason: "invalid-groups" | "incomplete-membership-snapshot" | "provider-unavailable"; + }>; + +async function resolveGoogleWorkspaceDirectoryGroups(params: { + adapter: EnterpriseIdentityProviderAdapter; + authority: EnterpriseIdentityProviderAuthority; + claims: JsonRecord; +}): Promise { + const membership = params.authority.membership; + if (membership.kind !== "google-workspace-directory") { + throw new Error("Google Workspace membership resolution requires directory authority"); + } + const email = readText(params.claims, membership.verifiedEmailClaim); + if (!email || !params.adapter.acquireDirectoryAccessToken) { + return { kind: "denied", reason: "incomplete-membership-snapshot" }; + } + let access: EnterpriseIdentityDirectoryAccessTokenResult; + try { + access = await params.adapter.acquireDirectoryAccessToken(); + } catch { + return { kind: "denied", reason: "provider-unavailable" }; + } + if (access.kind !== "available" || !access.accessToken.trim()) { + return { kind: "denied", reason: "provider-unavailable" }; + } + const allowedGroups = new Set(membership.roleGroupResourceNames); + const groups = new Set(); + let pageToken: string | undefined; + for (let page = 0; page < MAX_DIRECTORY_MEMBERSHIP_PAGES; page += 1) { + const url = new URL( + "https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchTransitiveGroups", + ); + url.searchParams.set( + "query", + `member_key_id == '${email.replaceAll("'", "\\'")}' && 'cloudidentity.googleapis.com/groups.discussion_forum' in labels`, + ); + if (membership.customerId) { + url.searchParams.set( + "query", + `${url.searchParams.get("query")} && parent == 'customers/${membership.customerId}'`, + ); + } + url.searchParams.set("pageSize", "1000"); + if (pageToken) { + url.searchParams.set("pageToken", pageToken); + } + let response: Response; + try { + response = await fetch(url, { + headers: { accept: "application/json", authorization: `Bearer ${access.accessToken}` }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + } catch { + return { kind: "denied", reason: "provider-unavailable" }; + } + if (!response.ok) { + return { kind: "denied", reason: "provider-unavailable" }; + } + let payload: unknown; + try { + payload = await response.json(); + } catch { + return { kind: "denied", reason: "invalid-groups" }; + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return { kind: "denied", reason: "invalid-groups" }; + } + const record = payload as JsonRecord; + const memberships = record.memberships; + if (!Array.isArray(memberships) || memberships.length > 1_000) { + return { kind: "denied", reason: "invalid-groups" }; + } + for (const entry of memberships) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return { kind: "denied", reason: "invalid-groups" }; + } + const group = (entry as JsonRecord).group; + if (typeof group !== "string" || !group.startsWith("groups/")) { + return { kind: "denied", reason: "invalid-groups" }; + } + if (allowedGroups.has(group)) { + groups.add(group); + if ( + groups.size > membership.maxGroups || + groups.size > MAX_ENTERPRISE_GROUPS_PER_SNAPSHOT + ) { + return { kind: "denied", reason: "invalid-groups" }; + } + } + } + const nextPageToken = record.nextPageToken; + if (nextPageToken === undefined) { + return { kind: "verified", groups: Object.freeze([...groups].toSorted()) }; + } + if (typeof nextPageToken !== "string" || !nextPageToken) { + return { kind: "denied", reason: "invalid-groups" }; + } + pageToken = nextPageToken; + } + return { kind: "denied", reason: "invalid-groups" }; +} + +function hasRequiredClaims( + claims: JsonRecord, + requiredClaims: readonly { claim: string; value: string | boolean }[] | undefined, +): boolean { + return (requiredClaims ?? []).every((required) => claims[required.claim] === required.value); +} + +function hasRequiredAssurance( + claims: JsonRecord, + authority: EnterpriseIdentityProviderAuthority, + now: number, +): boolean { + const authenticatedAt = readTimestamp(claims, "auth_time"); + if ( + authenticatedAt === undefined || + authenticatedAt > now || + now - authenticatedAt > authority.assurance.maxAuthenticationAgeMs + ) { + return false; + } + const acceptedAcrValues = authority.assurance.acceptedAcrValues ?? []; + if (acceptedAcrValues.length > 0 && !acceptedAcrValues.includes(readText(claims, "acr") ?? "")) { + return false; + } + const requiredAmrValues = authority.assurance.requiredAmrValues ?? []; + const amr = readStringArray(claims.amr); + return ( + requiredAmrValues.length === 0 || + Boolean(amr && requiredAmrValues.every((value) => amr.includes(value))) + ); +} + +function selectAuthority( + adapter: EnterpriseIdentityProviderAdapter, + issuer: string, +): EnterpriseIdentityProviderAuthority | undefined { + return adapter.authorities.find( + (authority) => authority.issuer === issuer || authority.acceptedIssuerAliases?.includes(issuer), + ); +} + +async function resolveRegisteredJwks( + jwksUri: string, + now: number, + forceRefresh = false, +): Promise { + const cached = jwksByUri.get(jwksUri); + if (!forceRefresh && cached && cached.expiresAt > now) { + return cached.jwks; + } + try { + const response = await fetch(jwksUri, { + headers: { accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + return undefined; + } + const parsed: unknown = await response.json(); + if ( + !parsed || + typeof parsed !== "object" || + !Array.isArray((parsed as { keys?: unknown }).keys) + ) { + return undefined; + } + const jwks = Object.freeze({ keys: Object.freeze((parsed as EnterpriseOidcJwks).keys) }); + jwksByUri.set(jwksUri, Object.freeze({ jwks, expiresAt: now + JWKS_CACHE_TTL_MS })); + return jwks; + } catch { + return undefined; + } +} + +/** + * Core owns all claim validation and signature verification. An adapter only + * contributes sealed static authority metadata and cannot construct a fact. + */ +export async function verifyEnterpriseOidcIdentity(params: { + adapter: EnterpriseIdentityProviderAdapter; + token: string; + /** One-time Gateway transaction nonce. Enterprise admission has no token-only path. */ + expectedNonce: string; + now?: number; +}): Promise { + const token = params.token.trim(); + const parts = token.split("."); + if (parts.length !== 3 || parts.some((part) => !part)) { + return { kind: "denied", reason: "malformed-token" }; + } + const [encodedHeader, encodedClaims, encodedSignature] = parts as [string, string, string]; + const header = base64urlJson(encodedHeader); + const claims = base64urlJson(encodedClaims); + if (!header || !claims) { + return { kind: "denied", reason: "malformed-token" }; + } + if (header.alg !== "RS256") { + return { kind: "denied", reason: "unsupported-algorithm" }; + } + const issuer = readText(claims, "iss"); + if (!issuer) { + return { kind: "denied", reason: "wrong-issuer" }; + } + const authority = selectAuthority(params.adapter, issuer); + if (!authority || authority.algorithm !== "RS256") { + return { kind: "denied", reason: "wrong-issuer" }; + } + const audiences = readAudiences(claims.aud); + if ( + !audiences || + !audiences.some((audience) => authority.audiences.includes(audience)) || + !hasAuthorizedParty(audiences, claims, authority) + ) { + return { kind: "denied", reason: "wrong-audience" }; + } + const tenantMatches = + authority.tenantBinding.kind === "issuer" + ? true + : readText(claims, authority.tenantBinding.claim) === authority.tenantBinding.value; + if (!tenantMatches) { + return { kind: "denied", reason: "wrong-tenant" }; + } + const now = params.now ?? Date.now(); + const expiresAt = readTimestamp(claims, "exp"); + const issuedAt = readTimestamp(claims, "iat"); + const notBefore = readTimestamp(claims, "nbf"); + if (!expiresAt || !issuedAt) { + return { kind: "denied", reason: "expired" }; + } + if (expiresAt <= now) { + return { kind: "denied", reason: "expired" }; + } + if (notBefore !== undefined && notBefore > now) { + return { kind: "denied", reason: "not-yet-valid" }; + } + if (issuedAt > now || now - issuedAt > authority.maxSnapshotAgeMs) { + return { kind: "denied", reason: "stale-snapshot" }; + } + const keyId = readText(header, "kid"); + if (!keyId) { + return { kind: "denied", reason: "unknown-key" }; + } + let jwks = await resolveRegisteredJwks(authority.jwksUri, now); + if (!jwks) { + return { kind: "denied", reason: "provider-unavailable" }; + } + let key = jwks.keys.find( + (candidate) => + candidate.kid === keyId && + candidate.kty === "RSA" && + candidate.use !== "enc" && + (candidate.alg === undefined || candidate.alg === "RS256") && + (candidate.key_ops === undefined || candidate.key_ops.includes("verify")), + ); + // A cached JWKS can legitimately predate a key rotation. Refresh once for + // an unknown key id, then fail closed rather than broadening key selection. + if (!key) { + jwks = await resolveRegisteredJwks(authority.jwksUri, now, true); + key = jwks?.keys.find( + (candidate) => + candidate.kid === keyId && + candidate.kty === "RSA" && + candidate.use !== "enc" && + (candidate.alg === undefined || candidate.alg === "RS256") && + (candidate.key_ops === undefined || candidate.key_ops.includes("verify")), + ); + } + if (!key) { + return { kind: "denied", reason: "unknown-key" }; + } + let validSignature = false; + try { + validSignature = verify( + "RSA-SHA256", + Buffer.from(`${encodedHeader}.${encodedClaims}`, "utf8"), + createPublicKey({ key, format: "jwk" }), + Buffer.from(encodedSignature, "base64url"), + ); + } catch { + return { kind: "denied", reason: "unknown-key" }; + } + if (!validSignature) { + return { kind: "denied", reason: "invalid-signature" }; + } + if (readText(claims, "nonce") !== params.expectedNonce) { + return { kind: "denied", reason: "wrong-nonce" }; + } + const subject = readText(claims, "sub"); + if (!subject) { + return { kind: "denied", reason: "missing-subject" }; + } + if (!hasRequiredClaims(claims, authority.requiredClaims)) { + return { kind: "denied", reason: "missing-required-claim" }; + } + if (!hasRequiredAssurance(claims, authority, now)) { + return { kind: "denied", reason: "wrong-assurance" }; + } + const groups = + authority.membership.kind === "google-workspace-directory" + ? await resolveGoogleWorkspaceDirectoryGroups({ adapter: params.adapter, authority, claims }) + : (() => { + if (hasIncompleteMembershipIndicator(claims, authority.membership)) { + return { kind: "denied", reason: "incomplete-membership-snapshot" } as const; + } + const groupValue = claims[authority.membership.claim]; + if (groupValue === undefined && authority.membership.required) { + return { kind: "denied", reason: "incomplete-membership-snapshot" } as const; + } + if (groupValue !== undefined && !Array.isArray(groupValue)) { + return { kind: "denied", reason: "incomplete-membership-snapshot" } as const; + } + const resolved = + groupValue === undefined + ? Object.freeze([]) + : readGroups( + groupValue, + authority.membership.maxGroups, + authority.membership.roleGroupIds, + ); + return resolved + ? ({ kind: "verified", groups: resolved } as const) + : ({ kind: "denied", reason: "invalid-groups" } as const); + })(); + if (groups.kind !== "verified") { + return groups; + } + // The digest is an opaque revision token. Raw claims and bearer JWT bytes + // never cross into SQLite, audit output, or a memory authorization context. + const evidenceRevision = `oidc1_${createHash("sha256") + .update(`${encodedHeader}.${encodedClaims}`) + .digest("base64url")}`; + return { + kind: "verified", + identity: Object.freeze({ + providerId: params.adapter.providerPrefix, + issuer: authority.issuer, + tenant: authority.tenantId, + subject, + groups: groups.groups, + evidenceRevision, + observedAt: now, + expiresAt: Math.min( + expiresAt, + issuedAt + authority.maxSnapshotAgeMs, + readTimestamp(claims, "auth_time")! + authority.assurance.maxAuthenticationAgeMs, + ), + }), + }; +} + +/** + * The verifier is the sole state writer for upstream identity claims. It + * persists reduced principal and membership evidence only after core has + * accepted the signature and every authority-bound claim above. + */ +export function persistVerifiedEnterpriseOidcIdentity(params: { + identity: VerifiedEnterpriseOidcIdentity; + options?: OpenClawStateDatabaseOptions; +}): PersistedEnterpriseOidcIdentity { + return persistMemoryEnterpriseIdentity({ + verified: { + providerId: params.identity.providerId, + issuer: params.identity.issuer, + tenant: params.identity.tenant, + subject: params.identity.subject, + evidenceRevision: params.identity.evidenceRevision, + observedAt: params.identity.observedAt, + expiresAt: params.identity.expiresAt, + }, + groups: params.identity.groups, + options: params.options, + }); +} diff --git a/src/state/memory-session-subject.test.ts b/src/state/memory-session-subject.test.ts index 9853e344fdf4..722562b40662 100644 --- a/src/state/memory-session-subject.test.ts +++ b/src/state/memory-session-subject.test.ts @@ -818,6 +818,7 @@ describe("memory session subject", () => { bindingId: binding.bindingId, options: agentOptions, }); + let externalFactsCurrent = true; const facts = captureTrustedMemoryAccessFacts({ requestId: "request-1", runId: "run-1", @@ -844,6 +845,7 @@ describe("memory session subject", () => { egressCapabilityIds: ["reply.final"], egressRegistryRevision: "egress-1", }, + recheck: () => externalFactsCurrent, operation: "read", hostFactsRevision: "host-1", }); @@ -877,6 +879,9 @@ describe("memory session subject", () => { egressCapabilityIds: ["reply.final"], }, }); + externalFactsCurrent = false; + expect(materializeTrustedMemoryAccessContext(result.context)).toBeUndefined(); + externalFactsCurrent = true; const changedHostFacts = createTrustedMemoryAccessContext({ sessionKey: "agent:main:direct:dm", sessionId: "session-1", diff --git a/src/state/openclaw-agent-db.generated.d.ts b/src/state/openclaw-agent-db.generated.d.ts index e4eb2b2a7760..638e551e7752 100644 --- a/src/state/openclaw-agent-db.generated.d.ts +++ b/src/state/openclaw-agent-db.generated.d.ts @@ -174,90 +174,6 @@ export interface MemoryCompactionPolicySources { source_session_id: string; } -export interface MemoryPostboxItems { - agent_id: string; - content: string; - content_hash: string; - created_at: number; - item_id: string; - purged_at: number | null; - reviewed_at: number | null; - reviewed_by_principal_id: string | null; - sender_evidence_ref: string; - source_channel_ref: string; - source_handle_id: string; - state: string; - target_store_id: string; -} - -export interface MemoryPostboxRateLimits { - agent_id: string; - deposit_count: number; - source_channel_ref: string; - target_store_id: string; - updated_at: number; - window_started_at: number; -} - -export interface MemoryPostboxReviewedCopies { - agent_id: string; - created_at: number; - item_id: string; - resource_id: string; - reviewed_content_hash: string; - revision_id: string; -} - -export interface MemoryPostboxSettings { - agent_id: string; - mode: string; - updated_at: number; - updated_by_principal_id: string; -} - -export interface MemoryPostboxSourceHandles { - agent_id: string; - created_at: number; - expires_at: number; - sender_evidence_ref: string; - source_channel_ref: string; - source_handle_id: string; - source_message_ref: string; - source_session_id: string; - target_store_id: string; - used_at: number | null; -} - -export interface MemoryProjectionTargets { - agent_id: string; - audience_id: string; - audience_kind: string; - configured_by_principal_id: string; - created_at: number; - store_id: string; -} - -export interface MemoryProjections { - agent_id: string; - copy_revision_id: string; - created_at: number; - expires_at: number | null; - expiry_audit_reason: string | null; - expiry_kind: string; - preview: string; - projection_id: string; - publisher_principal_id: string; - purpose: string; - reviewed_by_principal_id: string; - revocation_behavior: string; - revoked_at: number | null; - source_revision_id: string; - state: string; - target_audience_id: string; - target_audience_kind: string; - target_store_id: string; -} - export interface MemoryEmbeddingCache { dims: number | null; embedding: string; @@ -315,6 +231,14 @@ export interface MemoryIndexState { revision: number; } +export interface MemoryLineageEdges { + child_revision_id: string; + created_at: number; + parent_id: string; + parent_kind: string; + relation_kind: string; +} + export interface MemoryMigrations { classification_json: string; cutover_at: number | null; @@ -382,21 +306,79 @@ export interface MemoryPolicySets { policy_set_id: string; } -export interface MemoryRevisionPolicyRequirements { +export interface MemoryPostboxItems { + agent_id: string; + content: string; + content_hash: string; created_at: number; - expected_revision_id: string; - expected_revocation_epoch: number; - policy_id: string; - requirement_kind: string; + item_id: string; + purged_at: number | null; + reviewed_at: number | null; + reviewed_by_principal_id: string | null; + sender_evidence_ref: string; + source_channel_ref: string; + source_handle_id: string; + state: string; + target_store_id: string; +} + +export interface MemoryPostboxRateLimits { + agent_id: string; + deposit_count: number; + source_channel_ref: string; + target_store_id: string; + updated_at: number; + window_started_at: number; +} + +export interface MemoryPostboxReviewedCopies { + agent_id: string; + created_at: number; + item_id: string; + resource_id: string; + reviewed_content_hash: string; revision_id: string; } -export interface MemoryLineageEdges { - child_revision_id: string; +export interface MemoryPostboxSettings { + agent_id: string; + mode: string; + target_store_id: string; + updated_at: number; + updated_by_principal_id: string; +} + +export interface MemoryPostboxSourceHandles { + agent_id: string; created_at: number; - parent_id: string; - parent_kind: string; - relation_kind: string; + expires_at: number; + sender_evidence_ref: string; + source_channel_ref: string; + source_handle_id: string; + source_message_ref: string; + source_session_id: string; + target_store_id: string; + used_at: number | null; +} + +export interface MemoryPreoutputExposureAuthorizationFacts { + actor_evidence_json: string; + created_at: number; + delegation_snapshot_json: string; + exposure_set_id: string; + host_facts_revision: string; +} + +export interface MemoryPreoutputExposureEnterpriseMembershipSets { + created_at: number; + exposure_set_id: string; + snapshot_count: number; +} + +export interface MemoryPreoutputExposureEnterpriseMemberships { + created_at: number; + exposure_set_id: string; + snapshot_id: string; } export interface MemoryPreoutputExposureLedger { @@ -422,12 +404,34 @@ export interface MemoryPreoutputExposureLedger { subject_revision: string; } -export interface MemoryPreoutputExposureAuthorizationFacts { - actor_evidence_json: string; +export interface MemoryProjectionTargets { + agent_id: string; + audience_id: string; + audience_kind: string; + configured_by_principal_id: string; created_at: number; - delegation_snapshot_json: string; - exposure_set_id: string; - host_facts_revision: string; + store_id: string; +} + +export interface MemoryProjections { + agent_id: string; + copy_revision_id: string; + created_at: number; + expires_at: number | null; + expiry_audit_reason: string | null; + expiry_kind: string; + preview: string; + projection_id: string; + publisher_principal_id: string; + purpose: string; + reviewed_by_principal_id: string; + revocation_behavior: string; + revoked_at: number | null; + source_revision_id: string; + state: string; + target_audience_id: string; + target_audience_kind: string; + target_store_id: string; } export interface MemoryResourceRevisions { @@ -467,6 +471,15 @@ export interface MemoryResources { store_id: string; } +export interface MemoryRevisionPolicyRequirements { + created_at: number; + expected_revision_id: string; + expected_revocation_epoch: number; + policy_id: string; + requirement_kind: string; + revision_id: string; +} + export interface MemoryRunExposureResources { created_at: number; exposure_set_id: string; @@ -919,11 +932,6 @@ export interface DB { memory_audit_outbox: MemoryAuditOutbox; memory_compaction_policies: MemoryCompactionPolicies; memory_compaction_policy_sources: MemoryCompactionPolicySources; - memory_postbox_items: MemoryPostboxItems; - memory_postbox_rate_limits: MemoryPostboxRateLimits; - memory_postbox_reviewed_copies: MemoryPostboxReviewedCopies; - memory_postbox_settings: MemoryPostboxSettings; - memory_postbox_source_handles: MemoryPostboxSourceHandles; memory_embedding_cache: MemoryEmbeddingCache; memory_index_chunk_provenance: MemoryIndexChunkProvenance; memory_index_chunk_recall_metadata: MemoryIndexChunkRecallMetadata; @@ -931,21 +939,28 @@ export interface DB { memory_index_meta: MemoryIndexMeta; memory_index_sources: MemoryIndexSources; memory_index_state: MemoryIndexState; + memory_lineage_edges: MemoryLineageEdges; memory_migrations: MemoryMigrations; memory_policies: MemoryPolicies; memory_policy_entries: MemoryPolicyEntries; memory_policy_revisions: MemoryPolicyRevisions; memory_policy_set_members: MemoryPolicySetMembers; memory_policy_sets: MemoryPolicySets; + memory_postbox_items: MemoryPostboxItems; + memory_postbox_rate_limits: MemoryPostboxRateLimits; + memory_postbox_reviewed_copies: MemoryPostboxReviewedCopies; + memory_postbox_settings: MemoryPostboxSettings; + memory_postbox_source_handles: MemoryPostboxSourceHandles; + memory_preoutput_exposure_authorization_facts: MemoryPreoutputExposureAuthorizationFacts; + memory_preoutput_exposure_enterprise_membership_sets: MemoryPreoutputExposureEnterpriseMembershipSets; + memory_preoutput_exposure_enterprise_memberships: MemoryPreoutputExposureEnterpriseMemberships; + memory_preoutput_exposure_ledger: MemoryPreoutputExposureLedger; memory_projection_targets: MemoryProjectionTargets; memory_projections: MemoryProjections; - memory_revision_policy_requirements: MemoryRevisionPolicyRequirements; - memory_lineage_edges: MemoryLineageEdges; - memory_preoutput_exposure_authorization_facts: MemoryPreoutputExposureAuthorizationFacts; - memory_preoutput_exposure_ledger: MemoryPreoutputExposureLedger; memory_resource_revisions: MemoryResourceRevisions; memory_resource_subjects: MemoryResourceSubjects; memory_resources: MemoryResources; + memory_revision_policy_requirements: MemoryRevisionPolicyRequirements; memory_run_exposure_resources: MemoryRunExposureResources; memory_run_exposures: MemoryRunExposures; memory_scoped_chunk_vectors: MemoryScopedChunkVectors; diff --git a/src/state/openclaw-agent-schema.sql b/src/state/openclaw-agent-schema.sql index ba8965f9bc90..96ad960909d9 100644 --- a/src/state/openclaw-agent-schema.sql +++ b/src/state/openclaw-agent-schema.sql @@ -1144,6 +1144,52 @@ BEGIN SELECT RAISE(ABORT, 'pre-output exposure authorization facts cannot be deleted'); END; +-- Exact snapshot-to-exposure joins make enterprise revocation impact auditable +-- without storing groups, provider claims, resource titles, or memory content. +CREATE TABLE IF NOT EXISTS memory_preoutput_exposure_enterprise_membership_sets ( + exposure_set_id TEXT NOT NULL PRIMARY KEY, + snapshot_count INTEGER NOT NULL CHECK (snapshot_count >= 0), + created_at INTEGER NOT NULL, + FOREIGN KEY (exposure_set_id) + REFERENCES memory_preoutput_exposure_ledger(exposure_set_id) ON DELETE RESTRICT +) STRICT; + +CREATE TRIGGER IF NOT EXISTS memory_preoutput_exposure_enterprise_membership_sets_no_update +BEFORE UPDATE ON memory_preoutput_exposure_enterprise_membership_sets +BEGIN + SELECT RAISE(ABORT, 'pre-output exposure enterprise membership facts are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS memory_preoutput_exposure_enterprise_membership_sets_no_delete +BEFORE DELETE ON memory_preoutput_exposure_enterprise_membership_sets +BEGIN + SELECT RAISE(ABORT, 'pre-output exposure enterprise membership facts cannot be deleted'); +END; + +CREATE TABLE IF NOT EXISTS memory_preoutput_exposure_enterprise_memberships ( + exposure_set_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (exposure_set_id, snapshot_id), + FOREIGN KEY (exposure_set_id) + REFERENCES memory_preoutput_exposure_ledger(exposure_set_id) ON DELETE RESTRICT +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_memory_preoutput_exposure_enterprise_memberships_snapshot + ON memory_preoutput_exposure_enterprise_memberships(snapshot_id, exposure_set_id); + +CREATE TRIGGER IF NOT EXISTS memory_preoutput_exposure_enterprise_memberships_no_update +BEFORE UPDATE ON memory_preoutput_exposure_enterprise_memberships +BEGIN + SELECT RAISE(ABORT, 'pre-output exposure enterprise memberships are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS memory_preoutput_exposure_enterprise_memberships_no_delete +BEFORE DELETE ON memory_preoutput_exposure_enterprise_memberships +BEGIN + SELECT RAISE(ABORT, 'pre-output exposure enterprise memberships cannot be deleted'); +END; + CREATE TABLE IF NOT EXISTS transcript_event_memory_policies ( session_id TEXT NOT NULL, event_seq INTEGER NOT NULL, diff --git a/src/state/openclaw-agent-scoped-memory-schema.ts b/src/state/openclaw-agent-scoped-memory-schema.ts index 12e8ed628ff7..50f5cf8a7abf 100644 --- a/src/state/openclaw-agent-scoped-memory-schema.ts +++ b/src/state/openclaw-agent-scoped-memory-schema.ts @@ -24,6 +24,8 @@ export const AGENT_SCOPED_MEMORY_TABLES = [ "memory_run_exposure_resources", "memory_preoutput_exposure_ledger", "memory_preoutput_exposure_authorization_facts", + "memory_preoutput_exposure_enterprise_membership_sets", + "memory_preoutput_exposure_enterprise_memberships", "transcript_event_memory_policies", "transcript_event_memory_policy_details", "transcript_event_memory_policy_transitions", diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index 2183283598b5..a9d0d40b95b6 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -24,6 +24,15 @@ export const LAZY_ADDITIVE_STATE_TABLES = [ "memory_pairing_identity_receipts", "memory_principals", "memory_access_audit", + "memory_enterprise_access_decisions", + "memory_enterprise_evidence_transition_memberships", + "memory_enterprise_evidence_transition_profile_links", + "memory_enterprise_evidence_transitions", + "memory_enterprise_policy_drift_alerts", + "memory_enterprise_role_policy_observations", + "memory_enterprise_membership_snapshots", + "memory_enterprise_profile_links", + "memory_enterprise_principal_evidence", "sidebar_sections", "skill_workshop_proposal_events", "skill_workshop_proposal_origin_runs", @@ -40,6 +49,16 @@ export const LAZY_ADDITIVE_STATE_INDEXES = [ "idx_memory_principals_lookup", "idx_memory_principals_user_profile", "idx_memory_access_audit_agent_time", + "idx_memory_enterprise_access_decisions_subject_time", + "idx_memory_enterprise_evidence_transition_memberships_snapshot", + "idx_memory_enterprise_evidence_transition_profile_links_user", + "idx_memory_enterprise_evidence_transitions_principal", + "idx_memory_enterprise_policy_drift_alerts_subject_time", + "idx_memory_enterprise_membership_snapshots_current", + "idx_memory_enterprise_profile_links_active_enterprise", + "idx_memory_enterprise_profile_links_current_user", + "idx_memory_enterprise_principal_evidence_active_subject", + "idx_memory_enterprise_principal_evidence_principal", ] as const; /** Maximum time one synchronous SQLite call may wait for a lock. */ export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5_000; diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 9e2239b7053a..3acf4d297e79 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -876,6 +876,119 @@ export interface MemoryAccessAudit { subject_ref: string; } +export interface MemoryEnterpriseAccessDecisions { + actor_principal_id: string; + decision: string; + event_id: string; + membership_evidence_revision: string | null; + occurred_at: number; + operation: string; + policy_revision: string; + principal_evidence_revision: string; + provider_id: string; + reason_code: string; + received_at: number; + rule_ref: string; + subject_principal_id: string; + tenant_ref: string; +} + +export interface MemoryEnterpriseEvidenceTransitionMemberships { + created_at: number; + snapshot_id: string; + transition_id: string; +} + +export interface MemoryEnterpriseEvidenceTransitionProfileLinks { + created_at: number; + link_id: string; + transition_id: string; + user_principal_id: string; +} + +export interface MemoryEnterpriseEvidenceTransitions { + created_at: number; + kind: string; + principal_id: string; + provider_id: string; + revoked_at: number; + transition_id: string; +} + +export interface MemoryEnterpriseIdentityActions { + action_id: string; + actor_principal_id: string; + affected_identity_count: number; + affected_snapshot_count: number; + kind: string; + occurred_at: number; + provider_id: string; + target_user_principal_id: string; +} + +export interface MemoryEnterpriseMembershipSnapshots { + created_at: number; + evidence_revision: string; + expires_at: number; + group_ref: string; + observed_at: number; + principal_id: string; + provider_id: string; + revoked_at: number | null; + snapshot_id: string; + tenant_ref: string; +} + +export interface MemoryEnterprisePolicyDriftAlerts { + alert_id: string; + decision: string; + detected_at: number; + operation: string; + policy_id: string; + policy_revision: string; + previous_decision: string; + previous_policy_revision: string; + provider_id: string; + rule_ref: string; + subject_principal_id: string; + tenant_ref: string; +} + +export interface MemoryEnterprisePrincipalEvidence { + assurance: string; + evidence_revision: string; + expires_at: number; + issuer_ref: string; + observed_at: number; + principal_id: string; + provider_id: string; + revoked_at: number | null; + subject_ref: string; + tenant_ref: string; +} + +export interface MemoryEnterpriseProfileLinks { + created_at: number; + created_by_principal_id: string; + enterprise_principal_id: string; + link_id: string; + revision: string; + revoked_at: number | null; + user_principal_id: string; +} + +export interface MemoryEnterpriseRolePolicyObservations { + decision: string; + observed_at: number; + operation: string; + policy_id: string; + policy_revision: string; + provider_id: string; + rule_ref: string; + subject_principal_id: string; + tenant_ref: string; +} + export interface MemoryIdentityBindings { account_id: string; adapter_id: string; @@ -1731,6 +1844,16 @@ export interface DB { meeting_transcript_summaries: MeetingTranscriptSummaries; meeting_transcript_utterances: MeetingTranscriptUtterances; memory_access_audit: MemoryAccessAudit; + memory_enterprise_access_decisions: MemoryEnterpriseAccessDecisions; + memory_enterprise_evidence_transition_memberships: MemoryEnterpriseEvidenceTransitionMemberships; + memory_enterprise_evidence_transition_profile_links: MemoryEnterpriseEvidenceTransitionProfileLinks; + memory_enterprise_evidence_transitions: MemoryEnterpriseEvidenceTransitions; + memory_enterprise_identity_actions: MemoryEnterpriseIdentityActions; + memory_enterprise_membership_snapshots: MemoryEnterpriseMembershipSnapshots; + memory_enterprise_policy_drift_alerts: MemoryEnterprisePolicyDriftAlerts; + memory_enterprise_principal_evidence: MemoryEnterprisePrincipalEvidence; + memory_enterprise_profile_links: MemoryEnterpriseProfileLinks; + memory_enterprise_role_policy_observations: MemoryEnterpriseRolePolicyObservations; memory_identity_bindings: MemoryIdentityBindings; memory_pairing_identity_receipts: MemoryPairingIdentityReceipts; memory_principals: MemoryPrincipals; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 6813a8a3e028..a6e494873a85 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -287,6 +287,185 @@ CREATE INDEX IF NOT EXISTS idx_memory_pairing_identity_receipts_pending ON memory_pairing_identity_receipts(channel, account_id, request_identity_hmac, expires_at) WHERE consumed_at IS NULL; +-- Enterprise verifier evidence is feature-local. Provider identifiers are +-- manifest ids; every upstream issuer, tenant, subject, and group identifier +-- is reduced with the installation-local audit HMAC before it reaches SQLite. +CREATE TABLE IF NOT EXISTS memory_enterprise_principal_evidence ( + principal_id TEXT NOT NULL PRIMARY KEY, + provider_id TEXT NOT NULL, + issuer_ref TEXT NOT NULL, + tenant_ref TEXT NOT NULL, + subject_ref TEXT NOT NULL, + assurance TEXT NOT NULL CHECK (assurance IN ('oidc')), + evidence_revision TEXT NOT NULL, + observed_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + FOREIGN KEY (principal_id) REFERENCES memory_principals(principal_id) +) STRICT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_enterprise_principal_evidence_active_subject + ON memory_enterprise_principal_evidence(provider_id, tenant_ref, subject_ref) + WHERE revoked_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_memory_enterprise_principal_evidence_principal + ON memory_enterprise_principal_evidence(principal_id, revoked_at, expires_at); + +-- A membership fact is immutable evidence. A refresh records a new revision; +-- reads select only an observed, unrevoked, unexpired snapshot. +CREATE TABLE IF NOT EXISTS memory_enterprise_membership_snapshots ( + snapshot_id TEXT NOT NULL PRIMARY KEY, + principal_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + tenant_ref TEXT NOT NULL, + group_ref TEXT NOT NULL, + evidence_revision TEXT NOT NULL, + observed_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY (principal_id) REFERENCES memory_principals(principal_id), + UNIQUE (principal_id, provider_id, tenant_ref, group_ref, evidence_revision) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_memory_enterprise_membership_snapshots_current + ON memory_enterprise_membership_snapshots(principal_id, provider_id, tenant_ref, group_ref, expires_at) + WHERE revoked_at IS NULL; + +-- Refresh and revocation record the exact immutable evidence snapshots they +-- supersede. This does not infer impact from policy or resource revisions. +CREATE TABLE IF NOT EXISTS memory_enterprise_evidence_transitions ( + transition_id TEXT NOT NULL PRIMARY KEY, + principal_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('refresh', 'revoke')), + revoked_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (principal_id) REFERENCES memory_principals(principal_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_memory_enterprise_evidence_transitions_principal + ON memory_enterprise_evidence_transitions(principal_id, provider_id, revoked_at DESC); + +CREATE TRIGGER IF NOT EXISTS memory_enterprise_evidence_transitions_no_update +BEFORE UPDATE ON memory_enterprise_evidence_transitions +BEGIN + SELECT RAISE(ABORT, 'enterprise evidence transitions are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS memory_enterprise_evidence_transitions_no_delete +BEFORE DELETE ON memory_enterprise_evidence_transitions +BEGIN + SELECT RAISE(ABORT, 'enterprise evidence transitions cannot be deleted'); +END; + +CREATE TABLE IF NOT EXISTS memory_enterprise_evidence_transition_memberships ( + transition_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (transition_id, snapshot_id), + FOREIGN KEY (transition_id) + REFERENCES memory_enterprise_evidence_transitions(transition_id) ON DELETE RESTRICT +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_memory_enterprise_evidence_transition_memberships_snapshot + ON memory_enterprise_evidence_transition_memberships(snapshot_id, transition_id); + +CREATE TRIGGER IF NOT EXISTS memory_enterprise_evidence_transition_memberships_no_update +BEFORE UPDATE ON memory_enterprise_evidence_transition_memberships +BEGIN + SELECT RAISE(ABORT, 'enterprise evidence transition memberships are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS memory_enterprise_evidence_transition_memberships_no_delete +BEFORE DELETE ON memory_enterprise_evidence_transition_memberships +BEGIN + SELECT RAISE(ABORT, 'enterprise evidence transition memberships cannot be deleted'); +END; + +-- A lifecycle event belongs to the user profile linked at the moment the +-- event is recorded. It must not follow a later enterprise-profile relink. +CREATE TABLE IF NOT EXISTS memory_enterprise_evidence_transition_profile_links ( + transition_id TEXT NOT NULL PRIMARY KEY, + link_id TEXT NOT NULL, + user_principal_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (transition_id) + REFERENCES memory_enterprise_evidence_transitions(transition_id) ON DELETE RESTRICT, + FOREIGN KEY (link_id) + REFERENCES memory_enterprise_profile_links(link_id) ON DELETE RESTRICT, + FOREIGN KEY (user_principal_id) REFERENCES memory_principals(principal_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_memory_enterprise_evidence_transition_profile_links_user + ON memory_enterprise_evidence_transition_profile_links(user_principal_id, transition_id); + +CREATE TRIGGER IF NOT EXISTS memory_enterprise_evidence_transition_profile_links_no_update +BEFORE UPDATE ON memory_enterprise_evidence_transition_profile_links +BEGIN + SELECT RAISE(ABORT, 'enterprise evidence transition profile links are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS memory_enterprise_evidence_transition_profile_links_no_delete +BEFORE DELETE ON memory_enterprise_evidence_transition_profile_links +BEGIN + SELECT RAISE(ABORT, 'enterprise evidence transition profile links cannot be deleted'); +END; + +-- A Gateway user principal may be explicitly linked to one verified enterprise +-- principal. The link is an operator-owned association, not a session-member +-- record and not evidence that either principal is currently authorized. +CREATE TABLE IF NOT EXISTS memory_enterprise_profile_links ( + link_id TEXT NOT NULL PRIMARY KEY, + enterprise_principal_id TEXT NOT NULL, + user_principal_id TEXT NOT NULL, + created_by_principal_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + revoked_at INTEGER, + revision TEXT NOT NULL, + FOREIGN KEY (enterprise_principal_id) REFERENCES memory_principals(principal_id), + FOREIGN KEY (user_principal_id) REFERENCES memory_principals(principal_id), + FOREIGN KEY (created_by_principal_id) REFERENCES memory_principals(principal_id), + CHECK (enterprise_principal_id <> user_principal_id) +) STRICT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_enterprise_profile_links_active_enterprise + ON memory_enterprise_profile_links(enterprise_principal_id) + WHERE revoked_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_memory_enterprise_profile_links_current_user + ON memory_enterprise_profile_links(user_principal_id, revoked_at); + +-- Explicit enterprise identity controls are durable redacted operator evidence. +-- They retain only canonical principals and counts, never claims, groups, or memory content. +CREATE TABLE IF NOT EXISTS memory_enterprise_identity_actions ( + action_id TEXT NOT NULL PRIMARY KEY, + target_user_principal_id TEXT NOT NULL, + actor_principal_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('unlink', 'revoke')), + affected_identity_count INTEGER NOT NULL CHECK (affected_identity_count >= 0), + affected_snapshot_count INTEGER NOT NULL CHECK (affected_snapshot_count >= 0), + occurred_at INTEGER NOT NULL, + FOREIGN KEY (target_user_principal_id) REFERENCES memory_principals(principal_id), + FOREIGN KEY (actor_principal_id) REFERENCES memory_principals(principal_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_memory_enterprise_identity_actions_target_time + ON memory_enterprise_identity_actions(target_user_principal_id, occurred_at DESC, action_id); + +CREATE TRIGGER IF NOT EXISTS memory_enterprise_identity_actions_no_update +BEFORE UPDATE ON memory_enterprise_identity_actions +BEGIN + SELECT RAISE(ABORT, 'enterprise identity actions are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS memory_enterprise_identity_actions_no_delete +BEFORE DELETE ON memory_enterprise_identity_actions +BEGIN + SELECT RAISE(ABORT, 'enterprise identity actions cannot be deleted'); +END; + -- Redacted shared sink for durable memory decisions. It stores ids and hashes, -- never memory content, search terms, sender ids, or raw policy payloads. CREATE TABLE IF NOT EXISTS memory_access_audit ( @@ -308,6 +487,78 @@ CREATE TABLE IF NOT EXISTS memory_access_audit ( CREATE INDEX IF NOT EXISTS idx_memory_access_audit_agent_time ON memory_access_audit(agent_id, occurred_at DESC, event_id); +-- Redacted enterprise authorization decisions. This keeps durable access +-- evidence inspectable without retaining upstream claims, group names, or +-- memory content; tenant and rule references are already HMAC pseudonyms. +CREATE TABLE IF NOT EXISTS memory_enterprise_access_decisions ( + event_id TEXT NOT NULL PRIMARY KEY, + provider_id TEXT NOT NULL, + tenant_ref TEXT NOT NULL, + actor_principal_id TEXT NOT NULL, + subject_principal_id TEXT NOT NULL, + operation TEXT NOT NULL, + decision TEXT NOT NULL CHECK (decision IN ('allowed', 'denied', 'unavailable')), + reason_code TEXT NOT NULL, + rule_ref TEXT NOT NULL, + policy_revision TEXT NOT NULL, + principal_evidence_revision TEXT NOT NULL, + membership_evidence_revision TEXT, + occurred_at INTEGER NOT NULL, + received_at INTEGER NOT NULL +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_memory_enterprise_access_decisions_subject_time + ON memory_enterprise_access_decisions(subject_principal_id, occurred_at DESC, event_id); + +-- The selected memory plugin owns the actual policy evaluation. Core retains +-- only this redacted last-observation baseline and allow/deny revision flips. +CREATE TABLE IF NOT EXISTS memory_enterprise_role_policy_observations ( + provider_id TEXT NOT NULL, + tenant_ref TEXT NOT NULL, + subject_principal_id TEXT NOT NULL, + rule_ref TEXT NOT NULL, + policy_id TEXT NOT NULL, + operation TEXT NOT NULL, + policy_revision TEXT NOT NULL, + decision TEXT NOT NULL CHECK (decision IN ('allowed', 'denied', 'unavailable')), + observed_at INTEGER NOT NULL, + PRIMARY KEY (provider_id, tenant_ref, subject_principal_id, rule_ref, policy_id, operation) +) STRICT; + +CREATE TABLE IF NOT EXISTS memory_enterprise_policy_drift_alerts ( + alert_id TEXT NOT NULL PRIMARY KEY, + provider_id TEXT NOT NULL, + tenant_ref TEXT NOT NULL, + subject_principal_id TEXT NOT NULL, + rule_ref TEXT NOT NULL, + policy_id TEXT NOT NULL, + operation TEXT NOT NULL, + previous_policy_revision TEXT NOT NULL, + previous_decision TEXT NOT NULL CHECK (previous_decision IN ('allowed', 'denied')), + policy_revision TEXT NOT NULL, + decision TEXT NOT NULL CHECK (decision IN ('allowed', 'denied')), + detected_at INTEGER NOT NULL, + UNIQUE ( + provider_id, tenant_ref, subject_principal_id, rule_ref, policy_id, operation, + previous_policy_revision, previous_decision, policy_revision, decision + ) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_memory_enterprise_policy_drift_alerts_subject_time + ON memory_enterprise_policy_drift_alerts(subject_principal_id, detected_at DESC, alert_id); + +CREATE TRIGGER IF NOT EXISTS memory_enterprise_policy_drift_alerts_no_update +BEFORE UPDATE ON memory_enterprise_policy_drift_alerts +BEGIN + SELECT RAISE(ABORT, 'enterprise policy drift alerts are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS memory_enterprise_policy_drift_alerts_no_delete +BEFORE DELETE ON memory_enterprise_policy_drift_alerts +BEGIN + SELECT RAISE(ABORT, 'enterprise policy drift alerts cannot be deleted'); +END; + CREATE TABLE IF NOT EXISTS session_state_events ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, dedupe_key TEXT UNIQUE,