@stigmer/server 3.27.1 → 3.27.2

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.
Files changed (40) hide show
  1. package/dist/authorization/authorizer.d.ts +4 -2
  2. package/dist/authorization/authorizer.d.ts.map +1 -1
  3. package/dist/authorization/authorizer.js +4 -2
  4. package/dist/authorization/authorizer.js.map +1 -1
  5. package/dist/authorization/derived-tuples.d.ts +3 -1
  6. package/dist/authorization/derived-tuples.d.ts.map +1 -1
  7. package/dist/authorization/derived-tuples.js +46 -1
  8. package/dist/authorization/derived-tuples.js.map +1 -1
  9. package/dist/boot/list-indexes.d.ts.map +1 -1
  10. package/dist/boot/list-indexes.js +6 -0
  11. package/dist/boot/list-indexes.js.map +1 -1
  12. package/dist/domain/iampolicy/list-index.d.ts +2 -0
  13. package/dist/domain/iampolicy/list-index.d.ts.map +1 -0
  14. package/dist/domain/iampolicy/list-index.js +32 -0
  15. package/dist/domain/iampolicy/list-index.js.map +1 -0
  16. package/dist/domain/iampolicy/resource-store.d.ts.map +1 -1
  17. package/dist/domain/iampolicy/resource-store.js +24 -11
  18. package/dist/domain/iampolicy/resource-store.js.map +1 -1
  19. package/dist/extensions/list-read-scope.d.ts +2 -1
  20. package/dist/extensions/list-read-scope.d.ts.map +1 -1
  21. package/dist/extensions/list-read-scope.js +2 -1
  22. package/dist/extensions/list-read-scope.js.map +1 -1
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +5 -0
  26. package/dist/index.js.map +1 -1
  27. package/package.json +6 -6
  28. package/src/authorization/README.md +7 -5
  29. package/src/authorization/__tests__/derived-tuples.test.ts +173 -0
  30. package/src/authorization/__tests__/enterprise-model.ts +59 -0
  31. package/src/authorization/__tests__/list-read-scope.measure.test.ts +425 -55
  32. package/src/authorization/authorizer.ts +4 -2
  33. package/src/authorization/derived-tuples.ts +98 -14
  34. package/src/boot/__tests__/list-indexes.test.ts +4 -0
  35. package/src/boot/list-indexes.ts +6 -0
  36. package/src/domain/iampolicy/__tests__/resource-store.test.ts +65 -5
  37. package/src/domain/iampolicy/list-index.ts +33 -0
  38. package/src/domain/iampolicy/resource-store.ts +24 -11
  39. package/src/extensions/list-read-scope.ts +2 -1
  40. package/src/index.ts +5 -0
@@ -26,18 +26,39 @@
26
26
  * rules' one predicate). The person comparison itself is the
27
27
  * evaluator's, over the aliases `Person` carries.
28
28
  *
