@stigmer/server 3.27.1 → 3.28.0

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 (245) hide show
  1. package/README.md +2 -0
  2. package/dist/authorization/authorizer.d.ts +7 -5
  3. package/dist/authorization/authorizer.d.ts.map +1 -1
  4. package/dist/authorization/authorizer.js +7 -5
  5. package/dist/authorization/authorizer.js.map +1 -1
  6. package/dist/authorization/derived-tuples.d.ts +3 -1
  7. package/dist/authorization/derived-tuples.d.ts.map +1 -1
  8. package/dist/authorization/derived-tuples.js +52 -2
  9. package/dist/authorization/derived-tuples.js.map +1 -1
  10. package/dist/authorization/evaluator.d.ts +9 -10
  11. package/dist/authorization/evaluator.d.ts.map +1 -1
  12. package/dist/authorization/evaluator.js.map +1 -1
  13. package/dist/authorization/list-read-scope.d.ts.map +1 -1
  14. package/dist/authorization/list-read-scope.js +29 -18
  15. package/dist/authorization/list-read-scope.js.map +1 -1
  16. package/dist/authorization/model/bindings.d.ts +41 -0
  17. package/dist/authorization/model/bindings.d.ts.map +1 -0
  18. package/dist/authorization/model/bindings.js +89 -0
  19. package/dist/authorization/model/bindings.js.map +1 -0
  20. package/dist/authorization/model/data/authorization-model.json +3114 -0
  21. package/dist/authorization/model/default-of.d.ts +2 -2
  22. package/dist/authorization/model/execution-viewer.d.ts +3 -0
  23. package/dist/authorization/model/execution-viewer.d.ts.map +1 -0
  24. package/dist/authorization/model/execution-viewer.js +50 -0
  25. package/dist/authorization/model/execution-viewer.js.map +1 -0
  26. package/dist/authorization/model/index.d.ts +33 -21
  27. package/dist/authorization/model/index.d.ts.map +1 -1
  28. package/dist/authorization/model/index.js +74 -52
  29. package/dist/authorization/model/index.js.map +1 -1
  30. package/dist/authorization/model/openfga-json.d.ts +18 -0
  31. package/dist/authorization/model/openfga-json.d.ts.map +1 -0
  32. package/dist/authorization/model/openfga-json.js +266 -0
  33. package/dist/authorization/model/openfga-json.js.map +1 -0
  34. package/dist/authorization/model/rewrite.d.ts +39 -67
  35. package/dist/authorization/model/rewrite.d.ts.map +1 -1
  36. package/dist/authorization/model/rewrite.js +1 -117
  37. package/dist/authorization/model/rewrite.js.map +1 -1
  38. package/dist/authorization/strict-json.d.ts +20 -0
  39. package/dist/authorization/strict-json.d.ts.map +1 -0
  40. package/dist/authorization/strict-json.js +45 -0
  41. package/dist/authorization/strict-json.js.map +1 -0
  42. package/dist/boot/compose.d.ts.map +1 -1
  43. package/dist/boot/compose.js +9 -7
  44. package/dist/boot/compose.js.map +1 -1
  45. package/dist/boot/list-indexes.d.ts.map +1 -1
  46. package/dist/boot/list-indexes.js +6 -0
  47. package/dist/boot/list-indexes.js.map +1 -1
  48. package/dist/domain/iampolicy/list-index.d.ts +2 -0
  49. package/dist/domain/iampolicy/list-index.d.ts.map +1 -0
  50. package/dist/domain/iampolicy/list-index.js +32 -0
  51. package/dist/domain/iampolicy/list-index.js.map +1 -0
  52. package/dist/domain/iampolicy/resource-store.d.ts.map +1 -1
  53. package/dist/domain/iampolicy/resource-store.js +24 -11
  54. package/dist/domain/iampolicy/resource-store.js.map +1 -1
  55. package/dist/domain/workflowexecution/list-index.d.ts +1 -1
  56. package/dist/extensions/list-read-scope.d.ts +20 -3
  57. package/dist/extensions/list-read-scope.d.ts.map +1 -1
  58. package/dist/extensions/list-read-scope.js +22 -2
  59. package/dist/extensions/list-read-scope.js.map +1 -1
  60. package/dist/index.d.ts +8 -8
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +20 -5
  63. package/dist/index.js.map +1 -1
  64. package/package.json +7 -6
  65. package/src/authorization/README.md +48 -39
  66. package/src/authorization/__tests__/authorizer.test.ts +5 -14
  67. package/src/authorization/__tests__/derived-tuples.test.ts +192 -17
  68. package/src/authorization/__tests__/evaluator.test.ts +53 -160
  69. package/src/authorization/__tests__/facts.test.ts +5 -11
  70. package/src/authorization/__tests__/list-read-scope.measure.test.ts +424 -62
  71. package/src/authorization/__tests__/list-read-scope.test.ts +15 -14
  72. package/src/authorization/__tests__/organization-directory.test.ts +2 -2
  73. package/src/authorization/__tests__/store-test-kit.ts +246 -0
  74. package/src/authorization/__tests__/store-tests.test.ts +91 -223
  75. package/src/authorization/__tests__/support.ts +30 -2
  76. package/src/authorization/__tests__/throwaway-model.ts +75 -0
  77. package/src/authorization/__tests__/wire-permissions.test.ts +141 -107
  78. package/src/authorization/authorizer.ts +7 -5
  79. package/src/authorization/derived-tuples.ts +105 -16
  80. package/src/authorization/evaluator.ts +8 -10
  81. package/src/authorization/list-read-scope.ts +37 -22
  82. package/src/authorization/model/__tests__/openfga-json.test.ts +353 -0
  83. package/src/authorization/model/__tests__/registry.test.ts +102 -276
  84. package/src/authorization/model/bindings.ts +131 -0
  85. package/src/authorization/model/data/authorization-model.json +3114 -0
  86. package/src/authorization/model/default-of.ts +2 -2
  87. package/src/authorization/model/execution-viewer.ts +56 -0
  88. package/src/authorization/model/index.ts +81 -72
  89. package/src/authorization/model/openfga-json.ts +310 -0
  90. package/src/authorization/model/rewrite.ts +39 -188
  91. package/src/authorization/strict-json.ts +52 -0
  92. package/src/boot/__tests__/list-indexes.test.ts +4 -0
  93. package/src/boot/compose.ts +9 -7
  94. package/src/boot/list-indexes.ts +6 -0
  95. package/src/domain/iampolicy/__tests__/resource-store.test.ts +65 -5
  96. package/src/domain/iampolicy/list-index.ts +33 -0
  97. package/src/domain/iampolicy/resource-store.ts +24 -11
  98. package/src/extensions/__tests__/built-in-authorization-composed.test.ts +3 -6
  99. package/src/extensions/list-read-scope.ts +32 -4
  100. package/src/index.ts +20 -44
  101. package/dist/authorization/model/agent.d.ts +0 -2
  102. package/dist/authorization/model/agent.d.ts.map +0 -1
  103. package/dist/authorization/model/agent.js +0 -43
  104. package/dist/authorization/model/agent.js.map +0 -1
  105. package/dist/authorization/model/agent_channel.d.ts +0 -2
  106. package/dist/authorization/model/agent_channel.d.ts.map +0 -1
  107. package/dist/authorization/model/agent_channel.js +0 -39
  108. package/dist/authorization/model/agent_channel.js.map +0 -1
  109. package/dist/authorization/model/agent_execution.d.ts +0 -2
  110. package/dist/authorization/model/agent_execution.d.ts.map +0 -1
  111. package/dist/authorization/model/agent_execution.js +0 -25
  112. package/dist/authorization/model/agent_execution.js.map +0 -1
  113. package/dist/authorization/model/agent_instance.d.ts +0 -2
  114. package/dist/authorization/model/agent_instance.d.ts.map +0 -1
  115. package/dist/authorization/model/agent_instance.js +0 -48
  116. package/dist/authorization/model/agent_instance.js.map +0 -1
  117. package/dist/authorization/model/agent_share.d.ts +0 -2
  118. package/dist/authorization/model/agent_share.d.ts.map +0 -1
  119. package/dist/authorization/model/agent_share.js +0 -29
  120. package/dist/authorization/model/agent_share.js.map +0 -1
  121. package/dist/authorization/model/api_key.d.ts +0 -2
  122. package/dist/authorization/model/api_key.d.ts.map +0 -1
  123. package/dist/authorization/model/api_key.js +0 -27
  124. package/dist/authorization/model/api_key.js.map +0 -1
  125. package/dist/authorization/model/artifact.d.ts +0 -2
  126. package/dist/authorization/model/artifact.d.ts.map +0 -1
  127. package/dist/authorization/model/artifact.js +0 -27
  128. package/dist/authorization/model/artifact.js.map +0 -1
  129. package/dist/authorization/model/channel_app.d.ts +0 -2
  130. package/dist/authorization/model/channel_app.d.ts.map +0 -1
  131. package/dist/authorization/model/channel_app.js +0 -28
  132. package/dist/authorization/model/channel_app.js.map +0 -1
  133. package/dist/authorization/model/environment.d.ts +0 -2
  134. package/dist/authorization/model/environment.d.ts.map +0 -1
  135. package/dist/authorization/model/environment.js +0 -33
  136. package/dist/authorization/model/environment.js.map +0 -1
  137. package/dist/authorization/model/execution_context.d.ts +0 -2
  138. package/dist/authorization/model/execution_context.d.ts.map +0 -1
  139. package/dist/authorization/model/execution_context.js +0 -19
  140. package/dist/authorization/model/execution_context.js.map +0 -1
  141. package/dist/authorization/model/iam_policy.d.ts +0 -2
  142. package/dist/authorization/model/iam_policy.d.ts.map +0 -1
  143. package/dist/authorization/model/iam_policy.js +0 -31
  144. package/dist/authorization/model/iam_policy.js.map +0 -1
  145. package/dist/authorization/model/identity_account.d.ts +0 -2
  146. package/dist/authorization/model/identity_account.d.ts.map +0 -1
  147. package/dist/authorization/model/identity_account.js +0 -25
  148. package/dist/authorization/model/identity_account.js.map +0 -1
  149. package/dist/authorization/model/mcp_server.d.ts +0 -2
  150. package/dist/authorization/model/mcp_server.d.ts.map +0 -1
  151. package/dist/authorization/model/mcp_server.js +0 -38
  152. package/dist/authorization/model/mcp_server.js.map +0 -1
  153. package/dist/authorization/model/memory.d.ts +0 -2
  154. package/dist/authorization/model/memory.d.ts.map +0 -1
  155. package/dist/authorization/model/memory.js +0 -26
  156. package/dist/authorization/model/memory.js.map +0 -1
  157. package/dist/authorization/model/oauth_app.d.ts +0 -2
  158. package/dist/authorization/model/oauth_app.d.ts.map +0 -1
  159. package/dist/authorization/model/oauth_app.js +0 -31
  160. package/dist/authorization/model/oauth_app.js.map +0 -1
  161. package/dist/authorization/model/organization.d.ts +0 -2
  162. package/dist/authorization/model/organization.d.ts.map +0 -1
  163. package/dist/authorization/model/organization.js +0 -58
  164. package/dist/authorization/model/organization.js.map +0 -1
  165. package/dist/authorization/model/platform_client.d.ts +0 -2
  166. package/dist/authorization/model/platform_client.d.ts.map +0 -1
  167. package/dist/authorization/model/platform_client.js +0 -31
  168. package/dist/authorization/model/platform_client.js.map +0 -1
  169. package/dist/authorization/model/plugin.d.ts +0 -2
  170. package/dist/authorization/model/plugin.d.ts.map +0 -1
  171. package/dist/authorization/model/plugin.js +0 -45
  172. package/dist/authorization/model/plugin.js.map +0 -1
  173. package/dist/authorization/model/schedule.d.ts +0 -2
  174. package/dist/authorization/model/schedule.d.ts.map +0 -1
  175. package/dist/authorization/model/schedule.js +0 -34
  176. package/dist/authorization/model/schedule.js.map +0 -1
  177. package/dist/authorization/model/session.d.ts +0 -2
  178. package/dist/authorization/model/session.d.ts.map +0 -1
  179. package/dist/authorization/model/session.js +0 -38
  180. package/dist/authorization/model/session.js.map +0 -1
  181. package/dist/authorization/model/skill.d.ts +0 -2
  182. package/dist/authorization/model/skill.d.ts.map +0 -1
  183. package/dist/authorization/model/skill.js +0 -39
  184. package/dist/authorization/model/skill.js.map +0 -1
  185. package/dist/authorization/model/workflow.d.ts +0 -2
  186. package/dist/authorization/model/workflow.d.ts.map +0 -1
  187. package/dist/authorization/model/workflow.js +0 -39
  188. package/dist/authorization/model/workflow.js.map +0 -1
  189. package/dist/authorization/model/workflow_execution.d.ts +0 -2
  190. package/dist/authorization/model/workflow_execution.d.ts.map +0 -1
  191. package/dist/authorization/model/workflow_execution.js +0 -33
  192. package/dist/authorization/model/workflow_execution.js.map +0 -1
  193. package/dist/authorization/model/workflow_instance.d.ts +0 -2
  194. package/dist/authorization/model/workflow_instance.d.ts.map +0 -1
  195. package/dist/authorization/model/workflow_instance.js +0 -96
  196. package/dist/authorization/model/workflow_instance.js.map +0 -1
  197. package/dist/authorization/store-test-kit.d.ts +0 -62
  198. package/dist/authorization/store-test-kit.d.ts.map +0 -1
  199. package/dist/authorization/store-test-kit.js +0 -265
  200. package/dist/authorization/store-test-kit.js.map +0 -1
  201. package/src/authorization/__tests__/fixtures/fga/agent-channel-owner.fga.yaml +0 -112
  202. package/src/authorization/__tests__/fixtures/fga/agent-instance-creation.fga.yaml +0 -177
  203. package/src/authorization/__tests__/fixtures/fga/agent-share-owner.fga.yaml +0 -109
  204. package/src/authorization/__tests__/fixtures/fga/artifact-org-and-owner.fga.yaml +0 -128
  205. package/src/authorization/__tests__/fixtures/fga/blueprint-private-visibility.fga.yaml +0 -318
  206. package/src/authorization/__tests__/fixtures/fga/channel-app-owner.fga.yaml +0 -125
  207. package/src/authorization/__tests__/fixtures/fga/channel-participation.fga.yaml +0 -201
  208. package/src/authorization/__tests__/fixtures/fga/channel-session-visibility.fga.yaml +0 -187
  209. package/src/authorization/__tests__/fixtures/fga/default-instance-inheritance.fga.yaml +0 -214
  210. package/src/authorization/__tests__/fixtures/fga/mcp-server-authoring.fga.yaml +0 -129
  211. package/src/authorization/__tests__/fixtures/fga/memory-subject-only.fga.yaml +0 -72
  212. package/src/authorization/__tests__/fixtures/fga/org-admin-owner-inheritance.fga.yaml +0 -308
  213. package/src/authorization/__tests__/fixtures/fga/org-shared-environment.fga.yaml +0 -144
  214. package/src/authorization/__tests__/fixtures/fga/platform-visibility.fga.yaml +0 -199
  215. package/src/authorization/__tests__/fixtures/fga/provider-standing-viewer.fga.yaml +0 -40
  216. package/src/authorization/__tests__/fixtures/fga/reserved-label-writer.fga.yaml +0 -41
  217. package/src/authorization/__tests__/fixtures/fga/schedule-owner.fga.yaml +0 -113
  218. package/src/authorization/__tests__/fixtures/fga/schedule-session-visibility.fga.yaml +0 -214
  219. package/src/authorization/__tests__/fixtures/fga/session-personal-resource.fga.yaml +0 -117
  220. package/src/authorization/__tests__/fixtures/fga/workflow-execution-sharing.fga.yaml +0 -190
  221. package/src/authorization/model/agent.ts +0 -64
  222. package/src/authorization/model/agent_channel.ts +0 -57
  223. package/src/authorization/model/agent_execution.ts +0 -33
  224. package/src/authorization/model/agent_instance.ts +0 -66
  225. package/src/authorization/model/agent_share.ts +0 -40
  226. package/src/authorization/model/api_key.ts +0 -28
  227. package/src/authorization/model/artifact.ts +0 -38
  228. package/src/authorization/model/channel_app.ts +0 -39
  229. package/src/authorization/model/environment.ts +0 -48
  230. package/src/authorization/model/execution_context.ts +0 -20
  231. package/src/authorization/model/iam_policy.ts +0 -42
  232. package/src/authorization/model/identity_account.ts +0 -26
  233. package/src/authorization/model/mcp_server.ts +0 -59
  234. package/src/authorization/model/memory.ts +0 -27
  235. package/src/authorization/model/oauth_app.ts +0 -43
  236. package/src/authorization/model/organization.ts +0 -59
  237. package/src/authorization/model/platform_client.ts +0 -43
  238. package/src/authorization/model/plugin.ts +0 -66
  239. package/src/authorization/model/schedule.ts +0 -49
  240. package/src/authorization/model/session.ts +0 -50
  241. package/src/authorization/model/skill.ts +0 -60
  242. package/src/authorization/model/workflow.ts +0 -60
  243. package/src/authorization/model/workflow_execution.ts +0 -46
  244. package/src/authorization/model/workflow_instance.ts +0 -125
  245. package/src/authorization/store-test-kit.ts +0 -395
