mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
feat(memory): add enterprise identity operations
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1332,6 +1332,7 @@
|
||||
"plugins/voice-call",
|
||||
"plugins/vault",
|
||||
"plugins/onepassword",
|
||||
"plugins/memory-enterprise-identity",
|
||||
"plugins/memory-wiki",
|
||||
"plugins/llama-cpp",
|
||||
"plugins/memory-lancedb",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/<id>` 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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 |
|
||||
|
||||
@@ -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<typeof createBuiltinScopedMemoryAuthorizedRuntime>[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<T>(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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AuthorizedMemoryPlan> {
|
||||
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<AuthorizedMemoryPlan> {
|
||||
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<AuthorizedMemoryResultEnvelope<readonly AuthorizedMemorySearchResult[]>> {
|
||||
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<AuthorizedMemoryResultEnvelope<readonly AuthorizedMemorySearchResult[]>> {
|
||||
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<AuthorizedMemoryResultEnvelope<MemoryReadResult>> {
|
||||
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<MemoryWriteResult> {
|
||||
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<AuthorizedMemoryResultEnvelope<MemoryReadResult>> {
|
||||
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<MemoryWriteResult> {
|
||||
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<AuthorizedMemoryMutation, { kind: "import" }>;
|
||||
}): Promise<MemoryWriteResult> {
|
||||
return await writeAuthorizedMutation(params);
|
||||
},
|
||||
|
||||
async stageSealedCompaction(params: AuthorizedSealedCompactionStageParams) {
|
||||
return await stageSealedCompaction(params);
|
||||
},
|
||||
async syncAuthorized(params: {
|
||||
context: MemoryAccessContext;
|
||||
plan: AuthorizedMemoryPlan;
|
||||
}): Promise<AuthorizedMemoryResultEnvelope<MemorySyncResult>> {
|
||||
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<AuthorizedMemoryMutation, { kind: "import" }>;
|
||||
}): Promise<MemoryWriteResult> {
|
||||
return await writeAuthorizedMutation(params);
|
||||
},
|
||||
async exportAuthorized(params: {
|
||||
context: MemoryAccessContext;
|
||||
plan: AuthorizedMemoryPlan;
|
||||
handles: readonly AuthorizedResourceHandle[];
|
||||
}): Promise<AuthorizedMemoryResultEnvelope<MemoryExportResult>> {
|
||||
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<AuthorizedMemoryResultEnvelope<MemorySyncResult>> {
|
||||
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<AuthorizedMemoryResultEnvelope<AuthorizedMemoryStatus>> {
|
||||
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<AuthorizedMemoryResultEnvelope<MemoryExportResult>> {
|
||||
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<AuthorizedMemoryResultEnvelope<AuthorizedMemoryStatus>> {
|
||||
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: {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string | undefined>;
|
||||
|
||||
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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string | undefined>;
|
||||
type ClientSecretResolver = (value: unknown) => Promise<string | undefined>;
|
||||
|
||||
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/<id> 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<string, unknown>).client_email);
|
||||
const privateKey = text((credentials as Record<string, unknown>).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<id> 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string | undefined>;
|
||||
|
||||
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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<{
|
||||
|
||||
Generated
+31
@@ -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':
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AuthorizedSealedCompactionArtifact | typeof MEMORY_INVOCATION_UNAVAILABLE>;
|
||||
stage: (
|
||||
content: string,
|
||||
) => Promise<AuthorizedSealedCompactionArtifact | typeof MEMORY_INVOCATION_UNAVAILABLE>;
|
||||
}>;
|
||||
|
||||
type AuthorizedMemoryReadHostWithVirtualBroker = AuthorizedMemoryReadHost &
|
||||
@@ -96,6 +99,37 @@ function deliveryFacts(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function hasCurrentEnterpriseMemoryFacts(params: {
|
||||
userPrincipalId: string;
|
||||
verifiedPrincipals: ReadonlyArray<MemoryAccessContext["verifiedPrincipals"][number]>;
|
||||
verifiedMemberships: ReadonlyArray<MemoryAccessContext["verifiedMemberships"][number]>;
|
||||
}): 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<MemoryAccessContext["verifiedPrincipals"][number]> = [];
|
||||
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<AuthorizedMemoryWriteHost | undefined> {
|
||||
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) {
|
||||
|
||||
@@ -24,6 +24,10 @@ export const AGENT_FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -921,6 +921,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<InstallRecordBase, "source"> & {
|
||||
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<string, PluginEntryConfig>;
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<GatewayEnterpriseIdentityAuthorizationResult>;
|
||||
|
||||
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 = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>OpenClaw</title></head><body><p>${message}</p></body></html>`;
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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<string, EnterpriseOidcTransaction>();
|
||||
|
||||
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<string | undefined> {
|
||||
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<GatewayEnterpriseIdentityAuthorizationStart> {
|
||||
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<string | undefined> {
|
||||
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<string, unknown>).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<GatewayEnterpriseIdentityAuthorizationResult> {
|
||||
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<GatewayEnterpriseIdentityAuthorizationResult> {
|
||||
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<GatewayEnterpriseIdentityAuthorizationResult> {
|
||||
return await completeEnterpriseIdentityAuthorization(params);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
client: Record<string, unknown> | 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<typeof handler>[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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
})
|
||||
: [],
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -41,6 +41,10 @@ export type {
|
||||
OpenClawPluginServiceContext,
|
||||
OpenClawPluginToolContext,
|
||||
OpenClawPluginToolFactory,
|
||||
EnterpriseIdentityDirectoryAccessTokenResult,
|
||||
EnterpriseIdentityMembershipSource,
|
||||
EnterpriseIdentityProviderAdapter,
|
||||
EnterpriseIdentityProviderAuthority,
|
||||
PluginAgentEventEmitParams,
|
||||
PluginAgentEventEmitResult,
|
||||
PluginAgentEventSubscriptionRegistration,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<string>;
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<EnterpriseIdentityProviderServiceAvailability>;
|
||||
/** Resolves the confidential web client's secret only when core redeems a code. */
|
||||
resolveAuthorizationCodeClientSecret?: () => Promise<string | undefined>;
|
||||
/** Only relevant to google-workspace-directory membership. It never returns groups or identity facts. */
|
||||
acquireDirectoryAccessToken?: () =>
|
||||
| EnterpriseIdentityDirectoryAccessTokenResult
|
||||
| Promise<EnterpriseIdentityDirectoryAccessTokenResult>;
|
||||
};
|
||||
@@ -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<string>;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
params.manifest?.contracts?.enterpriseIdentityProviders?.some((prefix) =>
|
||||
params.operatorAllowlist.has(prefix),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasConfiguredStartupChannel(params: {
|
||||
plugin: InstalledPluginIndexRecord;
|
||||
manifestLookup: ManifestRegistryLookup;
|
||||
|
||||
@@ -59,6 +59,19 @@ export function resolveGatewayStartupMetadataPluginIds(params: {
|
||||
const scope = new Set<string>([...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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -347,6 +347,7 @@ const MANIFEST_CONTRACT_KEYS = [
|
||||
"externalAuthProviders",
|
||||
"embeddingProviders",
|
||||
"memoryEmbeddingProviders",
|
||||
"enterpriseIdentityProviders",
|
||||
"speechProviders",
|
||||
"realtimeTranscriptionProviders",
|
||||
"realtimeVoiceProviders",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -416,6 +416,7 @@ function mergeManifestContracts(
|
||||
"externalAuthProviders",
|
||||
"embeddingProviders",
|
||||
"memoryEmbeddingProviders",
|
||||
"enterpriseIdentityProviders",
|
||||
"speechProviders",
|
||||
"realtimeTranscriptionProviders",
|
||||
"realtimeVoiceProviders",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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<OpenClawPluginApi, MemoryEnterpriseAccessAuditReporter>();
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<MemoryPreoutputExposureLedgerDatabase>(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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: <K extends PluginHookName>(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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<OpenClawPluginApi["registerEnterpriseIdentityProvider"]>[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<OpenClawPluginApi["registerEnterpriseIdentityProvider"]>[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<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string>(),
|
||||
|
||||
@@ -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<string, ResolvedPluginRuntimeArtifact>;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, MemoryVerifiedMembership>();
|
||||
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;
|
||||
|
||||
@@ -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<Parameters<typeof writeMemoryEnterpriseAccessDecisionAudit>[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<string, unknown>;
|
||||
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<Record<string, unknown>>;
|
||||
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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<DatabaseSync>();
|
||||
|
||||
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<MemoryEnterpriseAccessAuditDatabase>(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<MemoryEnterpriseAccessAuditDatabase>(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<string, MemoryEnterpriseRoleAccessDecision>();
|
||||
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<MemoryEnterpriseAccessAuditDatabase>(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<MemoryEnterpriseAccessAuditDatabase>(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));
|
||||
}
|
||||
@@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, EnterpriseAdmission>();
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user