@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
@@ -0,0 +1,798 @@
1
+ /**
2
+ * IamPolicy controller (20260913.01, T01_0_plan.md §3a; T01_1_review.md
3
+ * Q-OR-2, Q-OR-3, Q-OR-7, Q-OR-8 and the slice-5 rulings Q-S5-1..10) —
4
+ * the ROW half of the IamPolicy contract served ONCE by @stigmer/server in
5
+ * every edition, all fourteen RPCs. The behavioural reference is the
6
+ * cloud's iam/policy/handlers.ts (the Java pipelines rendered flat), whose
7
+ * handlers retire against this module at the S3 re-point; the byte-pinned
8
+ * copy moved with it (constants.ts). The cloud keeps what differs per
9
+ * edition and registers it: its row store (`drivers.iamPolicyStore`), its
10
+ * grant scope (`drivers.policyGrantScope`), its OpenFGA query engine
11
+ * (`drivers.authorizationQueries`) and the tuple mirror in the lifecycle
12
+ * driver's two policy hooks — and reaches the write lanes here as
13
+ * in-process RPCs (Q-OR-1), never through an exported constructor.
14
+ *
15
+ * Shape (Q-S5-1). The six command RPCs are pipeline chains, each
16
+ * `Authorize → ValidateProto → <one domain step>` (steps.ts): the write is
17
+ * the grant path's, the annotation drives position 1, and the executor's
18
+ * fault mapping covers a store fault from inside the path. The eight
19
+ * query RPCs are direct handlers over `authorizeDirect` (the cloud's
20
+ * shape; the identity-account lookups' precedent). No chain here splices
21
+ * a tuple step: a policy is the authorization record, not a protected
22
+ * resource — the cloud builds policies outside any chain, the FGA
23
+ * `iam_policy` type is dead, and seeding scope links for a row would
24
+ * recurse (the ts-server guideline records this exception).
25
+ *
26
+ * What the cloud's handlers recorded as surprises, kept or corrected:
27
+ * - `delete` and `create` are idempotent (absent → the default instance;
28
+ * held → the row). `delete` now authorizes BEFORE it loads (Q-OR-2:
29
+ * the annotation names the spec's resource), so a caller without
30
+ * `can_grant_access` hears PERMISSION_DENIED even for an absent
31
+ * triple — strictly safer than the cloud's silent default instance.
32
+ * - `create` validates the role BEFORE it writes (the cloud's order): a
33
+ * held row under a role the kind does not grant is refused, never
34
+ * answered as a duplicate. It also grants to PEOPLE only (Q-S9-2,
35
+ * 2026-09-14): a principal that is not an identity account is
36
+ * INVALID_ARGUMENT — a row naming a resource as its principal is the
37
+ * structural link the hierarchy walk follows, `bootstrapPolicy`'s to
38
+ * write, and through this lane would have let a right on one
39
+ * organization list another's members. The cloud's create had the
40
+ * gap; the corrected answer lands at the re-point.
41
+ * - `checkMyPermission`, `checkAuthorization` and
42
+ * `listAuthorizedResourceIds` skip position 1 by proto option (IAM
43
+ * authorizing IAM would recurse); trust is authentication plus the
44
+ * principal-trust rule, compared against the caller's ACCOUNT
45
+ * (`accountForCaller`, Q-S4-1 — the trusted-local interceptor stamps
46
+ * the operator's email, so the stamp alone would never match).
47
+ * - `listAuthorizedPrincipalIds` is annotation-driven since slice 1
48
+ * (finding 7): the same target `listResourceAccessByPrincipal` checks.
49
+ * - `get` is the one load-then-authorize lane: its target is the ROW's
50
+ * resource, handed to the annotation's evaluation as an override.
51
+ * - the three system RPCs (`bootstrapPolicy`, `cleanupResourcePolicies`,
52
+ * `bootstrapRevokeOrgAccess`) admit the platform's own pipelines only
53
+ * (`isPlatformPipelineCaller`) and refuse a wire user
54
+ * PERMISSION_DENIED with the annotation's own `error_msg`, BEFORE the
55
+ * chain — under the permissive Authorizer the annotation alone would
56
+ * admit anyone (Q-OR-7). Position 1 still runs the static platform
57
+ * check, so the cloud's `can_bootstrap_iam` FGA check is unchanged.
58
+ * - every kind string that came off the wire passes the domain's wire
59
+ * refusals (Q-S5-2) BEFORE position 1 (Q-S6-1, 2026-09-14):
60
+ * INVALID_ARGUMENT with the pinned copy, on every lane, in every
61
+ * edition. This is not input validation moved ahead of authorization
62
+ * — it is naming the authorization target. A kind string that names
63
+ * no kind names nothing to authorize against, so there is no
64
+ * position-1 question to ask; `checkMyPermission` refuses an unknown
65
+ * permission name first for the same reason, and the cloud's
66
+ * handlers kept this order (`kindFromSpecString` before
67
+ * `authorizeRpc`), so the wire is unchanged at the re-point. Safe by
68
+ * construction: the protovalidate interceptor (chain position 3) has
69
+ * validated every request before a handler runs, so both refs are
70
+ * present. The resolver's "resolution never throws" rule
71
+ * (steps/authorize.ts) is untouched — its unknown-kind deny remains
72
+ * the backstop for a stored row on `get`, the one kind string that is
73
+ * not wire input.
74
+ *
75
+ * `checkMyPermission` has one definition (Q-OR-8; Q-S5-3), in this order:
76
+ * UNAUTHENTICATED for an empty identity; the relation must be an
77
+ * IamPermission name; the resource kind must be known; contextual
78
+ * policies ride the query engine or refuse UNIMPLEMENTED; then (1) a kind
79
+ * this edition does not serve is `false` — nobody holds a permission on a
80
+ * kind that does not exist here, which is what keeps the operator-only
81
+ * settings navigation hidden on open source; (2) `can_grant_access` on a
82
+ * kind outside the grant scope is `false` — the console's PermissionGate
83
+ * hides grant controls with no new SDK surface (Q-OR-5); (3) the composed
84
+ * Authorizer: allow → true, deny and not-found → false, unavailable →
85
+ * INTERNAL, never softened into a denial.
86
+ *
87
+ * Proven by __tests__/controller.test.ts (every arm, over a router
88
+ * transport with fakes), __tests__/iampolicy.test.ts (the composed
89
+ * trusted-local server) and the iampolicy conformance suite.
90
+ */
91
+ import { create } from "@bufbuild/protobuf";
92
+ import type { DescMessage, DescMethod } from "@bufbuild/protobuf";
93
+ import { getOption } from "@bufbuild/protobuf";
94
+ import { EmptySchema } from "@bufbuild/protobuf/wkt";
95
+ import type { Empty } from "@bufbuild/protobuf/wkt";
96
+ import { Code, ConnectError } from "@connectrpc/connect";
97
+ import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
98
+
99
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
100
+ import { config as rpcAuthorizationConfig } from "@stigmer/protos/ai/stigmer/commons/rpc/method_options_pb";
101
+ import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
102
+ import { IamPolicyCommandController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/command_pb";
103
+ import type {
104
+ CheckAuthorizationInput,
105
+ CheckAuthorizationResult,
106
+ CheckMyPermissionInput,
107
+ GetPrincipalsCountInput,
108
+ IamPolicyId,
109
+ ListAuthorizedPrincipalIdsInput,
110
+ ListAuthorizedResourceIdsInput,
111
+ ListResourceAccessInput,
112
+ PrincipalResourceInput,
113
+ RevokeOrgAccessInput,
114
+ } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
115
+ import {
116
+ AuthorizedPrincipalIdsListSchema,
117
+ AuthorizedResourceIdsListSchema,
118
+ CheckAuthorizationResultSchema,
119
+ PrincipalResourceRolesSchema,
120
+ PrincipalsCountSchema,
121
+ ResourceAccessByPrincipalListSchema,
122
+ RevokeOrgAccessInputSchema,
123
+ } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
124
+ import { IamPolicyQueryController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/query_pb";
125
+ import type {
126
+ ApiResourceRef,
127
+ IamPolicySpec,
128
+ } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
129
+ import {
130
+ ApiResourceRefSchema,
131
+ IamPolicySpecSchema,
132
+ } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
133
+ import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
134
+ import type { ServerEdition } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
135
+
136
+ import type { Logger } from "../../boot/logger.js";
137
+ import type { AuthorizationQueryEngine } from "../../extensions/authorization-queries.js";
138
+ import type { Authorizer } from "../../extensions/authorizer.js";
139
+ import { isPlatformPipelineCaller } from "../../extensions/identity.js";
140
+ import type { CallerIdentity } from "../../extensions/identity.js";
141
+ import type { PolicyGrantScope } from "../../extensions/policy-grant-scope.js";
142
+ import {
143
+ kindByEnumName,
144
+ kindEnumName,
145
+ kindServedByEdition,
146
+ } from "../../pipeline/apiresource-meta.js";
147
+ import { internalError } from "../../pipeline/errors.js";
148
+ import { apiResourceKindKey } from "../../pipeline/interceptors/apiresource.js";
149
+ import { callerIdentityOf } from "../../pipeline/interceptors/auth.js";
150
+ import { newPipeline } from "../../pipeline/pipeline.js";
151
+ import { RequestContext } from "../../pipeline/request-context.js";
152
+ import {
153
+ AUTHORIZATION_UNAVAILABLE_MESSAGE,
154
+ authorizeDirect,
155
+ evaluateAuthorizer,
156
+ newAuthorizeStep,
157
+ } from "../../pipeline/steps/authorize.js";
158
+ import { newValidateProtoStep } from "../../pipeline/steps/validation.js";
159
+ import { accountForCaller } from "../identityaccount/resolve.js";
160
+ import type { AccountsByCaller } from "../identityaccount/resolve.js";
161
+ import { buildPrincipalAccessList, resolveHierarchy } from "./access-lists.js";
162
+ import {
163
+ AUTHENTICATION_REQUIRED_MESSAGE,
164
+ AUTHORIZATION_QUERIES_UNIMPLEMENTED_MESSAGE,
165
+ policyNotFoundMessage,
166
+ unknownPermissionMessage,
167
+ } from "./constants.js";
168
+ import { newAccountDisplayResolver } from "./display-resolver.js";
169
+ import type { AccountsByIds } from "./display-resolver.js";
170
+ import type { IamPolicyGrantPath } from "./grant-path.js";
171
+ import { permissionByEnumName } from "./permissions.js";
172
+ import {
173
+ assignableRelations,
174
+ isAssignableRole,
175
+ roleInfoFromRelation,
176
+ } from "./roles.js";
177
+ import {
178
+ POLICY_RESULT_KEY,
179
+ newCleanupResourceStep,
180
+ newGrantStep,
181
+ newRevokeOrgAccessStep,
182
+ newRevokeStep,
183
+ newValidateGrantableRoleStep,
184
+ } from "./steps.js";
185
+ import type { IamPolicyStore } from "./store.js";
186
+ import {
187
+ refsOf,
188
+ requireKnownPrincipalKind,
189
+ requireKnownResourceKind,
190
+ requireWellFormedTriple,
191
+ } from "./wire-refusals.js";
192
+
193
+ /**
194
+ * The Java pre-migration `_self` principal alias — rejected with a
195
+ * pointer at checkMyPermission (kept only for the precise error copy).
196
+ */
197
+ const SELF_PRINCIPAL_ID = "_self";
198
+
199
+ /** The reads this controller makes of the identity-account port: the caller's account and display enrichment. */
200
+ export type IamPolicyAccounts = AccountsByCaller & AccountsByIds;
201
+
202
+ export interface IamPolicyControllerDeps {
203
+ readonly grantPath: IamPolicyGrantPath;
204
+ readonly policies: IamPolicyStore;
205
+ readonly accounts: IamPolicyAccounts;
206
+ /** The ONE composed Authorizer — position 1 of every chain and every direct handler's `authorizeDirect`. */
207
+ readonly authorizer: Authorizer;
208
+ /** The composed grant scope, or open source's organization-only default (compose.ts installs it at the `??` site). */
209
+ readonly grantScope: PolicyGrantScope;
210
+ /** The composed query engine — undefined = the tuple-half RPCs refuse UNIMPLEMENTED. */
211
+ readonly queries: AuthorizationQueryEngine | undefined;
212
+ /** The served edition — `checkMyPermission`'s first arm reads a kind's tier against it. */
213
+ readonly edition: ServerEdition;
214
+ readonly logger: Logger;
215
+ }
216
+
217
+ /** Registers both IamPolicy services on the router (routes stage). */
218
+ export function registerIamPolicyServices(
219
+ router: ConnectRouter,
220
+ deps: IamPolicyControllerDeps,
221
+ ): void {
222
+ const displayResolver = newAccountDisplayResolver(deps.accounts);
223
+ const command = IamPolicyCommandController.method;
224
+
225
+ router.service(IamPolicyCommandController, {
226
+ create: (spec, ctx) => createPolicy(deps, spec, ctx),
227
+ delete: (spec, ctx) => deletePolicy(deps, spec, ctx),
228
+ bootstrapPolicy: (spec, ctx) => {
229
+ guardSystemRpc(command.bootstrapPolicy, callerIdentityOf(ctx));
230
+ return grantThroughChain(deps, command.bootstrapPolicy, spec, ctx, false);
231
+ },
232
+ cleanupResourcePolicies: (ref, ctx) => {
233
+ guardSystemRpc(command.cleanupResourcePolicies, callerIdentityOf(ctx));
234
+ return cleanupResourcePolicies(deps, ref, ctx);
235
+ },
236
+ revokeOrgAccess: (input, ctx) =>
237
+ revokeOrgAccess(deps, command.revokeOrgAccess, input, ctx),
238
+ bootstrapRevokeOrgAccess: (input, ctx) => {
239
+ guardSystemRpc(command.bootstrapRevokeOrgAccess, callerIdentityOf(ctx));
240
+ return revokeOrgAccess(
241
+ deps,
242
+ command.bootstrapRevokeOrgAccess,
243
+ input,
244
+ ctx,
245
+ );
246
+ },
247
+ });
248
+
249
+ router.service(IamPolicyQueryController, {
250
+ get: (id, ctx) => get(deps, id, ctx),
251
+ checkMyPermission: (input, ctx) => checkMyPermission(deps, input, ctx),
252
+ checkAuthorization: (input, ctx) => checkAuthorization(deps, input, ctx),
253
+ listAuthorizedResourceIds: (input, ctx) =>
254
+ listAuthorizedResourceIds(deps, input, ctx),
255
+ listAuthorizedPrincipalIds: (input, ctx) =>
256
+ listAuthorizedPrincipalIds(deps, input, ctx),
257
+ listResourceAccessByPrincipal: (input, ctx) =>
258
+ listResourceAccessByPrincipal(deps, displayResolver, input, ctx),
259
+ getPrincipalResourceRoles: (input, ctx) =>
260
+ getPrincipalResourceRoles(deps, input, ctx),
261
+ getPrincipalsCount: (input, ctx) => getPrincipalsCount(deps, input, ctx),
262
+ });
263
+ }
264
+
265
+ function kindOf(ctx: HandlerContext): ApiResourceKind {
266
+ return ctx.values.get(apiResourceKindKey);
267
+ }
268
+
269
+ /**
270
+ * The admission rule of the three system RPCs (Q-OR-7): the platform's own
271
+ * pipelines only, refused with the RPC's own annotation copy — the proto
272
+ * owns that sentence, so no second pinned copy exists here.
273
+ */
274
+ function guardSystemRpc(method: DescMethod, caller: CallerIdentity): void {
275
+ if (isPlatformPipelineCaller(caller)) {
276
+ return;
277
+ }
278
+ throw new ConnectError(
279
+ getOption(method, rpcAuthorizationConfig).errorMsg,
280
+ Code.PermissionDenied,
281
+ );
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // The command chains
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /** `create`: the wire refusal, then Authorize → ValidateProto → ValidateGrantableRole → Grant. */
289
+ function createPolicy(
290
+ deps: IamPolicyControllerDeps,
291
+ spec: IamPolicySpec,
292
+ ctx: HandlerContext,
293
+ ): Promise<IamPolicy> {
294
+ requireWellFormedTriple(spec);
295
+ return grantThroughChain(
296
+ deps,
297
+ IamPolicyCommandController.method.create,
298
+ spec,
299
+ ctx,
300
+ true,
301
+ );
302
+ }
303
+
304
+ /**
305
+ * The grant chain both `create` and `bootstrapPolicy` run; only the user
306
+ * lane validates the role — bootstrap writes structural relations no
307
+ * scope would admit.
308
+ */
309
+ async function grantThroughChain(
310
+ deps: IamPolicyControllerDeps,
311
+ method: DescMethod,
312
+ spec: IamPolicySpec,
313
+ ctx: HandlerContext,
314
+ validateRole: boolean,
315
+ ): Promise<IamPolicy> {
316
+ const reqCtx = new RequestContext(
317
+ IamPolicySpecSchema,
318
+ spec,
319
+ callerIdentityOf(ctx),
320
+ kindOf(ctx),
321
+ );
322
+ const pipeline = newPipeline<typeof IamPolicySpecSchema>(
323
+ `iampolicy-${method.localName}`,
324
+ deps.logger,
325
+ )
326
+ .addStep(newAuthorizeStep(method, deps.authorizer))
327
+ .addStep(newValidateProtoStep());
328
+ if (validateRole) {
329
+ pipeline.addStep(newValidateGrantableRoleStep(deps.grantScope));
330
+ }
331
+ await pipeline.addStep(newGrantStep(deps.grantPath)).build().execute(reqCtx);
332
+ return policyResultOf(reqCtx, method);
333
+ }
334
+
335
+ /** `delete`: the wire refusal, then Authorize → ValidateProto → Revoke; the revoked row, or the default instance. */
336
+ async function deletePolicy(
337
+ deps: IamPolicyControllerDeps,
338
+ spec: IamPolicySpec,
339
+ ctx: HandlerContext,
340
+ ): Promise<IamPolicy> {
341
+ requireWellFormedTriple(spec);
342
+ const method = IamPolicyCommandController.method.delete;
343
+ const reqCtx = new RequestContext(
344
+ IamPolicySpecSchema,
345
+ spec,
346
+ callerIdentityOf(ctx),
347
+ kindOf(ctx),
348
+ );
349
+ await newPipeline<typeof IamPolicySpecSchema>("iampolicy-delete", deps.logger)
350
+ .addStep(newAuthorizeStep(method, deps.authorizer))
351
+ .addStep(newValidateProtoStep())
352
+ .addStep(newRevokeStep(deps.grantPath))
353
+ .build()
354
+ .execute(reqCtx);
355
+ return policyResultOf(reqCtx, method);
356
+ }
357
+
358
+ /** `revokeOrgAccess` and its system twin share one chain; only the annotation differs. */
359
+ async function revokeOrgAccess(
360
+ deps: IamPolicyControllerDeps,
361
+ method: DescMethod,
362
+ input: RevokeOrgAccessInput,
363
+ ctx: HandlerContext,
364
+ ): Promise<Empty> {
365
+ const reqCtx = new RequestContext(
366
+ RevokeOrgAccessInputSchema,
367
+ input,
368
+ callerIdentityOf(ctx),
369
+ kindOf(ctx),
370
+ );
371
+ await newPipeline<typeof RevokeOrgAccessInputSchema>(
372
+ `iampolicy-${method.localName}`,
373
+ deps.logger,
374
+ )
375
+ .addStep(newAuthorizeStep(method, deps.authorizer))
376
+ .addStep(newValidateProtoStep())
377
+ .addStep(newRevokeOrgAccessStep(deps.grantPath))
378
+ .build()
379
+ .execute(reqCtx);
380
+ return create(EmptySchema);
381
+ }
382
+
383
+ async function cleanupResourcePolicies(
384
+ deps: IamPolicyControllerDeps,
385
+ ref: ApiResourceRef,
386
+ ctx: HandlerContext,
387
+ ): Promise<Empty> {
388
+ const method = IamPolicyCommandController.method.cleanupResourcePolicies;
389
+ const reqCtx = new RequestContext(
390
+ ApiResourceRefSchema,
391
+ ref,
392
+ callerIdentityOf(ctx),
393
+ kindOf(ctx),
394
+ );
395
+ await newPipeline<typeof ApiResourceRefSchema>(
396
+ "iampolicy-cleanupResourcePolicies",
397
+ deps.logger,
398
+ )
399
+ .addStep(newAuthorizeStep(method, deps.authorizer))
400
+ .addStep(newValidateProtoStep())
401
+ .addStep(newCleanupResourceStep(deps.grantPath))
402
+ .build()
403
+ .execute(reqCtx);
404
+ return create(EmptySchema);
405
+ }
406
+
407
+ function policyResultOf<Desc extends DescMessage>(
408
+ reqCtx: RequestContext<Desc>,
409
+ method: DescMethod,
410
+ ): IamPolicy {
411
+ const result = reqCtx.get(POLICY_RESULT_KEY);
412
+ if (result === undefined) {
413
+ throw internalError(
414
+ new Error(`${method.localName} pipeline completed without a policy`),
415
+ "policy not found in context",
416
+ );
417
+ }
418
+ return result as IamPolicy;
419
+ }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // The direct query handlers
423
+ // ---------------------------------------------------------------------------
424
+
425
+ /** `get`: load THEN authorize — the target is the row's resource. */
426
+ async function get(
427
+ deps: IamPolicyControllerDeps,
428
+ id: IamPolicyId,
429
+ ctx: HandlerContext,
430
+ ): Promise<IamPolicy> {
431
+ const policy = await read(
432
+ () => deps.policies.findById(id.value),
433
+ "failed to load IAM policy",
434
+ );
435
+ if (policy === undefined) {
436
+ throw new ConnectError(policyNotFoundMessage(id.value), Code.NotFound);
437
+ }
438
+ // A stored row is not wire input: its kind resolves without a refusal
439
+ // sentence (a legacy row with an unknown kind is the Authorizer's to
440
+ // deny, as position 1 would), and its id is the target.
441
+ const { resource } = refsOf(specOfRow(policy));
442
+ await authorizeDirect(
443
+ IamPolicyQueryController.method.get,
444
+ deps.authorizer,
445
+ callerIdentityOf(ctx),
446
+ id,
447
+ { resourceKind: kindByEnumName(resource.kind), resourceId: resource.id },
448
+ );
449
+ return policy;
450
+ }
451
+
452
+ /** The one definition — see the module header for the order and its reasons. */
453
+ async function checkMyPermission(
454
+ deps: IamPolicyControllerDeps,
455
+ input: CheckMyPermissionInput,
456
+ ctx: HandlerContext,
457
+ ): Promise<CheckAuthorizationResult> {
458
+ const caller = requireAuthenticated(callerIdentityOf(ctx));
459
+ const permission = permissionByEnumName(input.relation);
460
+ if (permission === undefined) {
461
+ throw new ConnectError(
462
+ unknownPermissionMessage(input.relation),
463
+ Code.InvalidArgument,
464
+ );
465
+ }
466
+ const resource = requireRef(input.resource);
467
+ const resourceKind = requireKnownResourceKind(resource.kind);
468
+
469
+ if (input.contextualPolicies.length > 0) {
470
+ const engine = requireQueryEngine(deps);
471
+ const principal = create(ApiResourceRefSchema, {
472
+ kind: kindEnumName(ApiResourceKind.identity_account),
473
+ id: await callerPrincipalId(deps, caller),
474
+ });
475
+ const policy = create(IamPolicySpecSchema, {
476
+ principal,
477
+ relation: input.relation,
478
+ resource,
479
+ });
480
+ const allowed = await engine.check(policy, input.contextualPolicies);
481
+ return create(CheckAuthorizationResultSchema, { isAuthorized: allowed });
482
+ }
483
+
484
+ // Arm 1: nobody holds a permission on a kind this edition does not serve.
485
+ if (!kindServedByEdition(resourceKind, deps.edition)) {
486
+ return create(CheckAuthorizationResultSchema, { isAuthorized: false });
487
+ }
488
+ // Arm 2: a grant target outside the composed scope is nobody's to grant.
489
+ if (
490
+ permission === IamPermission.can_grant_access &&
491
+ deps.grantScope.grantableRoles(resourceKind).length === 0
492
+ ) {
493
+ return create(CheckAuthorizationResultSchema, { isAuthorized: false });
494
+ }
495
+ // Arm 3: the composed Authorizer's answer, an outage never softened.
496
+ const decision = await evaluateAuthorizer(deps.authorizer, caller, {
497
+ permission,
498
+ resourceKind,
499
+ resourceId: resource.id,
500
+ });
501
+ switch (decision.kind) {
502
+ case "allow":
503
+ return create(CheckAuthorizationResultSchema, { isAuthorized: true });
504
+ case "deny":
505
+ case "not-found":
506
+ return create(CheckAuthorizationResultSchema, { isAuthorized: false });
507
+ case "unavailable":
508
+ throw internalError(decision.cause, AUTHORIZATION_UNAVAILABLE_MESSAGE);
509
+ default: {
510
+ const exhaustive: never = decision;
511
+ throw internalError(
512
+ new Error(`unknown decision ${JSON.stringify(exhaustive)}`),
513
+ AUTHORIZATION_UNAVAILABLE_MESSAGE,
514
+ );
515
+ }
516
+ }
517
+ }
518
+
519
+ async function checkAuthorization(
520
+ deps: IamPolicyControllerDeps,
521
+ input: CheckAuthorizationInput,
522
+ ctx: HandlerContext,
523
+ ): Promise<CheckAuthorizationResult> {
524
+ const caller = requireAuthenticated(callerIdentityOf(ctx));
525
+ const policy = requireInputPolicy(input.policy);
526
+ await enforcePrincipalTrust(deps, caller, policy.principal);
527
+ requireWellFormedTriple(policy);
528
+ const engine = requireQueryEngine(deps);
529
+ const allowed = await engine.check(policy, input.contextualPolicies);
530
+ return create(CheckAuthorizationResultSchema, { isAuthorized: allowed });
531
+ }
532
+
533
+ /** The skip lane with no resource (finding 7): trust on the principal, then the engine. */
534
+ async function listAuthorizedResourceIds(
535
+ deps: IamPolicyControllerDeps,
536
+ input: ListAuthorizedResourceIdsInput,
537
+ ctx: HandlerContext,
538
+ ) {
539
+ const caller = requireAuthenticated(callerIdentityOf(ctx));
540
+ const principal = requireRef(input.principal);
541
+ await enforcePrincipalTrust(deps, caller, principal);
542
+ requireKnownPrincipalKind(principal.kind);
543
+ requireKnownResourceKind(input.resourceKind);
544
+ const engine = requireQueryEngine(deps);
545
+ const resourceIds = await engine.listResourceIds(
546
+ principal,
547
+ input.relation,
548
+ input.resourceKind,
549
+ input.contextualPolicies,
550
+ );
551
+ return create(AuthorizedResourceIdsListSchema, {
552
+ resourceIds: [...resourceIds],
553
+ });
554
+ }
555
+
556
+ async function listAuthorizedPrincipalIds(
557
+ deps: IamPolicyControllerDeps,
558
+ input: ListAuthorizedPrincipalIdsInput,
559
+ ctx: HandlerContext,
560
+ ) {
561
+ const resource = requireRef(input.resource);
562
+ requireKnownResourceKind(resource.kind);
563
+ requireKnownPrincipalKind(input.principalKind);
564
+ await authorizeDirect(
565
+ IamPolicyQueryController.method.listAuthorizedPrincipalIds,
566
+ deps.authorizer,
567
+ callerIdentityOf(ctx),
568
+ input,
569
+ );
570
+ const engine = requireQueryEngine(deps);
571
+ const principalIds = await engine.listPrincipalIds(
572
+ resource,
573
+ input.relation,
574
+ input.principalKind,
575
+ input.contextualPolicies,
576
+ );
577
+ return create(AuthorizedPrincipalIdsListSchema, {
578
+ principalIds: [...principalIds],
579
+ });
580
+ }
581
+
582
+ async function listResourceAccessByPrincipal(
583
+ deps: IamPolicyControllerDeps,
584
+ displayResolver: ReturnType<typeof newAccountDisplayResolver>,
585
+ input: ListResourceAccessInput,
586
+ ctx: HandlerContext,
587
+ ) {
588
+ const resource = requireRef(input.resource);
589
+ requireKnownResourceKind(resource.kind);
590
+ await authorizeDirect(
591
+ IamPolicyQueryController.method.listResourceAccessByPrincipal,
592
+ deps.authorizer,
593
+ callerIdentityOf(ctx),
594
+ input,
595
+ );
596
+ const entries = await read(async () => {
597
+ const hierarchy = await resolveHierarchy(
598
+ deps.policies,
599
+ resource.kind,
600
+ resource.id,
601
+ input.includeInherited,
602
+ );
603
+ return buildPrincipalAccessList(deps.policies, displayResolver, hierarchy);
604
+ }, "failed to list resource access");
605
+ return create(ResourceAccessByPrincipalListSchema, {
606
+ entries: [...entries],
607
+ });
608
+ }
609
+
610
+ async function getPrincipalResourceRoles(
611
+ deps: IamPolicyControllerDeps,
612
+ input: PrincipalResourceInput,
613
+ ctx: HandlerContext,
614
+ ) {
615
+ const resource = requireRef(input.resource);
616
+ const principal = requireRef(input.principal);
617
+ requireKnownResourceKind(resource.kind);
618
+ requireKnownPrincipalKind(principal.kind);
619
+ await authorizeDirect(
620
+ IamPolicyQueryController.method.getPrincipalResourceRoles,
621
+ deps.authorizer,
622
+ callerIdentityOf(ctx),
623
+ input,
624
+ );
625
+ const rows = await read(
626
+ () =>
627
+ deps.policies.findByPrincipalAndResource(
628
+ principal.kind,
629
+ principal.id,
630
+ resource.kind,
631
+ resource.id,
632
+ ),
633
+ "failed to load principal roles",
634
+ );
635
+ const roles = rows
636
+ .map((policy) => policy.spec?.relation ?? "")
637
+ .filter(isAssignableRole)
638
+ .map(roleInfoFromRelation);
639
+ return create(PrincipalResourceRolesSchema, { roles });
640
+ }
641
+
642
+ async function getPrincipalsCount(
643
+ deps: IamPolicyControllerDeps,
644
+ input: GetPrincipalsCountInput,
645
+ ctx: HandlerContext,
646
+ ) {
647
+ // The wire's "" means any principal kind (the port's `undefined`).
648
+ const principalKind =
649
+ input.principalKind === "" ? undefined : input.principalKind;
650
+ if (principalKind !== undefined) {
651
+ requireKnownPrincipalKind(principalKind);
652
+ }
653
+ await authorizeDirect(
654
+ IamPolicyQueryController.method.getPrincipalsCount,
655
+ deps.authorizer,
656
+ callerIdentityOf(ctx),
657
+ input,
658
+ );
659
+ const count = await read(
660
+ () =>
661
+ deps.policies.countDistinctPrincipalsByResource(
662
+ kindEnumName(ApiResourceKind.organization),
663
+ input.orgId,
664
+ principalKind,
665
+ assignableRelations(),
666
+ ),
667
+ "failed to count principals",
668
+ );
669
+ return create(PrincipalsCountSchema, { count });
670
+ }
671
+
672
+ // ---------------------------------------------------------------------------
673
+ // Shared rules
674
+ // ---------------------------------------------------------------------------
675
+
676
+ /** Java's authenticated-caller precondition on the check handlers. */
677
+ function requireAuthenticated(caller: CallerIdentity): CallerIdentity {
678
+ if (caller.identityId === "") {
679
+ throw new ConnectError(
680
+ AUTHENTICATION_REQUIRED_MESSAGE,
681
+ Code.Unauthenticated,
682
+ );
683
+ }
684
+ return caller;
685
+ }
686
+
687
+ /**
688
+ * The principal the caller IS, for the engine and the trust rule: the
689
+ * account `accountForCaller` resolves (the trusted-local stamp is an
690
+ * email; a composition verifier that ran before any row existed stamps
691
+ * a subject), else the stamped identity — an unprovisioned subject
692
+ * matches no tuple, which is the cloud's answer for it too.
693
+ */
694
+ async function callerPrincipalId(
695
+ deps: IamPolicyControllerDeps,
696
+ caller: CallerIdentity,
697
+ ): Promise<string> {
698
+ const account = await read(
699
+ () => accountForCaller(deps.accounts, caller),
700
+ "failed to resolve the caller's account",
701
+ );
702
+ return account?.metadata?.id ?? caller.identityId;
703
+ }
704
+
705
+ /**
706
+ * The Java EnforcePrincipalTrust rules in order: the platform's own
707
+ * pipelines (`machine`, `internal`) may ask about any principal; a user
708
+ * only about their own account.
709
+ */
710
+ async function enforcePrincipalTrust(
711
+ deps: IamPolicyControllerDeps,
712
+ caller: CallerIdentity,
713
+ principal: ApiResourceRef | undefined,
714
+ ): Promise<void> {
715
+ if (caller.callerClass === "machine" || caller.callerClass === "internal") {
716
+ return;
717
+ }
718
+ if (principal?.kind !== kindEnumName(ApiResourceKind.identity_account)) {
719
+ throw new ConnectError(
720
+ "Only self identity_account permission checks are supported",
721
+ Code.InvalidArgument,
722
+ );
723
+ }
724
+ const requestedId = principal.id;
725
+ if (requestedId === "" || requestedId === SELF_PRINCIPAL_ID) {
726
+ throw new ConnectError(
727
+ `The '${SELF_PRINCIPAL_ID}' principal alias is not supported — ` +
728
+ "use checkMyPermission for self permission checks",
729
+ Code.InvalidArgument,
730
+ );
731
+ }
732
+ if (requestedId !== (await callerPrincipalId(deps, caller))) {
733
+ throw new ConnectError(
734
+ "Principal must be the authenticated caller",
735
+ Code.PermissionDenied,
736
+ );
737
+ }
738
+ }
739
+
740
+ /** The tuple-half capability, or the edition's UNIMPLEMENTED — never INTERNAL. */
741
+ function requireQueryEngine(
742
+ deps: IamPolicyControllerDeps,
743
+ ): AuthorizationQueryEngine {
744
+ if (deps.queries === undefined) {
745
+ throw new ConnectError(
746
+ AUTHORIZATION_QUERIES_UNIMPLEMENTED_MESSAGE,
747
+ Code.Unimplemented,
748
+ );
749
+ }
750
+ return deps.queries;
751
+ }
752
+
753
+ /** Boundary-validated required refs — absence here is a contract bug. */
754
+ function requireRef(ref: ApiResourceRef | undefined): ApiResourceRef {
755
+ if (ref === undefined) {
756
+ throw internalError(
757
+ new Error("required reference missing after boundary validation"),
758
+ "required reference missing after boundary validation",
759
+ );
760
+ }
761
+ return ref;
762
+ }
763
+
764
+ /** Boundary-validated required policy input — absence here is a contract bug. */
765
+ function requireInputPolicy(policy: IamPolicySpec | undefined): IamPolicySpec {
766
+ if (policy === undefined) {
767
+ throw internalError(
768
+ new Error("required policy missing after boundary validation"),
769
+ "required policy missing after boundary validation",
770
+ );
771
+ }
772
+ return policy;
773
+ }
774
+
775
+ /** A stored row's spec — a row without one is corrupt storage, an infrastructure fault. */
776
+ function specOfRow(policy: IamPolicy): IamPolicySpec {
777
+ if (policy.spec === undefined) {
778
+ throw internalError(
779
+ new Error(
780
+ `policy ${policy.metadata?.id ?? "?"} has no spec — corrupt row`,
781
+ ),
782
+ "failed to load IAM policy",
783
+ );
784
+ }
785
+ return policy.spec;
786
+ }
787
+
788
+ /** A direct handler's store read: a fault is INTERNAL with a static message, never "not found". */
789
+ async function read<T>(
790
+ operation: () => Promise<T>,
791
+ message: string,
792
+ ): Promise<T> {
793
+ try {
794
+ return await operation();
795
+ } catch (error) {
796
+ throw internalError(error, message);
797
+ }
798
+ }