@@ -1,61 +1,52 @@
1
1
  /**
2
- * The relation-rewrite vocabulary the model declarations are written in —
3
- * the forms an OpenFGA `define` line takes in the cloud's `.fga` files,
4
- * and nothing more:
2
+ * The evaluator's vocabulary for the authorization model: the five
3
+ * relation-rewrite forms OpenFGA's JSON uses in the compiled model
4
+ * (data/authorization-model.json, read by openfga-json.ts), and a kind's
5
+ * declaration as the evaluator consumes it. The model itself is the
6
+ * `.fga` files under fga/model; this module only names its shapes.
5
7
  *
6
- * define organization: [organization] → direct(objectOf("organization"))
8
+ * define organization: [organization] → this, subjects [organization]
7
9
  * define owner: [identity_account] or admin from organization
8
- * → union(direct(objectOf("identity_account")), from("admin", "organization"))
9
- * define viewer: [identity_account, organization#member, organization#viewer]
10
- * or owner or platform_viewer
11
- * → union(direct(objectOf(...), usersetOf(...), usersetOf(...)),
12
- * computed("owner"), computed("platform_viewer"))
10
+ * → union(this, from("admin", "organization"))
11
+ * define viewer: [identity_account, organization#viewer] or owner
12
+ * → union(this, computed("owner"))
13
13
  * define member: [identity_account] and viewer from organization
14
- * → intersection(direct(objectOf("identity_account")), from("viewer", "organization"))
15
- * define can_view: viewer → computed("viewer")
14
+ * → intersection(this, from("viewer", "organization"))
15
+ * define can_view: viewer → computed("viewer")
16
16
  *
17
17
  * `and` exists for one shape: a relation that is true only while another
18
- * holds too — a team's member is a person granted the role AND still one
18
+ * holds too (a team's member is a person granted the role AND still one
19
19
  * of the organization's viewers, so leaving the organization ends every
20
- * team-derived grant with no cleanup. A line is either a union or an
21
- * intersection of leaves, never both: the DSL would need parentheses to
22
- * mix them, the cloud's reader refuses parentheses, and `declareKind`
23
- * refuses the nesting that would need them. `but not` has no node; the day
24
- * a line needs it, the transcript cannot be written and the gap is a
20
+ * team-derived grant with no cleanup). Operators nest to any depth, as
21
+ * the DSL's parentheses allow; the evaluator recurses through them.
22
+ * `but not`, wildcards (`type:*`) and conditions have no form here: the
23
+ * reader refuses them by name, and the day the model needs one is a
25
24
  * design act.
26
25
  *
27
- * A subject type is an object type or a userset; the model admits no
28
- * wildcard (`type:*`) and declares no condition, so neither has a form
29
- * here. The day a line needs one, the transcript cannot be written and
30
- * the gap is a design act.
31
- *
32
- * A declaration file (model/<kind>.ts) is a TRANSCRIPT of its `.fga`
33
- * file: the same relations, in the file's order, each line rewritten in
34
- * these builders so a reader with the model open sees the same text, and
35
- * so a structural compare against the file (the cloud's drift test at
36
- * the re-pin) needs no translation table.
37
- *
38
26
  * The direct form carries its type restrictions (`[identity_account,
39
27
  * organization#viewer]`) because the evaluator ENFORCES them: a tuple
40
28
  * whose subject the line does not list is ignored, which is what OpenFGA
41
- * does at write time. That is the fail-closed property of this design —
29
+ * does at write time. That is the fail-closed property of this design:
42
30
  * a derivation that drifts from the model (a userset the line dropped)
43
- * makes open source deny, never allow.
31
+ * makes the evaluator deny, never allow.
44
32
  *
45
33
  * `derived` names the relations `kind_meta.authorization` cannot derive
46
- * from the row alone: `default_of` on an instance (the
47
- * blueprint's `status.default_instance_id` must name this row — one
48
- * blueprint read through the loader; default-of.ts, shared by the two
49
- * instance kinds) and `execution_viewer` on a workflow instance
50
- * (`spec.execution_visibility`; workflow_instance.ts). A declaration that
51
- * has such a relation states the rule beside the transcript; the tuple
52
- * source dispatches to it by relation.
34
+ * from the row alone: `default_of` on an instance (the blueprint's
35
+ * `status.default_instance_id` must name this row; default-of.ts, shared
36
+ * by the two instance kinds) and `execution_viewer` on a workflow
37
+ * instance (`spec.execution_visibility`; execution-viewer.ts). The
38
+ * binding table (bindings.ts) attaches them; the tuple source dispatches
39
+ * to them by relation.
40
+ *
41
+ * Only types live here: the one producer of production declarations is
42
+ * the reader joined with the bindings (model/index.ts). Tests build
43
+ * throwaway models (a cycle, a chain past the depth bound) with the
44
+ * builders in __tests__/throwaway-model.ts.
53
45
  */
