Skip to content

Commit fa152c9

Browse files
os-zhuangclaude
andauthored
P0 security: enforce-or-remove unenforced security props (ADR-0049) (#1899)
* fix(formula): catch unknown functions in CEL conditions at build (#1877) cel-js's `check()` returns a `TypeCheckResult` object (`{ valid, error }`), not an array, so `compile()`'s `Array.isArray(checkErrors)` guard never matched and the type-check verdict was silently discarded. A condition calling an unknown function (`PRIOR(status)`, a typo'd `isBlnk(...)`) type-checks as `found no matching overload`, but that result never surfaced — so `objectstack compile`, `registerFlow`, and the `validate_expression` tool all accepted the predicate, which then silently no-op'd the flow at runtime. Read the documented `{ valid, error }` shape instead. Because every author surface routes through this one `compile()`, the fix closes the gap for flow conditions, validation rules, and field formulas at once. Verified cel-js's `check()` cleanly separates valid predicates from unknown-function ones with no false positives on existing conditions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(changeset): formula unknown-function condition validation * fix(security): fail-closed for destructive ops; mark dangling permission bits experimental (ADR-0049) The metadata liveness audit (#1878) found a cluster of security properties that parse but enforce nothing — a false sense of compliance. ADR-0049 establishes the enforce-or-remove gate: a security property must be enforced, explicitly `experimental`, or absent — never parsed-but-silently-unenforced. This is the no-regret slice (PR A) of the P0 cluster: - PermissionEvaluator now fails CLOSED for the destructive operation class (transfer/restore/purge). Previously any operation absent from OPERATION_TO_PERMISSION fell through to default-allow, so a future destructive op added without a map entry would be silently ungated. Non-destructive unknown ops retain default-allow (custom read-side ops unaffected). - ObjectPermission allowTransfer/allowRestore/allowPurge marked `[EXPERIMENTAL — not enforced]` in the spec: the operations they gate do not yet exist and no runtime consumer reads them. Re-introduced with enforcement when the feature lands (#1883). - Adds a test asserting destructive ops fail closed even for a fully- permissioned set. Refs #1878 #1883 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(security): enforce live-but-ungated access gaps — agent access & object API exposure (ADR-0049) PR B of the #1878 P0 cluster: the items where the feature already exists but the gate was missing or bypassed (vs PR A, which neutralised dangling spec promises). #1884 — agent access control was a no-op. The chat route only enforced the static route-level permissions; each agent's own `access`/`permissions` were never consulted, so "who can chat with this agent" enforced nothing. Adds a pure `evaluateAgentAccess` (required-permissions ALL, allow-list match by userId/role, fail-closed on missing user) wired into the chat route after the active check. `visibility` (organization/private) is intentionally not enforced here — the request context carries no tenant/owner — and that gap is documented rather than half-enforced. #1889 — object `apiEnabled`/`apiMethods` were ignored by the HTTP/MCP data dispatch; an object could not be hidden from the API nor its methods restricted. Adds a pure `checkApiExposure` gate in `callData`: `apiEnabled:false` → 404, non-whitelisted op → 405. System/internal contexts bypass; an unresolvable definition falls open to the schema defaults (apiEnabled defaults true), so the gate is a pure additive restriction with no regression. Both helpers are unit-tested (agent-access 9, api-exposure 8). The action `disabled`-CEL gap (#1885) lives in the objectui renderer and is fixed there in a separate commit. Refs #1878 #1884 #1889 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bb00a50 commit fa152c9

10 files changed

Lines changed: 468 additions & 6 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# ADR-0049: Spec must not declare security properties the runtime does not enforce (enforce-or-remove gate)
2+
3+
**Status**: Proposed (2026-06-15)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay), [ADR-0010](./0010-metadata-protection-model.md) (package provenance), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (authoring lifecycle)
6+
**Consumers**: `@objectstack/spec` (security/identity schemas), `@objectstack/plugin-security` (`PermissionEvaluator`, `SecurityPlugin`), spec authors, the metadata-property liveness audit follow-ups (#1878 P0 cluster).
7+
**Surfaced by**: the metadata property liveness audit (#1878, `docs/audits/`) — which found that **roughly half of all spec properties are dead**, and that a cluster of *security* properties is **parsed but unenforced**.
8+
9+
---
10+
11+
## TL;DR
12+
13+
A protocol-level audit cross-referenced every spec property against its actual
14+
runtime consumers. The most serious finding is a cluster of **security
15+
properties that imply an access-control boundary but enforce nothing**:
16+
`PolicySchema` (100% dead — password/session/MFA/IP/audit), permission
17+
lifecycle bits (`allowTransfer`/`allowRestore`/`allowPurge`), `agent`
18+
access-control, flow `runAs`, object `apiEnabled`/`apiMethods`, action
19+
`disabled`, role `parent`, and `SharingRuleSchema`.
20+
21+
A security property that parses but does nothing is **worse than absent**: it
22+
produces a *false sense of compliance*. An admin who sets `allowPurge: false`
23+
or authors a strict password policy believes a boundary exists where none does.
24+
25+
**Decision.** A spec property that names a security/access-control boundary
26+
**must be in exactly one of three states**:
27+
28+
1. **Enforced** — a runtime consumer reads it and changes a decision (`file:line`).
29+
2. **`experimental`** — explicitly marked and documented as *not yet enforced*,
30+
so authoring it is a known no-op (roadmapped, not a promise).
31+
3. **Absent** — removed from the spec.
32+
33+
Shipping a security property in a fourth state — *parsed, unmarked, unenforced*
34+
— is prohibited. This is the **enforce-or-remove gate**.
35+
36+
A second, roadmap-independent defect compounds the first: `PermissionEvaluator`
37+
**fails open** for operations it doesn't recognise
38+
(`permission-evaluator.ts:35`, `if (!permKey) return true`). Any future
39+
destructive operation added without registering it in `OPERATION_TO_PERMISSION`
40+
is silently ungated. The evaluator must **fail closed** for the destructive
41+
operation class.
42+
43+
---
44+
45+
## Context
46+
47+
- Evidence: `docs/audits/2026-06-security-identity-property-liveness.md` and the
48+
cross-type synthesis in `docs/audits/README.md` (cluster #1).
49+
- The CRUD path *is* enforced: `SecurityPlugin` (`security-plugin.ts:326`)
50+
resolves permission sets and calls `PermissionEvaluator.checkObjectPermission`,
51+
which maps the ObjectQL operation to an `ObjectPermission` key via
52+
`OPERATION_TO_PERMISSION` (`permission-evaluator.ts:8-16`).
53+
- That map covers only `find/findOne/count/aggregate/insert/update/delete`. The
54+
three destructive permission bits in the spec
55+
(`permission.zod.ts:28-30` — `allowTransfer`/`allowRestore`/`allowPurge`)
56+
have **no operation pointing at them**, and the operations they describe
57+
(`transfer`/`restore`/`purge`) **do not yet exist** as ObjectQL operations.
58+
So the bits are dangling, and the `if (!permKey) return true` default means
59+
that *if* such an operation were added without a map entry, it would be
60+
allowed for everyone.
61+
62+
## Decision — staged by the platform's current (pre-MVP) phase
63+
64+
The audit's instinct was "enforce every unenforced security prop." At the
65+
current milestone that is the **wrong default**: building enforcement for
66+
features that do not exist yet is speculative. The real, shippable liability is
67+
the *false promise*, not the missing feature. So we split the P0 cluster by
68+
**whether the feature already exists**:
69+
70+
| Situation | Items | Phase action |
71+
|---|---|---|
72+
| **Feature does not exist; spec bit is a dangling promise** | `PolicySchema` (#1882), permission lifecycle bits (#1883), `SharingRuleSchema` spec form (#1887), flow `runAs` (#1888) | **Remove or mark `experimental`** now. Re-introduce *with* the feature + enforcement at M2/production. |
73+
| **Feature is live; the gate is missing or bypassed** | agent access-control (#1884), object `apiEnabled`/`apiMethods` (#1889), action `disabled` CEL (#1885) | **Enforce** now — these are real, exploitable gaps and the fix is a localized check at the route/renderer. |
74+
75+
Plus one **no-regret correctness fix**, independent of roadmap:
76+
77+
- `PermissionEvaluator` fails **closed** for the destructive operation class:
78+
introduce an explicit set of sensitive/destructive operations; an unrecognised
79+
operation in that class is **denied**, not allowed. (Non-destructive unknown
80+
operations may retain default-allow to avoid breaking custom read-side ops.)
81+
82+
### `experimental` convention for the "mark, don't remove" path
83+
84+
For a roadmapped property we keep but cannot yet enforce, annotate it so the
85+
no-op is explicit to authors and tooling, rather than silently parsing:
86+
87+
- prefix the Zod `.describe()` with **`[EXPERIMENTAL — not enforced]`**, and
88+
- where the surrounding schema already carries a status/stability enum (e.g.
89+
`model-registry.zod.ts`, `plugin-capability.zod.ts`), prefer that enum.
90+
91+
Removal is preferred over marking when there is no committed roadmap for the
92+
property — a smaller spec surface is the stronger default pre-MVP.
93+
94+
## Consequences
95+
96+
- **Positive.** No spec property silently misleads an admin about a security
97+
boundary. The evaluator can no longer be made to fail open by adding a
98+
destructive operation. The P0 cluster splits into a cheap no-regret PR
99+
(evaluator fail-closed + mark/remove dangling bits) and a small enforcement
100+
PR (live-but-ungated features), deferring the heavy work (policy registration,
101+
sharing-rule engine reconciliation) to when the feature lands.
102+
- **Negative / cost.** Removing or `experimental`-tagging spec bits is a
103+
spec-surface change; seeds/fixtures that author the removed bits must be
104+
updated (low risk pre-MVP). The fail-closed change requires enumerating the
105+
destructive operation class so legitimate custom operations are not denied.
106+
- **Follow-up.** This ADR is the umbrella decision for the #1878 P0 cluster;
107+
each sub-issue records its enforce/experimental/remove disposition against the
108+
table above.
109+
110+
## Non-goals
111+
112+
- Building the transfer/restore/purge, policy-enforcement, or sharing-rule
113+
engines themselves — those are feature work for M2/production, tracked by
114+
their respective issues.
115+
- The P1 (ADR-0021 analytics migration) and P2 (spec hygiene) clusters of
116+
#1878 — non-security, governed separately.

‎packages/plugins/plugin-security/src/permission-evaluator.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ const OPERATION_TO_PERMISSION: Record<string, keyof ObjectPermission> = {
1515
delete: 'allowDelete',
1616
};
1717

18+
/**
19+
* Destructive operation class — operations that must FAIL CLOSED when they are
20+
* not mapped to a concrete permission key. See ADR-0049: an unrecognised
21+
* destructive operation (e.g. a future `transfer`/`restore`/`purge` added
22+
* without a matching `OPERATION_TO_PERMISSION` entry, gated by the spec's
23+
* `allowTransfer`/`allowRestore`/`allowPurge` bits) must be DENIED rather than
24+
* silently allowed by the default-allow fallthrough. Non-destructive unknown
25+
* operations retain default-allow so custom read-side operations are not broken.
26+
*/
27+
const DESTRUCTIVE_OPERATIONS = new Set<string>(['transfer', 'restore', 'purge']);
28+
1829
/**
1930
* PermissionEvaluator
2031
*
@@ -32,7 +43,12 @@ export class PermissionEvaluator {
3243
permissionSets: PermissionSet[]
3344
): boolean {
3445
const permKey = OPERATION_TO_PERMISSION[operation];
35-
if (!permKey) return true; // Unknown operations are allowed by default
46+
if (!permKey) {
47+
// Fail CLOSED for the destructive operation class (ADR-0049): an
48+
// unrecognised destructive op must be denied, never silently allowed.
49+
// Other unknown operations are allowed by default.
50+
return !DESTRUCTIVE_OPERATIONS.has(operation);
51+
}
3652

3753
for (const ps of permissionSets) {
3854
// Honour the `'*'` wildcard sentinel — admin permission sets typically

‎packages/plugins/plugin-security/src/security-plugin.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,11 +610,24 @@ describe('PermissionEvaluator', () => {
610610
expect(evaluator.checkObjectPermission('insert', 'contact', [ps])).toBe(false);
611611
});
612612

613-
it('should allow unknown operations by default', () => {
613+
it('should allow unknown (non-destructive) operations by default', () => {
614614
const evaluator = new PermissionEvaluator();
615615
expect(evaluator.checkObjectPermission('unknownOp', 'contact', [])).toBe(true);
616616
});
617617

618+
it('should fail CLOSED for unmapped destructive operations (ADR-0049)', () => {
619+
const evaluator = new PermissionEvaluator();
620+
// transfer/restore/purge are not in OPERATION_TO_PERMISSION; they must be
621+
// denied rather than falling through to default-allow — even for an
622+
// otherwise fully-permissioned set.
623+
const ps = makePermSet('admin', {
624+
contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true },
625+
});
626+
expect(evaluator.checkObjectPermission('transfer', 'contact', [ps])).toBe(false);
627+
expect(evaluator.checkObjectPermission('restore', 'contact', [ps])).toBe(false);
628+
expect(evaluator.checkObjectPermission('purge', 'contact', [ps])).toBe(false);
629+
});
630+
618631
it('should allow via viewAllRecords', () => {
619632
const evaluator = new PermissionEvaluator();
620633
const ps = makePermSet('viewer', { task: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false, viewAllRecords: true } });
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { checkApiExposure } from './api-exposure.js';
5+
6+
describe('checkApiExposure (#1889)', () => {
7+
it('falls open when the definition is unresolvable', () => {
8+
expect(checkApiExposure(undefined, 'get').allowed).toBe(true);
9+
expect(checkApiExposure(null, 'create').allowed).toBe(true);
10+
});
11+
12+
it('allows by default (apiEnabled defaults true, no whitelist)', () => {
13+
expect(checkApiExposure({}, 'query').allowed).toBe(true);
14+
expect(checkApiExposure({ apiEnabled: true }, 'delete').allowed).toBe(true);
15+
});
16+
17+
it('hides the object (404) when apiEnabled is false', () => {
18+
const d = checkApiExposure({ apiEnabled: false }, 'get');
19+
expect(d.allowed).toBe(false);
20+
expect(d.status).toBe(404);
21+
});
22+
23+
describe('apiMethods whitelist', () => {
24+
it('allows a whitelisted operation', () => {
25+
// query maps to ApiMethod 'list'
26+
expect(checkApiExposure({ apiMethods: ['list', 'get'] }, 'query').allowed).toBe(true);
27+
expect(checkApiExposure({ apiMethods: ['list', 'get'] }, 'get').allowed).toBe(true);
28+
});
29+
30+
it('blocks a non-whitelisted operation (405)', () => {
31+
const d = checkApiExposure({ apiMethods: ['list', 'get'] }, 'create');
32+
expect(d.allowed).toBe(false);
33+
expect(d.status).toBe(405);
34+
expect(d.reason).toContain('create');
35+
});
36+
37+
it('maps delete/update/create/find correctly', () => {
38+
const ro = { apiMethods: ['list', 'get'] };
39+
expect(checkApiExposure(ro, 'delete').allowed).toBe(false);
40+
expect(checkApiExposure(ro, 'update').allowed).toBe(false);
41+
expect(checkApiExposure(ro, 'find').allowed).toBe(true); // find → list
42+
});
43+
44+
it('an empty whitelist is treated as no restriction', () => {
45+
expect(checkApiExposure({ apiMethods: [] }, 'create').allowed).toBe(true);
46+
});
47+
48+
it('does not gate actions with no ApiMethod mapping', () => {
49+
expect(checkApiExposure({ apiMethods: ['list'] }, 'somethingCustom').allowed).toBe(true);
50+
});
51+
});
52+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Object-level API exposure gate (ADR-0049, #1889).
5+
*
6+
* Objects declare `apiEnabled` (default true) and an optional `apiMethods`
7+
* whitelist, but the HTTP/MCP data dispatch previously ignored both — an object
8+
* could not actually be hidden from the API, nor could its allowed operations
9+
* be restricted. This module decides, for a given data action, whether the
10+
* object's declared exposure permits it.
11+
*
12+
* Both fields are *additive restrictions* over a default-allow surface
13+
* (`apiEnabled` defaults true; absent `apiMethods` means "all operations").
14+
* Therefore an unresolvable object definition fails OPEN here — that matches
15+
* the schema defaults and avoids breaking traffic when metadata is briefly
16+
* unavailable. The gate is a no-op for system/internal contexts (callers pass
17+
* `isSystem` and skip this check entirely).
18+
*/
19+
20+
/** The exposure-relevant slice of an object definition. */
21+
export interface ObjectApiDef {
22+
apiEnabled?: boolean;
23+
apiMethods?: string[] | null;
24+
}
25+
26+
export interface ApiExposureDecision {
27+
allowed: boolean;
28+
/** HTTP status to return when denied (404 hides, 405 = method not allowed). */
29+
status?: number;
30+
reason?: string;
31+
}
32+
33+
/**
34+
* Map an internal `callData` action onto the spec `ApiMethod` vocabulary
35+
* (`object.zod.ts` → `ApiMethod`). Actions with no mapping are not gated by
36+
* `apiMethods` (they still respect `apiEnabled`).
37+
*/
38+
const ACTION_TO_API_METHOD: Record<string, string> = {
39+
create: 'create',
40+
get: 'get',
41+
update: 'update',
42+
delete: 'delete',
43+
query: 'list',
44+
find: 'list',
45+
batch: 'bulk',
46+
};
47+
48+
export function checkApiExposure(def: ObjectApiDef | null | undefined, action: string): ApiExposureDecision {
49+
// Unresolvable definition → fall open to the schema defaults.
50+
if (!def) return { allowed: true };
51+
52+
// `apiEnabled: false` hides the object from the API entirely → 404.
53+
if (def.apiEnabled === false) {
54+
return { allowed: false, status: 404, reason: 'object is not exposed via the API' };
55+
}
56+
57+
// `apiMethods` whitelist (when present and non-empty) restricts operations.
58+
const whitelist = def.apiMethods;
59+
if (Array.isArray(whitelist) && whitelist.length > 0) {
60+
const method = ACTION_TO_API_METHOD[action];
61+
// Only gate actions that map to a known ApiMethod; unmapped actions pass.
62+
if (method && !whitelist.includes(method)) {
63+
return {
64+
allowed: false,
65+
status: 405,
66+
reason: `API operation '${method}' is not allowed for this object`,
67+
};
68+
}
69+
}
70+
71+
return { allowed: true };
72+
}

‎packages/runtime/src/http-dispatcher.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CoreServiceName } from '@objectstack/spec/system';
55
import { pluralToSingular, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
66
import type { ExecutionContext } from '@objectstack/spec/kernel';
77
import { setPackageDisabled } from './package-state-store.js';
8+
import { checkApiExposure } from './api-exposure.js';
89

910
/** Minimal local interface — full EnvironmentScopeManager was removed in Phase R. */
1011
interface EnvironmentScopeManager {
@@ -258,6 +259,24 @@ export class HttpDispatcher {
258259
scopeId?: string,
259260
executionContext?: ExecutionContext,
260261
): Promise<any> {
262+
// ── Object-level API exposure gate (ADR-0049, #1889) ─────
263+
// Honour the object's `apiEnabled` / `apiMethods` declarations for
264+
// external traffic. System/internal contexts bypass — these flags
265+
// govern API *exposure*, not internal engine self-writes.
266+
if (!executionContext?.isSystem && params?.object) {
267+
let def: any;
268+
try {
269+
const meta = await this.resolveService('metadata', scopeId);
270+
def = await (meta as any)?.getObject?.(params.object);
271+
} catch {
272+
def = undefined; // fall open to schema defaults (apiEnabled=true)
273+
}
274+
const gate = checkApiExposure(def, action);
275+
if (!gate.allowed) {
276+
throw { statusCode: gate.status ?? 403, message: gate.reason ?? 'API access denied' };
277+
}
278+
}
279+
261280
const protocol = await this.resolveService('protocol', scopeId);
262281
const qlService = dataDriver ?? await this.getObjectQLService(scopeId);
263282
const ql = qlService ?? await this.resolveService('objectql', scopeId);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { evaluateAgentAccess } from './agent-access.js';
5+
6+
describe('evaluateAgentAccess (#1884)', () => {
7+
const user = (over: Partial<{ userId: string; roles: string[]; permissions: string[] }> = {}) => ({
8+
userId: 'u1',
9+
roles: [] as string[],
10+
permissions: [] as string[],
11+
...over,
12+
});
13+
14+
it('allows when the agent declares no access/permissions', () => {
15+
expect(evaluateAgentAccess({}, user()).allowed).toBe(true);
16+
expect(evaluateAgentAccess({ visibility: 'private' }, user()).allowed).toBe(true);
17+
});
18+
19+
it('fails closed when the user is missing', () => {
20+
const d = evaluateAgentAccess({ access: ['u1'] }, undefined);
21+
expect(d.allowed).toBe(false);
22+
});
23+
24+
describe('required permissions (must hold ALL)', () => {
25+
it('denies when a required permission is missing', () => {
26+
const d = evaluateAgentAccess({ permissions: ['hr:read', 'hr:write'] }, user({ permissions: ['hr:read'] }));
27+
expect(d.allowed).toBe(false);
28+
expect(d.reason).toContain('hr:write');
29+
});
30+
31+
it('allows when all are held via permissions', () => {
32+
expect(evaluateAgentAccess({ permissions: ['hr:read'] }, user({ permissions: ['hr:read'] })).allowed).toBe(true);
33+
});
34+
35+
it('satisfies a required entry via roles too (permissions OR roles)', () => {
36+
expect(evaluateAgentAccess({ permissions: ['manager'] }, user({ roles: ['manager'] })).allowed).toBe(true);
37+
});
38+
});
39+
40+
describe('access allow-list (must match one)', () => {
41+
it('allows a listed user id', () => {
42+
expect(evaluateAgentAccess({ access: ['u1', 'u2'] }, user()).allowed).toBe(true);
43+
});
44+
45+
it('allows a listed role', () => {
46+
expect(evaluateAgentAccess({ access: ['support'] }, user({ roles: ['support'] })).allowed).toBe(true);
47+
});
48+
49+
it('denies a caller not on the list', () => {
50+
const d = evaluateAgentAccess({ access: ['u2', 'admins'] }, user({ userId: 'u1', roles: ['sales'] }));
51+
expect(d.allowed).toBe(false);
52+
expect(d.reason).toMatch(/access list/);
53+
});
54+
});
55+
56+
it('enforces permissions AND allow-list together', () => {
57+
const agent = { permissions: ['ai:beta'], access: ['vip'] };
58+
// has permission but not on allow-list
59+
expect(evaluateAgentAccess(agent, user({ permissions: ['ai:beta'], roles: [] })).allowed).toBe(false);
60+
// on allow-list but missing permission
61+
expect(evaluateAgentAccess(agent, user({ roles: ['vip'], permissions: [] })).allowed).toBe(false);
62+
// both satisfied
63+
expect(evaluateAgentAccess(agent, user({ roles: ['vip'], permissions: ['ai:beta'] })).allowed).toBe(true);
64+
});
65+
});

0 commit comments

Comments
 (0)