29
- * The SOURCE joins two records per (object, relation): the person's own
30
- * IamPolicy rows on the object (read ONCE per source through
31
- * `findByPrincipal` — every row open source writes names an account id,
32
- * so the person's account id is the whole key, and on the OSS adapter one
33
- * read is one scan whatever the number of organizations walked) and the
34
- * tuples derived from the object's row (loaded once per object through
35
- * the loader, which a declaration's `derived` rule also reads related
36
- * rows through). An absent row is no tuples — the target's own not-found
37
- * is the driver's arm, and a parent that no longer exists simply grants
38
- * nothing, as in the cloud; any other store fault propagates so the
39
- * driver folds it to `unavailable`. One source per check (or per list
40
- * call for the list scope): its memo is the check's.
29
+ * The SOURCE joins two records per (object, relation): the IamPolicy rows
30
+ * a check can meet on the object, and the tuples derived from the
31
+ * object's row (loaded once per object through the loader, which a
32
+ * declaration's `derived` rule also reads related rows through). The rows
33
+ * are read ONCE per source through `findByPrincipal`, keyed by the
34
+ * principals a check can reach the person through:
35
+ *
36
+ * - the person's account — every role and every grant made to them;
37
+ * - every team the person holds `member` on — a grant made to
38
+ * `team:<id>#member` names the team, never the person, so it is read
39
+ * by the team's id. The set is complete without recursion: a team's
40
+ * membership is a direct grant only and a team never contains a team,
41
+ * so every team that can admit the person appears among the person's
42
+ * own rows. The rows grant nothing by being read: the evaluator still
43
+ * asks whether the person is the team's member, which the model bounds
44
+ * by the organization (`member: [identity_account] and viewer from
45
+ * organization`), so a person who left the organization reads the
46
+ * team's rows and is admitted by none of them.
47
+ *
48
+ * Open source serves no team (the kind's tier is enterprise), so the
49
+ * person's rows name none and the second read never happens: one read per
50
+ * source, an indexed read on the OSS adapter (its list index by
51
+ * principal) whatever the number of organizations walked or rows held.
52
+ * An edition that composes teams over the built-in
53
+ * evaluator pays one read more per team the person holds. The rows are
54
+ * indexed by (object, relation) once, so a list's thousands of candidates
55
+ * each find theirs without walking the rest.
56
+ *
57
+ * An absent row is no tuples — the target's own not-found is the driver's
58
+ * arm, and a parent that no longer exists simply grants nothing, as in the
59
+ * cloud; any other store fault propagates so the driver folds it to
60
+ * `unavailable`. One source per check (or per list call for the list
61
+ * scope): its memo is the check's.
41
62
  *
42
63
  * A source may be SEEDED with the facts of objects the caller already
43
64
  * holds (the list scope's candidates, decoded by the lane and carried on
@@ -102,6 +123,10 @@ export const ROW_RECORDED_OWNER_KINDS: ReadonlySet<ApiResourceKind> = new Set([
102
123
  ApiResourceKind.organization,
103
124
  ]);
104
125
 
126
+ /** The FGA type a team grant names, and the relation that makes a person one of its members (the model's `team#member`). */
127
+ const TEAM_TYPE = kindEnumName(ApiResourceKind.team);
128
+ const TEAM_MEMBER_RELATION = "member";
129
+
105
130
  function account(id: string): Subject {
106
131
  return { form: "object", object: { type: ACCOUNT_TYPE, id } };
107
132
  }
@@ -209,7 +234,9 @@ export interface DerivedTupleSource extends TupleSource {
209
234
  * The person's own IamPolicy rows as tuples — the ONE read of them this
210
235
  * source makes, shared with `tuplesOf`. A list-shaped consumer (the
211
236
  * organization directory; the list scope) reads its candidate objects
212
- * from here instead of scanning the port a second time.
237
+ * from here instead of scanning the port a second time. The rows granted
238
+ * to the person's teams are not among them: they name the team, and
239
+ * `tuplesOf` serves them where a check meets them.
213
240
  */
214
241
  personTuples(): Promise<ReadonlyArray<Tuple>>;
215
242
  }
@@ -235,6 +262,9 @@ export function newDerivedTupleSource(
235
262
  );
236
263
  }
237
264
  let personRows: Promise<ReadonlyArray<Tuple>> | undefined;
265
+ let reachableRows:
266
+ | Promise<ReadonlyMap<string, ReadonlyArray<Tuple>>>
267
+ | undefined;
238
268
 
239
269
  const loader: RowLoader = {
240
270
  load(object) {
@@ -284,6 +314,38 @@ export function newDerivedTupleSource(
284
314
  return personRows;
285
315
  }
286
316
 
317
+ /**
318
+ * Every row a check can meet, keyed by (object, relation): the person's
319
+ * own, then the rows granted to each team the person holds `member` on
320
+ * (the module header says why that set is complete). A person in no
321
+ * team costs nothing beyond their own rows.
322
+ */
323
+ function reachableTuples(): Promise<
324
+ ReadonlyMap<string, ReadonlyArray<Tuple>>
325
+ > {
326
+ if (reachableRows === undefined) {
327
+ reachableRows = rowTuples().then(async (own) => {
328
+ const teams = new Set<string>();
329
+ for (const tuple of own) {
330
+ if (
331
+ tuple.object.type === TEAM_TYPE &&
332
+ tuple.relation === TEAM_MEMBER_RELATION &&
333
+ tuple.object.id !== ""
334
+ ) {
335
+ teams.add(tuple.object.id);
336
+ }
337
+ }
338
+ const granted = await Promise.all(
339
+ [...teams].map((team) =>
340
+ deps.policies.findByPrincipal(TEAM_TYPE, team),
341
+ ),
342
+ );
343
+ return indexByPair([...own, ...granted.flat().map(tupleOfRow)]);
344
+ });
345
+ }
346
+ return reachableRows;
347
+ }
348
+
287
349
  return {
288
350
  loader,
289
351
  personTuples: rowTuples,
@@ -292,7 +354,8 @@ export function newDerivedTupleSource(
292
354
  tuple.relation === relation &&
293
355
  tuple.object.type === object.type &&
294
356
  tuple.object.id === object.id;
295
- const fromRows = (await rowTuples()).filter(onPair);
357
+ const fromRows =
358
+ (await reachableTuples()).get(pairKey(object, relation)) ?? [];
296
359
  const declaration = model.byType(object.type);
297
360
  if (declaration === undefined) {
298
361
  return fromRows;
@@ -337,6 +400,27 @@ async function loadRow(
337
400
  }
338
401
  }
339
402
 
403
+ /** The key a tuple is served under: its object and relation. */
404
+ function pairKey(object: ObjectRef, relation: string): string {
405
+ return `${formatObjectRef(object)}#${relation}`;
406
+ }
407
+
408
+ function indexByPair(
409
+ tuples: ReadonlyArray<Tuple>,
410
+ ): ReadonlyMap<string, ReadonlyArray<Tuple>> {
411
+ const index = new Map<string, Tuple[]>();
412
+ for (const tuple of tuples) {
413
+ const key = pairKey(tuple.object, tuple.relation);
414
+ const held = index.get(key);
415
+ if (held === undefined) {
416
+ index.set(key, [tuple]);
417
+ } else {
418
+ held.push(tuple);
419
+ }
420
+ }
421
+ return index;
422
+ }
423
+
340
424
  /** An IamPolicy row as the tuple it records: `resource#relation@principal`. */
341
425
  function tupleOfRow(row: IamPolicy): Tuple {
342
426
  const spec = row.spec;
@@ -27,6 +27,10 @@ const PINNED: Readonly<
27
27
  fingerprint:
28
28
  "artifact{agent_execution=field:spec.source.agent_execution_id,workflow_execution=field:spec.source.workflow_execution_id}",
29
29
  },
30
+ iam_policy: {
31
+ revision: 1,
32
+ fingerprint: "iam_policy{principal=field:spec.principal.id}",
33
+ },
30
34
  session: {
31
35
  revision: 1,
32
36
  fingerprint:
@@ -10,9 +10,14 @@
10
10
  * parent's rows without decoding the whole kind; the kinds here were
11
11
  * chosen on the hosted edition's row counts and sizes (2026-09-23: every
12
12
  * other org-scoped kind held under a hundred rows and a hundred kilobytes).
13
+ * A kind also joins when a port read on every authorization check would
14
+ * otherwise decode the whole kind: `iam_policy`, whose adapter reads a
15
+ * person's rows by principal (measured 2026-09-24: about 4.6 microseconds
16
+ * per row per read, 47 ms a check at ten thousand rows on sqlite).
13
17
  */
14
18
  import { agentExecutionListIndex } from "../domain/agentexecution/list-index.js";
15
19
  import { artifactListIndex } from "../domain/artifact/list-index.js";
20
+ import { iamPolicyListIndex } from "../domain/iampolicy/list-index.js";
16
21
  import { sessionListIndex } from "../domain/session/list-index.js";
17
22
  import { workflowExecutionListIndex } from "../domain/workflowexecution/list-index.js";
18
23
  import type { ListIndexDeclaration } from "../store/list-index.js";
@@ -20,6 +25,7 @@ import type { ListIndexDeclaration } from "../store/list-index.js";
20
25
  export const LIST_INDEXES: ReadonlyArray<ListIndexDeclaration> = [
21
26
  agentExecutionListIndex,
22
27
  artifactListIndex,
28
+ iamPolicyListIndex,
23
29
  sessionListIndex,
24
30
  workflowExecutionListIndex,
25
31
  ];
@@ -2,10 +2,12 @@
2
2
  * Runs the IamPolicyStore port-contract kit (../store-contract.ts) over the
3
3
  * OSS adapter (../resource-store.ts) on both drivers through the drivers'
4
4
  * own fixtures (sqlite always; Postgres under TEST_DATABASE_URL), and pins
5
- * the one behaviour that is the OSS adapter's rather than the port's
6
- * (T01_1_review.md Q-OR-9): save refuses a policy whose id is not the
7
- * triple's derived id, the invariant that makes "one row per triple" the
8
- * primary key's job in open source.
5
+ * the two behaviours that are the OSS adapter's rather than the port's:
6
+ * save refuses a policy whose id is not the triple's derived id, the
7
+ * invariant that makes "one row per triple" the primary key's job in open
8
+ * source; and a principal's rows are read through the kind's list index,
9
+ * never by decoding the whole kind, because the built-in authorizer reads
10
+ * them on every check.
9
11
  *
10
12
  * The kit's case list is pinned by name so a case cannot drop out unnoticed:
11
13
  * the cloud driver's test iterates the same export over `cloud.iam_policy`
@@ -21,6 +23,7 @@ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/
21
23
  import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
22
24
  import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
23
25
 
26
+ import { LIST_INDEXES } from "../../../boot/list-indexes.js";
24
27
  import type { Store } from "../../../store/interface.js";
25
28
  import { PostgresStore } from "../../../store/postgres/store.js";
26
29
  import {
@@ -82,7 +85,11 @@ const postgresFixture: DriverFixture = {
82
85
  if (postgresDatabase === undefined) {
83
86
  postgresDatabase = await createTestDatabase();
84
87
  }
85
- const store = await PostgresStore.open(postgresDatabase.databaseUrl);
88
+ const store = await PostgresStore.open(
89
+ postgresDatabase.databaseUrl,
90
+ undefined,
91
+ { listIndexes: LIST_INDEXES },
92
+ );
86
93
  await store.deleteResourcesByKind(ApiResourceKind.iam_policy);
87
94
  return { store, close: () => store.close() };
88
95
  },
@@ -160,6 +167,59 @@ describe.each([sqliteFixture, postgresFixture])(
160
167
  spec,
161
168
  );
162
169
  });
170
+
171
+ it("reads a principal's rows through the list index, never by decoding the whole kind", async () => {
172
+ let scans = 0;
173
+ const target = opened.store;
174
+ // Every method runs on the real store; only the scan is counted.
175
+ const counted = new Proxy(target, {
176
+ get(store, property) {
177
+ if (property === "listResources") {
178
+ return (kind: ApiResourceKind) => {
179
+ if (kind === ApiResourceKind.iam_policy) {
180
+ scans += 1;
181
+ }
182
+ return store.listResources(kind);
183
+ };
184
+ }
185
+ const value: unknown = Reflect.get(store, property, store);
186
+ return typeof value === "function" ? value.bind(store) : value;
187
+ },
188
+ });
189
+ const policies = newResourceIamPolicyStore(counted);
190
+ for (const [principal, relation] of [
191
+ ["ida_wtr3jcf281yfk9xx61kj59fsme", "admin"],
192
+ ["ida_wtr3jcf281yfk9xx61kj59fsme", "member"],
193
+ ["ida_0hlf2yb5mhkgb3bdzkkrptqf4d", "member"],
194
+ ] as const) {
195
+ const spec = orgRole(principal, relation, "acme");
196
+ await policies.save(
197
+ create(IamPolicySchema, {
198
+ apiVersion: IAM_POLICY_API_VERSION,
199
+ kind: IAM_POLICY_KIND,
200
+ metadata: create(ApiResourceMetadataSchema, {
201
+ id: policyIdFor(spec),
202
+ }),
203
+ spec,
204
+ }),
205
+ );
206
+ }
207
+ const found = await policies.findByPrincipal(
208
+ "identity_account",
209
+ "ida_wtr3jcf281yfk9xx61kj59fsme",
210
+ );
211
+ expect(found.map((policy) => policy.spec?.relation).sort()).toEqual([
212
+ "admin",
213
+ "member",
214
+ ]);
215
+ expect(
216
+ await policies.findByPrincipal(
217
+ "team",
218
+ "ida_wtr3jcf281yfk9xx61kj59fsme",
219
+ ),
220
+ ).toEqual([]);
221
+ expect(scans).toBe(0);
222
+ });
163
223
  });
164
224
  },