54
46
  import type { DescMessage, Message } from "@bufbuild/protobuf";
55
47
 
56
48
  import type { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
57
49
 
58
- import { kindEnumName } from "../../pipeline/apiresource-meta.js";
59
50
  import type { ObjectRef, RowLoader, Tuple } from "../tuples.js";
60
51
 
61
52
  /** One entry of a direct line's type restriction list. */
@@ -88,38 +79,6 @@ export type Rewrite =
88
79
  readonly members: ReadonlyArray<Rewrite>;
89
80
  };
90
81
 
91
- // ---------------------------------------------------------------------------
92
- // Builders — named so a transcript reads like its `.fga` line.
93
- // ---------------------------------------------------------------------------
94
-
95
- export function objectOf(type: string): SubjectType {
96
- return { form: "object", type };
97
- }
98
-
99
- export function usersetOf(type: string, relation: string): SubjectType {
100
- return { form: "userset", type, relation };
101
- }
102
-
103
- export function direct(...subjects: ReadonlyArray<SubjectType>): Rewrite {
104
- return { node: "this", subjects };
105
- }
106
-
107
- export function computed(relation: string): Rewrite {
108
- return { node: "computed", relation };
109
- }
110
-
111
- export function from(relation: string, tupleset: string): Rewrite {
112
- return { node: "from", relation, tupleset };
113
- }
114
-
115
- export function union(...members: ReadonlyArray<Rewrite>): Rewrite {
116
- return { node: "union", members };
117
- }
118
-
119
- export function intersection(...members: ReadonlyArray<Rewrite>): Rewrite {
120
- return { node: "intersection", members };
121
- }
122
-
123
82
  // ---------------------------------------------------------------------------
