@stigmer/server 3.15.2-dev.20260913223433 → 3.15.3-dev.20260914100347

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 (201) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +103 -9
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/domain/iampolicy/access-lists.d.ts +27 -0
  5. package/dist/domain/iampolicy/access-lists.d.ts.map +1 -0
  6. package/dist/domain/iampolicy/access-lists.js +124 -0
  7. package/dist/domain/iampolicy/access-lists.js.map +1 -0
  8. package/dist/domain/iampolicy/constants.d.ts +135 -0
  9. package/dist/domain/iampolicy/constants.d.ts.map +1 -0
  10. package/dist/domain/iampolicy/constants.js +203 -0
  11. package/dist/domain/iampolicy/constants.js.map +1 -0
  12. package/dist/domain/iampolicy/controller.d.ts +29 -0
  13. package/dist/domain/iampolicy/controller.d.ts.map +1 -0
  14. package/dist/domain/iampolicy/controller.js +451 -0
  15. package/dist/domain/iampolicy/controller.js.map +1 -0
  16. package/dist/domain/iampolicy/display-resolver.d.ts +6 -0
  17. package/dist/domain/iampolicy/display-resolver.d.ts.map +1 -0
  18. package/dist/domain/iampolicy/display-resolver.js +90 -0
  19. package/dist/domain/iampolicy/display-resolver.js.map +1 -0
  20. package/dist/domain/iampolicy/grant-path.d.ts +42 -0
  21. package/dist/domain/iampolicy/grant-path.d.ts.map +1 -0
  22. package/dist/domain/iampolicy/grant-path.js +183 -0
  23. package/dist/domain/iampolicy/grant-path.js.map +1 -0
  24. package/dist/domain/iampolicy/grant-scope.d.ts +4 -0
  25. package/dist/domain/iampolicy/grant-scope.d.ts.map +1 -0
  26. package/dist/domain/iampolicy/grant-scope.js +34 -0
  27. package/dist/domain/iampolicy/grant-scope.js.map +1 -0
  28. package/dist/domain/iampolicy/membership.d.ts +111 -0
  29. package/dist/domain/iampolicy/membership.d.ts.map +1 -0
  30. package/dist/domain/iampolicy/membership.js +152 -0
  31. package/dist/domain/iampolicy/membership.js.map +1 -0
  32. package/dist/domain/iampolicy/permissions.d.ts +19 -0
  33. package/dist/domain/iampolicy/permissions.d.ts.map +1 -0
  34. package/dist/domain/iampolicy/permissions.js +27 -0
  35. package/dist/domain/iampolicy/permissions.js.map +1 -0
  36. package/dist/domain/iampolicy/resource-store.d.ts +4 -0
  37. package/dist/domain/iampolicy/resource-store.d.ts.map +1 -0
  38. package/dist/domain/iampolicy/resource-store.js +135 -0
  39. package/dist/domain/iampolicy/resource-store.js.map +1 -0
  40. package/dist/domain/iampolicy/role-lifecycle.d.ts +9 -0
  41. package/dist/domain/iampolicy/role-lifecycle.d.ts.map +1 -0
  42. package/dist/domain/iampolicy/role-lifecycle.js +106 -0
  43. package/dist/domain/iampolicy/role-lifecycle.js.map +1 -0
  44. package/dist/domain/iampolicy/roles.d.ts +11 -0
  45. package/dist/domain/iampolicy/roles.d.ts.map +1 -0
  46. package/dist/domain/iampolicy/roles.js +78 -0
  47. package/dist/domain/iampolicy/roles.js.map +1 -0
  48. package/dist/domain/iampolicy/specs.d.ts +7 -0
  49. package/dist/domain/iampolicy/specs.d.ts.map +1 -0
  50. package/dist/domain/iampolicy/specs.js +45 -0
  51. package/dist/domain/iampolicy/specs.js.map +1 -0
  52. package/dist/domain/iampolicy/steps.d.ts +18 -0
  53. package/dist/domain/iampolicy/steps.d.ts.map +1 -0
  54. package/dist/domain/iampolicy/steps.js +117 -0
  55. package/dist/domain/iampolicy/steps.js.map +1 -0
  56. package/dist/domain/iampolicy/store-contract.d.ts +8 -0
  57. package/dist/domain/iampolicy/store-contract.d.ts.map +1 -0
  58. package/dist/domain/iampolicy/store-contract.js +258 -0
  59. package/dist/domain/iampolicy/store-contract.js.map +1 -0
  60. package/dist/domain/iampolicy/store.d.ts +78 -0
  61. package/dist/domain/iampolicy/store.d.ts.map +1 -0
  62. package/dist/domain/iampolicy/store.js +13 -0
  63. package/dist/domain/iampolicy/store.js.map +1 -0
  64. package/dist/domain/iampolicy/wire-refusals.d.ts +25 -0
  65. package/dist/domain/iampolicy/wire-refusals.d.ts.map +1 -0
  66. package/dist/domain/iampolicy/wire-refusals.js +76 -0
  67. package/dist/domain/iampolicy/wire-refusals.js.map +1 -0
  68. package/dist/domain/identityaccount/constants.d.ts +33 -0
  69. package/dist/domain/identityaccount/constants.d.ts.map +1 -1
  70. package/dist/domain/identityaccount/constants.js +2 -48
  71. package/dist/domain/identityaccount/constants.js.map +1 -1
  72. package/dist/domain/identityaccount/controller.d.ts +8 -1
  73. package/dist/domain/identityaccount/controller.d.ts.map +1 -1
  74. package/dist/domain/identityaccount/controller.js +54 -29
  75. package/dist/domain/identityaccount/controller.js.map +1 -1
  76. package/dist/domain/identityaccount/operator.d.ts.map +1 -1
  77. package/dist/domain/identityaccount/operator.js +2 -4
  78. package/dist/domain/identityaccount/operator.js.map +1 -1
  79. package/dist/domain/identityaccount/provisioning.d.ts +20 -1
  80. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
  81. package/dist/domain/identityaccount/provisioning.js +17 -3
  82. package/dist/domain/identityaccount/provisioning.js.map +1 -1
  83. package/dist/domain/identityaccount/resolve.d.ts +23 -0
  84. package/dist/domain/identityaccount/resolve.d.ts.map +1 -1
  85. package/dist/domain/identityaccount/resolve.js +17 -0
  86. package/dist/domain/identityaccount/resolve.js.map +1 -1
  87. package/dist/domain/identityaccount/store-contract.d.ts +4 -18
  88. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
  89. package/dist/domain/identityaccount/store-contract.js +12 -47
  90. package/dist/domain/identityaccount/store-contract.js.map +1 -1
  91. package/dist/extensions/authorization-queries.d.ts +81 -0
  92. package/dist/extensions/authorization-queries.d.ts.map +1 -0
  93. package/dist/extensions/authorization-queries.js +2 -0
  94. package/dist/extensions/authorization-queries.js.map +1 -0
  95. package/dist/extensions/drivers.d.ts +49 -2
  96. package/dist/extensions/drivers.d.ts.map +1 -1
  97. package/dist/extensions/identity.d.ts +14 -0
  98. package/dist/extensions/identity.d.ts.map +1 -1
  99. package/dist/extensions/identity.js +18 -1
  100. package/dist/extensions/identity.js.map +1 -1
  101. package/dist/extensions/policy-grant-scope.d.ts +50 -0
  102. package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
  103. package/dist/extensions/policy-grant-scope.js +2 -0
  104. package/dist/extensions/policy-grant-scope.js.map +1 -0
  105. package/dist/extensions/registry.d.ts +23 -1
  106. package/dist/extensions/registry.d.ts.map +1 -1
  107. package/dist/extensions/registry.js +30 -0
  108. package/dist/extensions/registry.js.map +1 -1
  109. package/dist/extensions/resource-authorization.d.ts +63 -0
  110. package/dist/extensions/resource-authorization.d.ts.map +1 -1
  111. package/dist/index.d.ts +7 -1
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +2 -0
  114. package/dist/index.js.map +1 -1
  115. package/dist/pipeline/apiresource-meta.d.ts +56 -1
  116. package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
  117. package/dist/pipeline/apiresource-meta.js +155 -1
  118. package/dist/pipeline/apiresource-meta.js.map +1 -1
  119. package/dist/pipeline/interceptors/auth.d.ts +12 -0
  120. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  121. package/dist/pipeline/interceptors/auth.js +13 -1
  122. package/dist/pipeline/interceptors/auth.js.map +1 -1
  123. package/dist/pipeline/steps/authorize.d.ts +20 -5
  124. package/dist/pipeline/steps/authorize.d.ts.map +1 -1
  125. package/dist/pipeline/steps/authorize.js +37 -17
  126. package/dist/pipeline/steps/authorize.js.map +1 -1
  127. package/dist/pipeline/steps/defaults.d.ts +10 -0
  128. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  129. package/dist/pipeline/steps/defaults.js +39 -0
  130. package/dist/pipeline/steps/defaults.js.map +1 -1
  131. package/dist/store/port-contract.d.ts +61 -0
  132. package/dist/store/port-contract.d.ts.map +1 -0
  133. package/dist/store/port-contract.js +67 -0
  134. package/dist/store/port-contract.js.map +1 -0
  135. package/package.json +4 -4
  136. package/src/boot/compose.ts +107 -10
  137. package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
  138. package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
  139. package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
  140. package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
  141. package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
  142. package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
  143. package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
  144. package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
  145. package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
  146. package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
  147. package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
  148. package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
  149. package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
  150. package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
  151. package/src/domain/iampolicy/__tests__/support.ts +195 -0
  152. package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
  153. package/src/domain/iampolicy/access-lists.ts +191 -0
  154. package/src/domain/iampolicy/constants.ts +243 -0
  155. package/src/domain/iampolicy/controller.ts +798 -0
  156. package/src/domain/iampolicy/display-resolver.ts +123 -0
  157. package/src/domain/iampolicy/grant-path.ts +270 -0
  158. package/src/domain/iampolicy/grant-scope.ts +36 -0
  159. package/src/domain/iampolicy/membership.ts +298 -0
  160. package/src/domain/iampolicy/permissions.ts +35 -0
  161. package/src/domain/iampolicy/resource-store.ts +186 -0
  162. package/src/domain/iampolicy/role-lifecycle.ts +132 -0
  163. package/src/domain/iampolicy/roles.ts +95 -0
  164. package/src/domain/iampolicy/specs.ts +53 -0
  165. package/src/domain/iampolicy/steps.ts +178 -0
  166. package/src/domain/iampolicy/store-contract.ts +450 -0
  167. package/src/domain/iampolicy/store.ts +103 -0
  168. package/src/domain/iampolicy/wire-refusals.ts +110 -0
  169. package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
  170. package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
  171. package/src/domain/identityaccount/constants.ts +16 -31
  172. package/src/domain/identityaccount/controller.ts +65 -35
  173. package/src/domain/identityaccount/operator.ts +5 -5
  174. package/src/domain/identityaccount/provisioning.ts +43 -5
  175. package/src/domain/identityaccount/resolve.ts +43 -0
  176. package/src/domain/identityaccount/store-contract.ts +22 -70
  177. package/src/extensions/__tests__/composed-support.ts +87 -0
  178. package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
  179. package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
  180. package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
  181. package/src/extensions/__tests__/identity.test.ts +62 -0
  182. package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
  183. package/src/extensions/authorization-queries.ts +97 -0
  184. package/src/extensions/drivers.ts +49 -2
  185. package/src/extensions/identity.ts +21 -0
  186. package/src/extensions/policy-grant-scope.ts +50 -0
  187. package/src/extensions/registry.ts +62 -1
  188. package/src/extensions/resource-authorization.ts +65 -0
  189. package/src/index.ts +20 -0
  190. package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
  191. package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
  192. package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
  193. package/src/pipeline/apiresource-meta.ts +181 -1
  194. package/src/pipeline/interceptors/auth.ts +14 -1
  195. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
  196. package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
  197. package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
  198. package/src/pipeline/steps/authorize.ts +46 -21
  199. package/src/pipeline/steps/defaults.ts +42 -0
  200. package/src/store/__tests__/port-contract.test.ts +123 -0
  201. package/src/store/port-contract.ts +102 -0
