@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
@@ -1,21 +1,55 @@
1
1
  /**
2
- * The list scope's cost, measured — the plan's storage-impact line ("a
3
- * measurement, not an assumption, decides whether a driver needs
2
+ * The built-in evaluator's cost, measured — the plan's storage-impact line
3
+ * ("a measurement, not an assumption, decides whether a driver needs
4
4
  * anything"). Gated on `AUTHORIZATION_MEASURE=1` and SKIPPED otherwise
5
5
  * (the `TEST_DATABASE_URL` idiom: an environment variable read once,
6
6
  * `describe.skipIf`), because ten thousand rows on Postgres take a while
7
- * and the number is recorded in the project's execution log, not asserted:
8
- * no budget is ruled for open source, and the cloud's 300 ms is a
9
- * different engine's.
7
+ * and the numbers are recorded in a project log, not asserted: timing on
8
+ * a shared machine is noise in a unit suite. No budget is ruled for open
9
+ * source, and the cloud's 300 ms is a different engine's; an edition that
10
+ * serves teams over this evaluator holds a page of a paged list to 300 ms
11
+ * and a point check to 50 ms, read from these lines, and records the
12
+ * single 10,000-candidate call (a request that asks for every row, the
13
+ * enumeration lanes) as its worst case.
10
14
  *
11
- * Five shapes at 1k and 10k rows, on both drivers, each timed once after
12
- * the seed: the restrict verb over a pure owner-only kind (session), over
13
- * a kind with one userset hop (org-visible agents for a member), over the
14
- * parent hop at two densities (executions in ten sessions, and in a
15
- * thousand), and over the kind whose `derived` rule reads the row
16
- * (instances of one agent); the enumeration verb over the blueprint kind,
17
- * which is the console library's path. The seed reuses the module's
18
- * fixtures so the rows are the rows the other tests reason about.
15
+ * Two axes. The CANDIDATE count (1k and 10k rows of the listed kind) and
16
+ * the POLICY TABLE every check and every list batch reads a person's rows
17
+ * from through the IamPolicy port (indexed by principal on the OSS
18
+ * adapter; resource-store.ts).
19
+ * Every shape runs at an Enterprise-sized population: one organization of
20
+ * 1,000 members in 50 teams of 20, teams granted `viewer` on half the
21
+ * candidate agents, one person granted directly on 2% of them, and other
22
+ * members' direct grants filling the table to the candidate count (10,000
23
+ * rows at 10k; the base population alone is past 1k).
24
+ *
25
+ * The shapes, each timed once after the seed, both drivers:
26
+ * - the five open-source shapes: the restrict verb over a pure
27
+ * owner-only kind (session), over a kind with one userset hop
28
+ * (org-visible agents for a member), over the parent hop at two
29
+ * densities (executions in ten sessions, and in a thousand), over the
30
+ * kind whose `derived` rule reads the row (instances of one agent);
31
+ * the enumeration verb over the blueprint kind (the console library's
32
+ * path);
33
+ * - five Enterprise shapes over private agents: a team member's grants
34
+ * (the team hop through the model's organization bound); the same for
35
+ * a person in five teams (one more policy read per team); a person's
36
+ * direct grants; a former member who keeps the team's row and is
37
+ * admitted by none of it (the bound's correctness at volume); and the
38
+ * enumeration verb for the team member;
39
+ * - pages, as the paged lanes run them (pipeline/steps/list-page.ts): a
40
+ * page that fills in one batch (one scope call of 100 candidates) and
41
+ * a caller's worst page (five calls of 100, the examine budget of 500,
42
+ * each call its own source as `admit` makes it), for the team member,
43
+ * the direct grantee and an organization member, at the population of
44
+ * 10,000 policy rows;
45
+ * - point checks: one `can_view` through a team grant and one through a
46
+ * direct grant, each over 100 checks with a fresh source per check
47
+ * (the Authorizer's own shape), the mean reported.
48
+ *
49
+ * Every shape states what its caller keeps, so a measurement over an
50
+ * evaluator that answered wrongly measures nothing. The model is the
51
+ * built-in one with the Enterprise team type beside it
52
+ * (enterprise-model.ts); the open-source shapes answer the same under it.
19
53
  */