165
225
  );
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The IamPolicy list index (store/list-index.ts): `principal` is the key
3
+ * the open-source adapter's `findByPrincipal` reads (resource-store.ts).
4
+ * It is not a lane's index: the built-in authorizer reads a person's rows,
5
+ * and the rows granted to each team they belong to, on every check and
6
+ * every list batch (authorization/derived-tuples.ts), and without the key
7
+ * each of those reads decodes the whole kind — a table that grows with
8
+ * every member, organization and grant.
9
+ *
10
+ * The key is the principal's id alone: a key reads one string field, and
11
+ * an id carries its kind's prefix (`ida_` for an account, `tm_` for a
12
+ * team), so the index narrows to one principal and the adapter's
13
+ * predicate on (kind, id) keeps the answer exact whatever the index
14
+ * returns. A policy is not organization-scoped the way a lane's rows are,
15
+ * so the read names no organization; the key table's lookup index leads
16
+ * with the key, not the organization (the v6 migration of each driver).
17
+ *
18
+ * A change to `keys` bumps `revision` (boot/__tests__/list-indexes.test.ts
19
+ * pins the pair).
20
+ */
21
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
22
+ import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
23
+
24
+ import { declareListIndex, field } from "../../store/list-index.js";
25
+
26
+ export const iamPolicyListIndex = declareListIndex({
27
+ kind: ApiResourceKind.iam_policy,
28
+ schema: IamPolicySchema,
29
+ revision: 1,
30
+ keys: {
31
+ principal: field("spec.principal.id"),
32
+ },
33
+ });
@@ -4,14 +4,21 @@
4
4
  * §3a, §4). The composition root installs it when no extension registers