@@ -8,6 +8,39 @@
8
8
  * Both editions resolve the same strings from the same proto metadata; the
9
9
  * visibility helpers mirror Cloud's VisibilityConfigResolver so the
10
10
  * cross-edition error contract is identical by construction.
11
+ *
12
+ * The contract carries TWO kind vocabularies, and this module holds one
13
+ * lookup for each so they are never merged:
14
+ *
15
+ * - A resource's own `kind` field is the `kind_meta.name` ("McpServer").
16
+ * `getKindEnum` reads it by canonical name (case-insensitive,
17
+ * underscores ignored) and throws on an unknown value.
18
+ * - An `ApiResourceRef.kind` — the IamPolicy spec's principal and
19
+ * resource, the FGA object type — is the enum MEMBER name
20
+ * ("mcp_server"). `kindByEnumName` reads it by the descriptor's exact
21
+ * proto name and never throws (20260913.01, Q-OR-2). Exact, because the
22
+ * IamPolicy id is derived from the spec's text (Q-OR-9): a lenient
23
+ * match would let "Organization" and "organization" mint two rows for
24
+ * one grant. Reading the descriptor's name is not deriving a spelling;
25
+ * the #545 rule is about recovering kind_meta.name from an enum, which
26
+ * neither lookup does. A reader asked about a kind that arrived
27
+ * through this vocabulary follows its doctrine: `grantableRolesFor`
28
+ * answers the empty list for the unknown kind instead of throwing,
29
+ * because the kind it is asked about may have come off the wire.
30
+ * `kindEnumName` is the inverse — how server code SPELLS a kind when
31
+ * it builds an `ApiResourceRef` itself (the built-in role lifecycle's
32
+ * cleanup refs; the organization-role specs) — so no caller reaches
33
+ * for the enum's reverse index by hand.
34
+ *
35
+ * A kind's TIER meets the served EDITION in exactly one place on the
36
+ * server, `tierServedByEdition` / `kindServedByEdition` (20260913.01
37
+ * Q-OR-8; claim check C5) — the twin of the SDK's rank functions
38
+ * (sdk/typescript/src/resource-availability.ts), pinned equal on the whole
39
+ * matrix by __tests__/kind-served-by-edition.test.ts. The console reads
40
+ * the SDK's answer to decide what to SHOW; `checkMyPermission` reads the
41
+ * server's to decide what is HELD; a disagreement is a surface that
42
+ * appears and then fails. The enum NUMBERS are wire identifiers, never
43
+ * ranks (`enterprise` sits at 3 in both enums and ranks second).
11
44
  */
