@stigmer/server 3.27.0 → 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 +5 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +19 -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 +22 -0
@@ -14,6 +14,10 @@
14
14
  * source, an absent object answering nothing, a store fault propagating
15
15
  * as the fault it is — the ratified store-fault mapping, which is what
16
16
  * lets the driver above fold it to `unavailable` and never to a denial.
17
+ * Its grants-to-teams arm evaluates the Enterprise team type
18
+ * (enterprise-model.ts): a grant to a team reaches its members and nobody
19
+ * who left the organization, and a person in no team — every open-source
20
+ * caller — still costs the source exactly one read.
17
21
  */
18
22
  import { create } from "@bufbuild/protobuf";
19
23
  import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -45,6 +49,7 @@ import type { Person } from "../tuples.js";
45
49
  import { formatTuple, parseObjectRef } from "../tuples.js";
46
50
  import { driverFixtures, dropPostgresFixture } from "./drivers.js";
47
51
  import type { OpenedStore } from "./drivers.js";
52
+ import { enterpriseModel, teamDeclaration } from "./enterprise-model.js";
48
53
  import { fixtureRow } from "./support.js";
49
54
 
50
55
  function declared(type: string): KindDeclaration {
@@ -244,6 +249,7 @@ const SEEDED_KINDS: ReadonlyArray<ApiResourceKind> = [
244
249
  ApiResourceKind.workflow_instance,
245
250
  ApiResourceKind.workflow_execution,
246
251
  ApiResourceKind.identity_account,
252
+ ApiResourceKind.team,
247
253
  ];
248
254
 
249
255
  afterAll(dropPostgresFixture);
@@ -567,6 +573,173 @@ describe.each(driverFixtures(SEEDED_KINDS))(
567
573
  });
568
574
  });
569
575
 