124
83
  // A kind's declaration.
125
84
  // ---------------------------------------------------------------------------
@@ -139,127 +98,19 @@ export type DerivedRelation = (
139
98
 
140
99
  export interface KindDeclaration {
141
100
  readonly kind: ApiResourceKind;
142
- /** The FGA type name — the kind's enum member name, as the cloud renders objects. */
101
+ /** The FGA type name: the kind's enum member name, as every edition renders objects. */
143
102
  readonly type: string;
144
- /** The kind's message schema: what a stored row decodes with, and where its audit lives. */
145
- readonly schema: DescMessage;
146
- /** The `.fga` file this transcribes, relative to the cloud's `fga/` folder. */
103
+ /**
104
+ * The kind's message schema: what a stored row decodes with, and where
105
+ * its audit lives. Undefined for a ROWLESS type (bindings.ts), one with
106
+ * no stored resource (`platform`): it resolves over tuples alone, and
107
+ * no row is ever loaded or listed for it.
108
+ */
109
+ readonly schema: DescMessage | undefined;
110
+ /** The `.fga` file the type is defined in, relative to the server package. */
147
111
  readonly source: string;
148
- /** Every relation the file defines, in the file's order. */
112
+ /** Every relation the type defines, sorted by name. */
149
113
  readonly relations: ReadonlyMap<string, Rewrite>;
150
114
  /** The relations `kind_meta` cannot derive (the module header). */
151
115
  readonly derived: ReadonlyMap<string, DerivedRelation>;
152
116
  }
153
-
154
- export interface KindDeclarationInput {
155
- readonly kind: ApiResourceKind;
156
- readonly schema: DescMessage;
157
- readonly source: string;
158
- readonly relations: ReadonlyArray<readonly [name: string, rewrite: Rewrite]>;
159
- readonly derived?: ReadonlyArray<
160
- readonly [name: string, rule: DerivedRelation]
161
- >;
162
- }
163
-
164
- /**
165
- * Builds a declaration and refuses a transcript that cannot be right at
166
- * module load: a relation declared twice, a `computed` naming a relation
167
- * the kind does not define, a `from` whose tupleset the kind does not
168
- * define (its `relation` is the PARENT's and is resolved at evaluation),
169
- * a derived rule for a relation the transcript does not declare, and a
170
- * line no `.fga` file could hold without parentheses (a union inside an
171
- * intersection or the reverse, an operator nested in itself, an operator
172
- * over fewer than two members).
173
- */
174
- export function declareKind(input: KindDeclarationInput): KindDeclaration {
175
- const type = kindEnumName(input.kind);
176
- const relations = new Map<string, Rewrite>();
177
- for (const [name, rewrite] of input.relations) {
178
- if (relations.has(name)) {
179
- throw new Error(`${input.source}: relation '${name}' is declared twice`);
180
- }
181
- const shape = unwritableShape(rewrite);
182
- if (shape !== undefined) {
183
- throw new Error(`${input.source}: '${name}' ${shape}`);
184
- }
185
- relations.set(name, rewrite);
186
- }
187
- for (const [name, rewrite] of relations) {
188
- for (const reference of localReferences(rewrite)) {
189
- if (!relations.has(reference.relation)) {
190
- throw new Error(
191
- `${input.source}: '${name}' names ${reference.role} '${reference.relation}', which ${type} does not declare`,
192
- );
193
- }
194
- }
195
- }
196
- const derived = new Map<string, DerivedRelation>();
197
- for (const [name, rule] of input.derived ?? []) {
198
- if (!relations.has(name)) {
199
- throw new Error(
200
- `${input.source}: a derived rule for '${name}', which ${type} does not declare`,
201
- );
202
- }
203
- derived.set(name, rule);
204
- }
205
- return {
206
- kind: input.kind,
207
- type,
208
- schema: input.schema,
209
- source: input.source,
210
- relations,
211
- derived,
212
- };
213
- }
214
-
215
- /** The relations of the SAME kind a rewrite names: computed targets and tuplesets. */
216
- function localReferences(
217
- rewrite: Rewrite,
218
- ): ReadonlyArray<{ readonly role: string; readonly relation: string }> {
219
- switch (rewrite.node) {
220
- case "this":
221
- return [];
222
- case "computed":
223
- return [{ role: "relation", relation: rewrite.relation }];
224
- case "from":
225
- return [{ role: "tupleset", relation: rewrite.tupleset }];
226
- case "union":
227
- case "intersection":
228
- return rewrite.members.flatMap(localReferences);
229
- default: {
230
- const exhaustive: never = rewrite;
231
- throw new Error(`unknown rewrite node: ${JSON.stringify(exhaustive)}`);
232
- }
233
- }
234
- }
235
-
236
- /**
237
- * Why a line could not be written in a `.fga` file without parentheses,
238
- * or undefined when it can: an operator's members are leaves (a direct
239
- * list, a sibling relation, a tuple-to-userset), and there are at least
240
- * two of them.
241
- */
242
- function unwritableShape(rewrite: Rewrite): string | undefined {
243
- switch (rewrite.node) {
244
- case "this":
245
- case "computed":
246
- case "from":
247
- return undefined;
248
- case "union":
249
- case "intersection": {
250
- if (rewrite.members.length < 2) {
251
- return `is ${rewrite.node === "union" ? "an or" : "an and"} over fewer than two members`;
252
- }
253
- const nested = rewrite.members.find(
254
- (member) => member.node === "union" || member.node === "intersection",
255
- );
256
- return nested === undefined
257
- ? undefined
258
- : `nests ${nested.node === "union" ? "or" : "and"} inside ${rewrite.node === "union" ? "or" : "and"}, which a .fga line cannot say without parentheses`;
259
- }
260
- default: {
261
- const exhaustive: never = rewrite;
262
- throw new Error(`unknown rewrite node: ${JSON.stringify(exhaustive)}`);
263
- }
264
- }
265
- }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Strict reads of a parsed JSON or YAML value, for the two inputs the
3
+ * authorization module takes as data: the compiled model
4
+ * (model/openfga-json.ts) and the `.fga.yaml` store tests the evaluator is
5
+ * proven against (__tests__/store-test-kit.ts). Both are written by a
6
+ * format this module does not own (OpenFGA's), so both are read as
7
+ * `unknown` and refused loudly on anything unexpected: a change upstream
8
+ * is a thrown fault naming where it failed, never a half-read input.
9
+ *
10
+ * Every function takes `where`, the path of the value in its document
11
+ * (`authorization-model.json: type_definitions[3].relations.viewer`), and
12
+ * puts it at the head of the message.
13
+ */
14
+
15
+ /** A mapping, refusing any key outside `allowedKeys` when they are given. */
16
+ export function record(
17
+ value: unknown,
18
+ where: string,
19
+ allowedKeys?: ReadonlyArray<string>,
20
+ ): Record<string, unknown> {
21
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
22
+ throw new Error(`${where}: expected a mapping`);
23
+ }
24
+ const entry = value as Record<string, unknown>;
25
+ if (allowedKeys !== undefined) {
26
+ for (const key of Object.keys(entry)) {
27
+ if (!allowedKeys.includes(key)) {
28
+ throw new Error(`${where}: unknown key '${key}'`);
29
+ }
30
+ }
31
+ }
32
+ return entry;
33
+ }
34
+
35
+ export function list(value: unknown, where: string): ReadonlyArray<unknown> {
36
+ if (!Array.isArray(value)) {
37
+ throw new Error(`${where}: expected a list`);
38
+ }
39
+ return value as ReadonlyArray<unknown>;
40
+ }
41
+
42
+ /** A list, or the empty list when the key is absent. */
43
+ export function optionalList(value: unknown, where: string): ReadonlyArray<unknown> {
44
+ return value === undefined ? [] : list(value, where);
45
+ }
46
+
47
+ export function text(value: unknown, where: string): string {
48
+ if (typeof value !== "string") {
49
+ throw new Error(`${where}: expected a string`);
50
+ }
51
+ return value;
52
+ }
@@ -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:
@@ -385,13 +385,15 @@ export async function composeServer(
385
385
  requireAuthentication,
386
386
  });