12
45
  import { getOption, hasOption } from "@bufbuild/protobuf";
13
46
  import type { DescEnumValue } from "@bufbuild/protobuf";
@@ -16,9 +49,12 @@ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apires
16
49
  import {
17
50
  ApiResourceKind,
18
51
  ApiResourceKindSchema,
52
+ ResourceTier,
19
53
  kind_meta,
20
54
  } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
21
55
  import type { ApiResourceKindMeta } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
56
+ import type { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
57
+ import { ServerEdition } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
22
58
 
23
59
  /** Go GetKindMeta: the kind_meta extension of the enum value. */
24
60
  export function getKindMeta(kind: ApiResourceKind): ApiResourceKindMeta {
@@ -55,19 +91,143 @@ export function getKindEnum(kindValue: string): ApiResourceKind {
55
91
  return kind;
56
92
  }
57
93
 
94
+ /**
95
+ * Resolves an `ApiResourceRef.kind` — a kind's enum MEMBER name, exactly
96
+ * as the proto spells it ("organization", "identity_account") — to the
97
+ * enum. Anything else, including the kind_meta spelling, a different case,
98
+ * the empty string and the zero value's own name, is
99
+ * `api_resource_kind_unknown`; never a throw, because the Authorize step
100
+ * resolves `resource_kind_path` through this at position 1 of every chain
101
+ * and resolution never fails a request (authorize.ts header). See the
102
+ * module header for why the match is exact.
103
+ */
104
+ export function kindByEnumName(value: string): ApiResourceKind {
105
+ return (
106
+ kindsByEnumName().get(value) ?? ApiResourceKind.api_resource_kind_unknown
107
+ );
108
+ }
109
+
110
+ /**
111
+ * The inverse of `kindByEnumName`: a kind spelled as an `ApiResourceRef.kind`
112
+ * — the descriptor's exact proto name ("organization", "identity_account").
113
+ * Total over the enum: the unknown kind spells itself, so a ref built from
114
+ * it round-trips to the unknown kind and the gate that reads it refuses
115
+ * the way it refuses a wire caller's garbage; never a throw.
116
+ */
117
+ export function kindEnumName(kind: ApiResourceKind): string {
118
+ // A number that is no member (a cast from outside the enum) spells as
119
+ // the unknown kind, which every descriptor carries as its zero value.
120
+ const value =
121
+ kindValueDescriptor(kind) ??
122
+ kindValueDescriptor(ApiResourceKind.api_resource_kind_unknown);
123
+ return value?.name ?? "";
124
+ }
125
+
58
126
  /**
59
127
  * Go DefaultVisibilityFor: blueprint kinds flagged defaults_to_org_visibility
60
128
  * get visibility_org (blueprints are shared org assets — a private default
61
129
  * would silently hide new blueprints from teammates); all others get
62
130
  * visibility_private.
63
131
  */
64
- export function defaultVisibilityFor(kind: ApiResourceKind): ApiResourceVisibility {
132
+ export function defaultVisibilityFor(
133
+ kind: ApiResourceKind,
134
+ ): ApiResourceVisibility {
65
135
  const config = getKindMeta(kind).authorization?.visibility;
66
136
  return config?.defaultsToOrgVisibility === true
67
137
  ? ApiResourceVisibility.visibility_org
68
138
  : ApiResourceVisibility.visibility_private;
69
139
  }
70
140
 
141
+ /**
142
+ * The roles a user may be granted on a resource of `kind` according to the
143
+ * CONTRACT — `kind_meta.authorization.grantable_roles`, the one source of
144
+ * "what can be granted at all" (Java AuthorizationConfigResolver
145
+ * .getGrantableRoles; the cloud's iam/policy/roles.ts grantableRolesFor,
146
+ * which this replaces at the 20260913.01 re-point). A kind that lists none
147
+ * is system-managed in every edition (identity_account, api_key,
148
+ * iam_policy, invitation, agent_execution); an edition's PolicyGrantScope
149
+ * (extensions/policy-grant-scope.ts) only narrows this list.
150
+ *
151
+ * Total, never a throw: the kind is an `ApiResourceRef.kind` resolved by
152
+ * `kindByEnumName`, so the unknown kind — the one member without
153
+ * `kind_meta` — is a legitimate argument and answers the empty list
154
+ * (module header, the second vocabulary).
155
+ */
156
+ export function grantableRolesFor(
157
+ kind: ApiResourceKind,
158
+ ): ReadonlyArray<IamRole> {
159
+ const valueDesc = kindValueDescriptor(kind);
160
+ if (valueDesc === undefined || !hasOption(valueDesc, kind_meta)) {
161
+ return [];
162
+ }
163
+ return getOption(valueDesc, kind_meta).authorization?.grantableRoles ?? [];
164
+ }
165
+
166
+ /**
167
+ * Whether an edition serves a tier: the tier names the MINIMUM edition,
168
+ * the editions are ordered oss < enterprise < cloud (each composes the
169
+ * previous one's units), so a tier admits its own edition and every one
170
+ * above. An edition the server does not know (`server_edition_unspecified`)
171
+ * serves everything — the SDK's own fallback: an older client against a
172
+ * newer server hides nothing. A tier the proto forgot is treated as core
173
+ * for the same reason the SDK does: hiding a served kind is the worse
174
+ * failure.
175
+ */
176
+ export function tierServedByEdition(
177
+ tier: ResourceTier,
178
+ edition: ServerEdition,
179
+ ): boolean {
180
+ if (edition === ServerEdition.server_edition_unspecified) {
181
+ return true;
182
+ }
183
+ return editionRank(edition) >= tierRank(tier);
184
+ }
185
+
186
+ /**
187
+ * Whether this edition serves a kind: its `kind_meta.tier` through the
188
+ * table above. Throws for a kind without `kind_meta` (the unknown kind):
189
+ * a caller that reaches this with wire input has skipped the kind
190
+ * refusals, and "served" for it would hide that bug.
191
+ */
192
+ export function kindServedByEdition(
193
+ kind: ApiResourceKind,
194
+ edition: ServerEdition,
195
+ ): boolean {
196
+ return tierServedByEdition(getKindMeta(kind).tier, edition);
197
+ }
198
+
199
+ function editionRank(edition: ServerEdition): number {
200
+ switch (edition) {
201
+ case ServerEdition.oss:
202
+ return 1;
203
+ case ServerEdition.enterprise:
204
+ return 2;
205
+ case ServerEdition.cloud:
206
+ case ServerEdition.server_edition_unspecified:
207
+ return 3;
208
+ default: {
209
+ const exhaustive: never = edition;
210
+ throw new Error(`unknown server edition: ${String(exhaustive)}`);
211
+ }
212
+ }
213
+ }
214
+
215
+ function tierRank(tier: ResourceTier): number {
216
+ switch (tier) {
217
+ case ResourceTier.open_source:
218
+ case ResourceTier.resource_tier_unspecified:
219
+ return 1;
220
+ case ResourceTier.enterprise:
221
+ return 2;
222
+ case ResourceTier.cloud_only:
223
+ return 3;
224
+ default: {
225
+ const exhaustive: never = tier;
226
+ throw new Error(`unknown resource tier: ${String(exhaustive)}`);
227
+ }
228
+ }
229
+ }
230
+
71
231
  /**
72
232
  * Go SupportsVisibility: PRIVATE and UNSPECIFIED are always supported (no
73
233
  * visibility grant); every other level requires the matching supports_*
@@ -134,3 +294,23 @@ function kindsByCanonicalName(): Map<string, ApiResourceKind> {
134
294
  }
135
295
  return canonicalNameCache;
136
296
  }
297
+
298
+ let enumNameCache: Map<string, ApiResourceKind> | undefined;
299
+
300
+ /**
301
+ * Every member by its descriptor name, the zero value included (it maps to
302
+ * itself, which is the unknown kind either way). A Map rather than the TS
303
+ * enum's reverse mapping, so a prototype key or a number spelled as text
304
+ * cannot resolve to anything.
305
+ */
306
+ function kindsByEnumName(): Map<string, ApiResourceKind> {
307
+ if (enumNameCache === undefined) {
308
+ enumNameCache = new Map(
309
+ ApiResourceKindSchema.values.map((value) => [
310
+ value.name,
311
+ value.number as ApiResourceKind,
312
+ ]),
313
+ );
314
+ }
315
+ return enumNameCache;
316
+ }
@@ -175,6 +175,19 @@ export function trustedLocalIdentity(): CallerIdentity {
175
175
  return trustedLocalIdentityFor(operatorIdentitySnapshot());
176
176
  }
177
177
 
178
+ /**
179
+ * The identityId of the UNCONFIGURED laptop's operator — no
180
+ * STIGMER_OPERATOR_EMAIL, so the trust domain's one principal has no
181
+ * email to be named by. It is what the interceptor stamps and therefore
182
+ * what every audit actor on such a laptop carries as `created_by.id`; the
183
+ * operator account derives its subject from it (`local|system`) and the
184
+ * membership rules read it as "not a person" when they classify creator
185
+ * stamps (domain/iampolicy/membership.ts). One home (20260913.01 slice 4,
186
+ * Q-S4-6): a second spelling anywhere would make a stamp and its reader
187
+ * disagree silently.
188
+ */
189
+ export const SYSTEM_OPERATOR_IDENTITY_ID = "system";
190
+
178
191
  /**
179
192
  * The trusted-local identity for a given operator — the one construction
180
193
  * of that principal, shared by the per-request stamp above and by the
@@ -187,7 +200,7 @@ export function trustedLocalIdentityFor(operator: {
187
200
  }): CallerIdentity {
188
201
  if (operator.email === "") {
189
202
  return {
190
- identityId: "system",
203
+ identityId: SYSTEM_OPERATOR_IDENTITY_ID,
191
204
  callerClass: "user",
192
205
  issuer: "",
193
206
  rawToken: "",
@@ -20,9 +20,32 @@
20
20
  * never reach the annotation's resolution (the skip lanes resolve their
21
21
  * target in the handler through `authorizeDirect`'s override). A config
22
22
  * that names NO kind (static or by path) is outside this invariant: the
23
- * check is then about the kind, not the id, and today no OSS-served method
24
- * is shaped that way (the IamPolicy RPCs that are belong to
25
- * `sp.oss-organization-roles`).
23
+ * check is then about the kind, not the id.
24
+ *
25
+ * The IamPolicy contract is the one place that shape exists, and it is
26
+ * pinned here by descriptor, not by served route, so the arm turns green
27
+ * when the ANNOTATIONS land (sp.oss-organization-roles slice 1) and not
28
+ * when the service is first served (slice 5). Of its nine `rpc.config`
29
+ * methods: six name their target — `create`, `delete`,
30
+ * `listResourceAccessByPrincipal`, `getPrincipalResourceRoles` by
31
+ * `resource_kind_path` (the spec's `resource.kind`, a STRING the resolver
32
+ * reads by enum name) + `field_path`; `revokeOrgAccess` and
33
+ * `getPrincipalsCount` statically. `get(IamPolicyId)` names no kind BY
34
+ * DESIGN: its target is inside the row it loads, so it is the one
35
+ * load-then-authorize lane (`authorizeDirect` with the override's
36
+ * resourceKind + resourceId, the getByEmail precedent). The two
37
+ * `listAuthorized*Ids` were the open question of S1 (finding 7): an
38
+ * annotation with a permission and no kind resolves to an EMPTY target,
39
+ * which the open-source permissive Authorizer allows and the cloud's
40
+ * OpenFGA Authorizer denies for every caller — the #1073 class this
41
+ * invariant cannot see. Ruled at slice 1 (2026-09-13, T01_1_review.md
42
+ * Q-OR-2): `listAuthorizedPrincipalIds` HAS a resource and names it (the
43
+ * same `can_view_access` target `listResourceAccessByPrincipal` enforces
44
+ * for the same question); `listAuthorizedResourceIds` has a principal and
45
+ * no resource, so it is an `is_skip_authorization` lane whose handler
46
+ * enforces `checkAuthorization`'s principal-trust rule (a user only for
47
+ * themselves; machine and internal for anyone) — a dead `can_view_access`
48
+ * config would have been exactly the kind-less shape this file guards.
26
49
  *
27
50
  * The mutation arms prove the check bites over the same function.
28
51
  */
@@ -41,6 +64,8 @@ import {
41
64
  is_public,
42
65
  is_skip_authorization,
43
66
  } from "@stigmer/protos/ai/stigmer/commons/rpc/method_options_pb";
67
+ import { IamPolicyCommandController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/command_pb";
68
+ import { IamPolicyQueryController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/query_pb";
44
69
 
45
70
  import { loadConfig } from "../../../boot/config.js";
46
71
  import { composeServer } from "../../../boot/compose.js";
@@ -151,6 +176,51 @@ describe("authorization annotations name where the resource id comes from", () =
151
176
  });
152
177
  });
153
178
 
179
+ describe("the IamPolicy contract names its targets (sp.oss-organization-roles slice 1, read by descriptor)", () => {
180
+ const targets = annotationTargets([
181
+ IamPolicyCommandController,
182
+ IamPolicyQueryController,
183
+ ]);
184
+ const byName = new Map(
185
+ targets.map((t) => [t.method.split("/")[1] ?? t.method, t]),
186
+ );
187
+
188
+ it.each([
189
+ "create",
190
+ "delete",
191
+ "revokeOrgAccess",
192
+ "listResourceAccessByPrincipal",
193
+ "getPrincipalResourceRoles",
194
+ "getPrincipalsCount",
195
+ "listAuthorizedPrincipalIds",
196
+ ])("%s names a resource kind AND where its id comes from", (method) => {
197
+ const target = byName.get(method);
198
+ expect(target, `${method} carries rpc.config`).toBeDefined();
199
+ expect(target?.namesKind, `${method} names a kind`).toBe(true);
200
+ expect(target?.namesId, `${method} names its id`).toBe(true);
201
+ });
202
+
203
+ it("get names no kind — the one load-then-authorize lane, its target inside the row", () => {
204
+ const get = byName.get("get");
205
+ expect(get, "get carries rpc.config").toBeDefined();
206
+ expect(get?.namesKind).toBe(false);
207
+ expect(get?.skipsResolution).toBe(false);
208
+ });
209
+
210
+ it("listAuthorizedResourceIds carries NO rpc.config and skips — its trust rule is the handler's (finding 7)", () => {
211
+ // Read the descriptor directly: `annotationTargets` lists only
212
+ // config-carrying methods, and this one must have left that set.
213
+ const method = IamPolicyQueryController.method.listAuthorizedResourceIds;
214
+ expect(byName.has("listAuthorizedResourceIds")).toBe(false);
215
+ expect(hasOption(method, rpcAuthorizationConfig)).toBe(false);
216
+ expect(getOption(method, is_skip_authorization)).toBe(true);
217
+ });
218
+
219
+ it("the invariant reports none of them once slice 1 lands", () => {
220
+ expect(unresolvableTargets(targets)).toEqual([]);
221
+ });
222
+ });
223
+
154
224
  describe("the check bites (mutation proofs over the same function)", () => {
155
225
  const shaped = (overrides: Partial<AnnotationTarget>): AnnotationTarget => ({
156
226
  method: "x.v1.Query/probe",
@@ -4,9 +4,11 @@
4
4
  * annotation's byte-pinned error_msg; unavailable → INTERNAL, never a
5
5
  * softened denial), the skip arms (internal caller class, is_public,
6
6
  * is_skip_authorization, no-config methods), check-target resolution
7
- * (field_path, static resource_id, absent field = empty id — never a
8
- * throw), and the mid-chain resolved-id pattern the traced ListVersions
9
- * handlers port onto.
7
+ * (field_path, static resource_id, absent field = empty id, a string
8
+ * resource_kind_path resolved by enum name with an unknown name = the
9
+ * unknown kind — never a throw), the direct-handler override in both its
10
+ * shapes (a server-side id; a server-side kind AND id), and the mid-chain
11
+ * resolved-id pattern the traced ListVersions handlers port onto.
10
12
  */
11
13
  import { describe, expect, it } from "vitest";
12
14
  import { create } from "@bufbuild/protobuf";
@@ -17,6 +19,7 @@ import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb"
17
19
  import { AgentExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/command_pb";
18
20
  import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
19
21
  import { IamPolicyCommandController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/command_pb";
22
+ import { IamPolicyQueryController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/query_pb";
20
23
  import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
21
24
  import { PlatformQueryController } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
22
25
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
@@ -37,9 +40,7 @@ import {
37
40
  } from "../authorize.js";
38
41
 
39
42
  /** Awaits the rejection and returns it as a ConnectError. */
40
- async function captureError(
41
- run: () => Promise<void>,
42
- ): Promise<ConnectError> {
43
+ async function captureError(run: () => Promise<void>): Promise<ConnectError> {
43
44
  try {
44
45
  await run();
45
46
  } catch (error) {
@@ -290,6 +291,75 @@ describe("check-target resolution (never a throw — byte-identity)", () => {
290
291
  },
291
292
  ]);
292
293
  });
294
+
295
+ // 20260913.01 (Q-OR-2): the IamPolicy RPCs name their target inside the
296
+ // request as a STRING kind (`ApiResourceRef.kind`, "organization"), so
297
+ // `resource_kind_path` resolves a string through the kind enum's names.
298
+ // The option had no user before this entry; a numeric field still works.
299
+ it("resource_kind_path over a string field resolves through the kind enum's names (IamPolicy create)", async () => {
300
+ const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
301
+ const method = IamPolicyCommandController.method.create;
302
+ const step = newAuthorizeStep(method, authorizer);
303
+ const ctx = new RequestContext(
304
+ method.input,
305
+ create(method.input, {
306
+ principal: { kind: "identity_account", id: "ida_alice" },
307
+ relation: "member",
308
+ resource: { kind: "organization", id: "acme" },
309
+ }),
310
+ testCallerIdentity(),
311
+ );
312
+ await step.execute(ctx);
313
+ expect(checks).toEqual([
314
+ {
315
+ permission: IamPermission.can_grant_access,
316
+ resourceKind: ApiResourceKind.organization,
317
+ resourceId: "acme",
318
+ },
319
+ ]);
320
+ });
321
+
322
+ it("an unknown kind name resolves to the unknown kind — never a throw; the Authorizer owns the decision", async () => {
323
+ const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
324
+ const method = IamPolicyCommandController.method.create;
325
+ const step = newAuthorizeStep(method, authorizer);
326
+ const ctx = new RequestContext(
327
+ method.input,
328
+ create(method.input, {
329
+ principal: { kind: "identity_account", id: "ida_alice" },
330
+ relation: "member",
331
+ resource: { kind: "spaceship", id: "acme" },
332
+ }),
333
+ testCallerIdentity(),
334
+ );
335
+ await expect(step.execute(ctx)).resolves.toBeUndefined();
336
+ expect(checks[0]?.resourceKind).toBe(
337
+ ApiResourceKind.api_resource_kind_unknown,
338
+ );
339
+ expect(checks[0]?.resourceId).toBe("acme");
340
+ });
341
+
342
+ it("revokeOrgAccess names the organization statically and its id by field_path (organization_id)", async () => {
343
+ const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
344
+ const method = IamPolicyCommandController.method.revokeOrgAccess;
345
+ const step = newAuthorizeStep(method, authorizer);
346
+ const ctx = new RequestContext(
347
+ method.input,
348
+ create(method.input, {
349
+ identityAccountId: "ida_alice",
350
+ organizationId: "acme",
351
+ }),
352
+ testCallerIdentity(),
353
+ );
354
+ await step.execute(ctx);
355
+ expect(checks).toEqual([
356
+ {
357
+ permission: IamPermission.can_grant_access,
358
+ resourceKind: ApiResourceKind.organization,
359
+ resourceId: "acme",
360
+ },
361
+ ]);
362
+ });
293
363
  });
294
364
 
295
365
  describe("authorizeDirect (the direct-handler arm, C2 Stage 4)", () => {
@@ -348,6 +418,49 @@ describe("authorizeDirect (the direct-handler arm, C2 Stage 4)", () => {
348
418
  ]);
349
419
  });
350
420
 
421
+ // 20260913.01 (Q-OR-2): the IamPolicy `get(IamPolicyId)` lane. Its
422
+ // annotation names a permission and NO kind, because the target is the
423
+ // loaded row's resource — kind AND id are server-side state. The
424
+ // override carries both; the annotation still owns the permission and
425
+ // the copy.
426
+ it("the target override may carry the resource KIND too — the IamPolicy get lane", async () => {
427
+ const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
428
+ const method = IamPolicyQueryController.method.get;
429
+ await authorizeDirect(
430
+ method,
431
+ authorizer,
432
+ testCallerIdentity(),
433
+ create(method.input, { value: "iamp_01row" }),
434
+ { resourceKind: ApiResourceKind.organization, resourceId: "acme" },
435
+ );
436
+ expect(checks).toEqual([
437
+ {
438
+ permission: IamPermission.can_view_access,
439
+ resourceKind: ApiResourceKind.organization,
440
+ resourceId: "acme",
441
+ },
442
+ ]);
443
+ });
444
+
445
+ it("an override with the id alone leaves the annotation's kind in place — the completeOAuthConnect lane is unchanged", async () => {
446
+ const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
447
+ const method = IamPolicyQueryController.method.get;
448
+ // A kind-less annotation plus an id-only override: the kind stays
449
+ // unknown, exactly what the annotation says. The lane that needs a
450
+ // kind must say so; nothing is inferred.
451
+ await authorizeDirect(
452
+ method,
453
+ authorizer,
454
+ testCallerIdentity(),
455
+ create(method.input, { value: "iamp_01row" }),
456
+ { resourceId: "acme" },
457
+ );
458
+ expect(checks[0]?.resourceKind).toBe(
459
+ ApiResourceKind.api_resource_kind_unknown,
460
+ );
461
+ expect(checks[0]?.resourceId).toBe("acme");
462
+ });
463
+
351
464
  it("the ruling-Q1 not-found arm maps identically from the direct entry", async () => {
352
465
  const { authorizer } = fakeAuthorizer({ kind: "not-found" });
353
466
  const error = await authorizeDirect(
@@ -438,18 +551,30 @@ describe("authorizeResolvedResource (the mid-chain resolved-id pattern, 20260830
438
551
  ),
439
552
  );
440
553
  expect(err.code).toBe(Code.PermissionDenied);
441
- expect(err.rawMessage).toBe("unauthorized to view workflow version history");
554
+ expect(err.rawMessage).toBe(
555
+ "unauthorized to view workflow version history",
556
+ );
442
557
  });
443
558
 
444
559
  it("deny without lane copy falls back to the reason, then the shared fallback", async () => {
445
560
  const reasoned = fakeAuthorizer({ kind: "deny", reason: "because" });
446
561
  const err1 = await captureError(() =>
447
- authorizeResolvedResource(reasoned.authorizer, testCallerIdentity(), check, ""),
562
+ authorizeResolvedResource(
563
+ reasoned.authorizer,
564
+ testCallerIdentity(),
565
+ check,
566
+ "",
567
+ ),
448
568
  );
449
569
  expect(err1.rawMessage).toBe("because");
450
570
  const bare = fakeAuthorizer({ kind: "deny", reason: "" });
451
571
  const err2 = await captureError(() =>
452
- authorizeResolvedResource(bare.authorizer, testCallerIdentity(), check, ""),
572
+ authorizeResolvedResource(
573
+ bare.authorizer,
574
+ testCallerIdentity(),
575
+ check,
576
+ "",
577
+ ),
453
578
  );
454
579
  expect(err2.rawMessage).toBe("permission denied");
455
580
  });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pins `derivedId`, the one encoder behind every id a kind derives from its
3
+ * natural key instead of minting (metadata.proto's `id` comment: a direct
4
+ * IdentityAccount from its subject, an IamPolicy from its triple; the
5
+ * Organization's slug needs no encoding). Lifted from the identity-account
6
+ * domain in 20260913.01 slice 2 so the second derived id did not carry a
7
+ * second copy of the grammar.
8
+ *
9
+ * The vectors here are the two domains' golden vectors restated through
10
+ * the shared function: `accountIdFor` and `policyIdFor` must equal
11
+ * `derivedId(prefix, text)` over their canonical text, and those domain
12
+ * tests pin the wire-adjacent constants. A change to the encoding
13
+ * re-addresses every derived row open source ever wrote.
14
+ */
15
+ import { describe, expect, it } from "vitest";
16
+
17
+ import { derivedId } from "../defaults.js";
18
+
19
+ const CROCKFORD_26 = /^[0-9abcdefghjkmnpqrstvwxyz]{26}$/;
20
+
21
+ describe("derivedId — prefix, underscore, the top 130 bits of sha256 as 26 Crockford chars", () => {
22
+ it("matches the identity-account golden vector", () => {
23
+ // accountIdFor("auth0|user123") as pinned in identityaccount/__tests__/constants.test.ts.
24
+ expect(derivedId("ida", "auth0|user123")).toBe(
25
+ "ida_wtr3jcf281yfk9xx61kj59fsme",
26
+ );
27
+ });
28
+
29
+ it("matches the IamPolicy golden vector over the canonical triple text", () => {
30
+ expect(
31
+ derivedId(
32
+ "iamp",
33
+ "identity_account:ida_wtr3jcf281yfk9xx61kj59fsme#@organization:acme#admin",
34
+ ),
35
+ ).toBe("iamp_vkbjb6nvqm77vtwnr2bj0x95f9");
36
+ });
37
+
38
+ it("has the minted id's shape whatever the prefix, so nothing downstream learns a second grammar", () => {
39
+ const id = derivedId("xyz", "anything");
40
+ expect(id.startsWith("xyz_")).toBe(true);
41
+ expect(id.slice("xyz_".length)).toMatch(CROCKFORD_26);
42
+ });
43
+
44
+ it("is a pure function of prefix and text: equal inputs, equal ids; a changed byte, a changed id", () => {
45
+ expect(derivedId("ida", "same")).toBe(derivedId("ida", "same"));
46
+ expect(derivedId("ida", "same")).not.toBe(derivedId("ida", "same "));
47
+ expect(derivedId("ida", "same")).not.toBe(derivedId("iamp", "same"));
48
+ });
49
+ });