5
5
  * `drivers.iamPolicyStore`.
6
6
  *
7
- * Reads. `findById` is a PRIMARY-KEY read. Every other find decodes
8
- * `listResources(iam_policy)` and filters in memory: the `resources` table
9
- * has no secondary index and `findByField` is single-hit, so a scan is the
10
- * honest shape, and the row count is members × organizations on a
11
- * self-host — measured in the entry's execution record (§4), not assumed.
12
- * The filters are the cloud store's WHERE clauses restated over the proto
13
- * (store.ts names each), so a driver's test over either edition reads the
14
- * same contract.
7
+ * Reads. `findById` is a PRIMARY-KEY read. `findByPrincipal` is an
8
+ * INDEXED read through the kind's list index (list-index.ts), because the
9
+ * built-in authorizer calls it on every check and every list batch — for
10
+ * the person, and once more per team they belong to — and a scan there
11
+ * costs a decode of every row the server holds, per call (measured
12
+ * 2026-09-24: about 4.6 microseconds per row, 47 ms a check at ten
13
+ * thousand rows on sqlite). The index narrows by the principal's id; the
14
+ * (kind, id) predicate decides, so the answer is exact whatever the index
15
+ * returns, and the store keeps it exact while an older binary still
16
+ * writes (store/interface.ts, `queryResources`). Every other find decodes
17
+ * `listResources(iam_policy)` and filters in memory: they serve the grant
18
+ * path and the access lists, not the check, and the row count behind them
19
+ * is members × organizations on a self-host. The filters are the cloud
20
+ * store's WHERE clauses restated over the proto (store.ts names each), so
21
+ * a driver's test over either edition reads the same contract.
15
22
  *