20
54
  import { create } from "@bufbuild/protobuf";
21
55
  import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -23,8 +57,11 @@ import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
23
57
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
24
58
  import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
25
59
  import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
60
+ import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
26
61
  import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
27
62
  import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
63
+ import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
64
+ import { ServerEdition } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
28
65
 
29
66
  import {
30
67
  IAM_POLICY_API_VERSION,
@@ -33,7 +70,7 @@ import {
33
70
  } from "../../domain/iampolicy/constants.js";
34
71
  import { newResourceIamPolicyStore } from "../../domain/iampolicy/resource-store.js";
35
72
  import type { IamPolicyStore } from "../../domain/iampolicy/store.js";
36
- import { orgRole } from "../../domain/iampolicy/__tests__/support.js";
73
+ import { orgRole, triple } from "../../domain/iampolicy/__tests__/support.js";
37
74
  import { accountIdFor } from "../../domain/identityaccount/constants.js";
38
75
  import { newResourceIdentityAccountStore } from "../../domain/identityaccount/resource-store.js";
39
76
  import type { IdentityAccountStore } from "../../domain/identityaccount/store.js";
@@ -42,11 +79,12 @@ import type { CallerIdentity } from "../../extensions/identity.js";
42
79
  import type { ListEntryMeta } from "../../extensions/list-read-scope.js";
43
80
  import { kindEnumName } from "../../pipeline/apiresource-meta.js";
44
81
  import { rowAuthorizationFactsOf } from "../../pipeline/steps/authorization-facts.js";
82
+ import { newBuiltInAuthorizer } from "../authorizer.js";
45
83
  import { newBuiltInListReadScope } from "../list-read-scope.js";
46
- import { builtInModel } from "../model/index.js";
47
84
  import type { KindDeclaration } from "../model/rewrite.js";
48
85
  import { driverFixtures, dropPostgresFixture } from "./drivers.js";
49
86
  import type { OpenedStore } from "./drivers.js";
87
+ import { enterpriseModel, teamDeclaration } from "./enterprise-model.js";
50
88
  import { fixtureRow } from "./support.js";
51
89
  import type { FixtureRowFacts } from "./support.js";
52
90
 
@@ -56,10 +94,57 @@ const SIZES = [1_000, 10_000];
56
94
  const FOUNDER = accountIdFor("auth0|founder");
57
95
  const MEMBER = accountIdFor("auth0|member");
58
96
  const VIEWER = accountIdFor("auth0|viewer");
97
+ /** In team 0. */
98
+ const TEAM_MEMBER = accountIdFor("auth0|team-member");
99
+ /** In teams 0 to 4. */
100
+ const MULTI_TEAM = accountIdFor("auth0|multi-team");
101
+ /** Granted `viewer` directly on 2% of the candidate agents. */
102
+ const DIRECT = accountIdFor("auth0|direct");
103
+ /** Still holds team 0's `member` row; holds no organization role. */
104
+ const FORMER = accountIdFor("auth0|former");
59
105
  const ORG = "acme";
60
106
 
107
+ const TEAMS = 50;
108
+ const TEAM_SIZE = 20;
109
+ const MEMBERS = TEAMS * TEAM_SIZE;
110
+ const MULTI_TEAM_COUNT = 5;
111
+ const POINT_CHECKS = 100;
112
+ /** The page loop's largest page and its examine budget (pipeline/steps/list-page.ts). */
113
+ const PAGE_SIZE = 100;
114
+ const PAGE_EXAMINE_BUDGET = 500;
115
+ /** The population the pages run at: a 10,000-row policy table. */
116
+ const PAGE_POPULATION = 10_000;
117
+ /**
118
+ * A case seeds up to twenty thousand rows one write at a time before its
119
+ * timer starts; the suite's 15 s budget is for tests, not for this seed.
120
+ */
121
+ const MEASURE_TIMEOUT_MS = 600_000;
122
+
123
+ function teamId(i: number): string {
124
+ return `tm_${i}`;
125
+ }
126
+
127
+ function populationMemberId(i: number): string {
128
+ return `ida_pop_${i}`;
129
+ }
130
+
131
+ /** The candidate agent of index `i`: private, the founder's. */
132
+ function enterpriseAgentId(i: number): string {
133
+ return `agt_e_${i}`;
134
+ }
135
+
136
+ /** The team granted `viewer` on agent `i`, or undefined: every even agent, round-robin over the teams. */
137
+ function teamGrantedOn(i: number): number | undefined {
138
+ return i % 2 === 0 ? i % TEAMS : undefined;
139
+ }
140
+
141
+ /** Whether `DIRECT` is granted `viewer` on agent `i`: 2% of the agents. */
142
+ function directGrantOn(i: number): boolean {
143
+ return i % 50 === 1;
144
+ }
145
+
61
146
  function declared(type: string): KindDeclaration {
62
- const declaration = builtInModel.byType(type);
147
+ const declaration = enterpriseModel.byType(type);
63
148
  if (declaration === undefined) {
64
149
  throw new Error(`${type} is declared`);
65
150
  }
@@ -97,6 +182,37 @@ function sessionRow(i: number): FixtureRowFacts {
97
182
  };
98
183
  }
99
184
 
185
+ function enterpriseAgentRow(i: number): FixtureRowFacts {
186
+ return {
187
+ id: enterpriseAgentId(i),
188
+ org: ORG,
189
+ visibility: ApiResourceVisibility.visibility_private,
190
+ createdBy: FOUNDER,
191
+ };
192
+ }
193
+
194
+ /** How many of `n` agents a person in teams `0..teams-1` views through team grants. */
195
+ function keptThroughTeams(n: number, teams: number): number {
196
+ let kept = 0;
197
+ for (let i = 0; i < n; i += 1) {
198
+ const team = teamGrantedOn(i);
199
+ if (team !== undefined && team < teams) {
200
+ kept += 1;
201
+ }
202
+ }
203
+ return kept;
204
+ }
205
+
206
+ function keptDirectly(n: number): number {
207
+ let kept = 0;
208
+ for (let i = 0; i < n; i += 1) {
209
+ if (directGrantOn(i)) {
210
+ kept += 1;
211
+ }
212
+ }
213
+ return kept;
214
+ }
215
+
100
216
  const SHAPES: ReadonlyArray<Shape> = [
101
217
  {
102
218
  name: "restrict: sessions, owner-only, the founder's own",
@@ -191,8 +307,138 @@ const SHAPES: ReadonlyArray<Shape> = [
191
307
  // other; the cost measured is the rule's read of every candidate row.
192
308
  kept: () => 1,
193
309
  },
310
+ {
311
+ name: "restrict: private agents shared with the caller's team (the team hop through the organization bound)",
312
+ kind: "agent",
313
+ caller: TEAM_MEMBER,
314
+ verb: "restrict",
315
+ row: enterpriseAgentRow,
316
+ kept: (n) => keptThroughTeams(n, 1),
317
+ },
318
+ {
319
+ name: "restrict: private agents shared with any of the caller's five teams (one policy read more per team)",
320
+ kind: "agent",
321
+ caller: MULTI_TEAM,
322
+ verb: "restrict",
323
+ row: enterpriseAgentRow,
324
+ kept: (n) => keptThroughTeams(n, MULTI_TEAM_COUNT),
325
+ },
326
+ {
327
+ name: "restrict: private agents granted to the caller directly (a per-resource grant)",
328
+ kind: "agent",
329
+ caller: DIRECT,
330
+ verb: "restrict",
331
+ row: enterpriseAgentRow,
332
+ kept: keptDirectly,
333
+ },
334
+ {
335
+ name: "restrict: a former member keeps the team's row and is admitted by none of it",
336
+ kind: "agent",
337
+ caller: FORMER,
338
+ verb: "restrict",
339
+ row: enterpriseAgentRow,
340
+ kept: () => 0,
341
+ },
342
+ {
343
+ name: "enumerate: private agents for the team member (the library's path)",
344
+ kind: "agent",
345
+ caller: TEAM_MEMBER,
346
+ verb: "enumerate",
347
+ row: enterpriseAgentRow,
348
+ kept: (n) => keptThroughTeams(n, 1),
349
+ },
350
+ ];
351
+
352
+ interface PageShape {
353
+ readonly name: string;
354
+ readonly caller: string;
355
+ /** The candidate of index `i`, an agent. */
356
+ row(i: number): FixtureRowFacts;
357
+ /** How many of the first `n` candidates the caller keeps. */
358
+ kept(n: number): number;
359
+ }
360
+
361
+ const PAGE_SHAPES: ReadonlyArray<PageShape> = [
362
+ {
363
+ name: "the team member's private agents shared with their team",
364
+ caller: TEAM_MEMBER,
365
+ row: enterpriseAgentRow,
366
+ kept: (n) => keptThroughTeams(n, 1),
367
+ },
368
+ {
369
+ name: "the direct grantee's private agents granted to them",
370
+ caller: DIRECT,
371
+ row: enterpriseAgentRow,
372
+ kept: keptDirectly,
373
+ },
374
+ {
375
+ name: "an organization member's org-visible agents",
376
+ caller: MEMBER,
377
+ row: (i) => ({
378
+ id: `agt_${i}`,
379
+ org: ORG,
380
+ visibility: ApiResourceVisibility.visibility_org,
381
+ createdBy: FOUNDER,
382
+ }),
383
+ kept: (n) => n,
384
+ },
194
385
  ];
195
386
 
387
+ /** The Enterprise population's IamPolicy rows at candidate size `n`. */
388
+ function populationPolicies(n: number): IamPolicySpec[] {
389
+ const specs: IamPolicySpec[] = [
390
+ orgRole(FOUNDER, "owner", ORG),
391
+ orgRole(MEMBER, "member", ORG),
392
+ orgRole(VIEWER, "viewer", ORG),
393
+ orgRole(TEAM_MEMBER, "member", ORG),
394
+ orgRole(MULTI_TEAM, "member", ORG),
395
+ orgRole(DIRECT, "member", ORG),
396
+ ];
397
+ const membership = (accountId: string, team: number): IamPolicySpec =>
398
+ triple({ kind: "identity_account", id: accountId }, "member", {
399
+ kind: "team",
400
+ id: teamId(team),
401
+ });
402
+ specs.push(membership(TEAM_MEMBER, 0), membership(FORMER, 0));
403
+ for (let team = 0; team < MULTI_TEAM_COUNT; team += 1) {
404
+ specs.push(membership(MULTI_TEAM, team));
405
+ }
406
+ for (let i = 0; i < MEMBERS; i += 1) {
407
+ specs.push(orgRole(populationMemberId(i), "member", ORG));
408
+ specs.push(membership(populationMemberId(i), i % TEAMS));
409
+ }
410
+ const viewerOn = (
411
+ principal: { kind: string; id: string; relation?: string },
412
+ i: number,
413
+ ): IamPolicySpec =>
414
+ triple(principal, "viewer", { kind: "agent", id: enterpriseAgentId(i) });
415
+ const unclaimed: number[] = [];
416
+ for (let i = 0; i < n; i += 1) {
417
+ const team = teamGrantedOn(i);
418
+ if (team !== undefined) {
419
+ specs.push(
420
+ viewerOn({ kind: "team", id: teamId(team), relation: "member" }, i),
421
+ );
422
+ } else if (directGrantOn(i)) {
423
+ specs.push(viewerOn({ kind: "identity_account", id: DIRECT }, i));
424
+ } else {
425
+ unclaimed.push(i);
426
+ }
427
+ }
428
+ // Other members' direct grants fill the table to the candidate count.
429
+ const filler = Math.min(unclaimed.length, Math.max(0, n - specs.length));
430
+ for (let k = 0; k < filler; k += 1) {
431
+ const i = unclaimed[k] ?? 0;
432
+ specs.push(
433
+ viewerOn(
434
+ { kind: "identity_account", id: populationMemberId(i % MEMBERS) },
435
+ i,
436
+ ),
437
+ );
438
+ }
439
+ return specs;
440
+ }
441
+
196
442
  afterAll(dropPostgresFixture);
197
443
 
198
444
  describe
@@ -202,12 +448,13 @@ describe
202
448
  ApiResourceKind.iam_policy,
203
449
  ApiResourceKind.identity_account,
204
450
  ApiResourceKind.organization,
451
+ ApiResourceKind.team,
205
452
  ApiResourceKind.agent,
206
453
  ApiResourceKind.session,
207
454
  ApiResourceKind.agent_execution,
208
455
  ApiResourceKind.agent_instance,
209
456
  ]),