576
+ /**
577
+ * The grants made to a person's teams, through the real source and the
578
+ * evaluator over the Enterprise team type (enterprise-model.ts). Seeded:
579
+ * `acme` with Dave a member and Root its owner; `tm_core` and
580
+ * `tm_other` in `acme`; Dave and Erin granted `member` on `tm_core`,
581
+ * Erin holding no organization role (she left); `agt_core` shared with
582
+ * `tm_core#member` and `agt_other` with `tm_other#member`, both private
583
+ * and Carol's.
584
+ */
585
+ describe.skipIf(fixture.skip)("the grants made to a person's teams", () => {
586
+ let opened: OpenedStore;
587
+ let counted: ReturnType<typeof countingPolicies>;
588
+ const ERIN: Person = {
589
+ accountId: "ida_erin",
590
+ aliases: new Set(["ida_erin"]),
591
+ };
592
+
593
+ async function seed(
594
+ declaration: KindDeclaration,
595
+ facts: Parameters<typeof fixtureRow>[1],
596
+ ): Promise<void> {
597
+ await opened.store.saveResource(
598
+ declaration.kind,
599
+ facts.id,
600
+ declaration.schema,
601
+ fixtureRow(declaration, facts),
602
+ );
603
+ }
604
+
605
+ beforeEach(async () => {
606
+ opened = await fixture.open();
607
+ counted = countingPolicies(newResourceIamPolicyStore(opened.store));
608
+ await seed(declared("organization"), {
609
+ id: "acme",
610
+ org: "",
611
+ visibility: ApiResourceVisibility.visibility_private,
612
+ createdBy: ROOT.accountId,
613
+ });
614
+ for (const id of ["tm_core", "tm_other"]) {
615
+ await seed(teamDeclaration, {
616
+ id,
617
+ org: "acme",
618
+ visibility: ApiResourceVisibility.visibility_private,
619
+ createdBy: ROOT.accountId,
620
+ });
621
+ }
622
+ for (const [id, team] of [
623
+ ["agt_core", "tm_core"],
624
+ ["agt_other", "tm_other"],
625
+ ] as const) {
626
+ await seed(declared("agent"), {
627
+ id,
628
+ org: "acme",
629
+ visibility: ApiResourceVisibility.visibility_private,
630
+ createdBy: "ida_carol",
631
+ });
632
+ await grant(
633
+ counted.policies,
634
+ triple({ kind: "team", id: team, relation: "member" }, "viewer", {
635
+ kind: "agent",
636
+ id,
637
+ }),
638
+ );
639
+ }
640
+ await grant(counted.policies, orgRole(ROOT.accountId, "owner", "acme"));
641
+ await grant(
642
+ counted.policies,
643
+ orgRole(DAVE.accountId, "member", "acme"),
644
+ );
645
+ for (const member of [DAVE, ERIN]) {
646
+ await grant(
647
+ counted.policies,
648
+ triple(
649
+ { kind: "identity_account", id: member.accountId },
650
+ "member",
651
+ { kind: "team", id: "tm_core" },
652
+ ),
653
+ );
654
+ }
655
+ });
656
+
657
+ afterEach(async () => {
658
+ await opened.close();
659
+ });
660
+
661
+ function sourceFor(person: Person) {
662
+ return newDerivedTupleSource(
663
+ {
664
+ store: opened.store,
665
+ policies: counted.policies,
666
+ accounts: newResourceIdentityAccountStore(opened.store),
667
+ model: enterpriseModel,
668
+ },
669
+ person,
670
+ );
671
+ }
672
+
673
+ async function allowed(
674
+ person: Person,
675
+ object: string,
676
+ relation: string,
677
+ ): Promise<boolean> {
678
+ return checkRelation(
679
+ { model: enterpriseModel, source: sourceFor(person) },
680
+ parseObjectRef(object),
681
+ relation,
682
+ person,
683
+ );
684
+ }
685
+
686
+ it("a grant to a team reaches its member: the source serves the team's row on the object, and the member views it", async () => {
687
+ expect(
688
+ (
689
+ await sourceFor(DAVE).tuplesOf(
690
+ parseObjectRef("agent:agt_core"),
691
+ "viewer",
692
+ )
693
+ ).map(formatTuple),
694
+ ).toEqual(["agent:agt_core#viewer@team:tm_core#member"]);
695
+ expect(await allowed(DAVE, "agent:agt_core", "can_view")).toBe(true);
696
+ });
697
+
698
+ it("a member who left the organization reads the team's rows and is admitted by none of them — the model's bound, not a cleanup", async () => {
699
+ expect(
700
+ (
701
+ await sourceFor(ERIN).tuplesOf(
702
+ parseObjectRef("agent:agt_core"),
703
+ "viewer",
704
+ )
705
+ ).map(formatTuple),
706
+ ).toEqual(["agent:agt_core#viewer@team:tm_core#member"]);
707
+ expect(await allowed(ERIN, "agent:agt_core", "can_view")).toBe(false);
708
+ });
709
+
710
+ it("a grant to a team the person is not in never enters their source", async () => {
711
+ expect(
712
+ await sourceFor(DAVE).tuplesOf(
713
+ parseObjectRef("agent:agt_other"),
714
+ "viewer",
715
+ ),
716
+ ).toEqual([]);
717
+ expect(await allowed(DAVE, "agent:agt_other", "can_view")).toBe(false);
718
+ expect(await allowed(ROOT, "agent:agt_core", "can_view")).toBe(true);
719
+ });
720
+
721
+ it("reads once for a person in no team and once more per team held, however many objects are asked", async () => {
722
+ const root = sourceFor(ROOT);
723
+ await root.tuplesOf(parseObjectRef("agent:agt_core"), "viewer");
724
+ await root.tuplesOf(parseObjectRef("agent:agt_other"), "viewer");
725
+ expect(counted.reads()).toBe(1);
726
+ const dave = sourceFor(DAVE);
727
+ await dave.tuplesOf(parseObjectRef("agent:agt_core"), "viewer");
728
+ await dave.tuplesOf(parseObjectRef("agent:agt_other"), "viewer");
729
+ await dave.tuplesOf(parseObjectRef("organization:acme"), "member");
730
+ expect(counted.reads()).toBe(1 + 2);
731
+ });
732
+
733
+ it("personTuples stays the person's own rows — the organization directory's candidates do not grow with team grants", async () => {
734
+ expect(
735
+ (await sourceFor(DAVE).personTuples()).map(formatTuple).sort(),
736
+ ).toEqual([
737
+ "organization:acme#member@identity_account:ida_dave",
738
+ "team:tm_core#member@identity_account:ida_dave",
739
+ ]);
740
+ });
741
+ });
742
+
570
743
  /**
571
744
  * The two derived rules through the real source and the evaluator:
572
745
  * `default_of` from the blueprint's pointer, `execution_viewer` from
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The Enterprise team type as the tests evaluate it: a transcript of
3
+ * fga/model/iam/team.fga in the shape of every open-source transcript
4
+ * (`../model/*.ts`), and a model that is the built-in one plus that
5
+ * declaration. Open source serves no team — the kind's tier is
6
+ * enterprise, so `builtInModel` declares none — yet its transcripts
7
+ * already name the team grant (`agent`'s `viewer` accepts
8
+ * `team#member`), and the derived tuple source reads the grants made to
9
+ * a person's teams. These fixtures let the source's tests and the list
10
+ * scope's cost measurement evaluate that path over real rows without
11
+ * declaring the kind in the edition that does not serve it.
12
+ *
13
+ * The membership line is the model's one intersection: a member is a
14
+ * person granted the role AND still one of the organization's viewers, so
15
+ * leaving the organization ends every team-derived grant with no cleanup.
16
+ * Guests hold `organization#guest`, never viewer, so a guest is never a
17
+ * member.
18
+ */
19
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
20
+ import { TeamSchema } from "@stigmer/protos/ai/stigmer/iam/team/v1/api_pb";
21
+
22
+ import type { Model } from "../model/index.js";
23
+ import { builtInModel, newModel } from "../model/index.js";
24
+ import {
25
+ computed,
26
+ declareKind,
27
+ direct,
28
+ from,
29
+ intersection,
30
+ objectOf,
31
+ } from "../model/rewrite.js";
32
+
33
+ export const teamDeclaration = declareKind({
34
+ kind: ApiResourceKind.team,
35
+ schema: TeamSchema,
36
+ source: "fga/model/iam/team.fga",
37
+ relations: [
38
+ ["organization", direct(objectOf("organization"))],
39
+ [
40
+ "member",
41
+ intersection(
42
+ direct(objectOf("identity_account")),
43
+ from("viewer", "organization"),
44
+ ),
45
+ ],
46
+ ["viewer", from("viewer", "organization")],
47
+ ["can_view", computed("viewer")],
48
+ ["can_edit", from("admin", "organization")],
49
+ ["can_delete", from("admin", "organization")],
50
+ ["can_grant_access", from("admin", "organization")],
51
+ ["can_view_access", computed("viewer")],
52
+ ],
53
+ });
54
+
55
+ /** The built-in declarations and the team type; the evaluator reaches them by kind and type, so their order carries nothing. */
56
+ export const enterpriseModel: Model = newModel([
57
+ ...builtInModel.declarations,
58
+ teamDeclaration,
59
+ ]);