387
387
  const builtInAuthorization = authorizationPosture !== "unit-authorizer";
388
- // The built-in authorizer declares the open-source kinds and no other
389
- // (its registry is pinned set-equal to that tier), so a composition
390
- // that serves a wider edition must bring its own Authorizer: every
391
- // check on a kind the model does not declare would otherwise fault
392
- // INTERNAL per request. Refused at boot, the same class as a
393
- // misconfigured registry (DD-006 §2b) and the verifierless posture
394
- // below — caught before any side effect.
388
+ // The built-in authorizer evaluates the one model every edition reads,
389
+ // but it derives each resource's tuples from the rows in this server's
390
+ // store. A wider edition's kinds are served by extensions that keep
391
+ // their rows in stores of their own, which the built-in tuple source
392
+ // cannot read, so a composition that serves a wider edition must bring
393
+ // its own Authorizer rather than get answers derived from rows that are
394
+ // not there. Refused at boot, the same class as a misconfigured
395
+ // registry and the verifierless posture below: caught before any side
396
+ // effect.
395
397
  if (
396
398
  authorizationPosture === "built-in" &&
397
399
  extensions.edition !== ServerEdition.oss
@@ -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) {
@@ -764,16 +764,13 @@ describe("built-in list scope (C4: two boots, one seed — the scope is the only
764
764
  let unscopedStore: Store | undefined;
765
765
  const passThrough: ListReadScope = {
766
766
  async authorizedResourceIds(_caller, kind) {
767
- const declaration = builtInModel.byKind(kind);
768
- if (unscopedStore === undefined || declaration === undefined) {
767
+ const schema = builtInModel.byKind(kind)?.schema;
768
+ if (unscopedStore === undefined || schema === undefined) {
769
769
  throw new Error("the pass-through is bound to a booted server");
770
770
  }
771
771
  const rows = await unscopedStore.listResources(kind);
772
772
  return new Set(
773
- rows.map(
774
- (bytes) =>
775
- metadataOf(fromBinary(declaration.schema, bytes))?.id ?? "",
776
- ),
773
+ rows.map((bytes) => metadataOf(fromBinary(schema, bytes))?.id ?? ""),
777
774
  );
778
775
  },
779
776
  restrictListEntries: (_caller, _kind, entries) =>