@stigmer/server 3.24.0 → 3.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authorization/authorizer.d.ts +10 -4
- package/dist/authorization/authorizer.d.ts.map +1 -1
- package/dist/authorization/authorizer.js +11 -4
- package/dist/authorization/authorizer.js.map +1 -1
- package/dist/authorization/model/index.d.ts +4 -4
- package/dist/authorization/model/index.d.ts.map +1 -1
- package/dist/authorization/model/index.js +2 -0
- package/dist/authorization/model/index.js.map +1 -1
- package/dist/authorization/model/oauth_app.d.ts.map +1 -1
- package/dist/authorization/model/oauth_app.js +3 -2
- package/dist/authorization/model/oauth_app.js.map +1 -1
- package/dist/authorization/model/platform_client.d.ts +2 -0
- package/dist/authorization/model/platform_client.d.ts.map +1 -0
- package/dist/authorization/model/platform_client.js +31 -0
- package/dist/authorization/model/platform_client.js.map +1 -0
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +88 -5
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/route-shadowing.d.ts +25 -0
- package/dist/boot/route-shadowing.d.ts.map +1 -0
- package/dist/boot/route-shadowing.js +17 -0
- package/dist/boot/route-shadowing.js.map +1 -0
- package/dist/domain/iampolicy/membership.d.ts +6 -1
- package/dist/domain/iampolicy/membership.d.ts.map +1 -1
- package/dist/domain/iampolicy/membership.js +8 -2
- package/dist/domain/iampolicy/membership.js.map +1 -1
- package/dist/domain/identityaccount/constants.d.ts +13 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -1
- package/dist/domain/identityaccount/constants.js +17 -0
- package/dist/domain/identityaccount/constants.js.map +1 -1
- package/dist/domain/identityaccount/controller.d.ts +3 -1
- package/dist/domain/identityaccount/controller.d.ts.map +1 -1
- package/dist/domain/identityaccount/controller.js +15 -6
- 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 +1 -0
- package/dist/domain/identityaccount/operator.js.map +1 -1
- package/dist/domain/identityaccount/provisioning.d.ts +18 -0
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
- package/dist/domain/identityaccount/provisioning.js +1 -0
- package/dist/domain/identityaccount/provisioning.js.map +1 -1
- package/dist/domain/identityaccount/resource-store.js +9 -5
- package/dist/domain/identityaccount/resource-store.js.map +1 -1
- package/dist/domain/identityaccount/steps.d.ts +11 -2
- package/dist/domain/identityaccount/steps.d.ts.map +1 -1
- package/dist/domain/identityaccount/steps.js +42 -4
- package/dist/domain/identityaccount/steps.js.map +1 -1
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
- package/dist/domain/identityaccount/store-contract.js +33 -0
- package/dist/domain/identityaccount/store-contract.js.map +1 -1
- package/dist/domain/identityaccount/store.d.ts +7 -5
- package/dist/domain/identityaccount/store.d.ts.map +1 -1
- package/dist/domain/identityaccount/store.js.map +1 -1
- package/dist/domain/platform/controller.d.ts +10 -0
- package/dist/domain/platform/controller.d.ts.map +1 -1
- package/dist/domain/platform/controller.js +2 -1
- package/dist/domain/platform/controller.js.map +1 -1
- package/dist/domain/platformclient/constants.d.ts +60 -0
- package/dist/domain/platformclient/constants.d.ts.map +1 -0
- package/dist/domain/platformclient/constants.js +78 -0
- package/dist/domain/platformclient/constants.js.map +1 -0
- package/dist/domain/platformclient/controller.d.ts +59 -0
- package/dist/domain/platformclient/controller.d.ts.map +1 -0
- package/dist/domain/platformclient/controller.js +191 -0
- package/dist/domain/platformclient/controller.js.map +1 -0
- package/dist/domain/platformclient/credentials.d.ts +13 -0
- package/dist/domain/platformclient/credentials.d.ts.map +1 -0
- package/dist/domain/platformclient/credentials.js +56 -0
- package/dist/domain/platformclient/credentials.js.map +1 -0
- package/dist/domain/platformclient/mint.d.ts +23 -0
- package/dist/domain/platformclient/mint.d.ts.map +1 -0
- package/dist/domain/platformclient/mint.js +254 -0
- package/dist/domain/platformclient/mint.js.map +1 -0
- package/dist/domain/platformclient/origin-guard.d.ts +9 -0
- package/dist/domain/platformclient/origin-guard.d.ts.map +1 -0
- package/dist/domain/platformclient/origin-guard.js +81 -0
- package/dist/domain/platformclient/origin-guard.js.map +1 -0
- package/dist/domain/platformclient/resource-store.d.ts +4 -0
- package/dist/domain/platformclient/resource-store.d.ts.map +1 -0
- package/dist/domain/platformclient/resource-store.js +117 -0
- package/dist/domain/platformclient/resource-store.js.map +1 -0
- package/dist/domain/platformclient/steps.d.ts +51 -0
- package/dist/domain/platformclient/steps.d.ts.map +1 -0
- package/dist/domain/platformclient/steps.js +339 -0
- package/dist/domain/platformclient/steps.js.map +1 -0
- package/dist/domain/platformclient/store-contract.d.ts +8 -0
- package/dist/domain/platformclient/store-contract.d.ts.map +1 -0
- package/dist/domain/platformclient/store-contract.js +208 -0
- package/dist/domain/platformclient/store-contract.js.map +1 -0
- package/dist/domain/platformclient/store.d.ts +58 -0
- package/dist/domain/platformclient/store.d.ts.map +1 -0
- package/dist/domain/platformclient/store.js +13 -0
- package/dist/domain/platformclient/store.js.map +1 -0
- package/dist/domain/platformclient/system-managed.d.ts +18 -0
- package/dist/domain/platformclient/system-managed.d.ts.map +1 -0
- package/dist/domain/platformclient/system-managed.js +80 -0
- package/dist/domain/platformclient/system-managed.js.map +1 -0
- package/dist/domain/platformclient/token-controller.d.ts +11 -0
- package/dist/domain/platformclient/token-controller.d.ts.map +1 -0
- package/dist/domain/platformclient/token-controller.js +32 -0
- package/dist/domain/platformclient/token-controller.js.map +1 -0
- package/dist/domain/platformclient/verifier.d.ts +10 -0
- package/dist/domain/platformclient/verifier.d.ts.map +1 -0
- package/dist/domain/platformclient/verifier.js +76 -0
- package/dist/domain/platformclient/verifier.js.map +1 -0
- package/dist/encryption/key-manager.d.ts +20 -3
- package/dist/encryption/key-manager.d.ts.map +1 -1
- package/dist/encryption/key-manager.js +64 -42
- package/dist/encryption/key-manager.js.map +1 -1
- package/dist/extensions/caller-guards.d.ts +8 -3
- package/dist/extensions/caller-guards.d.ts.map +1 -1
- package/dist/extensions/drivers.d.ts +37 -0
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/guest-token-minting.d.ts +26 -0
- package/dist/extensions/guest-token-minting.d.ts.map +1 -0
- package/dist/extensions/guest-token-minting.js +2 -0
- package/dist/extensions/guest-token-minting.js.map +1 -0
- package/dist/extensions/identity.d.ts +6 -4
- package/dist/extensions/identity.d.ts.map +1 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/extensions/registry.d.ts +30 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +34 -1
- package/dist/extensions/registry.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +14 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +24 -0
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/dist/pipeline/steps/build-update-state.d.ts +11 -1
- package/dist/pipeline/steps/build-update-state.d.ts.map +1 -1
- package/dist/pipeline/steps/build-update-state.js +5 -2
- package/dist/pipeline/steps/build-update-state.js.map +1 -1
- package/dist/platformtoken/envelope.d.ts +100 -0
- package/dist/platformtoken/envelope.d.ts.map +1 -0
- package/dist/platformtoken/envelope.js +184 -0
- package/dist/platformtoken/envelope.js.map +1 -0
- package/dist/platformtoken/key-ring.d.ts +85 -0
- package/dist/platformtoken/key-ring.d.ts.map +1 -0
- package/dist/platformtoken/key-ring.js +164 -0
- package/dist/platformtoken/key-ring.js.map +1 -0
- package/package.json +6 -6
- package/src/authorization/README.md +1 -1
- package/src/authorization/__tests__/authorizer.test.ts +6 -2
- package/src/authorization/__tests__/wire-permissions.test.ts +1 -0
- package/src/authorization/authorizer.ts +11 -4
- package/src/authorization/model/__tests__/registry.test.ts +8 -1
- package/src/authorization/model/index.ts +6 -4
- package/src/authorization/model/oauth_app.ts +3 -2
- package/src/authorization/model/platform_client.ts +43 -0
- package/src/boot/__tests__/auth-enabled-composition.test.ts +2 -1
- package/src/boot/__tests__/route-shadowing.test.ts +67 -0
- package/src/boot/compose.ts +90 -5
- package/src/boot/route-shadowing.ts +47 -0
- package/src/domain/iampolicy/__tests__/membership.test.ts +7 -0
- package/src/domain/iampolicy/membership.ts +8 -2
- package/src/domain/identityaccount/__tests__/platform-client-accounts.test.ts +124 -0
- package/src/domain/identityaccount/__tests__/resource-store.test.ts +1 -0
- package/src/domain/identityaccount/constants.ts +20 -0
- package/src/domain/identityaccount/controller.ts +16 -4
- package/src/domain/identityaccount/operator.ts +1 -0
- package/src/domain/identityaccount/provisioning.ts +17 -0
- package/src/domain/identityaccount/resource-store.ts +9 -5
- package/src/domain/identityaccount/steps.ts +58 -3
- package/src/domain/identityaccount/store-contract.ts +51 -0
- package/src/domain/identityaccount/store.ts +7 -5
- package/src/domain/platform/__tests__/platform.test.ts +7 -1
- package/src/domain/platform/controller.ts +12 -1
- package/src/domain/platformclient/__tests__/controller.test.ts +397 -0
- package/src/domain/platformclient/__tests__/credentials.test.ts +46 -0
- package/src/domain/platformclient/__tests__/mint.test.ts +415 -0
- package/src/domain/platformclient/__tests__/resource-store.test.ts +140 -0
- package/src/domain/platformclient/__tests__/system-managed.test.ts +101 -0
- package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +207 -0
- package/src/domain/platformclient/constants.ts +120 -0
- package/src/domain/platformclient/controller.ts +449 -0
- package/src/domain/platformclient/credentials.ts +68 -0
- package/src/domain/platformclient/mint.ts +412 -0
- package/src/domain/platformclient/origin-guard.ts +117 -0
- package/src/domain/platformclient/resource-store.ts +150 -0
- package/src/domain/platformclient/steps.ts +472 -0
- package/src/domain/platformclient/store-contract.ts +318 -0
- package/src/domain/platformclient/store.ts +63 -0
- package/src/domain/platformclient/system-managed.ts +115 -0
- package/src/domain/platformclient/token-controller.ts +49 -0
- package/src/domain/platformclient/verifier.ts +106 -0
- package/src/encryption/key-manager.ts +87 -44
- package/src/extensions/__tests__/extension-composition.test.ts +3 -2
- package/src/extensions/__tests__/platform-client-composed.test.ts +288 -0
- package/src/extensions/__tests__/registry.test.ts +4 -1
- package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
- package/src/extensions/caller-guards.ts +8 -3
- package/src/extensions/drivers.ts +37 -0
- package/src/extensions/guest-token-minting.ts +32 -0
- package/src/extensions/identity.ts +6 -4
- package/src/extensions/registry.ts +77 -3
- package/src/index.ts +55 -0
- package/src/pipeline/interceptors/auth.ts +27 -0
- package/src/pipeline/steps/__tests__/references.test.ts +2 -0
- package/src/pipeline/steps/build-update-state.ts +5 -2
- package/src/platformtoken/__tests__/envelope.test.ts +277 -0
- package/src/platformtoken/__tests__/key-ring.test.ts +205 -0
- package/src/platformtoken/envelope.ts +276 -0
- package/src/platformtoken/key-ring.ts +266 -0
- package/src/runnerauth/__tests__/runner-subject-composed.test.ts +9 -3
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the PlatformClient mint over fakes of its four ports, so every arm is
|
|
3
|
+
* asserted without a server:
|
|
4
|
+
* - no ring (a server that trusts every request) and a verify-only ring
|
|
5
|
+
* refuse FAILED_PRECONDITION before any credential is read;
|
|
6
|
+
* - an unknown client_id and a wrong secret answer one UNAUTHENTICATED
|
|
7
|
+
* copy; an expired secret refuses, an unset or never-expiring one mints;
|
|
8
|
+
* an org_id other than the owning organization refuses;
|
|
9
|
+
* - an existing platform-client account is reused with no grant and no
|
|
10
|
+
* write; an account under the subject in any other mode is refused;
|
|
11
|
+
* - a client that does not provision refuses an unknown user;
|
|
12
|
+
* - provisioning GRANTS FIRST on the derived account id, then creates the
|
|
13
|
+
* account as the client with the cloud's shape; a failed grant writes
|
|
14
|
+
* nothing; a failed create leaves the grant, and the next mint completes
|
|
15
|
+
* the account; a concurrent winner is read back;
|
|
16
|
+
* - the default role is viewer, owner is refused, and the token carries
|
|
17
|
+
* the cloud's claim set for the account.
|
|
18
|
+
*/
|
|
19
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
22
|
+
import { create } from "@bufbuild/protobuf";
|
|
23
|
+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
24
|
+
import { describe, expect, it } from "vitest";
|
|
25
|
+
|
|
26
|
+
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
27
|
+
import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
28
|
+
import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
|
|
29
|
+
import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
30
|
+
import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
|
|
31
|
+
import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
32
|
+
import { PlatformClientSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
33
|
+
import { MintUserTokenRequestSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
|
|
34
|
+
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
35
|
+
|
|
36
|
+
import type { CallerIdentity } from "../../../extensions/identity.js";
|
|
37
|
+
import { silentLogger } from "../../../extensions/__tests__/composed-support.js";
|
|
38
|
+
import { verifyPlatformToken } from "../../../platformtoken/envelope.js";
|
|
39
|
+
import { platformTokenKeyRingFromPem } from "../../../platformtoken/key-ring.js";
|
|
40
|
+
import type { PlatformTokenKeyRing } from "../../../platformtoken/key-ring.js";
|
|
41
|
+
import type { IamPolicyGrantPath } from "../../iampolicy/grant-path.js";
|
|
42
|
+
import { accountIdFor } from "../../identityaccount/constants.js";
|
|
43
|
+
import type { CreateAccountInput } from "../../identityaccount/provisioning.js";
|
|
44
|
+
import {
|
|
45
|
+
EXPIRED_CLIENT_SECRET_MESSAGE,
|
|
46
|
+
INVALID_CLIENT_CREDENTIALS_MESSAGE,
|
|
47
|
+
MINTING_DISABLED_MESSAGE,
|
|
48
|
+
MINTING_REQUIRES_AUTHENTICATION_MESSAGE,
|
|
49
|
+
PROVISIONING_FAILED_MESSAGE,
|
|
50
|
+
foreignAccountMessage,
|
|
51
|
+
noAccountMessage,
|
|
52
|
+
organizationMismatchMessage,
|
|
53
|
+
} from "../constants.js";
|
|
54
|
+
import { hashClientSecret } from "../credentials.js";
|
|
55
|
+
import { mintUserToken, platformClientSubject } from "../mint.js";
|
|
56
|
+
import type { PlatformClientMintDeps } from "../mint.js";
|
|
57
|
+
|
|
58
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
|
|
59
|
+
modulusLength: 2048,
|
|
60
|
+
});
|
|
61
|
+
const PUBLIC_PEM = publicKey.export({ format: "pem", type: "spki" }).toString();
|
|
62
|
+
const RING = platformTokenKeyRingFromPem({
|
|
63
|
+
privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
|
64
|
+
publicKeyPems: [PUBLIC_PEM],
|
|
65
|
+
});
|
|
66
|
+
const NOW = new Date("2026-09-23T10:00:00Z");
|
|
67
|
+
const SECRET = "stgm_cs_the-secret";
|
|
68
|
+
|
|
69
|
+
function platformClient(
|
|
70
|
+
spec: Partial<{
|
|
71
|
+
autoProvisionAccounts: boolean;
|
|
72
|
+
autoGrantOnOrg: boolean;
|
|
73
|
+
autoGrantRole: IamRole;
|
|
74
|
+
neverExpires: boolean;
|
|
75
|
+
expiresAt: Date;
|
|
76
|
+
}> = {},
|
|
77
|
+
): PlatformClient {
|
|
78
|
+
return create(PlatformClientSchema, {
|
|
79
|
+
metadata: { id: "pcl_dashboard", org: "acme", slug: "dashboard" },
|
|
80
|
+
spec: {
|
|
81
|
+
clientId: "stgm_cid_dashboard",
|
|
82
|
+
clientSecretHash: hashClientSecret(SECRET),
|
|
83
|
+
autoProvisionAccounts: spec.autoProvisionAccounts ?? true,
|
|
84
|
+
autoGrantOnOrg: spec.autoGrantOnOrg ?? false,
|
|
85
|
+
autoGrantRole: spec.autoGrantRole ?? IamRole.iam_role_unspecified,
|
|
86
|
+
neverExpires: spec.neverExpires ?? false,
|
|
87
|
+
...(spec.expiresAt !== undefined
|
|
88
|
+
? { expiresAt: timestampFromDate(spec.expiresAt) }
|
|
89
|
+
: {}),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Harness {
|
|
95
|
+
readonly deps: PlatformClientMintDeps;
|
|
96
|
+
readonly events: string[];
|
|
97
|
+
readonly created: Array<{
|
|
98
|
+
input: CreateAccountInput;
|
|
99
|
+
caller: CallerIdentity;
|
|
100
|
+
}>;
|
|
101
|
+
readonly granted: Array<{ spec: IamPolicySpec; caller: CallerIdentity }>;
|
|
102
|
+
readonly accounts: Map<string, IdentityAccount>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function harness(
|
|
106
|
+
options: {
|
|
107
|
+
client?: PlatformClient;
|
|
108
|
+
keys?: PlatformTokenKeyRing | undefined;
|
|
109
|
+
failGrant?: boolean;
|
|
110
|
+
failCreate?: "fault" | "lost-race";
|
|
111
|
+
} = {},
|
|
112
|
+
): Harness {
|
|
113
|
+
const client = options.client ?? platformClient();
|
|
114
|
+
const events: string[] = [];
|
|
115
|
+
const created: Harness["created"] = [];
|
|
116
|
+
const granted: Harness["granted"] = [];
|
|
117
|
+
const accounts = new Map<string, IdentityAccount>();
|
|
118
|
+
const grantPath: Pick<IamPolicyGrantPath, "grant"> = {
|
|
119
|
+
async grant(spec, caller) {
|
|
120
|
+
events.push("grant");
|
|
121
|
+
if (options.failGrant === true) throw new Error("policy store down");
|
|
122
|
+
granted.push({ spec, caller });
|
|
123
|
+
return { policy: create(IamPolicySchema), duplicate: false };
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const deps: PlatformClientMintDeps = {
|
|
127
|
+
clients: {
|
|
128
|
+
save: async () => {},
|
|
129
|
+
update: async () => {},
|
|
130
|
+
deleteById: async () => {},
|
|
131
|
+
findById: async () => client,
|
|
132
|
+
findByClientId: async (clientId) =>
|
|
133
|
+
clientId === client.spec?.clientId ? client : undefined,
|
|
134
|
+
findByOrgAndSlug: async () => undefined,
|
|
135
|
+
findByOrg: async () => [],
|
|
136
|
+
},
|
|
137
|
+
accounts: { findByIdpId: async (idpId) => accounts.get(idpId) },
|
|
138
|
+
createAccount: async (input, caller) => {
|
|
139
|
+
events.push("create");
|
|
140
|
+
const account = create(IdentityAccountSchema, {
|
|
141
|
+
metadata: { id: accountIdFor(input.spec.idpId), name: input.name },
|
|
142
|
+
spec: {
|
|
143
|
+
...input.spec,
|
|
144
|
+
provisioningMode: IdentityAccountProvisioningMode.platform_client,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
if (options.failCreate === "fault") throw new Error("account store down");
|
|
148
|
+
if (options.failCreate === "lost-race") {
|
|
149
|
+
accounts.set(input.spec.idpId, account);
|
|
150
|
+
throw new ConnectError("already exists", Code.AlreadyExists);
|
|
151
|
+
}
|
|
152
|
+
created.push({ input, caller });
|
|
153
|
+
accounts.set(input.spec.idpId, account);
|
|
154
|
+
return account;
|
|
155
|
+
},
|
|
156
|
+
grantPath,
|
|
157
|
+
keys: "keys" in options ? options.keys : RING,
|
|
158
|
+
logger: silentLogger,
|
|
159
|
+
now: () => NOW,
|
|
160
|
+
};
|
|
161
|
+
return { deps, events, created, granted, accounts };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function request(
|
|
165
|
+
overrides: Partial<{
|
|
166
|
+
clientSecret: string;
|
|
167
|
+
clientId: string;
|
|
168
|
+
userId: string;
|
|
169
|
+
orgId: string;
|
|
170
|
+
userEmail: string;
|
|
171
|
+
userName: string;
|
|
172
|
+
}> = {},
|
|
173
|
+
) {
|
|
174
|
+
return create(MintUserTokenRequestSchema, {
|
|
175
|
+
clientId: overrides.clientId ?? "stgm_cid_dashboard",
|
|
176
|
+
clientSecret: overrides.clientSecret ?? SECRET,
|
|
177
|
+
userId: overrides.userId ?? "user-7",
|
|
178
|
+
userEmail: overrides.userEmail ?? "pat@example.com",
|
|
179
|
+
userName: overrides.userName ?? "Pat Lee",
|
|
180
|
+
orgId: overrides.orgId ?? "",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function refusal(promise: Promise<unknown>): Promise<ConnectError> {
|
|
185
|
+
try {
|
|
186
|
+
await promise;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof ConnectError) return error;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
throw new Error("expected a ConnectError refusal");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const SUBJECT = platformClientSubject("acme", "user-7");
|
|
195
|
+
|
|
196
|
+
describe("mintUserToken — the server and the client", () => {
|
|
197
|
+
it("refuses without a ring and with a verify-only ring, before reading any credential", async () => {
|
|
198
|
+
const unposed = await refusal(
|
|
199
|
+
mintUserToken(harness({ keys: undefined }).deps, request()),
|
|
200
|
+
);
|
|
201
|
+
expect(unposed.code).toBe(Code.FailedPrecondition);
|
|
202
|
+
expect(unposed.rawMessage).toBe(MINTING_REQUIRES_AUTHENTICATION_MESSAGE);
|
|
203
|
+
|
|
204
|
+
const verifyOnly = platformTokenKeyRingFromPem({
|
|
205
|
+
publicKeyPems: [PUBLIC_PEM],
|
|
206
|
+
});
|
|
207
|
+
const disabled = await refusal(
|
|
208
|
+
mintUserToken(harness({ keys: verifyOnly }).deps, request()),
|
|
209
|
+
);
|
|
210
|
+
expect(disabled.code).toBe(Code.FailedPrecondition);
|
|
211
|
+
expect(disabled.rawMessage).toBe(MINTING_DISABLED_MESSAGE);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("answers an unknown client_id and a wrong secret with one UNAUTHENTICATED copy", async () => {
|
|
215
|
+
for (const bad of [
|
|
216
|
+
request({ clientId: "stgm_cid_unknown" }),
|
|
217
|
+
request({ clientSecret: "stgm_cs_wrong" }),
|
|
218
|
+
]) {
|
|
219
|
+
const denied = await refusal(mintUserToken(harness().deps, bad));
|
|
220
|
+
expect(denied.code).toBe(Code.Unauthenticated);
|
|
221
|
+
expect(denied.rawMessage).toBe(INVALID_CLIENT_CREDENTIALS_MESSAGE);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("refuses an expired secret; an unset or never-expiring one mints", async () => {
|
|
226
|
+
const past = new Date(NOW.getTime() - 1000);
|
|
227
|
+
const expired = await refusal(
|
|
228
|
+
mintUserToken(
|
|
229
|
+
harness({ client: platformClient({ expiresAt: past }) }).deps,
|
|
230
|
+
request(),
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
expect(expired.code).toBe(Code.FailedPrecondition);
|
|
234
|
+
expect(expired.rawMessage).toBe(EXPIRED_CLIENT_SECRET_MESSAGE);
|
|
235
|
+
|
|
236
|
+
await mintUserToken(harness({ client: platformClient() }).deps, request());
|
|
237
|
+
await mintUserToken(
|
|
238
|
+
harness({
|
|
239
|
+
client: platformClient({ expiresAt: past, neverExpires: true }),
|
|
240
|
+
}).deps,
|
|
241
|
+
request(),
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("refuses an org_id other than the owning organization, and accepts it or empty", async () => {
|
|
246
|
+
const other = await refusal(
|
|
247
|
+
mintUserToken(harness().deps, request({ orgId: "globex" })),
|
|
248
|
+
);
|
|
249
|
+
expect(other.code).toBe(Code.InvalidArgument);
|
|
250
|
+
expect(other.rawMessage).toBe(organizationMismatchMessage("acme"));
|
|
251
|
+
await mintUserToken(harness().deps, request({ orgId: "acme" }));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("refuses a user_id carrying the separator", async () => {
|
|
255
|
+
const denied = await refusal(
|
|
256
|
+
mintUserToken(harness().deps, request({ userId: "a|b" })),
|
|
257
|
+
);
|
|
258
|
+
expect(denied.code).toBe(Code.InvalidArgument);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("mintUserToken — the user", () => {
|
|
263
|
+
it("reuses an existing platform-client account with no grant and no write", async () => {
|
|
264
|
+
const h = harness({ client: platformClient({ autoGrantOnOrg: true }) });
|
|
265
|
+
h.accounts.set(
|
|
266
|
+
SUBJECT,
|
|
267
|
+
create(IdentityAccountSchema, {
|
|
268
|
+
metadata: { id: "ida_existing" },
|
|
269
|
+
spec: {
|
|
270
|
+
idpId: SUBJECT,
|
|
271
|
+
provisioningMode: IdentityAccountProvisioningMode.platform_client,
|
|
272
|
+
},
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
const minted = await mintUserToken(h.deps, request());
|
|
276
|
+
expect(h.events).toEqual([]);
|
|
277
|
+
const verified = verifyPlatformToken(RING, minted.accessToken, NOW);
|
|
278
|
+
expect(verified.outcome === "verified" && verified.token.subject).toBe(
|
|
279
|
+
"ida_existing",
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("refuses an account under the subject that no platform client provisioned", async () => {
|
|
284
|
+
const h = harness();
|
|
285
|
+
h.accounts.set(
|
|
286
|
+
SUBJECT,
|
|
287
|
+
create(IdentityAccountSchema, {
|
|
288
|
+
metadata: { id: "ida_direct" },
|
|
289
|
+
spec: {
|
|
290
|
+
idpId: SUBJECT,
|
|
291
|
+
provisioningMode: IdentityAccountProvisioningMode.direct,
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
const denied = await refusal(mintUserToken(h.deps, request()));
|
|
296
|
+
expect(denied.code).toBe(Code.FailedPrecondition);
|
|
297
|
+
expect(denied.rawMessage).toBe(foreignAccountMessage("user-7", "acme"));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("refuses an unknown user when the client does not provision", async () => {
|
|
301
|
+
const h = harness({
|
|
302
|
+
client: platformClient({ autoProvisionAccounts: false }),
|
|
303
|
+
});
|
|
304
|
+
const denied = await refusal(mintUserToken(h.deps, request()));
|
|
305
|
+
expect(denied.code).toBe(Code.FailedPrecondition);
|
|
306
|
+
expect(denied.rawMessage).toBe(noAccountMessage("user-7", "acme"));
|
|
307
|
+
expect(h.events).toEqual([]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("grants first on the derived id, then creates the account as the client with the cloud's shape", async () => {
|
|
311
|
+
const h = harness({
|
|
312
|
+
client: platformClient({
|
|
313
|
+
autoGrantOnOrg: true,
|
|
314
|
+
autoGrantRole: IamRole.member,
|
|
315
|
+
}),
|
|
316
|
+
});
|
|
317
|
+
const minted = await mintUserToken(h.deps, request());
|
|
318
|
+
|
|
319
|
+
expect(h.events).toEqual(["grant", "create"]);
|
|
320
|
+
const grant = h.granted[0];
|
|
321
|
+
expect(grant?.spec.principal?.id).toBe(accountIdFor(SUBJECT));
|
|
322
|
+
expect(grant?.spec.relation).toBe("member");
|
|
323
|
+
expect(grant?.spec.resource?.id).toBe("acme");
|
|
324
|
+
expect(grant?.caller).toEqual({
|
|
325
|
+
identityId: "pcl_dashboard",
|
|
326
|
+
callerClass: "internal",
|
|
327
|
+
issuer: "",
|
|
328
|
+
rawToken: "",
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const creation = h.created[0];
|
|
332
|
+
expect(creation?.caller.identityId).toBe("pcl_dashboard");
|
|
333
|
+
expect(creation?.input.provisioning).toEqual({
|
|
334
|
+
mode: "platform_client",
|
|
335
|
+
org: "acme",
|
|
336
|
+
});
|
|
337
|
+
expect(creation?.input.name).toBe("pat@example.com");
|
|
338
|
+
expect(creation?.input.spec).toMatchObject({
|
|
339
|
+
idpId: SUBJECT,
|
|
340
|
+
email: "pat@example.com",
|
|
341
|
+
firstName: "Pat",
|
|
342
|
+
lastName: "Lee",
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
expect(minted.tokenType).toBe("Bearer");
|
|
346
|
+
expect(minted.expiresIn).toBe(900);
|
|
347
|
+
const verified = verifyPlatformToken(RING, minted.accessToken, NOW);
|
|
348
|
+
expect(verified.outcome).toBe("verified");
|
|
349
|
+
if (verified.outcome !== "verified") return;
|
|
350
|
+
expect(verified.token.payload).toMatchObject({
|
|
351
|
+
sub: accountIdFor(SUBJECT),
|
|
352
|
+
ext_user_id: "user-7",
|
|
353
|
+
email: "pat@example.com",
|
|
354
|
+
name: "Pat Lee",
|
|
355
|
+
org: "acme",
|
|
356
|
+
platform_client_id: "pcl_dashboard",
|
|
357
|
+
});
|
|
358
|
+
expect(verified.token.tokenType).toBeUndefined();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("grants viewer when the role is unspecified, and refuses an owner auto-grant", async () => {
|
|
362
|
+
const h = harness({ client: platformClient({ autoGrantOnOrg: true }) });
|
|
363
|
+
await mintUserToken(h.deps, request());
|
|
364
|
+
expect(h.granted[0]?.spec.relation).toBe("viewer");
|
|
365
|
+
|
|
366
|
+
const owner = harness({
|
|
367
|
+
client: platformClient({
|
|
368
|
+
autoGrantOnOrg: true,
|
|
369
|
+
autoGrantRole: IamRole.owner,
|
|
370
|
+
}),
|
|
371
|
+
});
|
|
372
|
+
const denied = await refusal(mintUserToken(owner.deps, request()));
|
|
373
|
+
expect(denied.code).toBe(Code.InvalidArgument);
|
|
374
|
+
expect(owner.events).toEqual([]);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("writes nothing when the grant fails, and says the request is safe to retry", async () => {
|
|
378
|
+
const h = harness({
|
|
379
|
+
client: platformClient({ autoGrantOnOrg: true }),
|
|
380
|
+
failGrant: true,
|
|
381
|
+
});
|
|
382
|
+
const failed = await refusal(mintUserToken(h.deps, request()));
|
|
383
|
+
expect(failed.code).toBe(Code.Internal);
|
|
384
|
+
expect(failed.rawMessage).toBe(PROVISIONING_FAILED_MESSAGE);
|
|
385
|
+
expect(h.events).toEqual(["grant"]);
|
|
386
|
+
expect(h.accounts.size).toBe(0);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("leaves the grant when the create fails, and the next mint completes the account", async () => {
|
|
390
|
+
const failing = harness({
|
|
391
|
+
client: platformClient({ autoGrantOnOrg: true }),
|
|
392
|
+
failCreate: "fault",
|
|
393
|
+
});
|
|
394
|
+
await expect(mintUserToken(failing.deps, request())).rejects.toThrow(
|
|
395
|
+
"account store down",
|
|
396
|
+
);
|
|
397
|
+
expect(failing.events).toEqual(["grant", "create"]);
|
|
398
|
+
|
|
399
|
+
const retry = harness({ client: platformClient({ autoGrantOnOrg: true }) });
|
|
400
|
+
await mintUserToken(retry.deps, request());
|
|
401
|
+
expect(retry.granted[0]?.spec.principal?.id).toBe(
|
|
402
|
+
failing.granted[0]?.spec.principal?.id,
|
|
403
|
+
);
|
|
404
|
+
expect(retry.created).toHaveLength(1);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("reads a concurrent winner back when the create loses the race", async () => {
|
|
408
|
+
const h = harness({ failCreate: "lost-race" });
|
|
409
|
+
const minted = await mintUserToken(h.deps, request());
|
|
410
|
+
const verified = verifyPlatformToken(RING, minted.accessToken, NOW);
|
|
411
|
+
expect(verified.outcome === "verified" && verified.token.subject).toBe(
|
|
412
|
+
accountIdFor(SUBJECT),
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the PlatformClientStore port-contract kit (../store-contract.ts) over
|
|
3
|
+
* the OSS adapter (../resource-store.ts) on both drivers through the
|
|
4
|
+
* drivers' own fixtures (sqlite always; Postgres under TEST_DATABASE_URL),
|
|
5
|
+
* and pins the one behavior that is the OSS adapter's rather than the
|
|
6
|
+
* port's: `findByOrgAndSlug` is an EXACT pair — an empty organization is no
|
|
7
|
+
* wildcard, unlike the shared slug helper, so a slug lookup can never cross
|
|
8
|
+
* into another organization.
|
|
9
|
+
*
|
|
10
|
+
* The kit's case list is pinned by name here so a case cannot drop out of
|
|
11
|
+
* the kit unnoticed: the cloud driver's test iterates the same export and
|
|
12
|
+
* would silently prove less.
|
|
13
|
+
*/
|
|
14
|
+
import { create } from "@bufbuild/protobuf";
|
|
15
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
18
|
+
import { PlatformClientSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
19
|
+
|
|
20
|
+
import type { Store } from "../../../store/interface.js";
|
|
21
|
+
import { PostgresStore } from "../../../store/postgres/store.js";
|
|
22
|
+
import {
|
|
23
|
+
createTestDatabase,
|
|
24
|
+
testDatabaseAdminUrl,
|
|
25
|
+
type TestDatabase,
|
|
26
|
+
} from "../../../store/postgres/__tests__/support.js";
|
|
27
|
+
import { tempStore } from "../../../store/sqlite/__tests__/support.js";
|
|
28
|
+
import { newResourcePlatformClientStore } from "../resource-store.js";
|
|
29
|
+
import type { PlatformClientStoreContractFixture } from "../store-contract.js";
|
|
30
|
+
import { platformClientStoreContract } from "../store-contract.js";
|
|
31
|
+
|
|
32
|
+
/** The kit's contract lines, pinned: a dropped or renamed case is a visible diff here. */
|
|
33
|
+
const CONTRACT_CASE_NAMES = [
|
|
34
|
+
"save then findById, findByClientId and findByOrgAndSlug answer the saved client",
|
|
35
|
+
"lookups of a client that is not held answer undefined",
|
|
36
|
+
"save under a held id raises DuplicatePlatformClientError",
|
|
37
|
+
"save under a held (org, slug) raises DuplicatePlatformClientError",
|
|
38
|
+
"save under a held client_id raises DuplicatePlatformClientError",
|
|
39
|
+
"the same slug in another organization is a different client",
|
|
40
|
+
"update replaces the row in place",
|
|
41
|
+
"update of an unknown id writes nothing",
|
|
42
|
+
"deleteById frees the id, the client_id and the slug; deleting an unknown id resolves",
|
|
43
|
+
"findByOrg answers every client of the organization and no other",
|
|
44
|
+
"a disconnected store is an infrastructure fault, never 'not found'",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
interface OpenedStore {
|
|
48
|
+
readonly store: Store;
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface DriverFixture {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly skip: boolean;
|
|
55
|
+
open(): Promise<OpenedStore>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sqliteFixture: DriverFixture = {
|
|
59
|
+
name: "sqlite",
|
|
60
|
+
skip: false,
|
|
61
|
+
async open() {
|
|
62
|
+
const temp = tempStore();
|
|
63
|
+
return { store: temp.store, close: () => temp.cleanup() };
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
let postgresDatabase: TestDatabase | undefined;
|
|
68
|
+
const postgresFixture: DriverFixture = {
|
|
69
|
+
name: "postgres",
|
|
70
|
+
skip: testDatabaseAdminUrl() === undefined,
|
|
71
|
+
async open() {
|
|
72
|
+
if (postgresDatabase === undefined) {
|
|
73
|
+
postgresDatabase = await createTestDatabase();
|
|
74
|
+
}
|
|
75
|
+
const store = await PostgresStore.open(postgresDatabase.databaseUrl);
|
|
76
|
+
await store.deleteResourcesByKind(ApiResourceKind.platform_client);
|
|
77
|
+
return { store, close: () => store.close() };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
afterAll(async () => {
|
|
82
|
+
await postgresDatabase?.drop();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe.each([sqliteFixture, postgresFixture])(
|
|
86
|
+
"PlatformClientStore over the OSS adapter ($name)",
|
|
87
|
+
(fixture) => {
|
|
88
|
+
describe.skipIf(fixture.skip)("the port-contract kit", () => {
|
|
89
|
+
const cases = platformClientStoreContract(
|
|
90
|
+
async (): Promise<PlatformClientStoreContractFixture> => {
|
|
91
|
+
const opened = await fixture.open();
|
|
92
|
+
return {
|
|
93
|
+
store: newResourcePlatformClientStore(opened.store),
|
|
94
|
+
disconnect: () => opened.store.close(),
|
|
95
|
+
cleanup: () => opened.close(),
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
it("carries every contract line, by name", () => {
|
|
101
|
+
expect(cases.map((contractCase) => contractCase.name)).toEqual(
|
|
102
|
+
CONTRACT_CASE_NAMES,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
for (const contractCase of cases) {
|
|
107
|
+
it(contractCase.name, contractCase.run);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe.skipIf(fixture.skip)("the OSS adapter's own invariants", () => {
|
|
112
|
+
it("findByOrgAndSlug with an empty organization matches nothing, never every organization", async () => {
|
|
113
|
+
const opened = await fixture.open();
|
|
114
|
+
try {
|
|
115
|
+
const clients = newResourcePlatformClientStore(opened.store);
|
|
116
|
+
await clients.save(
|
|
117
|
+
create(PlatformClientSchema, {
|
|
118
|
+
metadata: {
|
|
119
|
+
id: "pcl_acme_dashboard",
|
|
120
|
+
org: "acme",
|
|
121
|
+
slug: "dashboard",
|
|
122
|
+
name: "d",
|
|
123
|
+
},
|
|
124
|
+
spec: { clientId: "stgm_cid_acme_dashboard" },
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
expect(
|
|
128
|
+
await clients.findByOrgAndSlug("", "dashboard"),
|
|
129
|
+
).toBeUndefined();
|
|
130
|
+
expect(await clients.findByOrgAndSlug("acme", "")).toBeUndefined();
|
|
131
|
+
expect(
|
|
132
|
+
(await clients.findByOrgAndSlug("acme", "dashboard"))?.metadata?.id,
|
|
133
|
+
).toBe("pcl_acme_dashboard");
|
|
134
|
+
} finally {
|
|
135
|
+
await opened.close();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the system-managed PlatformClient builder, the one place an edition
|
|
3
|
+
* gets the row it keeps for the platform's own tokens:
|
|
4
|
+
* - the row is the kind's own shape: a `pcl_` id, the owning org, the
|
|
5
|
+
* reserved slug, the kind's default visibility, and the reserved
|
|
6
|
+
* `stigmer.ai/system-managed` label the mutation chains refuse;
|
|
7
|
+
* - it satisfies the contract's protovalidate rules, so a driver that
|
|
8
|
+
* validates on write, or a later chain that reads it, sees a valid row;
|
|
9
|
+
* - its credentials are real and fresh per row (a `client_id` a unique
|
|
10
|
+
* index can hold), while no plaintext secret leaves the builder;
|
|
11
|
+
* - its audit names no actor and both slots carry the one creation
|
|
12
|
+
* instant;
|
|
13
|
+
* - a slug that is not reserved, or an empty organization, is refused
|
|
14
|
+
* loudly, so a system-managed client can never sit where a user's can.
|
|
15
|
+
*/
|
|
16
|
+
import { toJsonString } from "@bufbuild/protobuf";
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
20
|
+
import { PlatformClientSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
21
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
22
|
+
|
|
23
|
+
import { validator } from "../../../pipeline/steps/validation.js";
|
|
24
|
+
import { SYSTEM_SHARE_CLIENT_SLUG } from "../constants.js";
|
|
25
|
+
import { newSystemManagedPlatformClient } from "../system-managed.js";
|
|
26
|
+
|
|
27
|
+
const NOW = new Date("2026-09-23T10:00:00Z");
|
|
28
|
+
|
|
29
|
+
function build() {
|
|
30
|
+
return newSystemManagedPlatformClient({
|
|
31
|
+
org: "acme",
|
|
32
|
+
slug: SYSTEM_SHARE_CLIENT_SLUG,
|
|
33
|
+
name: "System Share Client",
|
|
34
|
+
now: NOW,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("newSystemManagedPlatformClient", () => {
|
|
39
|
+
it("builds the kind's own shape under the reserved slug, labelled system-managed", () => {
|
|
40
|
+
const client = build();
|
|
41
|
+
expect(client.apiVersion).toBe("iam.stigmer.ai/v1");
|
|
42
|
+
expect(client.kind).toBe("PlatformClient");
|
|
43
|
+
expect(client.metadata?.id).toMatch(/^pcl_[0-9a-z]{26}$/);
|
|
44
|
+
expect(client.metadata).toMatchObject({
|
|
45
|
+
org: "acme",
|
|
46
|
+
slug: "system-share-client",
|
|
47
|
+
name: "System Share Client",
|
|
48
|
+
labels: { "stigmer.ai/system-managed": "true" },
|
|
49
|
+
});
|
|
50
|
+
expect(client.metadata?.visibility).not.toBe(
|
|
51
|
+
ApiResourceVisibility.api_resource_visibility_unspecified,
|
|
52
|
+
);
|
|
53
|
+
expect(client.spec?.autoProvisionAccounts).toBe(false);
|
|
54
|
+
expect(client.spec?.autoGrantOnOrg).toBe(false);
|
|
55
|
+
expect(client.spec?.allowedOrigins).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("satisfies the contract's rules", () => {
|
|
59
|
+
expect(validator().validate(PlatformClientSchema, build()).kind).toBe(
|
|
60
|
+
"valid",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("carries real, fresh credentials and no plaintext secret", () => {
|
|
65
|
+
const first = build();
|
|
66
|
+
const second = build();
|
|
67
|
+
expect(first.spec?.clientId).toMatch(/^stgm_cid_[A-Za-z0-9_-]{43}$/);
|
|
68
|
+
expect(first.spec?.clientSecretHash).toHaveLength(43);
|
|
69
|
+
expect(first.spec?.secretFingerprint).toHaveLength(6);
|
|
70
|
+
expect(second.spec?.clientId).not.toBe(first.spec?.clientId);
|
|
71
|
+
expect(second.metadata?.id).not.toBe(first.metadata?.id);
|
|
72
|
+
expect(toJsonString(PlatformClientSchema, first)).not.toContain("stgm_cs_");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("stamps both audit slots with the creation instant and no actor", () => {
|
|
76
|
+
const audit = build().status?.audit;
|
|
77
|
+
for (const slot of [audit?.specAudit, audit?.statusAudit]) {
|
|
78
|
+
expect(slot?.event).toBe("created");
|
|
79
|
+
expect(slot?.createdBy).toBeUndefined();
|
|
80
|
+
expect(slot?.createdAt && timestampDate(slot.createdAt)).toEqual(NOW);
|
|
81
|
+
expect(slot?.updatedAt && timestampDate(slot.updatedAt)).toEqual(NOW);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("refuses a slug that is not reserved, and an empty organization", () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
newSystemManagedPlatformClient({
|
|
88
|
+
org: "acme",
|
|
89
|
+
slug: "dashboard",
|
|
90
|
+
name: "Dashboard",
|
|
91
|
+
}),
|
|
92
|
+
).toThrow(/not a reserved platform-client slug/);
|
|
93
|
+
expect(() =>
|
|
94
|
+
newSystemManagedPlatformClient({
|
|
95
|
+
org: "",
|
|
96
|
+
slug: SYSTEM_SHARE_CLIENT_SLUG,
|
|
97
|
+
name: "System Share Client",
|
|
98
|
+
}),
|
|
99
|
+
).toThrow(/needs an organization/);
|
|
100
|
+
});
|
|
101
|
+
});
|