@wtfalch/foundry 0.71.0 → 0.72.1
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 +1 -1
- package/dist/modules/sign-in.js +81 -9
- package/dist/modules/types.d.ts +2 -0
- package/dist/native-build.d.ts +4 -1
- package/dist/native-build.js +8 -2
- 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 +66 -0
- package/dist/plan/executor.js +136 -8
- 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 +15 -0
- package/dist/plan/plan-apply.js +21 -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/package.json +3 -3
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
2
|
+
import { customersOrgIdOf, registrarWanted } from './boule.js';
|
|
3
|
+
import { withTimeout } from './call-timeout.js';
|
|
4
|
+
import { testCustomersOrgKey } from './workspace-client.js';
|
|
5
|
+
/**
|
|
6
|
+
* The sign-in broker's ZITADEL credential for registering apps (auth ADR 0003, "Sign-in the broker makes"):
|
|
7
|
+
* `ZITADEL_REGISTRAR_TOKEN`, a machine user of its own, never the login-client PAT. Without it the broker
|
|
8
|
+
* logs "app sign-in off" and no Boule of that sign-in can register an app that signs people in.
|
|
9
|
+
*
|
|
10
|
+
* The user (`ZitadelPort.ensureRegistrarUser`) lives in the instance's first organisation and holds the
|
|
11
|
+
* project-creator role in the company's sign-in organisation and its customers organisations. Its token is
|
|
12
|
+
* minted once (platform-step rule 1), held at the target's `refs.registrarToken`, written to the broker's env
|
|
13
|
+
* and the broker deployed. A token already on the broker's env that foundry did not make is adopted: it is
|
|
14
|
+
* recorded as applied and left alone. Never a token in an error, a log or a recorded id.
|
|
15
|
+
*/
|
|
16
|
+
/** The broker env that holds the token. */
|
|
17
|
+
export const REGISTRAR_TOKEN_ENV = 'ZITADEL_REGISTRAR_TOKEN';
|
|
18
|
+
/** The machine user's name, the same for every instance. */
|
|
19
|
+
export const REGISTRAR_TOKEN_USER_NAME = 'foundry-broker-registrar';
|
|
20
|
+
/** The user id of a mint whose token was not yet confirmed delivered (no `:`, so it is a rule 1 guard). Ignored once applied. */
|
|
21
|
+
export const REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY = 'auth_registrar_token_undelivered';
|
|
22
|
+
/** `<user id>:<pat id>` once the token is held and on the broker; `adopted` for a value foundry did not make. Never the token. */
|
|
23
|
+
export const REGISTRAR_TOKEN_SCOPE_KEY = 'auth_registrar_token_applied';
|
|
24
|
+
/** {@link registrarOrgsMarker} of the organisations the user was last granted the role in. */
|
|
25
|
+
export const REGISTRAR_TOKEN_ORGS_SCOPE_KEY = 'auth_registrar_token_orgs';
|
|
26
|
+
/** The adopted marker's value. */
|
|
27
|
+
export const REGISTRAR_TOKEN_ADOPTED = 'adopted';
|
|
28
|
+
/** A stop whose text names ids only, never a cause, so a caller may log it as it is. */
|
|
29
|
+
export class RegistrarTokenError extends Error {
|
|
30
|
+
}
|
|
31
|
+
/** The held key store's read and write, and each ZITADEL call, stop at these: a hung call ends the step with a plain line. */
|
|
32
|
+
const HELD_KEY_TIMEOUT_MS = 15_000;
|
|
33
|
+
const ZITADEL_CALL_TIMEOUT_MS = 30_000;
|
|
34
|
+
const COOLIFY_CALL_TIMEOUT_MS = 60_000;
|
|
35
|
+
/** How many times the hold and the delivery are tried. */
|
|
36
|
+
const DELIVER_ATTEMPTS = 3;
|
|
37
|
+
/** The organisations an own company's registrar user covers besides the instance's first: its sign-in organisation and its customers organisations. */
|
|
38
|
+
export function registrarOrgIdsOf(org, recorded) {
|
|
39
|
+
const customers = org.customers?.enabled === true ? customersOrgIdOf(org, recorded) : undefined;
|
|
40
|
+
const test = org.customers?.enabled === true ? recorded[testCustomersOrgKey(org.id)] : undefined;
|
|
41
|
+
return [recorded[ZITADEL_ORG_SCOPE_KEY], customers, test].filter((id) => Boolean(id));
|
|
42
|
+
}
|
|
43
|
+
/** What the orgs marker holds: the ids, sorted, so a reorder is no change. */
|
|
44
|
+
export const registrarOrgsMarker = (orgIds) => [...new Set(orgIds)].sort().join(',');
|
|
45
|
+
/**
|
|
46
|
+
* Whether the company's broker still needs this step for its token: the token is wanted (customers or apps),
|
|
47
|
+
* and it is not applied or the organisations it covers are not the ones recorded now.
|
|
48
|
+
*/
|
|
49
|
+
export function registrarTokenDue(org, recorded) {
|
|
50
|
+
if (!registrarWanted(org))
|
|
51
|
+
return false;
|
|
52
|
+
const applied = recorded[REGISTRAR_TOKEN_SCOPE_KEY];
|
|
53
|
+
if (!applied)
|
|
54
|
+
return true;
|
|
55
|
+
if (applied === REGISTRAR_TOKEN_ADOPTED)
|
|
56
|
+
return false;
|
|
57
|
+
return ((recorded[REGISTRAR_TOKEN_ORGS_SCOPE_KEY] ?? '') !==
|
|
58
|
+
registrarOrgsMarker(registrarOrgIdsOf(org, recorded)));
|
|
59
|
+
}
|
|
60
|
+
/** The same input with a timeout on every held key, ZITADEL and Coolify call. */
|
|
61
|
+
function timed(c) {
|
|
62
|
+
const { heldKeys, zitadel, coolify } = c;
|
|
63
|
+
const { ensureRegistrarUser, mintRegistrarToken } = zitadel;
|
|
64
|
+
return {
|
|
65
|
+
...c,
|
|
66
|
+
coolify: {
|
|
67
|
+
listEnvs: (...args) => withTimeout('Coolify list env', COOLIFY_CALL_TIMEOUT_MS, () => coolify.listEnvs(...args)),
|
|
68
|
+
bulkUpsertEnvs: (...args) => withTimeout('Coolify write env', COOLIFY_CALL_TIMEOUT_MS, () => coolify.bulkUpsertEnvs(...args)),
|
|
69
|
+
deploy: (...args) => withTimeout('Coolify deploy', COOLIFY_CALL_TIMEOUT_MS, () => coolify.deploy(...args)),
|
|
70
|
+
},
|
|
71
|
+
heldKeys: heldKeys && {
|
|
72
|
+
resolve: (ref) => withTimeout('held key read', HELD_KEY_TIMEOUT_MS, () => heldKeys.resolve(ref)),
|
|
73
|
+
...(heldKeys.put
|
|
74
|
+
? {
|
|
75
|
+
put: (ref, value) => withTimeout('held key write', HELD_KEY_TIMEOUT_MS, () => heldKeys.put(ref, value)),
|
|
76
|
+
}
|
|
77
|
+
: {}),
|
|
78
|
+
},
|
|
79
|
+
zitadel: {
|
|
80
|
+
...(ensureRegistrarUser
|
|
81
|
+
? {
|
|
82
|
+
ensureRegistrarUser: (...args) => withTimeout('ZITADEL ensure registrar user', ZITADEL_CALL_TIMEOUT_MS, () => ensureRegistrarUser(...args)),
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
85
|
+
...(mintRegistrarToken
|
|
86
|
+
? {
|
|
87
|
+
mintRegistrarToken: (...args) => withTimeout('ZITADEL mint registrar token', ZITADEL_CALL_TIMEOUT_MS, () => mintRegistrarToken(...args)),
|
|
88
|
+
}
|
|
89
|
+
: {}),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Runs one try at a time, up to {@link DELIVER_ATTEMPTS}; the cause of the last failure is dropped. */
|
|
94
|
+
async function retried(sleep, action) {
|
|
95
|
+
for (let attempt = 1; attempt <= DELIVER_ATTEMPTS; attempt += 1) {
|
|
96
|
+
try {
|
|
97
|
+
await action();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// The cause can hold the token (a Coolify validation error echoes the value), so it is dropped.
|
|
102
|
+
if (attempt < DELIVER_ATTEMPTS)
|
|
103
|
+
await sleep(1000 * attempt);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Makes the broker hold a registrar token that covers the company's organisations. Resumable: a token held
|
|
110
|
+
* but not delivered is delivered, never minted again; a token minted whose hold did not finish stops with
|
|
111
|
+
* the ids named and nothing minted (a person removes {@link REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY} to make
|
|
112
|
+
* another). Returns what it did.
|
|
113
|
+
*/
|
|
114
|
+
export async function ensureBrokerRegistrarToken(input) {
|
|
115
|
+
const c = timed(input);
|
|
116
|
+
const covered = [c.homeOrgId, ...c.orgIds];
|
|
117
|
+
const wanted = registrarOrgsMarker(c.orgIds);
|
|
118
|
+
const envRows = await c.coolify.listEnvs(c.brokerAppId);
|
|
119
|
+
const live = envRows.find((e) => e.key === REGISTRAR_TOKEN_ENV && !e.is_preview)?.value;
|
|
120
|
+
if (live)
|
|
121
|
+
c.secret(live);
|
|
122
|
+
const before = c.recorded();
|
|
123
|
+
const applied = before[REGISTRAR_TOKEN_SCOPE_KEY];
|
|
124
|
+
if (applied) {
|
|
125
|
+
if (!live) {
|
|
126
|
+
// The env was lost: the held token is delivered again; with none held, nothing is made over it.
|
|
127
|
+
const held = await c.heldKeys?.resolve(c.ref);
|
|
128
|
+
if (!held) {
|
|
129
|
+
throw new RegistrarTokenError(`${c.label}: the broker has no ${REGISTRAR_TOKEN_ENV} and the registrar token is not held (ref ${c.ref}); remove ${REGISTRAR_TOKEN_SCOPE_KEY} to make a new one`);
|
|
130
|
+
}
|
|
131
|
+
c.secret(held);
|
|
132
|
+
await deliver(c, held);
|
|
133
|
+
return 'delivered';
|
|
134
|
+
}
|
|
135
|
+
if (applied !== REGISTRAR_TOKEN_ADOPTED && before[REGISTRAR_TOKEN_ORGS_SCOPE_KEY] !== wanted) {
|
|
136
|
+
await grant(c, covered);
|
|
137
|
+
await c.record(REGISTRAR_TOKEN_ORGS_SCOPE_KEY, wanted);
|
|
138
|
+
await c.persist();
|
|
139
|
+
return 'grants';
|
|
140
|
+
}
|
|
141
|
+
return 'unchanged';
|
|
142
|
+
}
|
|
143
|
+
const undelivered = before[REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY];
|
|
144
|
+
const held = await c.heldKeys?.resolve(c.ref);
|
|
145
|
+
if (live && !undelivered && held !== live) {
|
|
146
|
+
// A value somebody set by hand (Candor FX's): kept as it is. Its user is not known, so no grants.
|
|
147
|
+
c.log(`${c.label}: the broker already has a ${REGISTRAR_TOKEN_ENV} foundry did not make; adopted, not replaced`);
|
|
148
|
+
await c.record(REGISTRAR_TOKEN_SCOPE_KEY, REGISTRAR_TOKEN_ADOPTED);
|
|
149
|
+
await c.persist();
|
|
150
|
+
return 'adopted';
|
|
151
|
+
}
|
|
152
|
+
if (undelivered && !held) {
|
|
153
|
+
throw new RegistrarTokenError(`${c.label}: the registrar token of ZITADEL machine user ${undelivered} was minted but never confirmed held; remove the recorded key ${REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY} to make a new one`);
|
|
154
|
+
}
|
|
155
|
+
if (held) {
|
|
156
|
+
// Made by a run that stopped after holding it: delivered now, nothing minted.
|
|
157
|
+
c.secret(held);
|
|
158
|
+
await grant(c, covered);
|
|
159
|
+
await c.record(REGISTRAR_TOKEN_ORGS_SCOPE_KEY, wanted);
|
|
160
|
+
await deliver(c, held);
|
|
161
|
+
await c.record(REGISTRAR_TOKEN_SCOPE_KEY, undelivered ? `${undelivered}:held` : 'held');
|
|
162
|
+
await c.persist();
|
|
163
|
+
return 'delivered';
|
|
164
|
+
}
|
|
165
|
+
if (typeof c.heldKeys?.put !== 'function') {
|
|
166
|
+
throw new RegistrarTokenError(`${c.label}: the held keys cannot hold the registrar token (ref ${c.ref})`);
|
|
167
|
+
}
|
|
168
|
+
if (!c.zitadel.ensureRegistrarUser || !c.zitadel.mintRegistrarToken) {
|
|
169
|
+
throw new RegistrarTokenError(`${c.label}: the host's ZITADEL port cannot make a registrar token`);
|
|
170
|
+
}
|
|
171
|
+
const { userId } = await c.zitadel.ensureRegistrarUser(covered, {
|
|
172
|
+
userName: REGISTRAR_TOKEN_USER_NAME,
|
|
173
|
+
name: 'Foundry (sign-in broker registrar)',
|
|
174
|
+
});
|
|
175
|
+
const { token, patId } = await c.zitadel.mintRegistrarToken(c.homeOrgId, userId);
|
|
176
|
+
c.secret(token);
|
|
177
|
+
// Recorded and persisted before the token goes anywhere: a crash from here on never mints a second one.
|
|
178
|
+
await c.record(REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY, userId);
|
|
179
|
+
await c.persist();
|
|
180
|
+
const put = c.heldKeys.put.bind(c.heldKeys);
|
|
181
|
+
if (!(await retried(c.sleep, () => put(c.ref, token)))) {
|
|
182
|
+
throw new RegistrarTokenError(`${c.label}: the registrar token of ZITADEL machine user ${userId} was minted but could not be held after ${DELIVER_ATTEMPTS} tries (ref ${c.ref})`);
|
|
183
|
+
}
|
|
184
|
+
await deliver(c, token, userId);
|
|
185
|
+
await c.record(REGISTRAR_TOKEN_ORGS_SCOPE_KEY, wanted);
|
|
186
|
+
// Last: a run that failed above plans the step again.
|
|
187
|
+
await c.record(REGISTRAR_TOKEN_SCOPE_KEY, `${userId}:${patId}`);
|
|
188
|
+
await c.persist();
|
|
189
|
+
return 'minted';
|
|
190
|
+
}
|
|
191
|
+
async function grant(c, covered) {
|
|
192
|
+
if (!c.zitadel.ensureRegistrarUser) {
|
|
193
|
+
throw new RegistrarTokenError(`${c.label}: the host's ZITADEL port cannot grant the registrar user`);
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await c.zitadel.ensureRegistrarUser(covered, {
|
|
197
|
+
userName: REGISTRAR_TOKEN_USER_NAME,
|
|
198
|
+
name: 'Foundry (sign-in broker registrar)',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new RegistrarTokenError(`${c.label}: the registrar user could not be granted its organisations; apply again`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Writes the token to the broker's env and deploys the broker, each retried; the cause is dropped. */
|
|
206
|
+
async function deliver(c, token, userId) {
|
|
207
|
+
const who = userId ? ` of ZITADEL machine user ${userId}` : '';
|
|
208
|
+
const wrote = await retried(c.sleep, () => c.coolify.bulkUpsertEnvs(c.brokerAppId, [{ key: REGISTRAR_TOKEN_ENV, value: token }]));
|
|
209
|
+
// Forced: an unforced deploy answers "skipped" when one for the same commit is queued, which would let
|
|
210
|
+
// this record delivery while the running broker never sees the token.
|
|
211
|
+
const deployed = wrote && (await retried(c.sleep, () => c.coolify.deploy(c.brokerAppId, true)));
|
|
212
|
+
if (!deployed) {
|
|
213
|
+
throw new RegistrarTokenError(`${c.label}: the registrar token${who} is held but could not be delivered to the broker after ${DELIVER_ATTEMPTS} tries; the next apply delivers it again`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -152,6 +152,8 @@ export declare function planBrokerRegistrar(org: DeclaredOrg, recorded: Readonly
|
|
|
152
152
|
brokerWired: boolean;
|
|
153
153
|
canHoldSecrets: boolean;
|
|
154
154
|
issuerKnown: boolean;
|
|
155
|
+
/** A company on the shared sign-in whose registrar user the host can grant (`ZitadelPort.ensureRegistrarUser`). */
|
|
156
|
+
sharedGrants?: boolean;
|
|
155
157
|
}): {
|
|
156
158
|
deliver: boolean;
|
|
157
159
|
/** Customers are off but the record cannot be taken away yet: Boule keeps its registrar env, in step with it. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
3
3
|
import { AUTH_REGISTRAR_KEY_ENV, AUTH_REGISTRAR_URL_ENV, customersOrgIdOf, registrarWanted, } from './boule.js';
|
|
4
|
+
import { REGISTRAR_TOKEN_ORGS_SCOPE_KEY, registrarOrgsMarker } from './broker-registrar-token.js';
|
|
4
5
|
import { loginHostnameFor } from './login-host.js';
|
|
5
6
|
import { brokerDeployRecordValue, missingBrokerInputs } from './platform-providers.js';
|
|
6
7
|
import { testCustomersOrgKey } from './workspace-client.js';
|
|
@@ -403,7 +404,10 @@ export function planBrokerRegistrar(org, recorded, plannedSteps, host) {
|
|
|
403
404
|
else {
|
|
404
405
|
const mint = recorded[REGISTRAR_MINTED_SCOPE_KEY];
|
|
405
406
|
if (mint &&
|
|
406
|
-
recorded[REGISTRAR_APPLIED_SCOPE_KEY] === registrarAppliedValue(wanted.want, mint)
|
|
407
|
+
recorded[REGISTRAR_APPLIED_SCOPE_KEY] === registrarAppliedValue(wanted.want, mint) &&
|
|
408
|
+
!(host.sharedGrants &&
|
|
409
|
+
recorded[REGISTRAR_TOKEN_ORGS_SCOPE_KEY] !==
|
|
410
|
+
registrarOrgsMarker([wanted.want.organizationId, ...wanted.want.customers]))) {
|
|
407
411
|
return { deliver, keepBouleEnv: false, step: null, blocked };
|
|
408
412
|
}
|
|
409
413
|
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.j
|
|
|
3
3
|
import { type AgoraHostReading } from './agora.js';
|
|
4
4
|
import { type BlueGreenServer } from './blue-green.js';
|
|
5
5
|
import { type LiveResource } from './live.js';
|
|
6
|
+
import { type OwnAuthHead } from './own-diff.js';
|
|
6
7
|
import { type OrgPlatformReadiness } from './platform-org.js';
|
|
7
8
|
import { type RemoteDatabaseFacts } from './remote-database.js';
|
|
8
9
|
/**
|
|
@@ -101,8 +102,12 @@ export interface PlanOrgOptions {
|
|
|
101
102
|
ownMailKeyHeld?: boolean;
|
|
102
103
|
/** The executor can mint an own org's Lettermint key (`FoundryStepExecutor.ownMailKeyMintable`). */
|
|
103
104
|
ownMailKeyMintable?: boolean;
|
|
105
|
+
/** The ZITADEL port can set the customers organisation's login policy. Only `false` plans none; unset plans it. */
|
|
106
|
+
customersLoginPolicy?: boolean;
|
|
104
107
|
/** C3: the executor can mint a customers directory credential (`ZitadelPort.mintOrgDirectoryReader`). */
|
|
105
108
|
customersDirectory?: boolean;
|
|
109
|
+
/** The ZITADEL port can grant the directory user its customer removal role (`ZitadelPort.ensureOrgDirectoryRoles`). */
|
|
110
|
+
customersDirectoryRoles?: boolean;
|
|
106
111
|
/** C3: the executor can mint Boule's Archon credential (`FoundryStepExecutorPorts.mintBouleArchonCredential`). */
|
|
107
112
|
bouleArchonCredential?: boolean;
|
|
108
113
|
/** The executor can store a Worker app database's CA and certificate (`FoundryStepExecutor.workerAppDbKeysPuttable`). Absent: true. */
|
|
@@ -113,6 +118,14 @@ export interface PlanOrgOptions {
|
|
|
113
118
|
ownSigninDbKeysPuttable?: boolean;
|
|
114
119
|
/** The executor's company ZITADEL port can write the sign-in mail (`FoundryStepExecutor.ownSigninMailWritable`). Absent: true. */
|
|
115
120
|
ownSigninMailWritable?: boolean;
|
|
121
|
+
/** #886: the head of the declared auth branch (`FoundryStepExecutor.ownAuthHead`). Absent: not read. */
|
|
122
|
+
ownAuthHead?: OwnAuthHead;
|
|
123
|
+
/** The executor's company ZITADEL port can make the broker's registrar token. Absent: not asked for. */
|
|
124
|
+
ownRegistrarTokenWritable?: boolean;
|
|
125
|
+
/** The executor can grant the shared sign-in's registrar user its organisations (`ZitadelPort.ensureRegistrarUser`). Absent: false. */
|
|
126
|
+
registrarGrants?: boolean;
|
|
127
|
+
/** An own org's registrar token is held. Absent: false. */
|
|
128
|
+
ownRegistrarTokenHeld?: boolean;
|
|
116
129
|
/** The host supplies the backup bucket the sign-in database dumps into (`FoundryStepExecutor.ownSigninDbBackupConfigured`). Absent: true. */
|
|
117
130
|
ownSigninDbBackupConfigured?: boolean;
|
|
118
131
|
/** The own jobs admin token's held-key ref holds a value. */
|
package/dist/plan/diff.js
CHANGED
|
@@ -9,7 +9,7 @@ import { planAppsDbRoute } from './apps-db-route.js';
|
|
|
9
9
|
import { APPS_DB_BLOCKED_KEY, planAppsDb } from './apps-db.js';
|
|
10
10
|
import { planAppsZones } from './apps-zones.js';
|
|
11
11
|
import { bgKey, planBlueGreen, } from './blue-green.js';
|
|
12
|
-
import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, customersRoomValue, desiredBouleEnv, planBouleOwners, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
12
|
+
import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY, CUSTOMERS_DIRECTORY_ROLES_VERSION, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_POLICY_SCOPE_KEY, CUSTOMERS_POLICY_VERSION, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, customersRoomValue, desiredBouleEnv, planBouleOwners, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
13
13
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, entryMcpProjectScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
14
14
|
import { bouleRegistrarDrift, planBrokerRegistrar } from './broker-registrar.js';
|
|
15
15
|
import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
|
|
@@ -265,6 +265,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
265
265
|
signinDbKeysPuttable: options.ownSigninDbKeysPuttable ?? true,
|
|
266
266
|
signinDbBackupConfigured: options.ownSigninDbBackupConfigured ?? true,
|
|
267
267
|
signinMailWritable: options.ownSigninMailWritable ?? true,
|
|
268
|
+
...(options.ownAuthHead ? { authHead: options.ownAuthHead } : {}),
|
|
269
|
+
registrarTokenWritable: options.ownRegistrarTokenWritable,
|
|
270
|
+
registrarTokenHeld: options.ownRegistrarTokenHeld ?? false,
|
|
268
271
|
});
|
|
269
272
|
if (ownAuth.step)
|
|
270
273
|
plannedSteps.push(ownAuth.step);
|
|
@@ -470,7 +473,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
470
473
|
// this array order, one at a time.
|
|
471
474
|
// Customers before the entries that sign them in: `pushAppEnv` and the
|
|
472
475
|
// broker entry read the customers organisation's id.
|
|
473
|
-
const customers = planCustomers(org, live, claimed, recordedIds, options.zitadelOrgMetadata ?? false);
|
|
476
|
+
const customers = planCustomers(org, live, claimed, recordedIds, options.zitadelOrgMetadata ?? false, options.customersLoginPolicy !== false);
|
|
474
477
|
if (customers.step)
|
|
475
478
|
plannedSteps.push(customers.step);
|
|
476
479
|
// The workspaces' own customers, before the clients and the workspace steps that read the organisation's id.
|
|
@@ -702,6 +705,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
702
705
|
const bouleCredentials = planBouleCredentials(org, recordedIds, {
|
|
703
706
|
archon: options.bouleArchonCredential === true,
|
|
704
707
|
directory: options.customersDirectory === true,
|
|
708
|
+
directoryRoles: options.customersDirectoryRoles === true,
|
|
705
709
|
}, platformReadiness.canHoldSecrets === true, (options.now ?? Date.now)());
|
|
706
710
|
// F13: Boule's `CUSTOMER_GRANTS`, from the entries that declare `uses.customerGrants`. Every reason it
|
|
707
711
|
// says nothing yet is a plan line, never silence.
|
|
@@ -723,6 +727,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
723
727
|
brokerWired: options.broker ?? false,
|
|
724
728
|
canHoldSecrets: platformReadiness.canHoldSecrets === true,
|
|
725
729
|
issuerKnown: Boolean(authIssuer),
|
|
730
|
+
sharedGrants: !ownsServices(org) && options.registrarGrants === true,
|
|
726
731
|
});
|
|
727
732
|
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, {
|
|
728
733
|
...bouleCredentials.wants,
|
|
@@ -3361,6 +3366,10 @@ function planBouleCredentials(org, recordedIds, host, canHoldSecrets, nowMs) {
|
|
|
3361
3366
|
wants: {
|
|
3362
3367
|
archonCredential,
|
|
3363
3368
|
customersDirectory,
|
|
3369
|
+
directoryRolesDue: customersDirectory &&
|
|
3370
|
+
host.directoryRoles === true &&
|
|
3371
|
+
Boolean(recordedIds[CUSTOMERS_DIRECTORY_USER_SCOPE_KEY]) &&
|
|
3372
|
+
recordedIds[CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY] !== CUSTOMERS_DIRECTORY_ROLES_VERSION,
|
|
3364
3373
|
archonRenewDue: archonCredential && archonCredentialRenewDue(recordedIds, nowMs),
|
|
3365
3374
|
},
|
|
3366
3375
|
blocked,
|
|
@@ -3479,6 +3488,10 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
3479
3488
|
!liveEnv[BOULE_CUSTOMERS_CREDENTIAL_ENV]) {
|
|
3480
3489
|
drift.push(BOULE_CUSTOMERS_CREDENTIAL_ENV);
|
|
3481
3490
|
}
|
|
3491
|
+
// The directory user that predates the customer removal role: the step grants it once.
|
|
3492
|
+
if (credentials.directoryRolesDue && !drift.includes(BOULE_CUSTOMERS_CREDENTIAL_ENV)) {
|
|
3493
|
+
drift.push(BOULE_CUSTOMERS_CREDENTIAL_ENV);
|
|
3494
|
+
}
|
|
3482
3495
|
for (const key of staleCustomersEnv(org, liveEnv)) {
|
|
3483
3496
|
if (!drift.includes(key))
|
|
3484
3497
|
drift.push(key);
|
|
@@ -4721,7 +4734,7 @@ function planBouleBrokerEntry(org, recordedIds, plannedSteps, brokerWired) {
|
|
|
4721
4734
|
* recorded, the live project grant to that org is claimed so it is not
|
|
4722
4735
|
* listed as unmanaged.
|
|
4723
4736
|
*/
|
|
4724
|
-
function planCustomers(org, live, claimed, recordedIds, metadataWired) {
|
|
4737
|
+
function planCustomers(org, live, claimed, recordedIds, metadataWired, policyAvailable = true) {
|
|
4725
4738
|
const signUp = org.customers?.signUp === true;
|
|
4726
4739
|
const wanted = org.customers?.enabled === true ||
|
|
4727
4740
|
org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers');
|
|
@@ -4776,8 +4789,14 @@ function planCustomers(org, live, claimed, recordedIds, metadataWired) {
|
|
|
4776
4789
|
blocked.push(signInMetadataBlocked(org));
|
|
4777
4790
|
const writeSignIn = signInDiffers && metadataWired;
|
|
4778
4791
|
const writeRoom = roomDiffers && metadataWired;
|
|
4779
|
-
|
|
4792
|
+
// The login policy needs no metadata write, so it is never blocked: a stack plans it once. A host whose
|
|
4793
|
+
// port cannot write it plans none, and no marker is owed.
|
|
4794
|
+
const writePolicy = wanted &&
|
|
4795
|
+
policyAvailable &&
|
|
4796
|
+
recordedIds[CUSTOMERS_POLICY_SCOPE_KEY] !== CUSTOMERS_POLICY_VERSION;
|
|
4797
|
+
if (applied === wantedSignUp && !writeSignIn && !writeRoom && !writePolicy) {
|
|
4780
4798
|
return { step: null, blocked };
|
|
4799
|
+
}
|
|
4781
4800
|
return {
|
|
4782
4801
|
step: {
|
|
4783
4802
|
action: {
|
|
@@ -4794,7 +4813,9 @@ function planCustomers(org, live, claimed, recordedIds, metadataWired) {
|
|
|
4794
4813
|
description: applied === wantedSignUp
|
|
4795
4814
|
? writeSignIn
|
|
4796
4815
|
? `${wantedSignIn ? 'set' : 'remove'} the emailed-code sign-in metadata of ${org.slug}'s customers`
|
|
4797
|
-
:
|
|
4816
|
+
: writeRoom
|
|
4817
|
+
? `${wantedRoom ? 'set' : 'remove'} the sign-in window text of ${org.slug}'s customers`
|
|
4818
|
+
: `set the login policy of ${org.slug}'s customers: every sign-in method and second step an app can choose`
|
|
4798
4819
|
: `turn self-registration ${wantedSignUp === '1' ? 'on' : 'off'} for ${org.slug}'s customers`,
|
|
4799
4820
|
destroys: false,
|
|
4800
4821
|
migrates: false,
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { GithubDeployKeysPort } from './github-deploy-keys.js';
|
|
|
17
17
|
import { type KeysRegistrarPort } from './keys-registrar.js';
|
|
18
18
|
import { type LoginHostBindingsPort } from './login-host.js';
|
|
19
19
|
import { type MailCopyPort } from './mail-copy.js';
|
|
20
|
+
import { type OwnAuthHead } from './own-diff.js';
|
|
20
21
|
import { type OwnServicesPort } from './own-services.js';
|
|
21
22
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
22
23
|
import type { EdgePortName } from './platform-org.js';
|
|
@@ -124,6 +125,8 @@ export interface CoolifyPort {
|
|
|
124
125
|
/** #788: where Coolify reports previews is not documented, so `previewsOn` reads both places. */
|
|
125
126
|
is_preview_deployments_enabled?: boolean;
|
|
126
127
|
custom_network_aliases?: string | null;
|
|
128
|
+
/** A fixed container name: while it is set Coolify runs no rolling update (`rollingUpdateSettings` clears it). */
|
|
129
|
+
custom_internal_name?: string | null;
|
|
127
130
|
health_check_enabled?: boolean;
|
|
128
131
|
ports_exposes?: string;
|
|
129
132
|
/** Coolify's own record of the resource limits (`plan/own-limits.ts`). */
|
|
@@ -1029,6 +1032,17 @@ export interface ZitadelPort {
|
|
|
1029
1032
|
enforceSecondFactor(orgId: string): Promise<{
|
|
1030
1033
|
changed: boolean;
|
|
1031
1034
|
}>;
|
|
1035
|
+
/**
|
|
1036
|
+
* Gives the customers org one fixed, permissive login policy that every customer app can narrow: username
|
|
1037
|
+
* and password allowed, passkeys allowed, no forced second factor, no external IdP, an authenticator app and
|
|
1038
|
+
* a security key as second factors, a user-verified passkey as the multi factor. `allowRegister` is left as
|
|
1039
|
+
* it is (`allowSelfRegistration` and `closeSelfRegistration` own it). An org on the instance default gets
|
|
1040
|
+
* its own policy. Idempotent: a policy already so set is no write. `zitadel-login-policy.ts` is the real
|
|
1041
|
+
* implementation. Optional: a host without it plans no policy (`customersLoginPolicyAvailable`).
|
|
1042
|
+
*/
|
|
1043
|
+
setCustomersLoginPolicy?(orgId: string): Promise<{
|
|
1044
|
+
changed: boolean;
|
|
1045
|
+
}>;
|
|
1032
1046
|
/** Grants another ZITADEL org access to a project (plan gap A9). Idempotent. */
|
|
1033
1047
|
findOrCreateProjectGrant(orgId: string, projectId: string, grantedOrgId: string): Promise<{
|
|
1034
1048
|
created: boolean;
|
|
@@ -1156,6 +1170,33 @@ export interface ZitadelPort {
|
|
|
1156
1170
|
token: string;
|
|
1157
1171
|
patId: string;
|
|
1158
1172
|
}>;
|
|
1173
|
+
/**
|
|
1174
|
+
* The sign-in broker's registrar user (`ZITADEL_REGISTRAR_TOKEN`, auth ADR 0003): a machine user of its
|
|
1175
|
+
* own in `orgIds[0]` with the project-creator role in every org of `orgIds`. Idempotent, mints no token,
|
|
1176
|
+
* so it is repeated whenever an org joins. Optional: a host without it gets the registrar token reported
|
|
1177
|
+
* blocked. `zitadel-machine-user.ts` is the real implementation.
|
|
1178
|
+
*/
|
|
1179
|
+
ensureRegistrarUser?(orgIds: readonly string[], input: {
|
|
1180
|
+
userName: string;
|
|
1181
|
+
name: string;
|
|
1182
|
+
}): Promise<{
|
|
1183
|
+
userId: string;
|
|
1184
|
+
}>;
|
|
1185
|
+
/**
|
|
1186
|
+
* A fresh token for the registrar user, made in its home org (`orgIds[0]` above); every token it holds is
|
|
1187
|
+
* revoked first. The caller holds the token and writes it to the broker's env; it never reaches an error,
|
|
1188
|
+
* a log or a recorded id.
|
|
1189
|
+
*/
|
|
1190
|
+
mintRegistrarToken?(homeOrgId: string, userId: string): Promise<{
|
|
1191
|
+
token: string;
|
|
1192
|
+
patId: string;
|
|
1193
|
+
}>;
|
|
1194
|
+
/**
|
|
1195
|
+
* Grants the directory user the read role and the user-management role in that one organisation, replacing
|
|
1196
|
+
* the roles it holds there; a user that is not a member yet is added. Idempotent. Optional: a host without
|
|
1197
|
+
* it leaves the directory user read-only, and Boule cannot remove a customer.
|
|
1198
|
+
*/
|
|
1199
|
+
ensureOrgDirectoryRoles?(orgId: string, userId: string): Promise<void>;
|
|
1159
1200
|
/** C3: revokes every PAT of the directory reader, so a company that turns customers off leaves no live credential. A PAT already gone is not an error. */
|
|
1160
1201
|
revokeOrgDirectoryReader?(orgId: string, userId: string): Promise<void>;
|
|
1161
1202
|
/**
|
|
@@ -2092,8 +2133,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2092
2133
|
private executeScoped;
|
|
2093
2134
|
/** Whether `ensureOwnAuthMailToken` can mint: the same two inputs it blocks on. */
|
|
2094
2135
|
ownMailKeyMintable(): boolean;
|
|
2136
|
+
/** Whether the ZITADEL port can grant the shared sign-in's registrar user a company's organisations. */
|
|
2137
|
+
registrarGrantsAvailable(): boolean;
|
|
2138
|
+
/** Whether the host's company ZITADEL port can make the broker's registrar token (`provision.auth.registrarTokenWritable`). */
|
|
2139
|
+
ownRegistrarTokenWritable(): boolean;
|
|
2140
|
+
/** Whether the ZITADEL port can set the customers organisation's login policy. Without it no policy is planned. */
|
|
2141
|
+
customersLoginPolicyAvailable(): boolean;
|
|
2095
2142
|
/** C3: whether the ZITADEL port can mint and revoke a customers directory credential. */
|
|
2096
2143
|
customersDirectoryAvailable(): boolean;
|
|
2144
|
+
/** Whether the ZITADEL port can grant the directory user its customer removal role. */
|
|
2145
|
+
customersDirectoryRolesAvailable(): boolean;
|
|
2097
2146
|
/** C3: whether the host supplied `mintBouleArchonCredential`. */
|
|
2098
2147
|
bouleArchonCredentialAvailable(): boolean;
|
|
2099
2148
|
/** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
|
|
@@ -2103,6 +2152,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2103
2152
|
* (`provision.auth.signinMailWritable`). The port is a lazy proxy that answers any name, so it cannot be probed.
|
|
2104
2153
|
*/
|
|
2105
2154
|
ownSigninMailWritable(): boolean;
|
|
2155
|
+
/**
|
|
2156
|
+
* #886: the head of the declared auth branch, read through the host's `ownServices.readBranchHead` under a
|
|
2157
|
+
* timeout. `undefined` when there is nothing to read: the ref is a tag or a commit (it names one build), or the
|
|
2158
|
+
* host supplies no read. A failed or malformed read is a fixed reason, never the cause (it can echo a credential).
|
|
2159
|
+
*/
|
|
2160
|
+
ownAuthHead(gitRef: string): Promise<OwnAuthHead | undefined>;
|
|
2106
2161
|
/** Whether the held keys can store a Worker app database's CA and certificate. */
|
|
2107
2162
|
workerAppDbKeysPuttable(): boolean;
|
|
2108
2163
|
/** Whether the host supplies the backup bucket a Worker app database dumps into (the files host's bucket and key ports). */
|
|
@@ -3513,6 +3568,11 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
3513
3568
|
* leaves one live token. `null` while the customers organisation is not recorded yet.
|
|
3514
3569
|
*/
|
|
3515
3570
|
private ensureCustomersDirectoryCredential;
|
|
3571
|
+
/**
|
|
3572
|
+
* A directory user made before it could remove customers gets that role in the customers organisation
|
|
3573
|
+
* (and in no other), once: the grant is idempotent and the marker is recorded after it.
|
|
3574
|
+
*/
|
|
3575
|
+
private ensureDirectoryRoles;
|
|
3516
3576
|
/** The held keys with a timeout on every call: a hung store stops the step with a plain line. */
|
|
3517
3577
|
private timedHeldKeys;
|
|
3518
3578
|
/** T3: the company's registrar key, held at `foundry:registrar:<org id>`; either step may make it. */
|
|
@@ -3529,6 +3589,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
3529
3589
|
* last and never holds the key; every error is redacted of it.
|
|
3530
3590
|
*/
|
|
3531
3591
|
private brokerRegistrar;
|
|
3592
|
+
/**
|
|
3593
|
+
* A company on the shared sign-in: the platform broker's registrar user holds the project-creator role in
|
|
3594
|
+
* the company's sign-in and customers organisations, so the broker can register its apps there. Repeated
|
|
3595
|
+
* whenever the organisations change; no token is made here (the platform's sign-in step makes it).
|
|
3596
|
+
*/
|
|
3597
|
+
private grantSharedRegistrar;
|
|
3532
3598
|
private customerGrantsCredential;
|
|
3533
3599
|
/**
|
|
3534
3600
|
* F13, app side: an entry with `uses.customerGrants` gets `CUSTOMER_GRANTS_CREDENTIAL` (made and held first,
|