@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.
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +103 -9
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/iampolicy/access-lists.d.ts +27 -0
- package/dist/domain/iampolicy/access-lists.d.ts.map +1 -0
- package/dist/domain/iampolicy/access-lists.js +124 -0
- package/dist/domain/iampolicy/access-lists.js.map +1 -0
- package/dist/domain/iampolicy/constants.d.ts +135 -0
- package/dist/domain/iampolicy/constants.d.ts.map +1 -0
- package/dist/domain/iampolicy/constants.js +203 -0
- package/dist/domain/iampolicy/constants.js.map +1 -0
- package/dist/domain/iampolicy/controller.d.ts +29 -0
- package/dist/domain/iampolicy/controller.d.ts.map +1 -0
- package/dist/domain/iampolicy/controller.js +451 -0
- package/dist/domain/iampolicy/controller.js.map +1 -0
- package/dist/domain/iampolicy/display-resolver.d.ts +6 -0
- package/dist/domain/iampolicy/display-resolver.d.ts.map +1 -0
- package/dist/domain/iampolicy/display-resolver.js +90 -0
- package/dist/domain/iampolicy/display-resolver.js.map +1 -0
- package/dist/domain/iampolicy/grant-path.d.ts +42 -0
- package/dist/domain/iampolicy/grant-path.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-path.js +183 -0
- package/dist/domain/iampolicy/grant-path.js.map +1 -0
- package/dist/domain/iampolicy/grant-scope.d.ts +4 -0
- package/dist/domain/iampolicy/grant-scope.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-scope.js +34 -0
- package/dist/domain/iampolicy/grant-scope.js.map +1 -0
- package/dist/domain/iampolicy/membership.d.ts +111 -0
- package/dist/domain/iampolicy/membership.d.ts.map +1 -0
- package/dist/domain/iampolicy/membership.js +152 -0
- package/dist/domain/iampolicy/membership.js.map +1 -0
- package/dist/domain/iampolicy/permissions.d.ts +19 -0
- package/dist/domain/iampolicy/permissions.d.ts.map +1 -0
- package/dist/domain/iampolicy/permissions.js +27 -0
- package/dist/domain/iampolicy/permissions.js.map +1 -0
- package/dist/domain/iampolicy/resource-store.d.ts +4 -0
- package/dist/domain/iampolicy/resource-store.d.ts.map +1 -0
- package/dist/domain/iampolicy/resource-store.js +135 -0
- package/dist/domain/iampolicy/resource-store.js.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts +9 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.js +106 -0
- package/dist/domain/iampolicy/role-lifecycle.js.map +1 -0
- package/dist/domain/iampolicy/roles.d.ts +11 -0
- package/dist/domain/iampolicy/roles.d.ts.map +1 -0
- package/dist/domain/iampolicy/roles.js +78 -0
- package/dist/domain/iampolicy/roles.js.map +1 -0
- package/dist/domain/iampolicy/specs.d.ts +7 -0
- package/dist/domain/iampolicy/specs.d.ts.map +1 -0
- package/dist/domain/iampolicy/specs.js +45 -0
- package/dist/domain/iampolicy/specs.js.map +1 -0
- package/dist/domain/iampolicy/steps.d.ts +18 -0
- package/dist/domain/iampolicy/steps.d.ts.map +1 -0
- package/dist/domain/iampolicy/steps.js +117 -0
- package/dist/domain/iampolicy/steps.js.map +1 -0
- package/dist/domain/iampolicy/store-contract.d.ts +8 -0
- package/dist/domain/iampolicy/store-contract.d.ts.map +1 -0
- package/dist/domain/iampolicy/store-contract.js +258 -0
- package/dist/domain/iampolicy/store-contract.js.map +1 -0
- package/dist/domain/iampolicy/store.d.ts +78 -0
- package/dist/domain/iampolicy/store.d.ts.map +1 -0
- package/dist/domain/iampolicy/store.js +13 -0
- package/dist/domain/iampolicy/store.js.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts +25 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.js +76 -0
- package/dist/domain/iampolicy/wire-refusals.js.map +1 -0
- package/dist/domain/identityaccount/constants.d.ts +33 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -1
- package/dist/domain/identityaccount/constants.js +2 -48
- package/dist/domain/identityaccount/constants.js.map +1 -1
- package/dist/domain/identityaccount/controller.d.ts +8 -1
- package/dist/domain/identityaccount/controller.d.ts.map +1 -1
- package/dist/domain/identityaccount/controller.js +54 -29
- package/dist/domain/identityaccount/controller.js.map +1 -1
- package/dist/domain/identityaccount/operator.d.ts.map +1 -1
- package/dist/domain/identityaccount/operator.js +2 -4
- package/dist/domain/identityaccount/operator.js.map +1 -1
- package/dist/domain/identityaccount/provisioning.d.ts +20 -1
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
- package/dist/domain/identityaccount/provisioning.js +17 -3
- package/dist/domain/identityaccount/provisioning.js.map +1 -1
- package/dist/domain/identityaccount/resolve.d.ts +23 -0
- package/dist/domain/identityaccount/resolve.d.ts.map +1 -1
- package/dist/domain/identityaccount/resolve.js +17 -0
- package/dist/domain/identityaccount/resolve.js.map +1 -1
- package/dist/domain/identityaccount/store-contract.d.ts +4 -18
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
- package/dist/domain/identityaccount/store-contract.js +12 -47
- package/dist/domain/identityaccount/store-contract.js.map +1 -1
- package/dist/extensions/authorization-queries.d.ts +81 -0
- package/dist/extensions/authorization-queries.d.ts.map +1 -0
- package/dist/extensions/authorization-queries.js +2 -0
- package/dist/extensions/authorization-queries.js.map +1 -0
- package/dist/extensions/drivers.d.ts +49 -2
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/identity.d.ts +14 -0
- package/dist/extensions/identity.d.ts.map +1 -1
- package/dist/extensions/identity.js +18 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/extensions/policy-grant-scope.d.ts +50 -0
- package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
- package/dist/extensions/policy-grant-scope.js +2 -0
- package/dist/extensions/policy-grant-scope.js.map +1 -0
- package/dist/extensions/registry.d.ts +23 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +30 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/extensions/resource-authorization.d.ts +63 -0
- package/dist/extensions/resource-authorization.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/apiresource-meta.d.ts +56 -1
- package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
- package/dist/pipeline/apiresource-meta.js +155 -1
- package/dist/pipeline/apiresource-meta.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +12 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +13 -1
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/dist/pipeline/steps/authorize.d.ts +20 -5
- package/dist/pipeline/steps/authorize.d.ts.map +1 -1
- package/dist/pipeline/steps/authorize.js +37 -17
- package/dist/pipeline/steps/authorize.js.map +1 -1
- package/dist/pipeline/steps/defaults.d.ts +10 -0
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +39 -0
- package/dist/pipeline/steps/defaults.js.map +1 -1
- package/dist/store/port-contract.d.ts +61 -0
- package/dist/store/port-contract.d.ts.map +1 -0
- package/dist/store/port-contract.js +67 -0
- package/dist/store/port-contract.js.map +1 -0
- package/package.json +4 -4
- package/src/boot/compose.ts +107 -10
- package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
- package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
- package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
- package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
- package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
- package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
- package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
- package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
- package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
- package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
- package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
- package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
- package/src/domain/iampolicy/__tests__/support.ts +195 -0
- package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
- package/src/domain/iampolicy/access-lists.ts +191 -0
- package/src/domain/iampolicy/constants.ts +243 -0
- package/src/domain/iampolicy/controller.ts +798 -0
- package/src/domain/iampolicy/display-resolver.ts +123 -0
- package/src/domain/iampolicy/grant-path.ts +270 -0
- package/src/domain/iampolicy/grant-scope.ts +36 -0
- package/src/domain/iampolicy/membership.ts +298 -0
- package/src/domain/iampolicy/permissions.ts +35 -0
- package/src/domain/iampolicy/resource-store.ts +186 -0
- package/src/domain/iampolicy/role-lifecycle.ts +132 -0
- package/src/domain/iampolicy/roles.ts +95 -0
- package/src/domain/iampolicy/specs.ts +53 -0
- package/src/domain/iampolicy/steps.ts +178 -0
- package/src/domain/iampolicy/store-contract.ts +450 -0
- package/src/domain/iampolicy/store.ts +103 -0
- package/src/domain/iampolicy/wire-refusals.ts +110 -0
- package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
- package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
- package/src/domain/identityaccount/constants.ts +16 -31
- package/src/domain/identityaccount/controller.ts +65 -35
- package/src/domain/identityaccount/operator.ts +5 -5
- package/src/domain/identityaccount/provisioning.ts +43 -5
- package/src/domain/identityaccount/resolve.ts +43 -0
- package/src/domain/identityaccount/store-contract.ts +22 -70
- package/src/extensions/__tests__/composed-support.ts +87 -0
- package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
- package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
- package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
- package/src/extensions/__tests__/identity.test.ts +62 -0
- package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
- package/src/extensions/authorization-queries.ts +97 -0
- package/src/extensions/drivers.ts +49 -2
- package/src/extensions/identity.ts +21 -0
- package/src/extensions/policy-grant-scope.ts +50 -0
- package/src/extensions/registry.ts +62 -1
- package/src/extensions/resource-authorization.ts +65 -0
- package/src/index.ts +20 -0
- package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
- package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
- package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
- package/src/pipeline/apiresource-meta.ts +181 -1
- package/src/pipeline/interceptors/auth.ts +14 -1
- package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
- package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
- package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
- package/src/pipeline/steps/authorize.ts +46 -21
- package/src/pipeline/steps/defaults.ts +42 -0
- package/src/store/__tests__/port-contract.test.ts +123 -0
- 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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
/**
|
|
60
|
-
export
|
|
61
|
-
|
|
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
|
|
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<
|
|
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
|
|
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
|
+
});
|