@stigmer/server 3.27.2 → 3.29.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 (227) hide show
  1. package/README.md +2 -0
  2. package/dist/authorization/authorizer.d.ts +3 -3
  3. package/dist/authorization/authorizer.js +3 -3
  4. package/dist/authorization/derived-tuples.js +6 -1
  5. package/dist/authorization/derived-tuples.js.map +1 -1
  6. package/dist/authorization/evaluator.d.ts +9 -10
  7. package/dist/authorization/evaluator.d.ts.map +1 -1
  8. package/dist/authorization/evaluator.js.map +1 -1
  9. package/dist/authorization/list-read-scope.d.ts.map +1 -1
  10. package/dist/authorization/list-read-scope.js +29 -18
  11. package/dist/authorization/list-read-scope.js.map +1 -1
  12. package/dist/authorization/model/bindings.d.ts +41 -0
  13. package/dist/authorization/model/bindings.d.ts.map +1 -0
  14. package/dist/authorization/model/bindings.js +89 -0
  15. package/dist/authorization/model/bindings.js.map +1 -0
  16. package/dist/authorization/model/data/authorization-model.json +3141 -0
  17. package/dist/authorization/model/default-of.d.ts +2 -2
  18. package/dist/authorization/model/execution-viewer.d.ts +3 -0
  19. package/dist/authorization/model/execution-viewer.d.ts.map +1 -0
  20. package/dist/authorization/model/execution-viewer.js +50 -0
  21. package/dist/authorization/model/execution-viewer.js.map +1 -0
  22. package/dist/authorization/model/index.d.ts +33 -21
  23. package/dist/authorization/model/index.d.ts.map +1 -1
  24. package/dist/authorization/model/index.js +74 -52
  25. package/dist/authorization/model/index.js.map +1 -1
  26. package/dist/authorization/model/openfga-json.d.ts +18 -0
  27. package/dist/authorization/model/openfga-json.d.ts.map +1 -0
  28. package/dist/authorization/model/openfga-json.js +266 -0
  29. package/dist/authorization/model/openfga-json.js.map +1 -0
  30. package/dist/authorization/model/rewrite.d.ts +39 -67
  31. package/dist/authorization/model/rewrite.d.ts.map +1 -1
  32. package/dist/authorization/model/rewrite.js +1 -117
  33. package/dist/authorization/model/rewrite.js.map +1 -1
  34. package/dist/authorization/strict-json.d.ts +20 -0
  35. package/dist/authorization/strict-json.d.ts.map +1 -0
  36. package/dist/authorization/strict-json.js +45 -0
  37. package/dist/authorization/strict-json.js.map +1 -0
  38. package/dist/boot/compose.d.ts.map +1 -1
  39. package/dist/boot/compose.js +9 -7
  40. package/dist/boot/compose.js.map +1 -1
  41. package/dist/domain/workflowexecution/list-index.d.ts +1 -1
  42. package/dist/extensions/list-read-scope.d.ts +20 -3
  43. package/dist/extensions/list-read-scope.d.ts.map +1 -1
  44. package/dist/extensions/list-read-scope.js +20 -1
  45. package/dist/extensions/list-read-scope.js.map +1 -1
  46. package/dist/index.d.ts +7 -8
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +15 -5
  49. package/dist/index.js.map +1 -1
  50. package/package.json +7 -6
  51. package/src/authorization/README.md +41 -34
  52. package/src/authorization/__tests__/authorizer.test.ts +5 -14
  53. package/src/authorization/__tests__/derived-tuples.test.ts +29 -27
  54. package/src/authorization/__tests__/evaluator.test.ts +53 -160
  55. package/src/authorization/__tests__/facts.test.ts +5 -11
  56. package/src/authorization/__tests__/list-read-scope.measure.test.ts +12 -20
  57. package/src/authorization/__tests__/list-read-scope.test.ts +15 -14
  58. package/src/authorization/__tests__/organization-directory.test.ts +2 -2
  59. package/src/authorization/__tests__/store-test-kit.ts +246 -0
  60. package/src/authorization/__tests__/store-tests.test.ts +92 -223
  61. package/src/authorization/__tests__/support.ts +30 -2
  62. package/src/authorization/__tests__/throwaway-model.ts +75 -0
  63. package/src/authorization/__tests__/wire-permissions.test.ts +141 -107
  64. package/src/authorization/authorizer.ts +3 -3
  65. package/src/authorization/derived-tuples.ts +7 -2
  66. package/src/authorization/evaluator.ts +8 -10
  67. package/src/authorization/list-read-scope.ts +37 -22
  68. package/src/authorization/model/__tests__/openfga-json.test.ts +353 -0
  69. package/src/authorization/model/__tests__/registry.test.ts +345 -277
  70. package/src/authorization/model/bindings.ts +131 -0
  71. package/src/authorization/model/data/authorization-model.json +3141 -0
  72. package/src/authorization/model/default-of.ts +2 -2
  73. package/src/authorization/model/execution-viewer.ts +56 -0
  74. package/src/authorization/model/index.ts +81 -72
  75. package/src/authorization/model/openfga-json.ts +310 -0
  76. package/src/authorization/model/rewrite.ts +39 -188
  77. package/src/authorization/strict-json.ts +52 -0
  78. package/src/boot/compose.ts +9 -7
  79. package/src/extensions/__tests__/built-in-authorization-composed.test.ts +3 -6
  80. package/src/extensions/list-read-scope.ts +32 -4
  81. package/src/index.ts +15 -44
  82. package/dist/authorization/model/agent.d.ts +0 -2
  83. package/dist/authorization/model/agent.d.ts.map +0 -1
  84. package/dist/authorization/model/agent.js +0 -43
  85. package/dist/authorization/model/agent.js.map +0 -1
  86. package/dist/authorization/model/agent_channel.d.ts +0 -2
  87. package/dist/authorization/model/agent_channel.d.ts.map +0 -1
  88. package/dist/authorization/model/agent_channel.js +0 -39
  89. package/dist/authorization/model/agent_channel.js.map +0 -1
  90. package/dist/authorization/model/agent_execution.d.ts +0 -2
  91. package/dist/authorization/model/agent_execution.d.ts.map +0 -1
  92. package/dist/authorization/model/agent_execution.js +0 -25
  93. package/dist/authorization/model/agent_execution.js.map +0 -1
  94. package/dist/authorization/model/agent_instance.d.ts +0 -2
  95. package/dist/authorization/model/agent_instance.d.ts.map +0 -1
  96. package/dist/authorization/model/agent_instance.js +0 -48
  97. package/dist/authorization/model/agent_instance.js.map +0 -1
  98. package/dist/authorization/model/agent_share.d.ts +0 -2
  99. package/dist/authorization/model/agent_share.d.ts.map +0 -1
  100. package/dist/authorization/model/agent_share.js +0 -29
  101. package/dist/authorization/model/agent_share.js.map +0 -1
  102. package/dist/authorization/model/api_key.d.ts +0 -2
  103. package/dist/authorization/model/api_key.d.ts.map +0 -1
  104. package/dist/authorization/model/api_key.js +0 -27
  105. package/dist/authorization/model/api_key.js.map +0 -1
  106. package/dist/authorization/model/artifact.d.ts +0 -2
  107. package/dist/authorization/model/artifact.d.ts.map +0 -1
  108. package/dist/authorization/model/artifact.js +0 -27
  109. package/dist/authorization/model/artifact.js.map +0 -1
  110. package/dist/authorization/model/channel_app.d.ts +0 -2
  111. package/dist/authorization/model/channel_app.d.ts.map +0 -1
  112. package/dist/authorization/model/channel_app.js +0 -28
  113. package/dist/authorization/model/channel_app.js.map +0 -1
  114. package/dist/authorization/model/environment.d.ts +0 -2
  115. package/dist/authorization/model/environment.d.ts.map +0 -1
  116. package/dist/authorization/model/environment.js +0 -33
  117. package/dist/authorization/model/environment.js.map +0 -1
  118. package/dist/authorization/model/execution_context.d.ts +0 -2
  119. package/dist/authorization/model/execution_context.d.ts.map +0 -1
  120. package/dist/authorization/model/execution_context.js +0 -19
  121. package/dist/authorization/model/execution_context.js.map +0 -1
  122. package/dist/authorization/model/iam_policy.d.ts +0 -2
  123. package/dist/authorization/model/iam_policy.d.ts.map +0 -1
  124. package/dist/authorization/model/iam_policy.js +0 -31
  125. package/dist/authorization/model/iam_policy.js.map +0 -1
  126. package/dist/authorization/model/identity_account.d.ts +0 -2
  127. package/dist/authorization/model/identity_account.d.ts.map +0 -1
  128. package/dist/authorization/model/identity_account.js +0 -25
  129. package/dist/authorization/model/identity_account.js.map +0 -1
  130. package/dist/authorization/model/mcp_server.d.ts +0 -2
  131. package/dist/authorization/model/mcp_server.d.ts.map +0 -1
  132. package/dist/authorization/model/mcp_server.js +0 -38
  133. package/dist/authorization/model/mcp_server.js.map +0 -1
  134. package/dist/authorization/model/memory.d.ts +0 -2
  135. package/dist/authorization/model/memory.d.ts.map +0 -1
  136. package/dist/authorization/model/memory.js +0 -26
  137. package/dist/authorization/model/memory.js.map +0 -1
  138. package/dist/authorization/model/oauth_app.d.ts +0 -2
  139. package/dist/authorization/model/oauth_app.d.ts.map +0 -1
  140. package/dist/authorization/model/oauth_app.js +0 -31
  141. package/dist/authorization/model/oauth_app.js.map +0 -1
  142. package/dist/authorization/model/organization.d.ts +0 -2
  143. package/dist/authorization/model/organization.d.ts.map +0 -1
  144. package/dist/authorization/model/organization.js +0 -58
  145. package/dist/authorization/model/organization.js.map +0 -1
  146. package/dist/authorization/model/platform_client.d.ts +0 -2
  147. package/dist/authorization/model/platform_client.d.ts.map +0 -1
  148. package/dist/authorization/model/platform_client.js +0 -31
  149. package/dist/authorization/model/platform_client.js.map +0 -1
  150. package/dist/authorization/model/plugin.d.ts +0 -2
  151. package/dist/authorization/model/plugin.d.ts.map +0 -1
  152. package/dist/authorization/model/plugin.js +0 -45
  153. package/dist/authorization/model/plugin.js.map +0 -1
  154. package/dist/authorization/model/schedule.d.ts +0 -2
  155. package/dist/authorization/model/schedule.d.ts.map +0 -1
  156. package/dist/authorization/model/schedule.js +0 -34
  157. package/dist/authorization/model/schedule.js.map +0 -1
  158. package/dist/authorization/model/session.d.ts +0 -2
  159. package/dist/authorization/model/session.d.ts.map +0 -1
  160. package/dist/authorization/model/session.js +0 -38
  161. package/dist/authorization/model/session.js.map +0 -1
  162. package/dist/authorization/model/skill.d.ts +0 -2
  163. package/dist/authorization/model/skill.d.ts.map +0 -1
  164. package/dist/authorization/model/skill.js +0 -39
  165. package/dist/authorization/model/skill.js.map +0 -1
  166. package/dist/authorization/model/workflow.d.ts +0 -2
  167. package/dist/authorization/model/workflow.d.ts.map +0 -1
  168. package/dist/authorization/model/workflow.js +0 -39
  169. package/dist/authorization/model/workflow.js.map +0 -1
  170. package/dist/authorization/model/workflow_execution.d.ts +0 -2
  171. package/dist/authorization/model/workflow_execution.d.ts.map +0 -1
  172. package/dist/authorization/model/workflow_execution.js +0 -33
  173. package/dist/authorization/model/workflow_execution.js.map +0 -1
  174. package/dist/authorization/model/workflow_instance.d.ts +0 -2
  175. package/dist/authorization/model/workflow_instance.d.ts.map +0 -1
  176. package/dist/authorization/model/workflow_instance.js +0 -96
  177. package/dist/authorization/model/workflow_instance.js.map +0 -1
  178. package/dist/authorization/store-test-kit.d.ts +0 -62
  179. package/dist/authorization/store-test-kit.d.ts.map +0 -1
  180. package/dist/authorization/store-test-kit.js +0 -265
  181. package/dist/authorization/store-test-kit.js.map +0 -1
  182. package/src/authorization/__tests__/enterprise-model.ts +0 -59
  183. package/src/authorization/__tests__/fixtures/fga/agent-channel-owner.fga.yaml +0 -112
  184. package/src/authorization/__tests__/fixtures/fga/agent-instance-creation.fga.yaml +0 -177
  185. package/src/authorization/__tests__/fixtures/fga/agent-share-owner.fga.yaml +0 -109
  186. package/src/authorization/__tests__/fixtures/fga/artifact-org-and-owner.fga.yaml +0 -128
  187. package/src/authorization/__tests__/fixtures/fga/blueprint-private-visibility.fga.yaml +0 -318
  188. package/src/authorization/__tests__/fixtures/fga/channel-app-owner.fga.yaml +0 -125
  189. package/src/authorization/__tests__/fixtures/fga/channel-participation.fga.yaml +0 -201
  190. package/src/authorization/__tests__/fixtures/fga/channel-session-visibility.fga.yaml +0 -187
  191. package/src/authorization/__tests__/fixtures/fga/default-instance-inheritance.fga.yaml +0 -214
  192. package/src/authorization/__tests__/fixtures/fga/mcp-server-authoring.fga.yaml +0 -129
  193. package/src/authorization/__tests__/fixtures/fga/memory-subject-only.fga.yaml +0 -72
  194. package/src/authorization/__tests__/fixtures/fga/org-admin-owner-inheritance.fga.yaml +0 -308
  195. package/src/authorization/__tests__/fixtures/fga/org-shared-environment.fga.yaml +0 -144
  196. package/src/authorization/__tests__/fixtures/fga/platform-visibility.fga.yaml +0 -199
  197. package/src/authorization/__tests__/fixtures/fga/provider-standing-viewer.fga.yaml +0 -40
  198. package/src/authorization/__tests__/fixtures/fga/reserved-label-writer.fga.yaml +0 -41
  199. package/src/authorization/__tests__/fixtures/fga/schedule-owner.fga.yaml +0 -113
  200. package/src/authorization/__tests__/fixtures/fga/schedule-session-visibility.fga.yaml +0 -214
  201. package/src/authorization/__tests__/fixtures/fga/session-personal-resource.fga.yaml +0 -117
  202. package/src/authorization/__tests__/fixtures/fga/workflow-execution-sharing.fga.yaml +0 -190
  203. package/src/authorization/model/agent.ts +0 -64
  204. package/src/authorization/model/agent_channel.ts +0 -57
  205. package/src/authorization/model/agent_execution.ts +0 -33
  206. package/src/authorization/model/agent_instance.ts +0 -66
  207. package/src/authorization/model/agent_share.ts +0 -40
  208. package/src/authorization/model/api_key.ts +0 -28
  209. package/src/authorization/model/artifact.ts +0 -38
  210. package/src/authorization/model/channel_app.ts +0 -39
  211. package/src/authorization/model/environment.ts +0 -48
  212. package/src/authorization/model/execution_context.ts +0 -20
  213. package/src/authorization/model/iam_policy.ts +0 -42
  214. package/src/authorization/model/identity_account.ts +0 -26
  215. package/src/authorization/model/mcp_server.ts +0 -59
  216. package/src/authorization/model/memory.ts +0 -27
  217. package/src/authorization/model/oauth_app.ts +0 -43
  218. package/src/authorization/model/organization.ts +0 -59
  219. package/src/authorization/model/platform_client.ts +0 -43
  220. package/src/authorization/model/plugin.ts +0 -66
  221. package/src/authorization/model/schedule.ts +0 -49
  222. package/src/authorization/model/session.ts +0 -50
  223. package/src/authorization/model/skill.ts +0 -60
  224. package/src/authorization/model/workflow.ts +0 -60
  225. package/src/authorization/model/workflow_execution.ts +0 -46
  226. package/src/authorization/model/workflow_instance.ts +0 -125
  227. package/src/authorization/store-test-kit.ts +0 -395
