@apart-tech/intelligence-core 1.11.4 → 1.11.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/ability.d.ts +148 -0
- package/dist/auth/ability.d.ts.map +1 -0
- package/dist/auth/ability.js +285 -0
- package/dist/auth/ability.js.map +1 -0
- package/dist/auth/ability.test.d.ts +2 -0
- package/dist/auth/ability.test.d.ts.map +1 -0
- package/dist/auth/ability.test.js +680 -0
- package/dist/auth/ability.test.js.map +1 -0
- package/dist/auth/delegation-jwt.d.ts +167 -0
- package/dist/auth/delegation-jwt.d.ts.map +1 -0
- package/dist/auth/delegation-jwt.js +237 -0
- package/dist/auth/delegation-jwt.js.map +1 -0
- package/dist/auth/delegation-jwt.test.d.ts +2 -0
- package/dist/auth/delegation-jwt.test.d.ts.map +1 -0
- package/dist/auth/delegation-jwt.test.js +283 -0
- package/dist/auth/delegation-jwt.test.js.map +1 -0
- package/dist/auth/principal.d.ts +94 -0
- package/dist/auth/principal.d.ts.map +1 -0
- package/dist/auth/principal.js +33 -0
- package/dist/auth/principal.js.map +1 -0
- package/dist/config/config.test.d.ts +2 -0
- package/dist/config/config.test.d.ts.map +1 -0
- package/dist/config/config.test.js +57 -0
- package/dist/config/config.test.js.map +1 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +17 -0
- package/dist/config/index.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/__tests__/jwt.test.d.ts +2 -0
- package/dist/lib/__tests__/jwt.test.d.ts.map +1 -0
- package/dist/lib/__tests__/jwt.test.js +97 -0
- package/dist/lib/__tests__/jwt.test.js.map +1 -0
- package/dist/lib/jwt.d.ts +20 -0
- package/dist/lib/jwt.d.ts.map +1 -1
- package/dist/lib/jwt.js +56 -3
- package/dist/lib/jwt.js.map +1 -1
- package/dist/services/__tests__/delegation-cleanup-service.test.d.ts +2 -0
- package/dist/services/__tests__/delegation-cleanup-service.test.d.ts.map +1 -0
- package/dist/services/__tests__/delegation-cleanup-service.test.js +211 -0
- package/dist/services/__tests__/delegation-cleanup-service.test.js.map +1 -0
- package/dist/services/agent-run-service.d.ts +44 -7
- package/dist/services/agent-run-service.d.ts.map +1 -1
- package/dist/services/agent-run-service.js +14 -0
- package/dist/services/agent-run-service.js.map +1 -1
- package/dist/services/agent-schedule-service.d.ts +21 -0
- package/dist/services/agent-schedule-service.d.ts.map +1 -1
- package/dist/services/agent-schedule-service.js +12 -0
- package/dist/services/agent-schedule-service.js.map +1 -1
- package/dist/services/audit-event-service.d.ts +76 -0
- package/dist/services/audit-event-service.d.ts.map +1 -0
- package/dist/services/audit-event-service.js +48 -0
- package/dist/services/audit-event-service.js.map +1 -0
- package/dist/services/delegation-cleanup-service.d.ts +133 -0
- package/dist/services/delegation-cleanup-service.d.ts.map +1 -0
- package/dist/services/delegation-cleanup-service.js +111 -0
- package/dist/services/delegation-cleanup-service.js.map +1 -0
- package/dist/services/edge-service.d.ts.map +1 -1
- package/dist/services/edge-service.js +3 -0
- package/dist/services/edge-service.js.map +1 -1
- package/dist/services/org-agent-type-service.d.ts +15 -0
- package/dist/services/org-agent-type-service.d.ts.map +1 -1
- package/dist/services/org-agent-type-service.js +2 -0
- package/dist/services/org-agent-type-service.js.map +1 -1
- package/dist/services/usage-service.d.ts +48 -0
- package/dist/services/usage-service.d.ts.map +1 -0
- package/dist/services/usage-service.js +116 -0
- package/dist/services/usage-service.js.map +1 -0
- package/dist/services/user-service.d.ts.map +1 -1
- package/dist/services/user-service.js +24 -6
- package/dist/services/user-service.js.map +1 -1
- package/dist/services/user-service.test.d.ts +2 -0
- package/dist/services/user-service.test.d.ts.map +1 -0
- package/dist/services/user-service.test.js +86 -0
- package/dist/services/user-service.test.js.map +1 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +3 -2
- package/prisma/schema.prisma +158 -82
- package/dist/db/schema.d.ts +0 -507
- package/dist/db/schema.d.ts.map +0 -1
- package/dist/db/schema.js +0 -77
- package/dist/db/schema.js.map +0 -1
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
import { createMongoAbility } from "@casl/ability";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { buildAbility, intersect, UnsupportedIntersectionError, } from "./ability.js";
|
|
4
|
+
// ── UserPrincipal ──────────────────────────────────────────────────────────
|
|
5
|
+
describe("buildAbility(UserPrincipal) — owner", () => {
|
|
6
|
+
const owner = {
|
|
7
|
+
type: "user",
|
|
8
|
+
id: "u-1",
|
|
9
|
+
email: "owner@example.com",
|
|
10
|
+
organizationId: "org-1",
|
|
11
|
+
role: "owner",
|
|
12
|
+
};
|
|
13
|
+
const ability = buildAbility(owner);
|
|
14
|
+
it("can manage everything via the wildcard", () => {
|
|
15
|
+
expect(ability.can("manage", "all")).toBe(true);
|
|
16
|
+
expect(ability.can("manage", "Organization")).toBe(true);
|
|
17
|
+
expect(ability.can("manage", "Membership")).toBe(true);
|
|
18
|
+
expect(ability.can("manage", "Invite")).toBe(true);
|
|
19
|
+
expect(ability.can("manage", "User")).toBe(true);
|
|
20
|
+
expect(ability.can("manage", "OrgConfig")).toBe(true);
|
|
21
|
+
expect(ability.can("manage", "AgentSchedule")).toBe(true);
|
|
22
|
+
expect(ability.can("manage", "Pii")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it("can perform every specific action on every subject", () => {
|
|
25
|
+
for (const action of [
|
|
26
|
+
"create",
|
|
27
|
+
"read",
|
|
28
|
+
"update",
|
|
29
|
+
"delete",
|
|
30
|
+
"bypass",
|
|
31
|
+
]) {
|
|
32
|
+
for (const subject of [
|
|
33
|
+
"Organization",
|
|
34
|
+
"Membership",
|
|
35
|
+
"Invite",
|
|
36
|
+
"User",
|
|
37
|
+
"OrgConfig",
|
|
38
|
+
"AgentSchedule",
|
|
39
|
+
"Pii",
|
|
40
|
+
"Node",
|
|
41
|
+
"Search",
|
|
42
|
+
"Import",
|
|
43
|
+
"Domain",
|
|
44
|
+
"Workspace",
|
|
45
|
+
"AgentRun",
|
|
46
|
+
"OrgAgentConfig",
|
|
47
|
+
"OrgEmbedding",
|
|
48
|
+
"UserSecret",
|
|
49
|
+
]) {
|
|
50
|
+
expect(ability.can(action, subject)).toBe(true);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
it("can bypass Pii via the manage wildcard (Phase 1e M3)", () => {
|
|
55
|
+
// Owners inherit `bypass` on every subject from `manage all`. This is
|
|
56
|
+
// the design call recorded in decision 500bfa31 — the bypass action is
|
|
57
|
+
// intentionally granted implicitly by `manage` so that owners and
|
|
58
|
+
// legacy api-keys keep their pre-Phase-1e behavior with no code change.
|
|
59
|
+
expect(ability.can("bypass", "Pii")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("buildAbility(UserPrincipal) — admin", () => {
|
|
63
|
+
const admin = {
|
|
64
|
+
type: "user",
|
|
65
|
+
id: "u-2",
|
|
66
|
+
email: "admin@example.com",
|
|
67
|
+
organizationId: "org-1",
|
|
68
|
+
role: "admin",
|
|
69
|
+
};
|
|
70
|
+
const ability = buildAbility(admin);
|
|
71
|
+
it("can manage invites", () => {
|
|
72
|
+
expect(ability.can("create", "Invite")).toBe(true);
|
|
73
|
+
expect(ability.can("read", "Invite")).toBe(true);
|
|
74
|
+
expect(ability.can("update", "Invite")).toBe(true);
|
|
75
|
+
expect(ability.can("delete", "Invite")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
it("can manage AgentSchedule (Phase 1e H7)", () => {
|
|
78
|
+
// Admins are responsible for the operational side of scheduled
|
|
79
|
+
// delegated agents — creating, updating, and deleting schedules. The
|
|
80
|
+
// trigger endpoint stays api-key-authed at the network layer; this
|
|
81
|
+
// rule gates the CRUD routes.
|
|
82
|
+
expect(ability.can("create", "AgentSchedule")).toBe(true);
|
|
83
|
+
expect(ability.can("read", "AgentSchedule")).toBe(true);
|
|
84
|
+
expect(ability.can("update", "AgentSchedule")).toBe(true);
|
|
85
|
+
expect(ability.can("delete", "AgentSchedule")).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
it("can read Organization, Membership, OrgConfig, and Pii", () => {
|
|
88
|
+
expect(ability.can("read", "Organization")).toBe(true);
|
|
89
|
+
expect(ability.can("read", "Membership")).toBe(true);
|
|
90
|
+
expect(ability.can("read", "OrgConfig")).toBe(true);
|
|
91
|
+
expect(ability.can("read", "Pii")).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
it("cannot write Organization or Membership", () => {
|
|
94
|
+
expect(ability.can("update", "Organization")).toBe(false);
|
|
95
|
+
expect(ability.can("delete", "Organization")).toBe(false);
|
|
96
|
+
expect(ability.can("create", "Membership")).toBe(false);
|
|
97
|
+
expect(ability.can("update", "Membership")).toBe(false);
|
|
98
|
+
expect(ability.can("delete", "Membership")).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
it("cannot write OrgConfig or Pii (Phase 1e H6/M3)", () => {
|
|
101
|
+
// Admins see the config surfaces but cannot change them — org-wide
|
|
102
|
+
// search/context config and PII policy are owner-only governance
|
|
103
|
+
// settings.
|
|
104
|
+
expect(ability.can("create", "OrgConfig")).toBe(false);
|
|
105
|
+
expect(ability.can("update", "OrgConfig")).toBe(false);
|
|
106
|
+
expect(ability.can("delete", "OrgConfig")).toBe(false);
|
|
107
|
+
expect(ability.can("create", "Pii")).toBe(false);
|
|
108
|
+
expect(ability.can("update", "Pii")).toBe(false);
|
|
109
|
+
expect(ability.can("delete", "Pii")).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
it("cannot bypass Pii scrubbing (Phase 1e M3)", () => {
|
|
112
|
+
// The bypass action is reserved for manage-grade principals. Admins
|
|
113
|
+
// cannot bypass PII even though they can read the PII config.
|
|
114
|
+
expect(ability.can("bypass", "Pii")).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
it("cannot bypass the wildcard", () => {
|
|
117
|
+
expect(ability.can("manage", "all")).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
// Phase 1b: admin can manage graph, import, org config subjects
|
|
120
|
+
it("can manage Node, Import, Domain, Workspace, AgentRun, OrgAgentConfig, OrgEmbedding (Phase 1b)", () => {
|
|
121
|
+
for (const subject of [
|
|
122
|
+
"Node",
|
|
123
|
+
"Import",
|
|
124
|
+
"Domain",
|
|
125
|
+
"Workspace",
|
|
126
|
+
"AgentRun",
|
|
127
|
+
"OrgAgentConfig",
|
|
128
|
+
"OrgEmbedding",
|
|
129
|
+
"UserSecret",
|
|
130
|
+
]) {
|
|
131
|
+
expect(ability.can("create", subject)).toBe(true);
|
|
132
|
+
expect(ability.can("read", subject)).toBe(true);
|
|
133
|
+
expect(ability.can("update", subject)).toBe(true);
|
|
134
|
+
expect(ability.can("delete", subject)).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
it("can read Search (Phase 1b)", () => {
|
|
138
|
+
expect(ability.can("read", "Search")).toBe(true);
|
|
139
|
+
expect(ability.can("create", "Search")).toBe(false);
|
|
140
|
+
expect(ability.can("update", "Search")).toBe(false);
|
|
141
|
+
expect(ability.can("delete", "Search")).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
describe("buildAbility(UserPrincipal) — member", () => {
|
|
145
|
+
const member = {
|
|
146
|
+
type: "user",
|
|
147
|
+
id: "u-3",
|
|
148
|
+
email: "member@example.com",
|
|
149
|
+
organizationId: "org-1",
|
|
150
|
+
role: "member",
|
|
151
|
+
};
|
|
152
|
+
const ability = buildAbility(member);
|
|
153
|
+
it("can read Organization, Membership, OrgConfig, AgentSchedule, and Pii", () => {
|
|
154
|
+
expect(ability.can("read", "Organization")).toBe(true);
|
|
155
|
+
expect(ability.can("read", "Membership")).toBe(true);
|
|
156
|
+
expect(ability.can("read", "OrgConfig")).toBe(true);
|
|
157
|
+
expect(ability.can("read", "AgentSchedule")).toBe(true);
|
|
158
|
+
expect(ability.can("read", "Pii")).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
it("cannot touch invites", () => {
|
|
161
|
+
expect(ability.can("read", "Invite")).toBe(false);
|
|
162
|
+
expect(ability.can("create", "Invite")).toBe(false);
|
|
163
|
+
expect(ability.can("update", "Invite")).toBe(false);
|
|
164
|
+
expect(ability.can("delete", "Invite")).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
it("cannot create AgentSchedule in Phase 1e (no self-ID CASL conditions)", () => {
|
|
167
|
+
// Members cannot create schedules this phase — see the risk accepted
|
|
168
|
+
// in story 1a630ff2. CASL conditions for "own schedules" are not in
|
|
169
|
+
// the starter vocabulary, and member-created schedules would need
|
|
170
|
+
// handler-level self-ID enforcement that this phase does not bring in.
|
|
171
|
+
expect(ability.can("create", "AgentSchedule")).toBe(false);
|
|
172
|
+
expect(ability.can("update", "AgentSchedule")).toBe(false);
|
|
173
|
+
expect(ability.can("delete", "AgentSchedule")).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
it("cannot write OrgConfig or Pii, cannot bypass Pii (Phase 1e H6/M3)", () => {
|
|
176
|
+
expect(ability.can("update", "OrgConfig")).toBe(false);
|
|
177
|
+
expect(ability.can("delete", "OrgConfig")).toBe(false);
|
|
178
|
+
expect(ability.can("update", "Pii")).toBe(false);
|
|
179
|
+
expect(ability.can("delete", "Pii")).toBe(false);
|
|
180
|
+
expect(ability.can("bypass", "Pii")).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
it("cannot write anything on Phase 1e subjects", () => {
|
|
183
|
+
expect(ability.can("update", "Organization")).toBe(false);
|
|
184
|
+
expect(ability.can("create", "Membership")).toBe(false);
|
|
185
|
+
expect(ability.can("delete", "Membership")).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
// Phase 1b: member can manage Node and Workspace (core graph work)
|
|
188
|
+
it("can manage Node and Workspace (Phase 1b — graph CRUD is the core work)", () => {
|
|
189
|
+
for (const subject of ["Node", "Workspace"]) {
|
|
190
|
+
expect(ability.can("create", subject)).toBe(true);
|
|
191
|
+
expect(ability.can("read", subject)).toBe(true);
|
|
192
|
+
expect(ability.can("update", subject)).toBe(true);
|
|
193
|
+
expect(ability.can("delete", subject)).toBe(true);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
it("can manage UserSecret (Phase 1b — self-service tokens)", () => {
|
|
197
|
+
expect(ability.can("create", "UserSecret")).toBe(true);
|
|
198
|
+
expect(ability.can("read", "UserSecret")).toBe(true);
|
|
199
|
+
expect(ability.can("update", "UserSecret")).toBe(true);
|
|
200
|
+
expect(ability.can("delete", "UserSecret")).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
it("can create and read AgentRun (Phase 1b)", () => {
|
|
203
|
+
expect(ability.can("create", "AgentRun")).toBe(true);
|
|
204
|
+
expect(ability.can("read", "AgentRun")).toBe(true);
|
|
205
|
+
expect(ability.can("update", "AgentRun")).toBe(false);
|
|
206
|
+
expect(ability.can("delete", "AgentRun")).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
it("can read but not write Import, Domain, OrgAgentConfig, OrgEmbedding, Search (Phase 1b)", () => {
|
|
209
|
+
for (const subject of [
|
|
210
|
+
"Import",
|
|
211
|
+
"Domain",
|
|
212
|
+
"OrgAgentConfig",
|
|
213
|
+
"OrgEmbedding",
|
|
214
|
+
"Search",
|
|
215
|
+
]) {
|
|
216
|
+
expect(ability.can("read", subject)).toBe(true);
|
|
217
|
+
expect(ability.can("create", subject)).toBe(false);
|
|
218
|
+
expect(ability.can("update", subject)).toBe(false);
|
|
219
|
+
expect(ability.can("delete", subject)).toBe(false);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
describe("buildAbility(UserPrincipal) — none (no active org)", () => {
|
|
224
|
+
const unscoped = {
|
|
225
|
+
type: "user",
|
|
226
|
+
id: "u-4",
|
|
227
|
+
email: "new@example.com",
|
|
228
|
+
organizationId: null,
|
|
229
|
+
role: "none",
|
|
230
|
+
};
|
|
231
|
+
const ability = buildAbility(unscoped);
|
|
232
|
+
it("can read User (handler must verify self-ID)", () => {
|
|
233
|
+
expect(ability.can("read", "User")).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
it("can create Membership for the accept-invite flow", () => {
|
|
236
|
+
expect(ability.can("create", "Membership")).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
it("cannot read Organization or existing Memberships", () => {
|
|
239
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
240
|
+
expect(ability.can("read", "Membership")).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
it("cannot touch invites as a subject (they're accepted via a separate token flow, not CASL-gated)", () => {
|
|
243
|
+
expect(ability.can("manage", "Invite")).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
it("can manage UserSecret even without an active org (Phase 1b)", () => {
|
|
246
|
+
expect(ability.can("create", "UserSecret")).toBe(true);
|
|
247
|
+
expect(ability.can("read", "UserSecret")).toBe(true);
|
|
248
|
+
expect(ability.can("update", "UserSecret")).toBe(true);
|
|
249
|
+
expect(ability.can("delete", "UserSecret")).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
it("cannot touch Phase 1b org-scoped subjects without an active org", () => {
|
|
252
|
+
expect(ability.can("read", "Node")).toBe(false);
|
|
253
|
+
expect(ability.can("read", "Search")).toBe(false);
|
|
254
|
+
expect(ability.can("read", "Import")).toBe(false);
|
|
255
|
+
expect(ability.can("read", "Domain")).toBe(false);
|
|
256
|
+
expect(ability.can("read", "Workspace")).toBe(false);
|
|
257
|
+
expect(ability.can("read", "AgentRun")).toBe(false);
|
|
258
|
+
expect(ability.can("read", "OrgAgentConfig")).toBe(false);
|
|
259
|
+
expect(ability.can("read", "OrgEmbedding")).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
// ── OrgAgentPrincipal ──────────────────────────────────────────────────────
|
|
263
|
+
describe("buildAbility(OrgAgentPrincipal) — legacyApiKey (pre-Phase-1c)", () => {
|
|
264
|
+
const legacy = {
|
|
265
|
+
type: "org_agent",
|
|
266
|
+
id: "legacy-api-key:apikey-1",
|
|
267
|
+
organizationId: "org-1",
|
|
268
|
+
name: "legacy api key",
|
|
269
|
+
legacyApiKey: true,
|
|
270
|
+
};
|
|
271
|
+
const ability = buildAbility(legacy);
|
|
272
|
+
it("grants full org access matching current API-key behavior", () => {
|
|
273
|
+
expect(ability.can("manage", "all")).toBe(true);
|
|
274
|
+
expect(ability.can("read", "Organization")).toBe(true);
|
|
275
|
+
expect(ability.can("update", "Membership")).toBe(true);
|
|
276
|
+
expect(ability.can("delete", "Invite")).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
// ── H4 interim-closure regression (Phase 1e) ────────────────────────────
|
|
279
|
+
//
|
|
280
|
+
// H4 ("API keys bound to agents with explicit permissions") is
|
|
281
|
+
// structurally blocked on the Phase 1c backfill of api_keys ->
|
|
282
|
+
// org_agent_types. Until that lands, the interim closure is that
|
|
283
|
+
// legacy api-keys synthesize an `OrgAgentPrincipal` with
|
|
284
|
+
// `legacyApiKey: true`, which grants `manage all`. The tests below
|
|
285
|
+
// lock in that interim behavior so that growing the CASL vocabulary
|
|
286
|
+
// (adding `OrgConfig`, `AgentSchedule`, `Pii`, and the new `bypass`
|
|
287
|
+
// action in Phase 1e) does NOT silently narrow what legacy api-keys
|
|
288
|
+
// can do. If any of these assertions ever flips to `false`, a
|
|
289
|
+
// production integration depending on the legacy api-key path has
|
|
290
|
+
// just lost access, and either the vocabulary change or the
|
|
291
|
+
// legacyApiKey branch in `buildAbility` needs to compensate.
|
|
292
|
+
it("H4 regression: legacy api-key can manage Phase 1e + 1b subjects", () => {
|
|
293
|
+
expect(ability.can("manage", "OrgConfig")).toBe(true);
|
|
294
|
+
expect(ability.can("manage", "AgentSchedule")).toBe(true);
|
|
295
|
+
expect(ability.can("manage", "Pii")).toBe(true);
|
|
296
|
+
// Phase 1b subjects — all must be accessible to legacy api-keys
|
|
297
|
+
expect(ability.can("manage", "Node")).toBe(true);
|
|
298
|
+
expect(ability.can("manage", "Search")).toBe(true);
|
|
299
|
+
expect(ability.can("manage", "Import")).toBe(true);
|
|
300
|
+
expect(ability.can("manage", "Domain")).toBe(true);
|
|
301
|
+
expect(ability.can("manage", "Workspace")).toBe(true);
|
|
302
|
+
expect(ability.can("manage", "AgentRun")).toBe(true);
|
|
303
|
+
expect(ability.can("manage", "OrgAgentConfig")).toBe(true);
|
|
304
|
+
expect(ability.can("manage", "OrgEmbedding")).toBe(true);
|
|
305
|
+
expect(ability.can("manage", "UserSecret")).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
it("H4 regression: legacy api-key can bypass Pii via manage wildcard (M3 interim)", () => {
|
|
308
|
+
expect(ability.can("bypass", "Pii")).toBe(true);
|
|
309
|
+
expect(ability.can("bypass", "OrgConfig")).toBe(true);
|
|
310
|
+
expect(ability.can("bypass", "AgentSchedule")).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
it("H4 regression: legacy api-key can do every concrete (action, subject) pair", () => {
|
|
313
|
+
for (const action of [
|
|
314
|
+
"create",
|
|
315
|
+
"read",
|
|
316
|
+
"update",
|
|
317
|
+
"delete",
|
|
318
|
+
"bypass",
|
|
319
|
+
]) {
|
|
320
|
+
for (const subject of [
|
|
321
|
+
"Organization",
|
|
322
|
+
"Membership",
|
|
323
|
+
"Invite",
|
|
324
|
+
"User",
|
|
325
|
+
"OrgConfig",
|
|
326
|
+
"AgentSchedule",
|
|
327
|
+
"Pii",
|
|
328
|
+
"Node",
|
|
329
|
+
"Search",
|
|
330
|
+
"Import",
|
|
331
|
+
"Domain",
|
|
332
|
+
"Workspace",
|
|
333
|
+
"AgentRun",
|
|
334
|
+
"OrgAgentConfig",
|
|
335
|
+
"OrgEmbedding",
|
|
336
|
+
"UserSecret",
|
|
337
|
+
]) {
|
|
338
|
+
expect(ability.can(action, subject)).toBe(true);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
describe("buildAbility(OrgAgentPrincipal) — non-legacy (Phase 1c placeholder)", () => {
|
|
344
|
+
const modern = {
|
|
345
|
+
type: "org_agent",
|
|
346
|
+
id: "orgagent-1",
|
|
347
|
+
organizationId: "org-1",
|
|
348
|
+
name: "a real bound agent (Phase 1c)",
|
|
349
|
+
legacyApiKey: false,
|
|
350
|
+
};
|
|
351
|
+
const ability = buildAbility(modern);
|
|
352
|
+
it("denies by default — no rules until Phase 1c wires intrinsicPolicy", () => {
|
|
353
|
+
expect(ability.can("manage", "all")).toBe(false);
|
|
354
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
355
|
+
expect(ability.can("read", "Membership")).toBe(false);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
// ── DelegatedAgentPrincipal ────────────────────────────────────────────────
|
|
359
|
+
describe("buildAbility(DelegatedAgentPrincipal) — rehydrated from capturedAbility", () => {
|
|
360
|
+
it("applies the captured rules exactly", () => {
|
|
361
|
+
// Fake captured snapshot: the agent can only read Organization and
|
|
362
|
+
// Membership — nothing else. Simulates a spawn-time intersection
|
|
363
|
+
// between a member user and a read-only agent policy.
|
|
364
|
+
const captured = [
|
|
365
|
+
{ action: "read", subject: "Organization" },
|
|
366
|
+
{ action: "read", subject: "Membership" },
|
|
367
|
+
];
|
|
368
|
+
const delegated = {
|
|
369
|
+
type: "delegated_agent",
|
|
370
|
+
agentRunId: "run-1",
|
|
371
|
+
behalfOfUserId: "u-3",
|
|
372
|
+
organizationId: "org-1",
|
|
373
|
+
capturedAbility: captured,
|
|
374
|
+
};
|
|
375
|
+
const ability = buildAbility(delegated);
|
|
376
|
+
expect(ability.can("read", "Organization")).toBe(true);
|
|
377
|
+
expect(ability.can("read", "Membership")).toBe(true);
|
|
378
|
+
expect(ability.can("update", "Organization")).toBe(false);
|
|
379
|
+
expect(ability.can("create", "Invite")).toBe(false);
|
|
380
|
+
expect(ability.can("manage", "all")).toBe(false);
|
|
381
|
+
});
|
|
382
|
+
it("yields deny-by-default when capturedAbility is garbage", () => {
|
|
383
|
+
const delegated = {
|
|
384
|
+
type: "delegated_agent",
|
|
385
|
+
agentRunId: "run-2",
|
|
386
|
+
behalfOfUserId: "u-3",
|
|
387
|
+
organizationId: "org-1",
|
|
388
|
+
capturedAbility: "this is not an array",
|
|
389
|
+
};
|
|
390
|
+
const ability = buildAbility(delegated);
|
|
391
|
+
expect(ability.can("manage", "all")).toBe(false);
|
|
392
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
393
|
+
expect(ability.can("read", "Membership")).toBe(false);
|
|
394
|
+
});
|
|
395
|
+
it("yields deny-by-default when capturedAbility is null", () => {
|
|
396
|
+
const delegated = {
|
|
397
|
+
type: "delegated_agent",
|
|
398
|
+
agentRunId: "run-3",
|
|
399
|
+
behalfOfUserId: "u-3",
|
|
400
|
+
organizationId: "org-1",
|
|
401
|
+
capturedAbility: null,
|
|
402
|
+
};
|
|
403
|
+
const ability = buildAbility(delegated);
|
|
404
|
+
expect(ability.can("manage", "all")).toBe(false);
|
|
405
|
+
});
|
|
406
|
+
it("yields deny-by-default when capturedAbility is an empty array", () => {
|
|
407
|
+
const delegated = {
|
|
408
|
+
type: "delegated_agent",
|
|
409
|
+
agentRunId: "run-4",
|
|
410
|
+
behalfOfUserId: "u-3",
|
|
411
|
+
organizationId: "org-1",
|
|
412
|
+
capturedAbility: [],
|
|
413
|
+
};
|
|
414
|
+
const ability = buildAbility(delegated);
|
|
415
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
416
|
+
});
|
|
417
|
+
it("handles a wildcard captured rule", () => {
|
|
418
|
+
// An agent whose intrinsic policy was "manage all" and whose invoking
|
|
419
|
+
// user was an owner — the intersection is still "manage all".
|
|
420
|
+
const captured = [{ action: "manage", subject: "all" }];
|
|
421
|
+
const delegated = {
|
|
422
|
+
type: "delegated_agent",
|
|
423
|
+
agentRunId: "run-5",
|
|
424
|
+
behalfOfUserId: "u-1",
|
|
425
|
+
organizationId: "org-1",
|
|
426
|
+
capturedAbility: captured,
|
|
427
|
+
};
|
|
428
|
+
const ability = buildAbility(delegated);
|
|
429
|
+
expect(ability.can("manage", "all")).toBe(true);
|
|
430
|
+
expect(ability.can("delete", "Invite")).toBe(true);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
// ── intersect() rule-set intersection (Phase 1d) ───────────────────────────
|
|
434
|
+
/**
|
|
435
|
+
* Helper for the intersect() tests: instead of asserting on the raw rule
|
|
436
|
+
* structure (which would couple the tests to the uncompressed
|
|
437
|
+
* representation chosen in Phase 1d), we rehydrate the intersection into
|
|
438
|
+
* a CASL ability and check behavior via `.can()`. This is robust to any
|
|
439
|
+
* future change in how the intersection is serialized — compression,
|
|
440
|
+
* different ordering, wildcard collapsing — as long as the authorization
|
|
441
|
+
* semantics stay the same.
|
|
442
|
+
*/
|
|
443
|
+
function rehydrate(rules) {
|
|
444
|
+
return createMongoAbility(rules);
|
|
445
|
+
}
|
|
446
|
+
describe("intersect() — identity cases", () => {
|
|
447
|
+
it("empty ∩ empty → deny-by-default", () => {
|
|
448
|
+
const result = intersect([], []);
|
|
449
|
+
const ability = rehydrate(result);
|
|
450
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
451
|
+
expect(ability.can("manage", "all")).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
it("X ∩ empty → deny-by-default (the empty side wins)", () => {
|
|
454
|
+
const result = intersect([{ action: "manage", subject: "all" }], []);
|
|
455
|
+
const ability = rehydrate(result);
|
|
456
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
457
|
+
expect(ability.can("manage", "all")).toBe(false);
|
|
458
|
+
});
|
|
459
|
+
it("empty ∩ X → deny-by-default (direction does not matter)", () => {
|
|
460
|
+
const result = intersect([], [{ action: "manage", subject: "all" }]);
|
|
461
|
+
const ability = rehydrate(result);
|
|
462
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
describe("intersect() — full overlap", () => {
|
|
466
|
+
it("manage-all ∩ manage-all → functionally manage-all", () => {
|
|
467
|
+
const result = intersect([{ action: "manage", subject: "all" }], [{ action: "manage", subject: "all" }]);
|
|
468
|
+
const ability = rehydrate(result);
|
|
469
|
+
// Every concrete (action, subject) pair should be allowed. We test
|
|
470
|
+
// the functional behavior rather than the rule count — the Phase 1d
|
|
471
|
+
// implementation emits 16 concrete rules, but a future compression
|
|
472
|
+
// pass might emit just one `manage all`, and either is correct.
|
|
473
|
+
for (const action of ["create", "read", "update", "delete"]) {
|
|
474
|
+
for (const subject of [
|
|
475
|
+
"Organization",
|
|
476
|
+
"Membership",
|
|
477
|
+
"Invite",
|
|
478
|
+
"User",
|
|
479
|
+
]) {
|
|
480
|
+
expect(ability.can(action, subject)).toBe(true);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
it("identical non-wildcard inputs → the same rules (functionally)", () => {
|
|
485
|
+
const input = [
|
|
486
|
+
{ action: "read", subject: "Organization" },
|
|
487
|
+
{ action: "read", subject: "Membership" },
|
|
488
|
+
];
|
|
489
|
+
const ability = rehydrate(intersect(input, input));
|
|
490
|
+
expect(ability.can("read", "Organization")).toBe(true);
|
|
491
|
+
expect(ability.can("read", "Membership")).toBe(true);
|
|
492
|
+
expect(ability.can("update", "Organization")).toBe(false);
|
|
493
|
+
expect(ability.can("manage", "all")).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
describe("intersect() — wildcards restrict to the narrower side", () => {
|
|
497
|
+
it("manage-all ∩ [read Organization] → read Organization", () => {
|
|
498
|
+
const ability = rehydrate(intersect([{ action: "manage", subject: "all" }], [{ action: "read", subject: "Organization" }]));
|
|
499
|
+
expect(ability.can("read", "Organization")).toBe(true);
|
|
500
|
+
expect(ability.can("update", "Organization")).toBe(false);
|
|
501
|
+
expect(ability.can("read", "Membership")).toBe(false);
|
|
502
|
+
expect(ability.can("create", "Invite")).toBe(false);
|
|
503
|
+
});
|
|
504
|
+
it("[read Org, read Membership] ∩ manage-all → read Org, read Membership", () => {
|
|
505
|
+
const ability = rehydrate(intersect([
|
|
506
|
+
{ action: "read", subject: "Organization" },
|
|
507
|
+
{ action: "read", subject: "Membership" },
|
|
508
|
+
], [{ action: "manage", subject: "all" }]));
|
|
509
|
+
expect(ability.can("read", "Organization")).toBe(true);
|
|
510
|
+
expect(ability.can("read", "Membership")).toBe(true);
|
|
511
|
+
expect(ability.can("read", "Invite")).toBe(false);
|
|
512
|
+
expect(ability.can("update", "Organization")).toBe(false);
|
|
513
|
+
});
|
|
514
|
+
it("manage-Organization ∩ read-everything-concrete → read Organization only", () => {
|
|
515
|
+
// "manage Invite" on the user side, "read all concrete subjects" on
|
|
516
|
+
// the agent side — only `read Invite` should survive (no other
|
|
517
|
+
// subject appears on both sides, and the actions intersect to
|
|
518
|
+
// {read} on Invite).
|
|
519
|
+
const ability = rehydrate(intersect([{ action: "manage", subject: "Invite" }], [
|
|
520
|
+
{ action: "read", subject: "Organization" },
|
|
521
|
+
{ action: "read", subject: "Membership" },
|
|
522
|
+
{ action: "read", subject: "Invite" },
|
|
523
|
+
{ action: "read", subject: "User" },
|
|
524
|
+
]));
|
|
525
|
+
expect(ability.can("read", "Invite")).toBe(true);
|
|
526
|
+
expect(ability.can("update", "Invite")).toBe(false);
|
|
527
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
describe("intersect() — disjoint inputs", () => {
|
|
531
|
+
it("[read Organization] ∩ [read Invite] → deny-by-default", () => {
|
|
532
|
+
const ability = rehydrate(intersect([{ action: "read", subject: "Organization" }], [{ action: "read", subject: "Invite" }]));
|
|
533
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
534
|
+
expect(ability.can("read", "Invite")).toBe(false);
|
|
535
|
+
expect(ability.can("manage", "all")).toBe(false);
|
|
536
|
+
});
|
|
537
|
+
it("[read Organization] ∩ [update Organization] → deny-by-default (same subject, different actions)", () => {
|
|
538
|
+
const ability = rehydrate(intersect([{ action: "read", subject: "Organization" }], [{ action: "update", subject: "Organization" }]));
|
|
539
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
540
|
+
expect(ability.can("update", "Organization")).toBe(false);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
describe("intersect() — realistic spawn scenarios", () => {
|
|
544
|
+
it("admin user ∩ invite-bot agent → the admin's Invite management, nothing else", () => {
|
|
545
|
+
// Mirrors the actual rule set buildAbility() emits for an admin
|
|
546
|
+
// UserPrincipal, intersected with an agent whose intrinsic policy
|
|
547
|
+
// is "manage Invite".
|
|
548
|
+
const adminRules = [
|
|
549
|
+
{ action: "manage", subject: "Invite" },
|
|
550
|
+
{ action: "read", subject: "Organization" },
|
|
551
|
+
{ action: "read", subject: "Membership" },
|
|
552
|
+
];
|
|
553
|
+
const inviteBotPolicy = [
|
|
554
|
+
{ action: "manage", subject: "Invite" },
|
|
555
|
+
];
|
|
556
|
+
const ability = rehydrate(intersect(adminRules, inviteBotPolicy));
|
|
557
|
+
expect(ability.can("create", "Invite")).toBe(true);
|
|
558
|
+
expect(ability.can("read", "Invite")).toBe(true);
|
|
559
|
+
expect(ability.can("update", "Invite")).toBe(true);
|
|
560
|
+
expect(ability.can("delete", "Invite")).toBe(true);
|
|
561
|
+
// Read-only affordances from the admin side should NOT leak through:
|
|
562
|
+
// the bot's intrinsic policy does not include them.
|
|
563
|
+
expect(ability.can("read", "Organization")).toBe(false);
|
|
564
|
+
expect(ability.can("read", "Membership")).toBe(false);
|
|
565
|
+
});
|
|
566
|
+
it("member user ∩ manage-all agent → member's read affordances only", () => {
|
|
567
|
+
// A read-only user runs a powerful agent: the intersection is
|
|
568
|
+
// bounded by the user, not by the agent. This is the whole reason
|
|
569
|
+
// the intersection is computed in the first place.
|
|
570
|
+
const memberRules = [
|
|
571
|
+
{ action: "read", subject: "Organization" },
|
|
572
|
+
{ action: "read", subject: "Membership" },
|
|
573
|
+
];
|
|
574
|
+
const powerfulAgentPolicy = [
|
|
575
|
+
{ action: "manage", subject: "all" },
|
|
576
|
+
];
|
|
577
|
+
const ability = rehydrate(intersect(memberRules, powerfulAgentPolicy));
|
|
578
|
+
expect(ability.can("read", "Organization")).toBe(true);
|
|
579
|
+
expect(ability.can("read", "Membership")).toBe(true);
|
|
580
|
+
expect(ability.can("create", "Invite")).toBe(false);
|
|
581
|
+
expect(ability.can("manage", "all")).toBe(false);
|
|
582
|
+
expect(ability.can("update", "Organization")).toBe(false);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
describe("intersect() — Phase 1e bypass semantics", () => {
|
|
586
|
+
// These tests lock in the delegation behavior for the bypass action
|
|
587
|
+
// added in Phase 1e. The crucial property is that `bypass Pii` is
|
|
588
|
+
// captured at spawn time only if BOTH the invoking user AND the agent's
|
|
589
|
+
// intrinsic policy include it. That is the whole point of the
|
|
590
|
+
// intersect — a member user running a manage-all agent never gets
|
|
591
|
+
// bypass authority, even though the agent's policy has it.
|
|
592
|
+
it("owner ∩ manage-all agent → bypass Pii survives", () => {
|
|
593
|
+
// Mirrors an owner spawning an agent with `manage all` intrinsic
|
|
594
|
+
// policy. Both sides grant bypass via the wildcard, so the
|
|
595
|
+
// intersection includes it.
|
|
596
|
+
const ownerRules = [{ action: "manage", subject: "all" }];
|
|
597
|
+
const agentPolicy = [{ action: "manage", subject: "all" }];
|
|
598
|
+
const ability = rehydrate(intersect(ownerRules, agentPolicy));
|
|
599
|
+
expect(ability.can("bypass", "Pii")).toBe(true);
|
|
600
|
+
});
|
|
601
|
+
it("member ∩ manage-all agent → bypass Pii does NOT survive (the whole point)", () => {
|
|
602
|
+
// The member's captured ability does not include bypass on any
|
|
603
|
+
// subject. Running a powerful agent does not broaden that — the
|
|
604
|
+
// delegated agent inherits the floor, not the ceiling.
|
|
605
|
+
const memberRules = [
|
|
606
|
+
{ action: "read", subject: "Organization" },
|
|
607
|
+
{ action: "read", subject: "Membership" },
|
|
608
|
+
{ action: "read", subject: "OrgConfig" },
|
|
609
|
+
{ action: "read", subject: "AgentSchedule" },
|
|
610
|
+
{ action: "read", subject: "Pii" },
|
|
611
|
+
];
|
|
612
|
+
const agentPolicy = [{ action: "manage", subject: "all" }];
|
|
613
|
+
const ability = rehydrate(intersect(memberRules, agentPolicy));
|
|
614
|
+
expect(ability.can("bypass", "Pii")).toBe(false);
|
|
615
|
+
// And the member's read affordances still come through.
|
|
616
|
+
expect(ability.can("read", "OrgConfig")).toBe(true);
|
|
617
|
+
expect(ability.can("read", "Pii")).toBe(true);
|
|
618
|
+
});
|
|
619
|
+
it("owner ∩ read-only agent → bypass does NOT survive (agent side blocks it)", () => {
|
|
620
|
+
// An agent whose intrinsic policy is `read Pii` does not grant
|
|
621
|
+
// bypass, regardless of how powerful the invoking user is. The
|
|
622
|
+
// intersection respects the agent's narrower grant.
|
|
623
|
+
const ownerRules = [{ action: "manage", subject: "all" }];
|
|
624
|
+
const readOnlyAgent = [
|
|
625
|
+
{ action: "read", subject: "Pii" },
|
|
626
|
+
];
|
|
627
|
+
const ability = rehydrate(intersect(ownerRules, readOnlyAgent));
|
|
628
|
+
expect(ability.can("bypass", "Pii")).toBe(false);
|
|
629
|
+
expect(ability.can("read", "Pii")).toBe(true);
|
|
630
|
+
expect(ability.can("update", "Pii")).toBe(false);
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
describe("intersect() — unsupported rule shapes", () => {
|
|
634
|
+
it("rejects a conditional rule on the left side", () => {
|
|
635
|
+
const leftWithCondition = [
|
|
636
|
+
{
|
|
637
|
+
action: "read",
|
|
638
|
+
subject: "Organization",
|
|
639
|
+
conditions: { organizationId: "org-1" },
|
|
640
|
+
},
|
|
641
|
+
];
|
|
642
|
+
expect(() => intersect(leftWithCondition, [])).toThrow(UnsupportedIntersectionError);
|
|
643
|
+
expect(() => intersect(leftWithCondition, [])).toThrow(/left\[0\]/);
|
|
644
|
+
});
|
|
645
|
+
it("rejects a conditional rule on the right side", () => {
|
|
646
|
+
const rightWithCondition = [
|
|
647
|
+
{
|
|
648
|
+
action: "read",
|
|
649
|
+
subject: "Organization",
|
|
650
|
+
conditions: { organizationId: "org-1" },
|
|
651
|
+
},
|
|
652
|
+
];
|
|
653
|
+
expect(() => intersect([], rightWithCondition)).toThrow(UnsupportedIntersectionError);
|
|
654
|
+
expect(() => intersect([], rightWithCondition)).toThrow(/right\[0\]/);
|
|
655
|
+
});
|
|
656
|
+
it("rejects a field-scoped rule", () => {
|
|
657
|
+
const withFields = [
|
|
658
|
+
{
|
|
659
|
+
action: "read",
|
|
660
|
+
subject: "Organization",
|
|
661
|
+
fields: ["name"],
|
|
662
|
+
},
|
|
663
|
+
];
|
|
664
|
+
expect(() => intersect(withFields, [])).toThrow(UnsupportedIntersectionError);
|
|
665
|
+
expect(() => intersect(withFields, [])).toThrow(/field scoping/);
|
|
666
|
+
});
|
|
667
|
+
it("identifies the offending index in a multi-rule input", () => {
|
|
668
|
+
const rules = [
|
|
669
|
+
{ action: "read", subject: "Organization" },
|
|
670
|
+
{ action: "read", subject: "Membership" },
|
|
671
|
+
{
|
|
672
|
+
action: "read",
|
|
673
|
+
subject: "Invite",
|
|
674
|
+
conditions: { organizationId: "org-1" },
|
|
675
|
+
},
|
|
676
|
+
];
|
|
677
|
+
expect(() => intersect(rules, [])).toThrow(/left\[2\]/);
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
//# sourceMappingURL=ability.test.js.map
|