16
23
  * Writes. `save` refuses a policy whose id is not its triple's derived id
17
24
  * (constants.ts policyIdFor): open source has no legacy random ids, and a
@@ -43,6 +50,7 @@ import { kindEnumName } from "../../pipeline/apiresource-meta.js";
43
50
  import { ResourceNotFoundError } from "../../store/interface.js";
44
51
  import type { Store } from "../../store/interface.js";
45
52
  import { USER_GRANT_PRINCIPAL_KINDS, policyIdFor } from "./constants.js";
53
+ import { iamPolicyListIndex } from "./list-index.js";
46
54
  import { DuplicatePolicyError } from "./store.js";
47
55
  import type { IamPolicyStore } from "./store.js";
48
56
 
@@ -72,7 +80,7 @@ export function newResourceIamPolicyStore(store: Store): IamPolicyStore {
72
80
  }
73
81
  }
74
82
 
75
- /** Every row of the kind, decoded — the scan behind every non-id read. */
83
+ /** Every row of the kind, decoded — the scan behind every read but by id and by principal. */
76
84
  async function all(): Promise<ReadonlyArray<IamPolicy>> {
77
85
  const rows = await store.listResources(KIND);
78
86
  return rows.map((bytes) => fromBinary(IamPolicySchema, bytes));
@@ -102,8 +110,13 @@ export function newResourceIamPolicyStore(store: Store): IamPolicyStore {
102
110
 
103
111
  findById: readById,
104
112
 
105
- findByPrincipal(principalKind, principalId) {
106
- return where((policy) => onPrincipal(policy, principalKind, principalId));
113
+ async findByPrincipal(principalKind, principalId) {
114
+ const rows = await store.queryResources(iamPolicyListIndex, {
115
+ anyKey: [{ name: "principal", value: principalId }],
116
+ });
117
+ return rows
118
+ .map((row) => fromBinary(IamPolicySchema, row.data))
119
+ .filter((policy) => onPrincipal(policy, principalKind, principalId));
107
120
  },
108
121
 
109
122
  findByResource(resourceKind, resourceId) {
@@ -224,7 +224,8 @@ export interface ScopedListResource {
224
224
  /**
225
225
  * The ONE consumption idiom for post-scan lanes (the shared-step
226
226
  * discipline of pipeline/steps/authorization-tuples.ts, rendered as a
227
- * helper because half the lanes are direct handlers):
227
+ * helper because half the lanes are direct handlers; on the barrel, so a
228
+ * composition's lane over its own table narrows the same way):
228
229
  *
229
230
  * - no scope composed → the input array unchanged (byte-identity;
230
231
  * `requestOrg` deliberately not consulted — the OSS single-tenant
package/src/index.ts CHANGED
@@ -274,6 +274,11 @@ export type {
274
274
  ListReadScope,
275
275
  ListEntryMeta,
276
276
  } from "./extensions/list-read-scope.js";
277
+ // The one consumption idiom of the list read scope for post-scan list lanes:
278
+ // exported so a list lane a composition serves from its own table narrows
279
+ // its rows exactly as the library's lanes do, instead of building each
280
+ // candidate's authorization facts by hand.
281
+ export { restrictListByReadScope } from "./extensions/list-read-scope.js";
277
282
  // The stigmer-cloud#572 seam: the identity a schedule fire acts as
278
283
  // (drivers.scheduleFireCaller) — the composition mints it per fire; the
279
284
  // RunStarter propagates it through the R5 in-process header. A mint that