@wtfalch/foundry 0.70.1 → 0.72.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/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/modules/sign-in.js +81 -9
- package/dist/modules/types.d.ts +2 -0
- package/dist/org.d.ts +18 -1
- package/dist/org.js +40 -0
- package/dist/plan/boule.d.ts +15 -0
- package/dist/plan/boule.js +13 -0
- package/dist/plan/broker-registrar-token.d.ts +69 -0
- package/dist/plan/broker-registrar-token.js +215 -0
- package/dist/plan/broker-registrar.d.ts +2 -0
- package/dist/plan/broker-registrar.js +5 -1
- package/dist/plan/diff.d.ts +13 -0
- package/dist/plan/diff.js +26 -5
- package/dist/plan/executor.d.ts +82 -0
- package/dist/plan/executor.js +172 -10
- package/dist/plan/health-check-settings.d.ts +13 -0
- package/dist/plan/health-check-settings.js +17 -0
- package/dist/plan/own-diff.d.ts +32 -0
- package/dist/plan/own-diff.js +67 -10
- package/dist/plan/own-hostnames.d.ts +8 -0
- package/dist/plan/own-hostnames.js +14 -0
- package/dist/plan/own-services.d.ts +7 -0
- package/dist/plan/own-services.js +2 -0
- package/dist/plan/plan-apply.d.ts +36 -0
- package/dist/plan/plan-apply.js +91 -0
- package/dist/plan/platform-diff.d.ts +9 -5
- package/dist/plan/platform-diff.js +16 -10
- package/dist/plan/platform-providers.d.ts +24 -2
- package/dist/plan/platform-providers.js +155 -12
- package/dist/plan/signin-mail.d.ts +18 -5
- package/dist/plan/signin-mail.js +14 -6
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +72 -46
- package/dist/plan/zitadel-machine-user.d.ts +14 -1
- package/dist/plan/zitadel-machine-user.js +96 -13
- package/dist/platform.d.ts +3 -0
- package/dist/release/fanout.d.ts +77 -0
- package/dist/release/fanout.js +177 -0
- package/dist/release/run.js +2 -1
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +3 -0
- package/dist/release/types.d.ts +9 -1
- package/dist/store/schema.d.ts +4 -0
- package/package.json +3 -3
|
@@ -29,7 +29,22 @@ const SECOND_FACTOR_SETTINGS = {
|
|
|
29
29
|
passwordlessType: 'PASSWORDLESS_TYPE_ALLOWED',
|
|
30
30
|
mfaInitSkipLifetime: '0s',
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* What {@link ZitadelPort.setCustomersLoginPolicy} sets: a superset of what any customer app needs, so one
|
|
34
|
+
* policy serves every app of the customers org. `allowRegister` is not here: `customers.signUp` owns it.
|
|
35
|
+
*/
|
|
36
|
+
const CUSTOMERS_POLICY_SETTINGS = {
|
|
37
|
+
forceMfa: false,
|
|
38
|
+
allowExternalIdp: false,
|
|
39
|
+
allowUsernamePassword: true,
|
|
40
|
+
passwordlessType: 'PASSWORDLESS_TYPE_ALLOWED',
|
|
41
|
+
};
|
|
32
42
|
const SECOND_FACTORS = ['SECOND_FACTOR_TYPE_OTP', 'SECOND_FACTOR_TYPE_U2F'];
|
|
43
|
+
/**
|
|
44
|
+
* The customers policy also keeps the emailed code as a second factor, and never removes it: the email-code
|
|
45
|
+
* session check may need it. The login app narrows the factors it offers an app.
|
|
46
|
+
*/
|
|
47
|
+
const CUSTOMERS_SECOND_FACTORS = [...SECOND_FACTORS, 'SECOND_FACTOR_TYPE_OTP_EMAIL'];
|
|
33
48
|
const MULTI_FACTORS = ['MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION'];
|
|
34
49
|
/** ZITADEL leaves false booleans and zero durations out of a read. */
|
|
35
50
|
const isZeroDuration = (value) => value === undefined || (typeof value === 'string' && /^0+(\.0+)?s$/.test(value));
|
|
@@ -90,56 +105,67 @@ export function createZitadelLoginPolicy(options) {
|
|
|
90
105
|
}
|
|
91
106
|
return settings;
|
|
92
107
|
};
|
|
108
|
+
/**
|
|
109
|
+
* Makes the org's login policy hold `want` and exactly these factor lists, writing nothing when it already
|
|
110
|
+
* does. Any setting not in `want` is copied back as it is. An org on the instance default gets its own
|
|
111
|
+
* policy (POST); a custom one is changed by PUT, then by adding the missing factors before removing the
|
|
112
|
+
* extra ones, so the org is never left with no second factor it needs.
|
|
113
|
+
*/
|
|
114
|
+
async function conform(orgId, want, wantSecond, wantMulti) {
|
|
115
|
+
const data = await call('GET', orgId, undefined);
|
|
116
|
+
const policy = data.policy ?? {};
|
|
117
|
+
const isDefault = data.isDefault === true || policy.isDefault === true;
|
|
118
|
+
const secondFactors = stringList(policy.secondFactors);
|
|
119
|
+
const multiFactors = stringList(policy.multiFactors);
|
|
120
|
+
const settingsDiffer = Object.entries(want).some(([key, value]) => typeof value === 'boolean'
|
|
121
|
+
? (policy[key] === true) !== value
|
|
122
|
+
: value === '0s'
|
|
123
|
+
? !isZeroDuration(policy[key])
|
|
124
|
+
: policy[key] !== value);
|
|
125
|
+
const factorsDiffer = !sameSet(secondFactors, wantSecond) || !sameSet(multiFactors, wantMulti);
|
|
126
|
+
if (!isDefault && !settingsDiffer && !factorsDiffer)
|
|
127
|
+
return { changed: false };
|
|
128
|
+
const settings = { ...settingsOf(policy), ...want };
|
|
129
|
+
if (isDefault) {
|
|
130
|
+
// The org's own policy, written whole: the factor lists go in the same body.
|
|
131
|
+
await call('POST', orgId, {
|
|
132
|
+
...settings,
|
|
133
|
+
secondFactors: wantSecond,
|
|
134
|
+
multiFactors: wantMulti,
|
|
135
|
+
idps: (policy.idps ?? []).map((idp) => ({
|
|
136
|
+
idpId: idp.idpId,
|
|
137
|
+
ownerType: 'IDP_OWNER_TYPE_SYSTEM',
|
|
138
|
+
})),
|
|
139
|
+
});
|
|
140
|
+
return { changed: true };
|
|
141
|
+
}
|
|
142
|
+
if (settingsDiffer)
|
|
143
|
+
await call('PUT', orgId, settings);
|
|
144
|
+
const factorPath = '/management/v1/policies/login';
|
|
145
|
+
for (const type of wantSecond.filter((t) => !secondFactors.includes(t))) {
|
|
146
|
+
await call('POST', orgId, { type }, `${factorPath}/second_factors`);
|
|
147
|
+
}
|
|
148
|
+
for (const type of wantMulti.filter((t) => !multiFactors.includes(t))) {
|
|
149
|
+
await call('POST', orgId, { type }, `${factorPath}/multi_factors`);
|
|
150
|
+
}
|
|
151
|
+
for (const type of secondFactors.filter((t) => !wantSecond.includes(t))) {
|
|
152
|
+
await call('DELETE', orgId, undefined, `${factorPath}/second_factors/${encodeURIComponent(type)}`);
|
|
153
|
+
}
|
|
154
|
+
for (const type of multiFactors.filter((t) => !wantMulti.includes(t))) {
|
|
155
|
+
await call('DELETE', orgId, undefined, `${factorPath}/multi_factors/${encodeURIComponent(type)}`);
|
|
156
|
+
}
|
|
157
|
+
return { changed: true };
|
|
158
|
+
}
|
|
93
159
|
return {
|
|
94
160
|
async enforceSecondFactor(orgId) {
|
|
95
161
|
if (!orgId)
|
|
96
162
|
throw new Error('enforceSecondFactor: orgId is required');
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const settingsDiffer = (policy.forceMfa === true) !== want.forceMfa ||
|
|
104
|
-
(policy.allowRegister === true) !== want.allowRegister ||
|
|
105
|
-
(policy.allowExternalIdp === true) !== want.allowExternalIdp ||
|
|
106
|
-
(policy.allowUsernamePassword === true) !== want.allowUsernamePassword ||
|
|
107
|
-
policy.passwordlessType !== want.passwordlessType ||
|
|
108
|
-
!isZeroDuration(policy.mfaInitSkipLifetime);
|
|
109
|
-
const factorsDiffer = !sameSet(secondFactors, SECOND_FACTORS) || !sameSet(multiFactors, MULTI_FACTORS);
|
|
110
|
-
if (!isDefault && !settingsDiffer && !factorsDiffer)
|
|
111
|
-
return { changed: false };
|
|
112
|
-
const settings = { ...settingsOf(policy), ...want };
|
|
113
|
-
if (isDefault) {
|
|
114
|
-
// The org's own policy, written whole: the factor lists go in the same body.
|
|
115
|
-
await call('POST', orgId, {
|
|
116
|
-
...settings,
|
|
117
|
-
secondFactors: SECOND_FACTORS,
|
|
118
|
-
multiFactors: MULTI_FACTORS,
|
|
119
|
-
idps: (policy.idps ?? []).map((idp) => ({
|
|
120
|
-
idpId: idp.idpId,
|
|
121
|
-
ownerType: 'IDP_OWNER_TYPE_SYSTEM',
|
|
122
|
-
})),
|
|
123
|
-
});
|
|
124
|
-
return { changed: true };
|
|
125
|
-
}
|
|
126
|
-
if (settingsDiffer)
|
|
127
|
-
await call('PUT', orgId, settings);
|
|
128
|
-
// Add before remove, so the org is never left with no second factor.
|
|
129
|
-
const factorPath = '/management/v1/policies/login';
|
|
130
|
-
for (const type of SECOND_FACTORS.filter((t) => !secondFactors.includes(t))) {
|
|
131
|
-
await call('POST', orgId, { type }, `${factorPath}/second_factors`);
|
|
132
|
-
}
|
|
133
|
-
for (const type of MULTI_FACTORS.filter((t) => !multiFactors.includes(t))) {
|
|
134
|
-
await call('POST', orgId, { type }, `${factorPath}/multi_factors`);
|
|
135
|
-
}
|
|
136
|
-
for (const type of secondFactors.filter((t) => !SECOND_FACTORS.includes(t))) {
|
|
137
|
-
await call('DELETE', orgId, undefined, `${factorPath}/second_factors/${encodeURIComponent(type)}`);
|
|
138
|
-
}
|
|
139
|
-
for (const type of multiFactors.filter((t) => !MULTI_FACTORS.includes(t))) {
|
|
140
|
-
await call('DELETE', orgId, undefined, `${factorPath}/multi_factors/${encodeURIComponent(type)}`);
|
|
141
|
-
}
|
|
142
|
-
return { changed: true };
|
|
163
|
+
return conform(orgId, SECOND_FACTOR_SETTINGS, SECOND_FACTORS, MULTI_FACTORS);
|
|
164
|
+
},
|
|
165
|
+
async setCustomersLoginPolicy(orgId) {
|
|
166
|
+
if (!orgId)
|
|
167
|
+
throw new Error('setCustomersLoginPolicy: orgId is required');
|
|
168
|
+
return conform(orgId, CUSTOMERS_POLICY_SETTINGS, CUSTOMERS_SECOND_FACTORS, MULTI_FACTORS);
|
|
143
169
|
},
|
|
144
170
|
async closeSelfRegistration(orgId) {
|
|
145
171
|
if (!orgId)
|
|
@@ -78,6 +78,13 @@ export declare class ZitadelLoginClientRoleConflictError extends Error {
|
|
|
78
78
|
}
|
|
79
79
|
/** The read-only organisation role a directory reader holds. It lists the organisation's users and changes nothing. */
|
|
80
80
|
export declare const ORG_DIRECTORY_READER_ROLE = "ORG_OWNER_VIEWER";
|
|
81
|
+
/**
|
|
82
|
+
* Boule removes a customer with this credential (boule#495). ZITADEL's narrowest built-in organisation role
|
|
83
|
+
* that holds `user.delete` is `ORG_USER_MANAGER`; it cannot be cut down to delete alone (it also lets the
|
|
84
|
+
* holder change users in that organisation). It is granted in the customers organisation only, never in the
|
|
85
|
+
* company's.
|
|
86
|
+
*/
|
|
87
|
+
export declare const ORG_DIRECTORY_USER_MANAGER_ROLE = "ORG_USER_MANAGER";
|
|
81
88
|
/**
|
|
82
89
|
* C3: thrown by `mintOrgDirectoryReader` when the user already exists with an organisation role other
|
|
83
90
|
* than the read-only one. That user was not made by foundry; granting it more or handing out its PAT
|
|
@@ -88,6 +95,12 @@ export declare class ZitadelDirectoryRoleConflictError extends Error {
|
|
|
88
95
|
readonly roles: readonly string[];
|
|
89
96
|
constructor(userId: string, roles: readonly string[]);
|
|
90
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* The organisation role of the broker's registrar user in each organisation it serves: it may make
|
|
100
|
+
* projects there, and a project it makes it owns. No member, user or login-policy rights, which
|
|
101
|
+
* `ORG_OWNER` would add.
|
|
102
|
+
*/
|
|
103
|
+
export declare const REGISTRAR_ROLE = "ORG_PROJECT_CREATOR";
|
|
91
104
|
export interface ZitadelMinterOptions {
|
|
92
105
|
/** The new instance's base URL, e.g. `https://auth.companyincluded.com`. */
|
|
93
106
|
url: string;
|
|
@@ -96,4 +109,4 @@ export interface ZitadelMinterOptions {
|
|
|
96
109
|
fetchImpl?: typeof fetch;
|
|
97
110
|
instanceHost?: string;
|
|
98
111
|
}
|
|
99
|
-
export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'mintOrgDirectoryReader' | 'revokeOrgDirectoryReader'>;
|
|
112
|
+
export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'mintOrgDirectoryReader' | 'revokeOrgDirectoryReader' | 'ensureOrgDirectoryRoles' | 'ensureRegistrarUser' | 'mintRegistrarToken'>;
|
|
@@ -86,6 +86,14 @@ export class ZitadelLoginClientRoleConflictError extends Error {
|
|
|
86
86
|
}
|
|
87
87
|
/** The read-only organisation role a directory reader holds. It lists the organisation's users and changes nothing. */
|
|
88
88
|
export const ORG_DIRECTORY_READER_ROLE = 'ORG_OWNER_VIEWER';
|
|
89
|
+
/**
|
|
90
|
+
* Boule removes a customer with this credential (boule#495). ZITADEL's narrowest built-in organisation role
|
|
91
|
+
* that holds `user.delete` is `ORG_USER_MANAGER`; it cannot be cut down to delete alone (it also lets the
|
|
92
|
+
* holder change users in that organisation). It is granted in the customers organisation only, never in the
|
|
93
|
+
* company's.
|
|
94
|
+
*/
|
|
95
|
+
export const ORG_DIRECTORY_USER_MANAGER_ROLE = 'ORG_USER_MANAGER';
|
|
96
|
+
const ORG_DIRECTORY_ROLES = [ORG_DIRECTORY_READER_ROLE, ORG_DIRECTORY_USER_MANAGER_ROLE];
|
|
89
97
|
/**
|
|
90
98
|
* C3: thrown by `mintOrgDirectoryReader` when the user already exists with an organisation role other
|
|
91
99
|
* than the read-only one. That user was not made by foundry; granting it more or handing out its PAT
|
|
@@ -95,12 +103,20 @@ export class ZitadelDirectoryRoleConflictError extends Error {
|
|
|
95
103
|
userId;
|
|
96
104
|
roles;
|
|
97
105
|
constructor(userId, roles) {
|
|
98
|
-
super(`ZITADEL machine user ${userId} already exists with organisation role(s) [${roles.join(', ') || 'none'}], not only [${
|
|
106
|
+
super(`ZITADEL machine user ${userId} already exists with organisation role(s) [${roles.join(', ') || 'none'}], not only [${ORG_DIRECTORY_ROLES.join(', ')}] -- refusing to reuse it as a directory reader`);
|
|
99
107
|
this.userId = userId;
|
|
100
108
|
this.roles = roles;
|
|
101
109
|
this.name = 'ZitadelDirectoryRoleConflictError';
|
|
102
110
|
}
|
|
103
111
|
}
|
|
112
|
+
/** Each call to ZITADEL gives up after this long. */
|
|
113
|
+
const CALL_TIMEOUT_MS = 30_000;
|
|
114
|
+
/**
|
|
115
|
+
* The organisation role of the broker's registrar user in each organisation it serves: it may make
|
|
116
|
+
* projects there, and a project it makes it owns. No member, user or login-policy rights, which
|
|
117
|
+
* `ORG_OWNER` would add.
|
|
118
|
+
*/
|
|
119
|
+
export const REGISTRAR_ROLE = 'ORG_PROJECT_CREATOR';
|
|
104
120
|
export function createZitadelAdminMinter(options) {
|
|
105
121
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
106
122
|
async function call(method, path, body, orgId) {
|
|
@@ -119,6 +135,7 @@ export function createZitadelAdminMinter(options) {
|
|
|
119
135
|
...(orgId ? { 'x-zitadel-orgid': orgId } : {}),
|
|
120
136
|
},
|
|
121
137
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
138
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
122
139
|
});
|
|
123
140
|
const text = await response.text();
|
|
124
141
|
let data = {};
|
|
@@ -306,21 +323,12 @@ export function createZitadelAdminMinter(options) {
|
|
|
306
323
|
if (!userId)
|
|
307
324
|
throw new Error('ZITADEL did not return the machine user id');
|
|
308
325
|
if (existing) {
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
.filter((m) => m.userId === userId)
|
|
312
|
-
.flatMap((m) => m.roles ?? []);
|
|
313
|
-
if (roles.some((role) => role !== ORG_DIRECTORY_READER_ROLE)) {
|
|
326
|
+
const roles = await memberRoles(orgId, userId);
|
|
327
|
+
if (roles.some((role) => !ORG_DIRECTORY_ROLES.includes(role))) {
|
|
314
328
|
throw new ZitadelDirectoryRoleConflictError(userId, roles);
|
|
315
329
|
}
|
|
316
330
|
}
|
|
317
|
-
|
|
318
|
-
await call('POST', '/management/v1/orgs/me/members', { userId, roles: [ORG_DIRECTORY_READER_ROLE] }, orgId);
|
|
319
|
-
}
|
|
320
|
-
catch (e) {
|
|
321
|
-
if (!(e instanceof ZitadelHttpError && e.status === 409))
|
|
322
|
-
throw e;
|
|
323
|
-
}
|
|
331
|
+
await ensureOrgDirectoryRoles(orgId, userId);
|
|
324
332
|
await revokeAllPats(orgId, userId);
|
|
325
333
|
const pat = await call('POST', `/management/v1/users/${userId}/pats`, {}, orgId);
|
|
326
334
|
const patId = typeof pat.tokenId === 'string' && pat.tokenId !== '' ? pat.tokenId : undefined;
|
|
@@ -329,6 +337,78 @@ export function createZitadelAdminMinter(options) {
|
|
|
329
337
|
}
|
|
330
338
|
return { userId, token: pat.token, patId };
|
|
331
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* The broker's registrar user: a machine user of its own in `orgIds[0]` (found by `userName`, else made)
|
|
342
|
+
* holding {@link REGISTRAR_ROLE} in every org of `orgIds`, each grant added only when the org does not
|
|
343
|
+
* have it yet (a 409 is the same). Mints no token, so it is safe to repeat whenever an org joins the
|
|
344
|
+
* list. Calls follow ZITADEL's published v1 management API and have not been run against a live
|
|
345
|
+
* instance.
|
|
346
|
+
*/
|
|
347
|
+
async function ensureRegistrarUser(orgIds, input) {
|
|
348
|
+
const home = orgIds[0];
|
|
349
|
+
if (!home)
|
|
350
|
+
throw new Error('the registrar user needs at least one organisation');
|
|
351
|
+
const found = await call('POST', '/management/v1/users/_search', {
|
|
352
|
+
queries: [
|
|
353
|
+
{ userNameQuery: { userName: input.userName, method: 'TEXT_QUERY_METHOD_EQUALS' } },
|
|
354
|
+
],
|
|
355
|
+
}, home);
|
|
356
|
+
let userId = found.result?.[0]?.id;
|
|
357
|
+
if (!userId) {
|
|
358
|
+
const created = await call('POST', '/management/v1/users/machine', {
|
|
359
|
+
userName: input.userName,
|
|
360
|
+
name: input.name,
|
|
361
|
+
accessTokenType: 'ACCESS_TOKEN_TYPE_BEARER',
|
|
362
|
+
}, home);
|
|
363
|
+
userId = typeof created.userId === 'string' ? created.userId : undefined;
|
|
364
|
+
}
|
|
365
|
+
if (!userId)
|
|
366
|
+
throw new Error('ZITADEL did not return the machine user id');
|
|
367
|
+
for (const orgId of new Set(orgIds)) {
|
|
368
|
+
try {
|
|
369
|
+
await call('POST', '/management/v1/orgs/me/members', { userId, roles: [REGISTRAR_ROLE] }, orgId);
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
if (!(e instanceof ZitadelHttpError && e.status === 409))
|
|
373
|
+
throw e;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { userId };
|
|
377
|
+
}
|
|
378
|
+
/** A fresh token for the registrar user; every token it holds is revoked first, so one stays live. */
|
|
379
|
+
async function mintRegistrarToken(homeOrgId, userId) {
|
|
380
|
+
await revokeAllPats(homeOrgId, userId);
|
|
381
|
+
const pat = await call('POST', `/management/v1/users/${userId}/pats`, {}, homeOrgId);
|
|
382
|
+
const patId = typeof pat.tokenId === 'string' && pat.tokenId !== '' ? pat.tokenId : undefined;
|
|
383
|
+
if (typeof pat.token !== 'string' || pat.token === '' || !patId) {
|
|
384
|
+
throw new Error(`ZITADEL returned no token for machine user ${userId}`);
|
|
385
|
+
}
|
|
386
|
+
return { token: pat.token, patId };
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* The directory user's roles in one organisation: {@link ORG_DIRECTORY_ROLES}, set whether it is a member
|
|
390
|
+
* yet or not (a 409 on the add means it is, and the roles are then replaced with a `PUT`). Calls follow
|
|
391
|
+
* ZITADEL's published v1 management API and have not been run against a live instance.
|
|
392
|
+
*/
|
|
393
|
+
async function ensureOrgDirectoryRoles(orgId, userId) {
|
|
394
|
+
try {
|
|
395
|
+
await call('POST', '/management/v1/orgs/me/members', { userId, roles: ORG_DIRECTORY_ROLES }, orgId);
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
if (!(e instanceof ZitadelHttpError && e.status === 409))
|
|
399
|
+
throw e;
|
|
400
|
+
// The PUT replaces every role, so the roles a person added stay: ours join the existing set.
|
|
401
|
+
const existing = await memberRoles(orgId, userId);
|
|
402
|
+
await call('PUT', `/management/v1/orgs/me/members/${userId}`, { roles: [...existing, ...ORG_DIRECTORY_ROLES.filter((r) => !existing.includes(r))] }, orgId);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/** The organisation roles one user holds in an organisation. */
|
|
406
|
+
async function memberRoles(orgId, userId) {
|
|
407
|
+
const members = await call('POST', '/management/v1/orgs/me/members/_search', { queries: [{ userIdQuery: { userId } }] }, orgId);
|
|
408
|
+
return (members.result ?? [])
|
|
409
|
+
.filter((m) => m.userId === userId)
|
|
410
|
+
.flatMap((m) => m.roles ?? []);
|
|
411
|
+
}
|
|
332
412
|
/** Deletes every PAT of a user; a PAT already gone (404) is the outcome wanted. */
|
|
333
413
|
async function revokeAllPats(orgId, userId) {
|
|
334
414
|
for (const patId of await listPats(orgId, userId)) {
|
|
@@ -342,7 +422,10 @@ export function createZitadelAdminMinter(options) {
|
|
|
342
422
|
}
|
|
343
423
|
}
|
|
344
424
|
return {
|
|
425
|
+
ensureRegistrarUser,
|
|
426
|
+
mintRegistrarToken,
|
|
345
427
|
mintOrgDirectoryReader,
|
|
428
|
+
ensureOrgDirectoryRoles,
|
|
346
429
|
revokeOrgDirectoryReader: revokeAllPats,
|
|
347
430
|
mintAdminMachineUser: (orgId, input) => mintMachineUser(orgId, input, 'IAM_OWNER').then(({ userId, token }) => ({ userId, token })),
|
|
348
431
|
mintLoginClientMachineUser: (orgId, input) => mintMachineUser(orgId, input, 'IAM_LOGIN_CLIENT'),
|
package/dist/platform.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export interface PlatformArchonService extends PlatformService {
|
|
|
20
20
|
blueGreen?: {
|
|
21
21
|
steps?: number[];
|
|
22
22
|
stepHoldMinutes?: number;
|
|
23
|
+
advance?: 'time' | 'signal' | 'approval';
|
|
24
|
+
/** Archon's switch only: `{ steps: [100], staffFirst: true }` deploys the idle copy and waits for `open`. */
|
|
25
|
+
staffFirst?: boolean;
|
|
23
26
|
};
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type ReleaseStore } from './state.js';
|
|
2
|
+
/** The built-in apps Company Included releases from Archon and then copies to every customer org. */
|
|
3
|
+
export type FanOutApp = 'boule' | 'agora';
|
|
4
|
+
export type FanOutOrgState = 'waiting' | 'deploying' | 'done' | 'failed';
|
|
5
|
+
/** C1: what Archon shows for one app's fan-out. */
|
|
6
|
+
export interface BuiltinFanOut {
|
|
7
|
+
digest: string | null;
|
|
8
|
+
done: boolean;
|
|
9
|
+
orgs: Array<{
|
|
10
|
+
orgId: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
state: FanOutOrgState;
|
|
13
|
+
at: string | null;
|
|
14
|
+
reason: string | null;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
/** Applied-state scope keys of one app's fan-out (on the owner org). */
|
|
18
|
+
export declare function fanOutKeys(app: FanOutApp): {
|
|
19
|
+
/** The commit being copied. */
|
|
20
|
+
digest: string;
|
|
21
|
+
/** The commit whose copy finished on every org. */
|
|
22
|
+
done: string;
|
|
23
|
+
orgPrefix: string;
|
|
24
|
+
/** `<state>|<ISO time>|<org id>|<fixed reason>` */
|
|
25
|
+
org: (slug: string) => string;
|
|
26
|
+
};
|
|
27
|
+
/** C1: where the fan-out of the owner's Boule or Agora stands, from the owner org's applied state. */
|
|
28
|
+
export declare function readBuiltinFanOut(store: Pick<ReleaseStore, 'getScopeIds'>, ownerOrgId: string, app: FanOutApp): Promise<BuiltinFanOut>;
|
|
29
|
+
/**
|
|
30
|
+
* The commit the owner's copy runs when it is fully live: one colour at 100 %, the other at 0, no release
|
|
31
|
+
* running and no open step window. Null otherwise.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fullyLiveDigest(ids: Readonly<Record<string, string>>, app: FanOutApp): string | null;
|
|
34
|
+
export type FanOutDeployResult = {
|
|
35
|
+
ok: true;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
reason: string;
|
|
39
|
+
};
|
|
40
|
+
export interface FanOutDeps {
|
|
41
|
+
ownerOrgId: string;
|
|
42
|
+
app: FanOutApp;
|
|
43
|
+
/** Whether the owner's `org.<app>.blueGreen` is declared. */
|
|
44
|
+
ownerDeclares: boolean;
|
|
45
|
+
store: Pick<ReleaseStore, 'openRequests' | 'consumeRequests' | 'appendEvents'>;
|
|
46
|
+
/** The owner org's recorded ids now. */
|
|
47
|
+
readIds(): Promise<Record<string, string>>;
|
|
48
|
+
/** Persists scope keys on the owner org (null removes one), under a short lease. */
|
|
49
|
+
write(writes: Record<string, string | null>): Promise<void>;
|
|
50
|
+
/** Every other org that has the built-in app, in any order. Never the owner. */
|
|
51
|
+
targets(): Promise<Array<{
|
|
52
|
+
orgId: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
}>>;
|
|
55
|
+
/** Puts the org's copy on `sha` in place and waits for the deployment. Fixed-text reasons only. */
|
|
56
|
+
deploy(target: {
|
|
57
|
+
orgId: string;
|
|
58
|
+
slug: string;
|
|
59
|
+
}, sha: string): Promise<FanOutDeployResult>;
|
|
60
|
+
now: () => Date;
|
|
61
|
+
/** Each deploy is abandoned after this long (default 12 minutes). */
|
|
62
|
+
deployTimeoutMs?: number;
|
|
63
|
+
}
|
|
64
|
+
export declare const FANOUT_DEPLOY_TIMEOUT_MS: number;
|
|
65
|
+
export interface FanOutRun {
|
|
66
|
+
/** Why nothing deployed, or `deployed`, `done`, `stopped`. Fixed text. */
|
|
67
|
+
outcome: string;
|
|
68
|
+
deployed: string[];
|
|
69
|
+
failed: string | null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* One fan-out pass for one app (release tick, after the owner's release ran). Deploys each other org's copy at
|
|
73
|
+
* the owner's live commit, in slug order, and stops at the first failure. Every org is deployed in this pass
|
|
74
|
+
* unless one fails; a pass that dies halfway leaves the finished orgs `done` and the interrupted one `deploying`,
|
|
75
|
+
* which the next pass deploys again (a deploy of the same commit is safe to repeat).
|
|
76
|
+
*/
|
|
77
|
+
export declare function runBuiltinFanOut(deps: FanOutDeps): Promise<FanOutRun>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { recordedFrom } from './state.js';
|
|
2
|
+
const COMMIT = /^[0-9a-f]{40}$/;
|
|
3
|
+
const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
4
|
+
const STATES = ['waiting', 'deploying', 'done', 'failed'];
|
|
5
|
+
/** Applied-state scope keys of one app's fan-out (on the owner org). */
|
|
6
|
+
export function fanOutKeys(app) {
|
|
7
|
+
const p = `fanout.${app}`;
|
|
8
|
+
return {
|
|
9
|
+
/** The commit being copied. */
|
|
10
|
+
digest: `${p}.digest`,
|
|
11
|
+
/** The commit whose copy finished on every org. */
|
|
12
|
+
done: `${p}.done`,
|
|
13
|
+
orgPrefix: `${p}.org.`,
|
|
14
|
+
/** `<state>|<ISO time>|<org id>|<fixed reason>` */
|
|
15
|
+
org: (slug) => `${p}.org.${slug}`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function assertApp(app) {
|
|
19
|
+
if (app !== 'boule' && app !== 'agora')
|
|
20
|
+
throw new Error('invalid fan-out app');
|
|
21
|
+
}
|
|
22
|
+
function parseOrg(ids, key, slug) {
|
|
23
|
+
if (!SLUG.test(slug))
|
|
24
|
+
return null;
|
|
25
|
+
const [state, at, orgId, ...rest] = (ids[key] ?? '').split('|');
|
|
26
|
+
if (!state || !STATES.includes(state) || !orgId)
|
|
27
|
+
return null;
|
|
28
|
+
return {
|
|
29
|
+
orgId,
|
|
30
|
+
slug,
|
|
31
|
+
state: state,
|
|
32
|
+
at: at || null,
|
|
33
|
+
reason: rest.join('|') || null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function readFrom(ids, app) {
|
|
37
|
+
const k = fanOutKeys(app);
|
|
38
|
+
const orgs = Object.keys(ids)
|
|
39
|
+
.filter((key) => key.startsWith(k.orgPrefix))
|
|
40
|
+
.map((key) => parseOrg(ids, key, key.slice(k.orgPrefix.length)))
|
|
41
|
+
.filter((o) => o !== null)
|
|
42
|
+
.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
|
|
43
|
+
const digest = ids[k.digest] ?? null;
|
|
44
|
+
return { digest, done: digest !== null && ids[k.done] === digest, orgs };
|
|
45
|
+
}
|
|
46
|
+
/** C1: where the fan-out of the owner's Boule or Agora stands, from the owner org's applied state. */
|
|
47
|
+
export async function readBuiltinFanOut(store, ownerOrgId, app) {
|
|
48
|
+
assertApp(app);
|
|
49
|
+
return readFrom(await store.getScopeIds(ownerOrgId), app);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The commit the owner's copy runs when it is fully live: one colour at 100 %, the other at 0, no release
|
|
53
|
+
* running and no open step window. Null otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export function fullyLiveDigest(ids, app) {
|
|
56
|
+
const r = recordedFrom({ ...ids }, app);
|
|
57
|
+
if (!r || r.release !== null)
|
|
58
|
+
return null;
|
|
59
|
+
const other = r.live === 'blue' ? 'green' : 'blue';
|
|
60
|
+
if (r.weights[r.live] !== 100 || r.weights[other] !== 0)
|
|
61
|
+
return null;
|
|
62
|
+
if (!['idle', 'passed', 'rolled_back'].includes(r.window.kind))
|
|
63
|
+
return null;
|
|
64
|
+
const digest = r.digests[r.live];
|
|
65
|
+
return digest && COMMIT.test(digest) ? digest : null;
|
|
66
|
+
}
|
|
67
|
+
export const FANOUT_DEPLOY_TIMEOUT_MS = 12 * 60_000;
|
|
68
|
+
async function bounded(run, ms) {
|
|
69
|
+
let timer;
|
|
70
|
+
const timeout = new Promise((resolve) => {
|
|
71
|
+
timer = setTimeout(() => resolve({ ok: false, reason: 'deployment timed out' }), ms);
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
return await Promise.race([run, timeout]);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* One fan-out pass for one app (release tick, after the owner's release ran). Deploys each other org's copy at
|
|
82
|
+
* the owner's live commit, in slug order, and stops at the first failure. Every org is deployed in this pass
|
|
83
|
+
* unless one fails; a pass that dies halfway leaves the finished orgs `done` and the interrupted one `deploying`,
|
|
84
|
+
* which the next pass deploys again (a deploy of the same commit is safe to repeat).
|
|
85
|
+
*/
|
|
86
|
+
export async function runBuiltinFanOut(deps) {
|
|
87
|
+
const { app } = deps;
|
|
88
|
+
assertApp(app);
|
|
89
|
+
const k = fanOutKeys(app);
|
|
90
|
+
const none = (outcome) => ({ outcome, deployed: [], failed: null });
|
|
91
|
+
const retries = (await deps.store.openRequests({ orgId: deps.ownerOrgId, entryKey: app })).filter((r) => r.action.kind === 'fanout_retry');
|
|
92
|
+
if (retries.length > 0) {
|
|
93
|
+
const failedNow = readFrom(await deps.readIds(), app).orgs.filter((o) => o.state === 'failed');
|
|
94
|
+
const writes = {};
|
|
95
|
+
for (const o of failedNow)
|
|
96
|
+
writes[k.org(o.slug)] = `waiting|${deps.now().toISOString()}|${o.orgId}|`;
|
|
97
|
+
if (Object.keys(writes).length > 0)
|
|
98
|
+
await deps.write(writes);
|
|
99
|
+
await deps.store.consumeRequests(retries.map((r) => r.id));
|
|
100
|
+
}
|
|
101
|
+
if (!deps.ownerDeclares)
|
|
102
|
+
return none('owner has no blue/green copy');
|
|
103
|
+
const ids = await deps.readIds();
|
|
104
|
+
const digest = fullyLiveDigest(ids, app);
|
|
105
|
+
if (!digest)
|
|
106
|
+
return none('owner copy is not fully live on one commit');
|
|
107
|
+
if (ids[k.done] === digest)
|
|
108
|
+
return none('commit already copied');
|
|
109
|
+
const targets = [...(await deps.targets())]
|
|
110
|
+
.filter((t) => t.orgId !== deps.ownerOrgId && SLUG.test(t.slug))
|
|
111
|
+
.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
|
|
112
|
+
const stamp = deps.now().toISOString();
|
|
113
|
+
const current = readFrom(ids, app);
|
|
114
|
+
const fresh = current.digest !== digest;
|
|
115
|
+
const sync = {};
|
|
116
|
+
if (fresh) {
|
|
117
|
+
sync[k.digest] = digest;
|
|
118
|
+
sync[k.done] = null;
|
|
119
|
+
}
|
|
120
|
+
const states = new Map(current.orgs.map((o) => [o.slug, o]));
|
|
121
|
+
for (const o of current.orgs) {
|
|
122
|
+
if (!targets.some((t) => t.slug === o.slug))
|
|
123
|
+
sync[k.org(o.slug)] = null;
|
|
124
|
+
}
|
|
125
|
+
for (const t of targets) {
|
|
126
|
+
const have = states.get(t.slug);
|
|
127
|
+
if (fresh || !have || have.orgId !== t.orgId) {
|
|
128
|
+
sync[k.org(t.slug)] = `waiting|${stamp}|${t.orgId}|`;
|
|
129
|
+
states.set(t.slug, { ...t, state: 'waiting', at: stamp, reason: null });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (Object.keys(sync).length > 0)
|
|
133
|
+
await deps.write(sync);
|
|
134
|
+
const run = { outcome: 'done', deployed: [], failed: null };
|
|
135
|
+
for (const t of targets) {
|
|
136
|
+
const state = states.get(t.slug)?.state;
|
|
137
|
+
if (state === 'done')
|
|
138
|
+
continue;
|
|
139
|
+
if (state === 'failed')
|
|
140
|
+
return { ...run, outcome: 'stopped', failed: t.slug };
|
|
141
|
+
await deps.write({ [k.org(t.slug)]: `deploying|${deps.now().toISOString()}|${t.orgId}|` });
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = await bounded(deps.deploy(t, digest), deps.deployTimeoutMs ?? FANOUT_DEPLOY_TIMEOUT_MS);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
console.error(`fanout ${app}: deploy threw ${error instanceof Error ? error.name : 'Error'}`);
|
|
148
|
+
result = { ok: false, reason: 'deployment failed' };
|
|
149
|
+
}
|
|
150
|
+
const at = deps.now().toISOString();
|
|
151
|
+
if (result.ok) {
|
|
152
|
+
await deps.write({ [k.org(t.slug)]: `done|${at}|${t.orgId}|` });
|
|
153
|
+
run.deployed.push(t.slug);
|
|
154
|
+
await record(deps, { at, kind: 'fanout_done', reason: t.slug });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const reason = result.reason.replace(/\|/g, ' ').slice(0, 120);
|
|
158
|
+
await deps.write({ [k.org(t.slug)]: `failed|${at}|${t.orgId}|${reason}` });
|
|
159
|
+
await record(deps, { at, kind: 'fanout_failed', reason: `${t.slug}: ${reason}` });
|
|
160
|
+
return { ...run, outcome: 'stopped', failed: t.slug };
|
|
161
|
+
}
|
|
162
|
+
await deps.write({ [k.done]: digest });
|
|
163
|
+
return run;
|
|
164
|
+
}
|
|
165
|
+
async function record(deps, event) {
|
|
166
|
+
try {
|
|
167
|
+
await deps.store.appendEvents({
|
|
168
|
+
orgId: deps.ownerOrgId,
|
|
169
|
+
entryKey: deps.app,
|
|
170
|
+
events: [event],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// The history is a record, not a gate: the state above already says what happened.
|
|
175
|
+
console.error(`fanout ${deps.app}: recording a history line failed`);
|
|
176
|
+
}
|
|
177
|
+
}
|
package/dist/release/run.js
CHANGED
|
@@ -28,7 +28,8 @@ export async function runRelease(input) {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
const k = releaseKeys(entryKey);
|
|
31
|
-
|
|
31
|
+
// `fanout_retry` belongs to the built-in fan-out (`fanout.ts`), which consumes it; this run leaves it open.
|
|
32
|
+
const all = (await store.openRequests({ orgId, entryKey })).filter((r) => r.action.kind !== 'fanout_retry');
|
|
32
33
|
// A stage request whose result was persisted before a crash is consumed, never run a second time.
|
|
33
34
|
const handledId = ids[k.stageCopyHandled];
|
|
34
35
|
const replayed = all.filter((r) => isStageCopyAction(r.action) && String(r.id) === handledId);
|
package/dist/release/state.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export declare function releaseKeys(entry: string): {
|
|
|
66
66
|
};
|
|
67
67
|
export declare function formatWeights(w: Record<Color, number>): string;
|
|
68
68
|
export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
|
|
69
|
-
export declare const RELEASE_EVENT_KINDS: readonly ["promote", "step", "check", "pause", "resume", "step_now", "approve", "open", "rollback", "passed", "hard_fail"];
|
|
69
|
+
export declare const RELEASE_EVENT_KINDS: readonly ["promote", "step", "check", "pause", "resume", "step_now", "approve", "open", "rollback", "passed", "hard_fail", "fanout_done", "fanout_failed"];
|
|
70
70
|
/** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
|
|
71
71
|
export declare const releaseEventPayload: z.ZodObject<{
|
|
72
72
|
percent: z.ZodOptional<z.ZodNumber>;
|
|
@@ -100,6 +100,8 @@ export declare const releaseEventKind: z.ZodEnum<{
|
|
|
100
100
|
resume: "resume";
|
|
101
101
|
step_now: "step_now";
|
|
102
102
|
approve: "approve";
|
|
103
|
+
fanout_done: "fanout_done";
|
|
104
|
+
fanout_failed: "fanout_failed";
|
|
103
105
|
}>;
|
|
104
106
|
/** The window JSON is the C2 state plus the active release's `release` field. */
|
|
105
107
|
export declare function parseWindow(value: string | undefined): {
|
package/dist/release/state.js
CHANGED
|
@@ -78,6 +78,8 @@ export const RELEASE_EVENT_KINDS = [
|
|
|
78
78
|
'rollback',
|
|
79
79
|
'passed',
|
|
80
80
|
'hard_fail',
|
|
81
|
+
'fanout_done',
|
|
82
|
+
'fanout_failed',
|
|
81
83
|
];
|
|
82
84
|
/** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
|
|
83
85
|
export const releaseEventPayload = z.object({
|
|
@@ -216,6 +218,7 @@ const actionSchema = z.discriminatedUnion('kind', [
|
|
|
216
218
|
z.object({ kind: z.literal('deploy_stage'), ref: z.string().regex(STAGE_REF_PATTERN) }),
|
|
217
219
|
z.object({ kind: z.literal('copy_prod_to_stage') }),
|
|
218
220
|
z.object({ kind: z.literal('undo_stage_copy') }),
|
|
221
|
+
z.object({ kind: z.literal('fanout_retry') }),
|
|
219
222
|
]);
|
|
220
223
|
/** C2: ask the runner to apply an action on its next run. Archon audits it. */
|
|
221
224
|
export async function requestReleaseAction(store, input) {
|
package/dist/release/types.d.ts
CHANGED
|
@@ -107,8 +107,16 @@ export type ReleaseAction = {
|
|
|
107
107
|
kind: 'copy_prod_to_stage';
|
|
108
108
|
} | {
|
|
109
109
|
kind: 'undo_stage_copy';
|
|
110
|
+
}
|
|
111
|
+
/** Owner org, entry key `boule` or `agora`: continue the built-in fan-out from the org that failed. */
|
|
112
|
+
| {
|
|
113
|
+
kind: 'fanout_retry';
|
|
110
114
|
};
|
|
111
|
-
export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail'
|
|
115
|
+
export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail'
|
|
116
|
+
/** The built-in fan-out put an org's copy on the live commit (`reason` is the org slug). */
|
|
117
|
+
| 'fanout_done'
|
|
118
|
+
/** The built-in fan-out stopped at an org (`reason` is `<slug>: <fixed text>`). */
|
|
119
|
+
| 'fanout_failed';
|
|
112
120
|
/** C1: one line of a release's history, written by the release run (`foundry_release_events`). */
|
|
113
121
|
export interface ReleaseEvent {
|
|
114
122
|
at: string;
|