210
- )("the built-in list read scope's cost on $name", (fixture) => {
457
+ )("the built-in evaluator's cost on $name", (fixture) => {
211
458
  describe.skipIf(fixture.skip)("measured", () => {
212
459
  let opened: OpenedStore;
213
460
  let policies: IamPolicyStore;
@@ -225,6 +472,31 @@ describe
225
472
  return row;
226
473
  }
227
474
 
475
+ /**
476
+ * Writes the population's rows through the generic Store, where the
477
+ * OSS adapter reads every policy from: the port's `save` would read
478
+ * each id before writing it, doubling a seed of ten thousand rows.
479
+ * Returns the policy table's size.
480
+ */
481
+ async function seedPopulation(n: number): Promise<number> {
482
+ const specs = populationPolicies(n);
483
+ for (const spec of specs) {
484
+ const id = policyIdFor(spec);
485
+ await opened.store.saveResource(
486
+ ApiResourceKind.iam_policy,
487
+ id,
488
+ IamPolicySchema,
489
+ create(IamPolicySchema, {
490
+ apiVersion: IAM_POLICY_API_VERSION,
491
+ kind: IAM_POLICY_KIND,
492
+ metadata: { id },
493
+ spec,
494
+ }),
495
+ );
496
+ }
497
+ return specs.length;
498
+ }
499
+
228
500
  beforeEach(async () => {
229
501
  opened = await fixture.open();
230
502
  policies = newResourceIamPolicyStore(opened.store);
@@ -233,6 +505,10 @@ describe
233
505
  [FOUNDER, "auth0|founder"],
234
506
  [MEMBER, "auth0|member"],
235
507
  [VIEWER, "auth0|viewer"],
508
+ [TEAM_MEMBER, "auth0|team-member"],
509
+ [MULTI_TEAM, "auth0|multi-team"],
510
+ [DIRECT, "auth0|direct"],
511
+ [FORMER, "auth0|former"],
236
512
  ] as const) {
237
513
  await accounts.save(
238
514
  create(IdentityAccountSchema, {
@@ -250,17 +526,16 @@ describe
250
526
  visibility: ApiResourceVisibility.visibility_private,
251
527
  createdBy: FOUNDER,
252
528
  });
253
- for (const spec of [
254
- orgRole(FOUNDER, "owner", ORG),
255
- orgRole(MEMBER, "member", ORG),
256
- orgRole(VIEWER, "viewer", ORG),
257
- ]) {
258
- await policies.save(
259
- create(IamPolicySchema, {
260
- apiVersion: IAM_POLICY_API_VERSION,
261
- kind: IAM_POLICY_KIND,
262
- metadata: { id: policyIdFor(spec) },
263
- spec,
529
+ for (let team = 0; team < TEAMS; team += 1) {
530
+ await opened.store.saveResource(
531
+ ApiResourceKind.team,
532
+ teamId(team),
533
+ teamDeclaration.schema,
534
+ fixtureRow(teamDeclaration, {
535
+ id: teamId(team),
536
+ org: ORG,
537
+ visibility: ApiResourceVisibility.visibility_private,
538
+ createdBy: FOUNDER,
264
539
  }),
265
540
  );
266
541
  }
@@ -272,14 +547,63 @@ describe
272
547
 
273
548
  for (const shape of SHAPES) {
274
549
  for (const n of SIZES) {
275
- it(`${shape.name} at ${n} rows`, async () => {
276
- const declaration = declared(shape.kind);
277
- for (const parent of shape.parents ?? []) {
278
- await save(parent.type, parent.facts);
279
- }
550
+ it(
551
+ `${shape.name} at ${n} rows`,
552
+ async () => {
553
+ const declaration = declared(shape.kind);
554
+ const policyRows = await seedPopulation(n);
555
+ for (const parent of shape.parents ?? []) {
556
+ await save(parent.type, parent.facts);
557
+ }
558
+ const entries: ListEntryMeta[] = [];
559
+ for (let i = 0; i < n; i += 1) {
560
+ const row = await save(shape.kind, shape.row(i, n));
561
+ entries.push({
562
+ ...rowAuthorizationFactsOf(declaration.kind, row),
563
+ labels: {},
564
+ });
565
+ }
566
+ const scope = newBuiltInListReadScope({
567
+ store: opened.store,
568
+ policies,
569
+ accounts,
570
+ logger: silentLogger,
571
+ model: enterpriseModel,
572
+ });
573
+ const started = performance.now();
574
+ const kept =
575
+ shape.verb === "restrict"
576
+ ? await scope.restrictListEntries(
577
+ resolved(shape.caller),
578
+ declaration.kind,
579
+ entries,
580
+ )
581
+ : await scope.authorizedResourceIds(
582
+ resolved(shape.caller),
583
+ declaration.kind,
584
+ );
585
+ const elapsedMs = Math.round(performance.now() - started);
586
+ // Every shape states what its caller keeps, so a measurement
587
+ // over a scope that answered wrongly measures nothing.
588
+ expect(kept.size).toBe(shape.kept?.(n) ?? n);
589
+ console.log(
590
+ `[measure] ${fixture.name} ${kindEnumName(declaration.kind)} ${shape.verb} n=${n} policies=${policyRows} elapsed=${elapsedMs}ms — ${shape.name}`,
591
+ );
592
+ },
593
+ MEASURE_TIMEOUT_MS,
594
+ );
595
+ }
596
+ }
597
+
598
+ for (const shape of PAGE_SHAPES) {
599
+ it(
600
+ `pages: ${shape.name}, at a ${PAGE_POPULATION}-row population`,
601
+ async () => {
602
+ const declaration = declared("agent");
603
+ const policyRows = await seedPopulation(PAGE_POPULATION);
280
604
  const entries: ListEntryMeta[] = [];
281
- for (let i = 0; i < n; i += 1) {
282
- const row = await save(shape.kind, shape.row(i, n));
605
+ for (let i = 0; i < PAGE_EXAMINE_BUDGET; i += 1) {
606
+ const row = await save("agent", shape.row(i));
283
607
  entries.push({
284
608
  ...rowAuthorizationFactsOf(declaration.kind, row),
285
609
  labels: {},
@@ -290,27 +614,73 @@ describe
290
614
  policies,
291
615
  accounts,
292
616
  logger: silentLogger,
617
+ model: enterpriseModel,
293
618
  });
294
- const started = performance.now();
295
- const kept =
296
- shape.verb === "restrict"
297
- ? await scope.restrictListEntries(
298
- resolved(shape.caller),
299
- declaration.kind,
300
- entries,
301
- )
302
- : await scope.authorizedResourceIds(
303
- resolved(shape.caller),
304
- declaration.kind,
305
- );
306
- const elapsedMs = Math.round(performance.now() - started);
307
- // Every shape states what its caller keeps, so a measurement
308
- // over a scope that answered wrongly measures nothing.
309
- expect(kept.size).toBe(shape.kept?.(n) ?? n);
310
- console.log(
311
- `[measure] ${fixture.name} ${kindEnumName(declaration.kind)} ${shape.verb} n=${n} elapsed=${elapsedMs}ms — ${shape.name}`,
312
- );
313
- });
619
+ const caller = resolved(shape.caller);
620
+ // A page's batches run one after another, each through the scope
621
+ // with its own source, exactly as the page loop calls `admit`.
622
+ async function page(batches: number): Promise<number> {
623
+ let kept = 0;
624
+ for (let b = 0; b < batches; b += 1) {
625
+ const batch = entries.slice(b * PAGE_SIZE, (b + 1) * PAGE_SIZE);
626
+ kept += (
627
+ await scope.restrictListEntries(caller, declaration.kind, batch)
628
+ ).size;
629
+ }
630
+ return kept;
631
+ }
632
+ for (const batches of [1, PAGE_EXAMINE_BUDGET / PAGE_SIZE]) {
633
+ const started = performance.now();
634
+ const kept = await page(batches);
635
+ const elapsedMs = Math.round(performance.now() - started);
636
+ expect(kept).toBe(shape.kept(batches * PAGE_SIZE));
637
+ console.log(
638
+ `[measure] ${fixture.name} agent page calls=${batches} candidates=${batches * PAGE_SIZE} policies=${policyRows} elapsed=${elapsedMs}ms — ${batches === 1 ? "a page that fills in one batch" : "the worst page"}: ${shape.name}`,
639
+ );
640
+ }
641
+ },
642
+ MEASURE_TIMEOUT_MS,
643
+ );
644
+ }
645
+
646
+ for (const [caller, agent, grant] of [
647
+ [TEAM_MEMBER, 0, "a team grant"],
648
+ [DIRECT, 1, "a direct grant"],
649
+ ] as const) {
650
+ for (const n of SIZES) {
651
+ it(
652
+ `point check: can_view through ${grant} at the ${n}-candidate population`,
653
+ async () => {
654
+ const policyRows = await seedPopulation(n);
655
+ await save("agent", enterpriseAgentRow(agent));
656
+ const authorizer = newBuiltInAuthorizer({
657
+ store: opened.store,
658
+ policies,
659
+ accounts,
660
+ edition: ServerEdition.oss,
661
+ logger: silentLogger,
662
+ model: enterpriseModel,
663
+ });
664
+ const check = {
665
+ permission: IamPermission.can_view,
666
+ resourceKind: ApiResourceKind.agent,
667
+ resourceId: enterpriseAgentId(agent),
668
+ };
669
+ const started = performance.now();
670
+ for (let k = 0; k < POINT_CHECKS; k += 1) {
671
+ // Each call builds its own source, the Authorizer's shape: no
672
+ // memo carries from one check to the next.
673
+ expect(
674
+ await authorizer.authorize(resolved(caller), check),
675
+ ).toEqual({ kind: "allow" });
676
+ }
677
+ const meanMs = (performance.now() - started) / POINT_CHECKS;
678
+ console.log(
679
+ `[measure] ${fixture.name} agent point-check policies=${policyRows} mean=${meanMs.toFixed(2)}ms over ${POINT_CHECKS} — can_view through ${grant}`,
680
+ );
681
+ },
682
+ MEASURE_TIMEOUT_MS,
683
+ );
314
684
  }
315
685
  }
316
686
  });
@@ -47,8 +47,10 @@
47
47
  *
48
48
  * Cost, per check (measured at slice 4): the target row (1), the caller's
49
49
  * account (1 primary-key read; 2 for a caller a composition verifier left
50
- * idp-shaped), the person's IamPolicy rows (1 scan of the port, once per
51
- * source), and one row per parent hop the model walks.
50
+ * idp-shaped), the person's IamPolicy rows (1 read of the port, once per
51
+ * source, indexed by principal on the OSS adapter; one more per team the
52
+ * person holds, which open source never has — derived-tuples.ts), and one
53
+ * row per parent hop the model walks.
52
54
  */
53
55
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
54
56
  import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";