@@ -15,6 +15,11 @@
15
15
  * parent), which is what lets the parity pin run over all of them.
16
16
  * `spec` and `status` overrides sit on top for the arms that need a
17
17
  * pointer or a level set.
18
+ *
19
+ * A fixture row exists only for a kind this edition stores rows of: every
20
+ * type of the model but a rowless one (`platform`, model/bindings.ts), so
21
+ * the builders take a StoredKindDeclaration and `storedDeclaration`
22
+ * refuses a rowless type by name.
18
23
  */
19
24
  import { create } from "@bufbuild/protobuf";
20
25
  import type { DescMessage, Message } from "@bufbuild/protobuf";
@@ -26,6 +31,7 @@ import {
26
31
  getKindMeta,
27
32
  kindEnumName,
28
33
  } from "../../pipeline/apiresource-meta.js";
34
+ import { builtInModel } from "../model/index.js";
29
35
  import type { KindDeclaration } from "../model/rewrite.js";
30
36
 
31
37
  export interface FixtureRowFacts {
@@ -39,6 +45,28 @@ export interface FixtureRowFacts {
39
45
  readonly status?: Readonly<Record<string, unknown>>;
40
46
  }
41
47
 
48
+ /** A declaration of a kind with stored rows: what a fixture row is built, saved and decoded with. */
49
+ export type StoredKindDeclaration = KindDeclaration & { readonly schema: DescMessage };
50
+
51
+ /** Whether rows of the declaration's kind are stored: every type but a rowless one. */
52
+ export function hasStoredRows(
53
+ declaration: KindDeclaration,
54
+ ): declaration is StoredKindDeclaration {
55
+ return declaration.schema !== undefined;
56
+ }
57
+
58
+ /** The built-in model's declaration of `type`, which must be a kind with stored rows. */
59
+ export function storedDeclaration(type: string): StoredKindDeclaration {
60
+ const declaration = builtInModel.byType(type);
61
+ if (declaration === undefined) {
62
+ throw new Error(`${type} is not a type of the model`);
63
+ }
64
+ if (!hasStoredRows(declaration)) {
65
+ throw new Error(`${type} is rowless: no row of it is stored`);
66
+ }
67
+ return declaration;
68
+ }
69
+
42
70
  /** The id the fixture gives the parent of `parentKind`: `<type>-1`. */
43
71
  export function fixtureParentId(parentKind: string): string {
44
72
  return `${kindEnumName(getKindEnum(parentKind))}-1`;
@@ -46,7 +74,7 @@ export function fixtureParentId(parentKind: string): string {
46
74
 
47
75
  /** A row of `declaration.kind` carrying exactly the given facts, its parents filled. */
48
76
  export function fixtureRow(
49
- declaration: KindDeclaration,
77
+ declaration: StoredKindDeclaration,
50
78
  facts: FixtureRowFacts,
51
79
  ): Message {
52
80
  // Typed loosely on purpose: the schema is any declared kind's, and
@@ -70,7 +98,7 @@ export function fixtureRow(
70
98
  }
71
99
 
72
100
  /** `kind_meta`'s parent spec fields for the kind, by generated name, each naming `<parentKind>-1`. */
73
- function parentFieldsOf(declaration: KindDeclaration): Record<string, string> {
101
+ function parentFieldsOf(declaration: StoredKindDeclaration): Record<string, string> {
74
102
  const config = getKindMeta(declaration.kind).authorization;
75
103
  const spec = specSchemaOf(declaration.schema);
76
104
  const fields: Record<string, string> = {};
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Builders for tests' throwaway models: a declaration written by hand in
3
+ * the evaluator's vocabulary (model/rewrite.ts), for the shapes the one
4
+ * real model must never hold (a cycle, a chain past the depth bound) and
5
+ * for proving a mechanism over a model small enough to read in the test.
6
+ * The built-in model is never written this way: it is the compiled
7
+ * OpenFGA JSON, read by model/openfga-json.ts.
8
+ *
9
+ * Nothing here validates: a test that declares a cycle means to. The
10
+ * builders are named so a declaration reads like the `.fga` line it
11
+ * stands for:
12
+ *
13
+ * define viewer: [identity_account, organization#viewer] or owner
14
+ * → union(direct(objectOf("identity_account"), usersetOf("organization", "viewer")), computed("owner"))
15
+ */
16
+ import type { DescMessage } from "@bufbuild/protobuf";
17
+
18
+ import type { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
19
+
20
+ import { kindEnumName } from "../../pipeline/apiresource-meta.js";
21
+ import type {
22
+ DerivedRelation,
23
+ KindDeclaration,
24
+ Rewrite,
25
+ SubjectType,
26
+ } from "../model/rewrite.js";
27
+
28
+ export function objectOf(type: string): SubjectType {
29
+ return { form: "object", type };
30
+ }
31
+
32
+ export function usersetOf(type: string, relation: string): SubjectType {
33
+ return { form: "userset", type, relation };
34
+ }
35
+
36
+ export function direct(...subjects: ReadonlyArray<SubjectType>): Rewrite {
37
+ return { node: "this", subjects };
38
+ }
39
+
40
+ export function computed(relation: string): Rewrite {
41
+ return { node: "computed", relation };
42
+ }
43
+
44
+ export function from(relation: string, tupleset: string): Rewrite {
45
+ return { node: "from", relation, tupleset };
46
+ }
47
+
48
+ export function union(...members: ReadonlyArray<Rewrite>): Rewrite {
49
+ return { node: "union", members };
50
+ }
51
+
52
+ export function intersection(...members: ReadonlyArray<Rewrite>): Rewrite {
53
+ return { node: "intersection", members };
54
+ }
55
+
56
+ export interface ThrowawayDeclarationInput {
57
+ readonly kind: ApiResourceKind;
58
+ /** Omitted for a rowless type. */
59
+ readonly schema?: DescMessage;
60
+ readonly source?: string;
61
+ readonly relations: ReadonlyArray<readonly [name: string, rewrite: Rewrite]>;
62
+ readonly derived?: ReadonlyArray<readonly [name: string, rule: DerivedRelation]>;
63
+ }
64
+
65
+ /** A declaration exactly as written, for `newModel`. */
66
+ export function throwawayDeclaration(input: ThrowawayDeclarationInput): KindDeclaration {
67
+ return {
68
+ kind: input.kind,
69
+ type: kindEnumName(input.kind),
70
+ schema: input.schema,
71
+ source: input.source ?? "throwaway",
72
+ relations: new Map(input.relations),
73
+ derived: new Map(input.derived ?? []),
74
+ };
75
+ }
@@ -1,83 +1,136 @@
1
1
  /**
2
- * Pins that every question the wire can ask this edition's authorizer
3
- * has a line in the model: for every `rpc.config`-annotated method the
4
- * open-source server SERVES whose annotation names a static kind this
5
- * edition serves, the permission is a relation that kind's transcript
6
- * declares. A relation the kind does not define answers `deny` here
7
- * (evaluator.ts) — an RPC annotated with such a pair would refuse every
8
- * caller, green on the permissive `local*` targets and dark only under
9
- * enforcement, so the gap is caught at the registry instead of on a
10
- * self-host.
2
+ * Pins the contract to the model over the whole wire: every question an
3
+ * RPC annotation asks the authorization model is a relation the model
4
+ * defines. The model (fga/model) and the contract (apis/, reaching this
5
+ * package as `@stigmer/protos`) are both this repository's, so their
6
+ * agreement is this repository's to keep, whichever edition serves the
7
+ * RPC. That is why the scope here is every service in `@stigmer/protos`,
8
+ * where authorize-annotation-completeness.test.ts scopes itself to the
9
+ * methods open source serves: that test governs the Authorize step, and a
10
+ * composition's own services are its own conformance suite's business;
11
+ * this one asks whether the model and the contract say the same thing.
11
12
  *
12
- * Scope follows authorize-annotation-completeness.test.ts: the empty
13
- * composition's routes, replayed into a recorder, are exactly the methods
14
- * the OSS Authorize step governs. Outside the pin, each for a stated
15
- * reason: `is_public` and `is_skip_authorization` methods (the annotation
16
- * is never resolved); a kind the edition does not serve (`platform`; the
17
- * driver refuses it before any evaluation, so under the built-in posture
18
- * nobody holds a platform permission); a kind named by
19
- * `resource_kind_path` (the IamPolicy lane's — the kind is the request's,
20
- * and the target's own declaration answers at run time).
13
+ * Two invariants:
14
+ * - every `rpc.config` annotation that names a static kind asks a
15
+ * permission that kind's type defines. A relation the type does not
16
+ * define answers `deny` in the built-in evaluator and a validation
17
+ * error in OpenFGA, so such an RPC refuses every caller, dark until
18
+ * someone runs it under enforcement;
19
+ * - the permission of every `resource_kind_path` lane (the IamPolicy
20
+ * lanes, whose kind is the request's) is defined on every kind whose
21
+ * `kind_meta` lists grantable roles, because each such kind is one the
22
+ * grant path admits.
23
+ *
24
+ * Outside the pin, as in the annotation-completeness precedent: `is_public`
25
+ * and `is_skip_authorization` methods (their annotation is never
26
+ * resolved), and a `config` that names no kind (`IamPolicyQueryController.get`,
27
+ * which authorizes the loaded policy's resource).
28
+ *
29
+ * What does not hold today is pinned, never dropped: `KNOWN_GAPS` lists
30
+ * each disagreement, and the test requires the computed list to equal it,
31
+ * so a fix to the model or the contract fails here until its line is
32
+ * removed.
33
+ *
34
+ * The walk reads the stubs' built `dist`. `make test-server` builds them
35
+ * first; a bare `vitest` run in a checkout whose `dist` is stale reads
36
+ * services the contract no longer has.
21
37
  */
22
- import { mkdtempSync, rmSync } from "node:fs";
23
- import { tmpdir } from "node:os";
38
+ import { readdirSync } from "node:fs";
39
+ import { createRequire } from "node:module";
24
40
  import path from "node:path";
25
41
 
42
+ import type { DescService } from "@bufbuild/protobuf";
26
43
  import { getOption, hasOption } from "@bufbuild/protobuf";
27
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
44
+ import { beforeAll, describe, expect, it } from "vitest";
28
45
 
29
- import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
46
+ import {
47
+ ApiResourceKind,
48
+ ApiResourceKindSchema,
49
+ } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
30
50
  import {
31
51
  config as rpcAuthorizationConfig,
32
52
  is_public,
33
53
  is_skip_authorization,
34
54
  } from "@stigmer/protos/ai/stigmer/commons/rpc/method_options_pb";
35
55
  import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
36
- import { ServerEdition } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
37
56
 
38
- import { loadConfig } from "../../boot/config.js";
39
- import { composeServer } from "../../boot/compose.js";
40
- import type { ComposedServer } from "../../boot/compose.js";
41
- import {
42
- baseConfig,
43
- servedServices,
44
- silentLogger,
45
- } from "../../extensions/__tests__/composed-support.js";
46
- import {
47
- kindEnumName,
48
- kindServedByEdition,
49
- } from "../../pipeline/apiresource-meta.js";
50
- import { declarationFor } from "../model/index.js";
57
+ import { grantableRolesFor, kindEnumName } from "../../pipeline/apiresource-meta.js";
58
+ import { builtInModel } from "../model/index.js";
59
+
60
+ /**
61
+ * Today's disagreements between the contract and the model, each with its
62
+ * reason. Removing a gap from the model or the contract fails the test
63
+ * until its line is removed here.
64
+ */
65
+ const KNOWN_GAPS: ReadonlyArray<string> = [
66
+ // Contract ahead of model, on purpose: Plan's proto says the relation
67
+ // lands in the model with the entry that serves the kind
68
+ // (apis/ai/stigmer/billing/plan/v1/command.proto), and no edition serves
69
+ // Plan yet.
70
+ "ai.stigmer.billing.plan.v1.PlanCommandController/create asks can_manage_plans on platform",
71
+ "ai.stigmer.billing.plan.v1.PlanCommandController/retire asks can_manage_plans on platform",
72
+ // Artifacts are grantable in kind_meta, but the model defines no access
73
+ // relations on them: https://github.com/stigmer/stigmer/issues/1268.
74
+ "the resource_kind_path lanes ask can_grant_access on artifact",
75
+ "the resource_kind_path lanes ask can_view_access on artifact",
76
+ ];
51
77
 
52
- /** One annotated, served, statically-targeted method: the question the wire asks. */
78
+ /** One annotated method: the question it asks the model. */
53
79
  interface WireQuestion {
54
80
  readonly method: string;
81
+ /** The static kind, or unknown when the kind is the request's (`kindPath`). */
55
82
  readonly kind: ApiResourceKind;
83
+ readonly kindPath: string;
56
84
  readonly permission: string;
57
85
  }
58
86
 
59
- describe("every static (kind, permission) the served RPCs ask about is a declared relation", () => {
60
- let dir: string;
61
- let server: ComposedServer;
87
+ /** Every service descriptor the stubs package exports, loaded through its own specifiers. */
88
+ async function everyService(): Promise<ReadonlyArray<DescService>> {
89
+ const require = createRequire(import.meta.url);
90
+ const anchor = "ai/stigmer/iam/v1/enum_pb";
91
+ const resolved = require.resolve(`@stigmer/protos/${anchor}`);
92
+ const dist = resolved.slice(0, resolved.length - `${anchor}.js`.length);
93
+ const modules = readdirSync(path.join(dist, "ai"), { recursive: true, encoding: "utf8" })
94
+ .filter((file) => file.endsWith("_pb.js"))
95
+ .map((file) => `ai/${file.slice(0, -".js".length).split(path.sep).join("/")}`)
96
+ .sort();
97
+ const services: DescService[] = [];
98
+ for (const specifier of modules) {
99
+ const loaded: Record<string, unknown> = await import(`@stigmer/protos/${specifier}`);
100
+ for (const value of Object.values(loaded)) {
101
+ if (isService(value)) {
102
+ services.push(value);
103
+ }
104
+ }
105
+ }
106
+ return services;
107
+ }
62
108
 
63
- beforeAll(async () => {
64
- dir = mkdtempSync(path.join(tmpdir(), "wire-permissions-test-"));
65
- server = await composeServer({
66
- config: loadConfig(baseConfig(dir)),
67
- logger: silentLogger,
68
- portOverride: 0,
69
- host: "127.0.0.1",
70
- });
71
- });
109
+ function isService(value: unknown): value is DescService {
110
+ return (
111
+ typeof value === "object" &&
112
+ value !== null &&
113
+ "kind" in value &&
114
+ value.kind === "service" &&
115
+ "methods" in value
116
+ );
117
+ }
72
118
 
73
- afterAll(async () => {
74
- await server.shutdown();
75
- rmSync(dir, { recursive: true, force: true });
119
+ /** The model's relations on a kind, or none when the model does not define it. */
120
+ function relationsOf(kind: ApiResourceKind): ReadonlySet<string> {
121
+ return new Set(builtInModel.byKind(kind)?.relations.keys() ?? []);
122
+ }
123
+
124
+ describe("the contract asks the model only what the model defines", () => {
125
+ let services: ReadonlyArray<DescService>;
126
+
127
+ beforeAll(async () => {
128
+ services = await everyService();
76
129
  });
77
130
 
78
- function wireQuestions(): WireQuestion[] {
79
- const questions: WireQuestion[] = [];
80
- for (const service of servedServices(server.routes)) {
131
+ function annotated(): ReadonlyArray<WireQuestion> {
132
+ const out: WireQuestion[] = [];
133
+ for (const service of services) {
81
134
  for (const method of service.methods) {
82
135
  if (
83
136
  !hasOption(method, rpcAuthorizationConfig) ||
@@ -87,68 +140,49 @@ describe("every static (kind, permission) the served RPCs ask about is a declare
87
140
  continue;
88
141
  }
89
142
  const config = getOption(method, rpcAuthorizationConfig);
90
- if (
91
- config.resourceKind === ApiResourceKind.api_resource_kind_unknown ||
92
- !kindServedByEdition(config.resourceKind, ServerEdition.oss)
93
- ) {
94
- continue;
95
- }
96
- questions.push({
143
+ out.push({
97
144
  method: `${service.typeName}/${method.name}`,
98
145
  kind: config.resourceKind,
146
+ kindPath: config.resourceKindPath,
99
147
  permission: IamPermission[config.permission] ?? "",
100
148
  });
101
149
  }
102
150
  }
103
- return questions;
151
+ return out;
104
152
  }
105
153
 
106
- it("names a declared relation for every question, with none undeclared", () => {
107
- const questions = wireQuestions();
108
- expect(questions.length).toBeGreaterThan(60);
109
- const undeclared = questions
110
- .filter(
111
- (q) => declarationFor(q.kind)?.relations.has(q.permission) !== true,
112
- )
113
- .map(
114
- (q) =>
115
- `${q.method} asks ${q.permission} on ${kindEnumName(q.kind)}, which its transcript does not declare`,
116
- );
117
- expect(undeclared).toEqual([]);
154
+ it("reads the whole contract, the IamPolicy lanes included", () => {
155
+ const questions = annotated();
156
+ expect(services.length).toBeGreaterThan(60);
157
+ expect(questions.length).toBeGreaterThan(150);
158
+ expect(questions.some((q) => q.kindPath !== "")).toBe(true);
118
159
  });
119
160
 
120
- it("asks about every served kind the model declares except the one no RPC targets statically", () => {
121
- // iam_policy's RPCs target the policy's RESOURCE (`resource_kind_path`
122
- // or the load-then-authorize `get`). Every other declared kind is asked
123
- // about by at least one static annotation — execution_context through
124
- // its `get` and `delete`; its runner-token lane stays a skip.
125
- const asked = new Set(wireQuestions().map((q) => kindEnumName(q.kind)));
126
- expect([...asked].sort()).toEqual(
127
- [
128
- "agent",
129
- "agent_channel",
130
- "agent_execution",
131
- "agent_instance",
132
- "agent_share",
133
- "api_key",
134
- "artifact",
135
- "channel_app",
136
- "environment",
137
- "execution_context",
138
- "identity_account",
139
- "mcp_server",
140
- "memory",
141
- "oauth_app",
142
- "organization",
143
- "platform_client",
144
- "plugin",
145
- "schedule",
146
- "session",
147
- "skill",
148
- "workflow",
149
- "workflow_execution",
150
- "workflow_instance",
151
- ].sort(),
161
+ it("defines every relation the contract asks about, except the known gaps", () => {
162
+ const questions = annotated();
163
+ const gaps: string[] = [];
164
+
165
+ for (const q of questions) {
166
+ if (q.kind !== ApiResourceKind.api_resource_kind_unknown && !relationsOf(q.kind).has(q.permission)) {
167
+ gaps.push(`${q.method} asks ${q.permission} on ${kindEnumName(q.kind)}`);
168
+ }
169
+ }
170
+
171
+ const pathPermissions = new Set(
172
+ questions.filter((q) => q.kindPath !== "").map((q) => q.permission),
152
173
  );
174
+ for (const value of ApiResourceKindSchema.values) {
175
+ const kind = value.number as ApiResourceKind;
176
+ if (kind === ApiResourceKind.api_resource_kind_unknown || grantableRolesFor(kind).length === 0) {
177
+ continue;
178
+ }
179
+ for (const permission of [...pathPermissions].sort()) {
180
+ if (!relationsOf(kind).has(permission)) {
181
+ gaps.push(`the resource_kind_path lanes ask ${permission} on ${kindEnumName(kind)}`);
182
+ }
183
+ }
184
+ }
185
+
186
+ expect(gaps.sort()).toEqual([...KNOWN_GAPS].sort());
153
187
  });
154
188
  });
@@ -5,8 +5,8 @@
5
5
  * tuples this row would have had, derived on the fly (evaluator.ts over
6
6
  * derived-tuples.ts). Every arm below mirrors the cloud's driver
7
7
  * (stigmer-cloud src/authorizer/fga-authorizer.ts) by name and copy, so
8
- * the two editions answer one question the same way and the drift test
9
- * can pin the arms and not only the model.
8
+ * the two editions answer one question the same way, over the one model
9
+ * both read (model/index.ts).
10
10
  *
11
11
  * The arms, in order:
12
12
  * 1. The cloud's four pre-checks with the Java service's copy, each a
@@ -87,7 +87,7 @@ import { formatObjectRef } from "./tuples.js";
87
87
  * their rows live in its own tables, out of its probe's reach; matching
88
88
  * the set here keeps the wire identical across editions (an unknown
89
89
  * platform client's id is PERMISSION_DENIED with the annotation's copy in
90
- * both). The cloud's model drift test pins every kind here inside its
90
+ * both). The cloud pins every kind here inside its own
91
91
  * `PROBE_EXEMPT_KINDS`, which is why this set is exported; a kind that
92
92
  * moves into open source joins this set in the same change.
93
93
  *
@@ -12,7 +12,7 @@
12
12
  * - owner SELF: <row>#owner@identity_account:<row id>
13
13
  * - creator: <row>#creator@identity_account:<creator stamp> (kinds flagged requires_creator_tuple)
14
14
  * - org-viewer: <row>#viewer@organization:<org>#viewer (visibility_org; cloud#257's shape)
15
- * - platform-viewer: none — it fans out over identity providers, a kind this edition does not serve
15
+ * - platform-viewer: none — it fans out over identity providers, which this edition does not serve: the model defines the type, and no tuple of it is derived here
16
16
  *
17
17
  * Two facts are this edition's own and are stated here, nowhere else:
18
18
  * - The ORGANIZATION's owner is a ROW, not a derivation. 2b's role
@@ -372,7 +372,11 @@ export function newDerivedTupleSource(
372
372
  };
373
373
  }
374
374
 
375
- /** The decoded row for an object, or undefined when the kind is not declared or the row does not exist. */
375
+ /**
376
+ * The decoded row for an object, or undefined when the kind is not
377
+ * declared, is rowless (`platform`, which resolves over tuples alone), or
378
+ * the row does not exist.
379
+ */
376
380
  async function loadRow(
377
381
  deps: Pick<DerivedTupleSourceDeps, "store" | "accounts">,
378
382
  model: Model,
@@ -382,6 +386,7 @@ async function loadRow(
382
386
  const kind = kindByEnumName(object.type);
383
387
  if (
384
388
  declaration === undefined ||
389
+ declaration.schema === undefined ||
385
390
  kind === ApiResourceKind.api_resource_kind_unknown ||
386
391
  object.id === ""
387
392
  ) {
@@ -30,11 +30,11 @@
30
30
  * it is being resolved is a cycle in the declarations, which is a bug,
31
31
  * and a resolution deeper than MAX_RESOLUTION_DEPTH is the same class of
32
32
  * bug; both throw, because the alternative — a quiet `false` — would turn
33
- * a broken transcript into a lockout nobody can see. A kind the model does
34
- * not declare is a fault as the check's TARGET (the driver refuses
35
- * unserved kinds before evaluation, so reaching here is a registry gap)
36
- * and simply an object with no relations when reached through a tuple
37
- * (`identity_provider#platform_user`, which no open-source tuple names).
33
+ * a broken model into a lockout nobody can see. A kind the model does
34
+ * not declare is a fault as the check's TARGET (the built-in model
35
+ * declares every kind, so reaching here is a registry gap) and simply an
36
+ * object with no relations when reached through a tuple (only a test's
37
+ * throwaway model omits a type).
38
38
  * A relation the kind does not declare is `false` — the ruled answer for
39
39
  * a wire question the model has no line for, where OpenFGA
40
40
  * itself answers a validation error.
@@ -59,14 +59,12 @@ import { ACCOUNT_TYPE, formatObjectRef, pairKey } from "./tuples.js";
59
59
  export const MAX_RESOLUTION_DEPTH = 25;
60
60
 
61
61
  /**
62
- * The faults an evaluation can raise. Two are consumer bugs by the seam
63
- * contracts (a kind the model does not declare; the `internal` class
64
- * offered to the list scope's restrict verb, which the shared helper
65
- * answers before any driver); the rest are the model's own.
62
+ * The faults an evaluation can raise. One is a consumer bug by the seam
63
+ * contracts (a kind the model does not declare, or a rowless one asked
64
+ * for its rows); the rest are the model's own.
66
65
  */
67
66
  export type EvaluationFault =
68
67
  | "undeclared-target-kind"
69
- | "internal-caller-offered"
70
68
  | "resolution-depth-exceeded"
71
69
  | "resolution-cycle";
72
70
 
@@ -40,27 +40,30 @@
40
40
  * returns the org-narrowed rows for the class before any driver is asked
41
41
  * (extensions/list-read-scope.ts, the header's contract line), the
42
42
  * Authorize step's trust-domain rule applied to a list answer. So this
43
- * driver's `restrictListEntries` REFUSES the class (`internal-caller-offered`):
44
- * being offered it means a caller reached the driver around the one
45
- * consumption idiom, and evaluating the server as a person would return
46
- * a quiet short list — the failure shape stigmer#1207 fixed. The
47
- * enumeration verb has no helper in front of it and keeps its own arm:
48
- * every id of the kind, as the built-in directory answers the class with
49
- * every organization. No in-process edge reaches an enumeration lane
50
- * today; the arm is pinned so a future one meets the rule.
43
+ * driver's `restrictListEntries` REFUSES the class with the seam's
44
+ * `InternalCallerOfferedError`: being offered it means a caller reached
45
+ * the driver around the one consumption idiom, and evaluating the server
46
+ * as a person would return a quiet short list — the failure shape
47
+ * stigmer#1207 fixed. The enumeration verb has no helper in front of it
48
+ * and keeps its own arm: every id of the kind, as the built-in directory
49
+ * answers the class with every organization. No in-process edge reaches
50
+ * an enumeration lane today; the arm is pinned so a future one meets the
51
+ * rule.
51
52
  *
52
53
  * Faults THROW and are never an empty answer (the seam: an empty set means
53
54
  * "authorized to see nothing"; an outage is the pipeline's sanitized
54
- * INTERNAL). A kind the model does not declare, like the internal class
55
- * above, is a consumer bug by the seam's contract and faults through the
56
- * evaluator, loud; the model declares exactly the open-source tier, so no
57
- * served lane can reach it.
55
+ * INTERNAL). A kind the model does not declare, or a rowless one
56
+ * (`platform`, which has no stored rows to list), is, like the internal
57
+ * class above, a consumer bug by the seam's contract, and faults through
58
+ * the evaluator's own error, loud; every served list lane lists a kind
59
+ * with rows.
58
60
  *
59
61
  * Logging. One debug line per call — kind, offered, kept, elapsed — what
60
62
  * an operator needs to answer "why is Alice's list short" beside the
61
63
  * Authorizer's per-denial lines; never the token.
62
64
  */
63
65
  import { fromBinary } from "@bufbuild/protobuf";
66
+ import type { DescMessage } from "@bufbuild/protobuf";
64
67
 
65
68
  import type { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
66
69
 
@@ -71,6 +74,7 @@ import type {
71
74
  ListEntryMeta,
72
75
  ListReadScope,
73
76
  } from "../extensions/list-read-scope.js";
77
+ import { InternalCallerOfferedError } from "../extensions/list-read-scope.js";
74
78
  import { kindEnumName } from "../pipeline/apiresource-meta.js";
75
79
  import type { Store } from "../store/interface.js";
76
80
  import { newDerivedTupleSource } from "./derived-tuples.js";
@@ -87,6 +91,9 @@ import type { ObjectRef, Person } from "./tuples.js";
87
91
  /** The relation every list lane reads through (the cloud's LIST_RELATION). */
88
92
  const LIST_RELATION = "can_view";
89
93
 
94
+ /** A declaration of a kind with stored rows: the only kind a list lane can list. */
95
+ type ListableDeclaration = KindDeclaration & { readonly schema: DescMessage };
96
+
90
97
  export interface BuiltInListReadScopeDeps {
91
98
  readonly store: Store;
92
99
  /** The port the grant path writes through — the person's rows are read from the same instance. */
@@ -153,8 +160,12 @@ export function newBuiltInListReadScope(
153
160
  return kept;
154
161
  }
155
162
 
156
- /** The declaration a verb evaluates against; an undeclared kind is the evaluator's own fault, raised before any read. */
157
- function requireDeclared(kind: ApiResourceKind): KindDeclaration {
163
+ /**
164
+ * The declaration a verb evaluates against, which must be a kind with
165
+ * stored rows. An undeclared kind, or a rowless one, is the evaluator's
166
+ * own fault, raised before any read.
167
+ */
168
+ function requireListable(kind: ApiResourceKind): ListableDeclaration {
158
169
  const declaration = model.byKind(kind);
159
170
  if (declaration === undefined) {
160
171
  throw new AuthorizationEvaluationError(
@@ -162,11 +173,18 @@ export function newBuiltInListReadScope(
162
173
  `no declaration for kind '${kindEnumName(kind)}' — a list lane asked the scope about a kind the model does not declare`,
163
174
  );
164
175
  }
165
- return declaration;
176
+ const schema = declaration.schema;
177
+ if (schema === undefined) {
178
+ throw new AuthorizationEvaluationError(
179
+ "undeclared-target-kind",
180
+ `kind '${kindEnumName(kind)}' has no stored rows — a list lane asked the scope about a rowless kind`,
181
+ );
182
+ }
183
+ return { ...declaration, schema };
166
184
  }
167
185
 
168
186
  /** Every row of `kind` the store holds, as facts; a row that does not decode is skipped. */
169
- async function everyRowOf(declaration: KindDeclaration): Promise<RowFacts[]> {
187
+ async function everyRowOf(declaration: ListableDeclaration): Promise<RowFacts[]> {
170
188
  const kind = declaration.kind;
171
189
  const rows = await deps.store.listResources(kind);
172
190
  const facts: RowFacts[] = [];
@@ -189,12 +207,9 @@ export function newBuiltInListReadScope(
189
207
  kind: ApiResourceKind,
190
208
  entries: ReadonlyArray<ListEntryMeta>,
191
209
  ): Promise<ReadonlySet<string>> {
192
- requireDeclared(kind);
210
+ requireListable(kind);
193
211
  if (caller.callerClass === "internal") {
194
- throw new AuthorizationEvaluationError(
195
- "internal-caller-offered",
196
- `the internal caller class was offered to the list scope for kind '${kindEnumName(kind)}' — the shared helper answers that class before any driver; a list lane reached the driver around it`,
197
- );
212
+ throw new InternalCallerOfferedError(kind);
198
213
  }
199
214
  // One candidate per unique id: a lane offers each row once, and a
200
215
  // duplicate would only spend a walk to learn the same answer.
@@ -211,7 +226,7 @@ export function newBuiltInListReadScope(
211
226
  caller: CallerIdentity,
212
227
  kind: ApiResourceKind,
213
228
  ): Promise<ReadonlySet<string>> {
214
- const candidates = await everyRowOf(requireDeclared(kind));
229
+ const candidates = await everyRowOf(requireListable(kind));
215
230
  if (caller.callerClass === "internal") {
216
231
  return new Set(candidates.map((facts) => facts.id));
217
232
  }