@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,23 +8,16 @@
8
8
  * `cloud.iam_identity_account` store), so "the port holds" is one
9
9
  * statement proven per driver, never restated per repository.
10
10
  *
11
- * Shape. The package's precedent is store/__tests__/store-contract.ts —
12
- * `describeStoreContract(makeFixture)`: a fresh fixture per test, named
13
- * escape hatches for the arms the interface cannot express, driver-physical
14
- * behavior left to each driver's own tests. This kit follows it in
15
- * everything but the framework: it is consumed by ANOTHER package's tests,
16
- * so it ships in dist/ and must not import vitest — a devDependency that
17
- * would enter the root barrel's runtime import graph. It therefore returns
18
- * cases instead of calling `describe`, asserts through node:assert/strict,
19
- * and the consumer's framework does `for (const c of cases) it(c.name,
20
- * c.run)`. Do not "fix" it back to the precedent's vitest shape.
21
- *
22
- * Every case makes a FRESH fixture, runs, and cleans up; a failing
23
- * assertion wins over a failing cleanup. `disconnect` is the one escape
24
- * hatch: the port has no lifecycle by design (a composition owns its
25
- * store's), yet "an outage never reads as no account" is the line that
26
- * matters most, so the fixture cuts the store from its database and the
27
- * kit asserts that the fault propagates instead of reading as `undefined`.
11
+ * Shape. The kit is a list of declarations over the port-contract runner
12
+ * (store/port-contract.ts, lifted from this file in 20260913.01 slice 2
13
+ * when the IamPolicy kit became the second): the runner owns the fresh
14
+ * fixture per case, the cleanup, the rule that a failing assertion wins
15
+ * over a failing cleanup, and the reason the kit returns cases instead of
16
+ * calling vitest's `describe`. `disconnect` is the one escape hatch: the
17
+ * port has no lifecycle by design (a composition owns its store's), yet
18
+ * "an outage never reads as no account" is the line that matters most, so
19
+ * the fixture cuts the store from its database and the kit asserts that
20
+ * the fault propagates instead of reading as `undefined`.
28
21
  *
29
22
  * What the kit deliberately does not carry: open source's primary-key read
30
23
  * (the findByField spy) and its derived-id refusal, the cloud's column
@@ -52,32 +45,21 @@ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityacc
52
45
  import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
53
46
  import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
54
47
 
48
+ import { portContractCases } from "../../store/port-contract.js";
49
+ import type {
50
+ PortContractCase,
51
+ PortContractDeclaration,
52
+ PortContractFixture,
53
+ } from "../../store/port-contract.js";
55
54
  import { accountIdFor } from "./constants.js";
56
55
  import { DuplicateAccountError } from "./store.js";
57
56
  import type { IdentityAccountStore } from "./store.js";
58
57
 
59
- /** One fresh, isolated store per case, plus the one escape hatch the port cannot express. */
60
- export interface IdentityAccountStoreContractFixture {
61
- readonly store: IdentityAccountStore;
62
- /**
63
- * Cuts the store from its database so every later call is an
64
- * infrastructure fault. `cleanup` still runs afterwards and must tolerate
65
- * a disconnected store (both OSS drivers' `close` is idempotent; a pool
66
- * fixture ends a per-case pool here and skips it in cleanup).
67
- */
68
- disconnect(): Promise<void>;
69
- cleanup(): Promise<void>;
70
- }
58
+ /** The runner's fixture over this port — the name a driver's test and the barrel know it by. */
59
+ export type IdentityAccountStoreContractFixture =
60
+ PortContractFixture<IdentityAccountStore>;
71
61
 
72
- export interface IdentityAccountStoreContractCase {
73
- /** The contract line, in the port's words; the framework prints it as the test name. */
74
- readonly name: string;
75
- /** Makes a FRESH fixture, runs the case, cleans up. A failing assertion wins over a failing cleanup. */
76
- run(): Promise<void>;
77
- }
78
-
79
- /** A case body over a live fixture; the kit owns the fixture's lifecycle around it. */
80
- type CaseBody = (fixture: IdentityAccountStoreContractFixture) => Promise<void>;
62
+ export type IdentityAccountStoreContractCase = PortContractCase;
81
63
 
82
64
  /** A direct account as the domain writes it: id derived from the subject, mode direct. */
83
65
  function directAccount(overrides: {
@@ -151,7 +133,7 @@ function subjectsOf(accounts: ReadonlyArray<IdentityAccount>): string[] {
151
133
  return accounts.map((account) => account.spec?.idpId ?? "");
152
134
  }
153
135
 
154
- const CASES: ReadonlyArray<readonly [string, CaseBody]> = [
136
+ const CASES: ReadonlyArray<PortContractDeclaration<IdentityAccountStore>> = [
155
137
  [
156
138
  "save then findById round-trips the account",
157
139
  async ({ store }) => {
@@ -433,39 +415,9 @@ const CASES: ReadonlyArray<readonly [string, CaseBody]> = [
433
415
  ],
434
416
  ];
435
417
 
436
- /**
437
- * Runs `body` over a fresh fixture. The body's failure is the one reported:
438
- * a cleanup failure after a failed body would otherwise replace the
439
- * assertion that matters with a teardown detail.
440
- */
441
- async function withFixture(
442
- makeFixture: () => Promise<IdentityAccountStoreContractFixture>,
443
- body: CaseBody,
444
- ): Promise<void> {
445
- const fixture = await makeFixture();
446
- let failed = false;
447
- try {
448
- await body(fixture);
449
- } catch (error) {
450
- failed = true;
451
- throw error;
452
- } finally {
453
- try {
454
- await fixture.cleanup();
455
- } catch (cleanupError) {
456
- if (!failed) {
457
- throw cleanupError;
458
- }
459
- }
460
- }
461
- }
462
-
463
418
  /** The port's cases over `makeFixture`, one fresh fixture per case. */
464
419
  export function identityAccountStoreContract(
465
420
  makeFixture: () => Promise<IdentityAccountStoreContractFixture>,
466
421
  ): ReadonlyArray<IdentityAccountStoreContractCase> {
467
- return CASES.map(([name, body]) => ({
468
- name,
469
- run: () => withFixture(makeFixture, body),
470
- }));
422
+ return portContractCases(CASES, makeFixture);
471
423
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared fakes for the composed-server extension proofs (the
3
+ * `*-composed.test.ts` files): the pieces a test needs to boot
4
+ * `composeServer` with a fake unit in the cloud's own shape — a declared
5
+ * require-authentication posture and the unit's OWN verifier — and to talk
6
+ * to it over the wire as a chosen subject.
7
+ *
8
+ * The verifier vouches for an unsigned JWT-shaped token (`header.payload.
9
+ * unsigned`) and admits its `sub` as a `user` whose identityId is the RAW
10
+ * subject with an empty issuer: it deliberately does NOT resolve to an
11
+ * account, exactly like a composition verifier that runs before any row
12
+ * exists, so a first provisioning runs for real with no network. It passes
13
+ * on every other token, so the OSS lanes composed ahead of it keep their
14
+ * claims. Lifted from identity-account-composed.test.ts (20260911.11 slice
15
+ * 3) when a second composed proof needed the same shape (20260913.01 S1).
16
+ */
17
+ import path from "node:path";
18
+
19
+ import type { Interceptor, Transport } from "@connectrpc/connect";
20
+ import { createGrpcTransport } from "@connectrpc/connect-node";
21
+
22
+ import { createLogger } from "../../boot/logger.js";
23
+ import type { CallerIdentity, IdentityVerifier } from "../identity.js";
24
+
25
+ export const silentLogger = createLogger({
26
+ level: "error",
27
+ pretty: false,
28
+ write: () => {},
29
+ });
30
+
31
+ /** The smallest config a composed server boots with, everything under `dir`. */
32
+ export function baseConfig(dir: string): Record<string, string> {
33
+ return {
34
+ STIGMER_MODEL_REGISTRY_REFRESH: "off",
35
+ TEMPORAL_HOST_PORT: "127.0.0.1:1",
36
+ DB_PATH: path.join(dir, "stigmer.db"),
37
+ STORAGE_PATH: path.join(dir, "storage"),
38
+ ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
39
+ };
40
+ }
41
+
42
+ /** An unsigned JWT-shaped token the fake verifier vouches for; idpIdOf reads its `sub` as it reads a real token's. */
43
+ export function fakeJwt(sub: string, email: string): string {
44
+ const segment = (value: unknown): string =>
45
+ Buffer.from(JSON.stringify(value)).toString("base64url");
46
+ return `${segment({ alg: "none" })}.${segment({ sub, email })}.unsigned`;
47
+ }
48
+
49
+ /** The composition's own verifier (the cloud's shape) — see the module header. */
50
+ export const fakeVerifier: IdentityVerifier = {
51
+ name: "fake-composition-verifier",
52
+ verify: (token) => {
53
+ const segments = token.split(".");
54
+ if (segments.length !== 3 || segments[2] !== "unsigned") {
55
+ return Promise.resolve(null);
56
+ }
57
+ const claims = JSON.parse(
58
+ Buffer.from(segments[1] ?? "", "base64url").toString("utf8"),
59
+ ) as { sub?: unknown; email?: unknown };
60
+ if (typeof claims.sub !== "string") {
61
+ return Promise.resolve(null);
62
+ }
63
+ const identity: CallerIdentity = {
64
+ identityId: claims.sub,
65
+ callerClass: "user",
66
+ issuer: "",
67
+ rawToken: token,
68
+ ...(typeof claims.email === "string" ? { email: claims.email } : {}),
69
+ };
70
+ return Promise.resolve(identity);
71
+ },
72
+ };
73
+
74
+ export function bearer(token: string): Interceptor {
75
+ return (next) => (request) => {
76
+ request.header.set("authorization", `Bearer ${token}`);
77
+ return next(request);
78
+ };
79
+ }
80
+
81
+ /** A gRPC transport to the composed server on `port`, presenting `token` when given. */
82
+ export function transportFor(port: number, token?: string): Transport {
83
+ return createGrpcTransport({
84
+ baseUrl: `http://127.0.0.1:${port}`,
85
+ interceptors: token !== undefined ? [bearer(token)] : [],
86
+ });
87
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Pins the built-in authorization POSTURE end to end (20260913.01,
3
+ * T01_1_review.md Q-OR-6): `builtInAuthorization = extensions.authorizer
4
+ * === undefined`, named once in compose.ts. The open-source membership
5
+ * rules and the built-in role lifecycle exist to feed the built-in
6
+ * authorizer; a composition that registers its OWN Authorizer has its own
7
+ * onboarding (the cloud's invitations, its personal-organization step, its
8
+ * tuple driver) and must get neither of them — otherwise every cloud
9
+ * organization create would write a second, unasked-for `owner` row and
10
+ * every first sign-in would hand out roles the cloud never granted.
11
+ *
12
+ * The NEGATIVE proof, over a composed server in the cloud's own shape (a
13
+ * declared require-authentication posture, the unit's verifier, the unit's
14
+ * Authorizer, no lifecycle driver): an organization created by a signed-in
15
+ * subject leaves NO policy row; the creator's first provisioning writes
16
+ * none (no ownership heal); a second subject's first provisioning writes
17
+ * none (no membership). Green before the domain lands and green after by
18
+ * design: its value is at the slice that wires compose.ts, where an
19
+ * unconditional install turns it red. Every negative assertion sits beside
20
+ * a positive one (the create succeeded, the accounts exist, the unit's
21
+ * Authorizer WAS consulted) so the file cannot pass vacuously.
22
+ *
23
+ * The POSITIVE proof of the WIRING (slice 4), through real boots on both
24
+ * open-source postures:
25
+ * - OIDC with no Authorizer (the unit's verifier alone): the creator
26
+ * founds an organization idp-shaped → no row yet; provisions → one
27
+ * `owner` row, stamped by the account; founds a SECOND organization
28
+ * after provisioning (the verifier still stamps the raw subject) → its
29
+ * owner row lands through the subject read of `accountForCaller`
30
+ * (Q-S4-1); a newcomer provisions → one `member` row per organization;
31
+ * provisions again → nothing new;
32
+ * - trusted-local: an organization created over the wire is owned by the
33
+ * operator's account; a second boot on the same database writes
34
+ * nothing (Q-S4-3); an owner row deleted underneath is written back at
35
+ * the next boot (Q-OR-6c).
36
+ * The positive proof of the RPCs over these rows is
37
+ * domain/iampolicy/__tests__/iampolicy.test.ts (slice 5).
38
+ */
39
+ import { mkdtempSync, rmSync } from "node:fs";
40
+ import { tmpdir } from "node:os";
41
+ import path from "node:path";
42
+
43
+ import { fromBinary } from "@bufbuild/protobuf";
44
+ import { createClient } from "@connectrpc/connect";
45
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
46
+
47
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
48
+ import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
49
+ import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
50
+ import { IdentityAccountCommandController } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/command_pb";
51
+ import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
52
+ import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
53
+
54
+ import { loadConfig } from "../../boot/config.js";
55
+ import { composeServer } from "../../boot/compose.js";
56
+ import type { ComposedServer } from "../../boot/compose.js";
57
+ import { policyIdFor } from "../../domain/iampolicy/constants.js";
58
+ import { orgRole } from "../../domain/iampolicy/__tests__/support.js";
59
+ import {
60
+ accountIdFor,
61
+ localIdpIdFor,
62
+ } from "../../domain/identityaccount/constants.js";
63
+ import {
64
+ resetOperatorIdentityForTests,
65
+ setOperatorIdentity,
66
+ } from "../../pipeline/steps/defaults.js";
67
+ import type { Authorizer } from "../authorizer.js";
68
+ import type { ServerExtension } from "../registry.js";
69
+ import {
70
+ baseConfig,
71
+ fakeJwt,
72
+ fakeVerifier,
73
+ silentLogger,
74
+ transportFor,
75
+ } from "./composed-support.js";
76
+
77
+ const CREATOR = "fake|creator";
78
+ const NEWCOMER = "fake|newcomer";
79
+
80
+ /** The policy rows a composed server's store holds, decoded — the one observation every describe below is about. */
81
+ async function policiesIn(server: ComposedServer): Promise<IamPolicy[]> {
82
+ const rows = await server.store.listResources(ApiResourceKind.iam_policy);
83
+ return rows.map((row) => fromBinary(IamPolicySchema, row));
84
+ }
85
+
86
+ /** `<relation>@<organization>` for every row `accountId` holds, sorted. */
87
+ function rolesHeldBy(
88
+ policies: ReadonlyArray<IamPolicy>,
89
+ accountId: string,
90
+ ): ReadonlyArray<string> {
91
+ return policies
92
+ .filter((p) => p.spec?.principal?.id === accountId)
93
+ .map((p) => `${p.spec?.relation}@${p.spec?.resource?.id}`)
94
+ .sort();
95
+ }
96
+
97
+ function organizationInput(slug: string) {
98
+ return {
99
+ apiVersion: "tenancy.stigmer.ai/v1",
100
+ kind: "Organization",
101
+ metadata: { name: slug, slug, org: "" },
102
+ spec: { description: slug },
103
+ };
104
+ }
105
+
106
+ describe("iam-policy posture (composed server, a unit's own Authorizer: nothing built in is installed)", () => {
107
+ let dir: string;
108
+ let server: ComposedServer;
109
+ let port: number;
110
+ let authorizerCalls = 0;
111
+
112
+ /** Allows everything and counts — the proof the posture switch saw a registered Authorizer. */
113
+ const permissiveAuthorizer: Authorizer = {
114
+ authorize: () => {
115
+ authorizerCalls += 1;
116
+ return Promise.resolve({ kind: "allow" });
117
+ },
118
+ };
119
+
120
+ beforeAll(async () => {
121
+ dir = mkdtempSync(path.join(tmpdir(), "iam-policy-composed-"));
122
+ const unit: ServerExtension = {
123
+ name: "fake-iam",
124
+ requireAuthentication: true,
125
+ identityVerifiers: [fakeVerifier],
126
+ authorizer: permissiveAuthorizer,
127
+ };
128
+ server = await composeServer({
129
+ config: loadConfig(baseConfig(dir)),
130
+ logger: silentLogger,
131
+ extensions: [unit],
132
+ portOverride: 0,
133
+ host: "127.0.0.1",
134
+ });
135
+ port = await server.start();
136
+ });
137
+
138
+ afterAll(async () => {
139
+ await server.shutdown();
140
+ rmSync(dir, { recursive: true, force: true });
141
+ });
142
+
143
+ /** The policy rows the composed server's store holds — the one observation the posture is about. */
144
+ async function policyRows(): Promise<ReadonlyArray<string>> {
145
+ const rows = await server.store.listResources(ApiResourceKind.iam_policy);
146
+ return rows.map(
147
+ (row) => fromBinary(IamPolicySchema, row).metadata?.id ?? "",
148
+ );
149
+ }
150
+
151
+ it("an organization created by a signed-in subject leaves no policy row — the built-in role lifecycle is not installed", async () => {
152
+ const asCreator = transportFor(
153
+ port,
154
+ fakeJwt(CREATOR, "creator@example.com"),
155
+ );
156
+ const created = await createClient(
157
+ OrganizationCommandController,
158
+ asCreator,
159
+ ).create({
160
+ apiVersion: "tenancy.stigmer.ai/v1",
161
+ kind: "Organization",
162
+ metadata: { name: "posture-org", slug: "posture-org", org: "" },
163
+ spec: { description: "created under a unit's own Authorizer" },
164
+ });
165
+ // Organization create is `is_skip_authorization` by annotation (anyone
166
+ // may found one), so the proof that the posture switch saw a registered
167
+ // Authorizer is the read that follows: `get` is annotated can_view.
168
+ await createClient(OrganizationQueryController, asCreator).get({
169
+ value: "posture-org",
170
+ });
171
+
172
+ expect(created.metadata?.id, "the create itself succeeded").toBe(
173
+ "posture-org",
174
+ );
175
+ expect(
176
+ authorizerCalls,
177
+ "the unit's Authorizer was consulted",
178
+ ).toBeGreaterThan(0);
179
+ expect(await policyRows()).toEqual([]);
180
+ });
181
+
182
+ it("the creator's first provisioning writes no row — the membership rules' ownership heal is not installed", async () => {
183
+ const account = await createClient(
184
+ IdentityAccountCommandController,
185
+ transportFor(port, fakeJwt(CREATOR, "creator@example.com")),
186
+ ).provisionMyAccount({});
187
+
188
+ expect(account.metadata?.id).toBe(accountIdFor(CREATOR));
189
+ expect(await policyRows()).toEqual([]);
190
+ });
191
+
192
+ it("a newcomer's first provisioning writes no row — no membership is handed out", async () => {
193
+ const account = await createClient(
194
+ IdentityAccountCommandController,
195
+ transportFor(port, fakeJwt(NEWCOMER, "newcomer@example.com")),
196
+ ).provisionMyAccount({});
197
+
198
+ expect(account.metadata?.id).toBe(accountIdFor(NEWCOMER));
199
+ expect(await policyRows()).toEqual([]);
200
+ });
201
+ });
202
+
203
+ describe("built-in authorization posture (composed server, OIDC with no Authorizer: both rules are installed)", () => {
204
+ let dir: string;
205
+ let server: ComposedServer;
206
+ let port: number;
207
+ const creatorId = accountIdFor(CREATOR);
208
+ const newcomerId = accountIdFor(NEWCOMER);
209
+
210
+ beforeAll(async () => {
211
+ dir = mkdtempSync(path.join(tmpdir(), "iam-policy-built-in-oidc-"));
212
+ // The unit vouches for tokens and declares the posture, and registers
213
+ // NO Authorizer — the open-source OIDC self-host's shape.
214
+ const unit: ServerExtension = {
215
+ name: "fake-oidc-only",
216
+ requireAuthentication: true,
217
+ identityVerifiers: [fakeVerifier],
218
+ };
219
+ server = await composeServer({
220
+ config: loadConfig(baseConfig(dir)),
221
+ logger: silentLogger,
222
+ extensions: [unit],
223
+ portOverride: 0,
224
+ host: "127.0.0.1",
225
+ });
226
+ port = await server.start();
227
+ });
228
+
229
+ afterAll(async () => {
230
+ await server.shutdown();
231
+ rmSync(dir, { recursive: true, force: true });
232
+ });
233
+
234
+ it("an organization founded idp-shaped, before any account exists, leaves no row yet", async () => {
235
+ const created = await createClient(
236
+ OrganizationCommandController,
237
+ transportFor(port, fakeJwt(CREATOR, "creator@example.com")),
238
+ ).create(organizationInput("founded-before"));
239
+
240
+ expect(created.metadata?.id).toBe("founded-before");
241
+ expect(await policiesIn(server)).toEqual([]);
242
+ });
243
+
244
+ it("the founder's first provisioning writes exactly one owner row, stamped by the account", async () => {
245
+ const account = await createClient(
246
+ IdentityAccountCommandController,
247
+ transportFor(port, fakeJwt(CREATOR, "creator@example.com")),
248
+ ).provisionMyAccount({});
249
+ expect(account.metadata?.id).toBe(creatorId);
250
+
251
+ const policies = await policiesIn(server);
252
+ expect(rolesHeldBy(policies, creatorId)).toEqual(["owner@founded-before"]);
253
+ expect(policies).toHaveLength(1);
254
+ expect(policies[0]?.metadata?.id).toBe(
255
+ policyIdFor(orgRole(creatorId, "owner", "founded-before")),
256
+ );
257
+ expect(policies[0]?.status?.audit?.specAudit?.createdBy?.id).toBe(
258
+ creatorId,
259
+ );
260
+ });
261
+
262
+ it("an organization founded AFTER provisioning is owned through the subject read — the verifier still stamps the raw subject", async () => {
263
+ await createClient(
264
+ OrganizationCommandController,
265
+ transportFor(port, fakeJwt(CREATOR, "creator@example.com")),
266
+ ).create(organizationInput("founded-after"));
267
+
268
+ const policies = await policiesIn(server);
269
+ expect(rolesHeldBy(policies, creatorId)).toEqual([
270
+ "owner@founded-after",
271
+ "owner@founded-before",
272
+ ]);
273
+ expect(
274
+ policies.find((p) => p.spec?.resource?.id === "founded-after")?.status
275
+ ?.audit?.specAudit?.createdBy?.id,
276
+ ).toBe(creatorId);
277
+ });
278
+
279
+ it("a newcomer's first provisioning makes them a member of every organization; provisioning again writes nothing", async () => {
280
+ const asNewcomer = transportFor(
281
+ port,
282
+ fakeJwt(NEWCOMER, "newcomer@example.com"),
283
+ );
284
+ const accounts = createClient(IdentityAccountCommandController, asNewcomer);
285
+
286
+ await accounts.provisionMyAccount({});
287
+ const after = (await policiesIn(server)).map((p) => p.metadata?.id).sort();
288
+ await accounts.provisionMyAccount({});
289
+
290
+ const policies = await policiesIn(server);
291
+ expect(rolesHeldBy(policies, newcomerId)).toEqual([
292
+ "member@founded-after",
293
+ "member@founded-before",
294
+ ]);
295
+ expect(policies.map((p) => p.metadata?.id).sort()).toEqual(after);
296
+ });
297
+ });
298
+
299
+ describe("built-in authorization posture (composed server, trusted-local: the operator owns what the laptop has)", () => {
300
+ const OPERATOR_EMAIL = "operator@example.com";
301
+ const operatorId = accountIdFor(localIdpIdFor(OPERATOR_EMAIL));
302
+ let dir: string;
303
+ let server: ComposedServer;
304
+ let port: number;
305
+
306
+ /** One boot on the describe's directory — the same database every time. */
307
+ async function boot(): Promise<void> {
308
+ server = await composeServer({
309
+ config: loadConfig({
310
+ ...baseConfig(dir),
311
+ STIGMER_OPERATOR_EMAIL: OPERATOR_EMAIL,
312
+ STIGMER_OPERATOR_NAME: "The Operator",
313
+ }),
314
+ logger: silentLogger,
315
+ portOverride: 0,
316
+ host: "127.0.0.1",
317
+ });
318
+ port = await server.start();
319
+ }
320
+
321
+ beforeAll(async () => {
322
+ dir = mkdtempSync(path.join(tmpdir(), "iam-policy-built-in-local-"));
323
+ setOperatorIdentity(OPERATOR_EMAIL, "The Operator");
324
+ await boot();
325
+ });
326
+
327
+ afterAll(async () => {
328
+ await server.shutdown();
329
+ resetOperatorIdentityForTests();
330
+ rmSync(dir, { recursive: true, force: true });
331
+ });
332
+
333
+ it("an organization created over the wire is owned by the operator's account — the caller carried an email, the row names the account", async () => {
334
+ await createClient(
335
+ OrganizationCommandController,
336
+ transportFor(port),
337
+ ).create(organizationInput("laptop-org"));
338
+
339
+ const policies = await policiesIn(server);
340
+ expect(rolesHeldBy(policies, operatorId)).toEqual(["owner@laptop-org"]);
341
+ expect(policies[0]?.status?.audit?.specAudit?.createdBy?.id).toBe(
342
+ operatorId,
343
+ );
344
+ });
345
+
346
+ it("a second boot on the same database writes nothing — the organization already has an owner", async () => {
347
+ const before = (await policiesIn(server)).map((p) => p.metadata?.id);
348
+ await server.shutdown();
349
+
350
+ await boot();
351
+
352
+ expect((await policiesIn(server)).map((p) => p.metadata?.id)).toEqual(
353
+ before,
354
+ );
355
+ });
356
+
357
+ it("an owner row deleted underneath the server is written back at the next boot", async () => {
358
+ const [row] = await policiesIn(server);
359
+ expect(row?.metadata?.id).toBeDefined();
360
+ await server.store.deleteResource(
361
+ ApiResourceKind.iam_policy,
362
+ row?.metadata?.id ?? "",
363
+ );
364
+ expect(await policiesIn(server)).toEqual([]);
365
+ await server.shutdown();
366
+
367
+ await boot();
368
+
369
+ expect(rolesHeldBy(await policiesIn(server), operatorId)).toEqual([
370
+ "owner@laptop-org",
371
+ ]);
372
+ });
373
+ });