@wtfalch/foundry 0.7.1 → 0.9.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/bootstrap/bootstrap.d.ts +12 -8
- package/dist/bootstrap/bootstrap.js +4 -12
- package/dist/bootstrap/declare.js +1 -5
- package/dist/bootstrap/versions.d.ts +8 -0
- package/dist/bootstrap/versions.js +31 -0
- package/dist/calls-sfu-deploy-support.d.ts +1 -1
- package/dist/calls-sfu-deploy-support.js +1 -1
- package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
- package/dist/calls-sfu-ssh-adapter.js +1 -1
- package/dist/calls-sfu.d.ts +5 -4
- package/dist/calls-sfu.js +5 -4
- package/dist/index.d.ts +4 -5
- package/dist/index.js +6 -3
- package/dist/migrations/0024_audit_events.sql +1 -1
- package/dist/migrations/0025_audit_display.sql +1 -1
- package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
- package/dist/migrations/0027_audit_chain.sql +1 -1
- package/dist/migrations/0028_audit_rls.sql +2 -2
- package/dist/migrations/0029_hosting_entries.sql +109 -0
- package/dist/migrations-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- package/dist/org.d.ts +59 -27
- package/dist/org.js +98 -26
- package/dist/plan/calls-matrix.d.ts +2 -0
- package/dist/plan/calls-matrix.js +2 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +100 -32
- package/dist/plan/executor.d.ts +324 -19
- package/dist/plan/executor.js +764 -34
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/login-host.d.ts +24 -0
- package/dist/plan/login-host.js +21 -0
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +26 -10
- package/dist/plan/platform-diff.d.ts +102 -12
- package/dist/plan/platform-diff.js +236 -15
- package/dist/plan/platform-providers.d.ts +43 -1
- package/dist/plan/platform-providers.js +245 -33
- package/dist/plan/zitadel-system-api.d.ts +64 -0
- package/dist/plan/zitadel-system-api.js +125 -0
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -1
- package/dist/platform.d.ts +1 -1
- package/dist/store/audit.d.ts +13 -17
- package/dist/store/audit.js +14 -18
- package/dist/store/postgres.d.ts +14 -0
- package/dist/store/postgres.js +15 -18
- package/dist/store/schema.d.ts +20 -1
- package/dist/store/schema.js +7 -1
- package/package.json +6 -3
- package/versions.json +1 -1
- package/dist/migrate.d.ts +0 -38
- package/dist/migrate.js +0 -47
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
|
|
2
2
|
import { isIPv4 } from 'node:net';
|
|
3
|
-
import { VERSIONS } from '../bootstrap/versions.js';
|
|
3
|
+
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
4
4
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
5
5
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
6
6
|
import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
|
|
7
|
-
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
7
|
+
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
8
8
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
9
|
+
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
9
10
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
10
11
|
function apexZoneKey(apex) {
|
|
11
12
|
return `cloudflare:zone:${apex}`;
|
|
@@ -93,6 +94,10 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
|
93
94
|
* #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
|
|
94
95
|
* ({@link provisionPlatformArchon}), once a host passes
|
|
95
96
|
* {@link PlatformArchonDeps}.
|
|
97
|
+
*
|
|
98
|
+
* #257 adds `coolify:backup:<service>`, a nightly R2 backup of each
|
|
99
|
+
* platform database ({@link provisionPlatformBackup}), once a host passes
|
|
100
|
+
* {@link PlatformBackupDeps}.
|
|
96
101
|
*/
|
|
97
102
|
export class FoundryPlatformStepExecutor {
|
|
98
103
|
cloudflare;
|
|
@@ -106,6 +111,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
106
111
|
files;
|
|
107
112
|
ai;
|
|
108
113
|
archon;
|
|
114
|
+
backup;
|
|
109
115
|
constructor(cloudflare, declaredOrgStore, options = {}) {
|
|
110
116
|
this.cloudflare = cloudflare;
|
|
111
117
|
this.declaredOrgStore = declaredOrgStore;
|
|
@@ -118,6 +124,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
118
124
|
this.files = options.files;
|
|
119
125
|
this.ai = options.ai;
|
|
120
126
|
this.archon = options.archon;
|
|
127
|
+
this.backup = options.backup;
|
|
121
128
|
}
|
|
122
129
|
async blockedSteps(platform, recordedIds = {}) {
|
|
123
130
|
const blocked = {};
|
|
@@ -131,8 +138,19 @@ export class FoundryPlatformStepExecutor {
|
|
|
131
138
|
}
|
|
132
139
|
else if (platform.auth.enabled && this.auth) {
|
|
133
140
|
const { reason: authBlocked, otherInputsMissing } = await authBrokerBlockedReason(this.auth, recordedIds);
|
|
134
|
-
if (authBlocked)
|
|
141
|
+
if (authBlocked) {
|
|
135
142
|
blocked.auth = authBlocked;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
// #248: the same "block, not throw" rule mail's own tag check
|
|
146
|
+
// follows. `VERSIONS.zitadel` is a real pin today, so this never
|
|
147
|
+
// fires in practice -- it is here so a future bump that lands a
|
|
148
|
+
// floating tag is caught the same way mail's would be.
|
|
149
|
+
const zitadelTagBlocked = floatingTagBlocked('ZITADEL', VERSIONS.zitadel, 'v4.17.3');
|
|
150
|
+
if (zitadelTagBlocked) {
|
|
151
|
+
blocked.auth = `the platform's sign-in is blocked, not planned: ${zitadelTagBlocked}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
136
154
|
// #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
|
|
137
155
|
if (otherInputsMissing.length > 0)
|
|
138
156
|
blocked.authOtherInputsMissing = otherInputsMissing;
|
|
@@ -198,6 +216,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
198
216
|
}
|
|
199
217
|
}
|
|
200
218
|
}
|
|
219
|
+
// Only once a database actually exists to back up -- the same
|
|
220
|
+
// "nothing to report yet" rule `planBackups` (`platform-diff.ts`)
|
|
221
|
+
// itself follows for "no database", so an otherwise-unrelated platform
|
|
222
|
+
// with no backup deps wired carries no standing blocked note.
|
|
223
|
+
if (!this.backup &&
|
|
224
|
+
PLATFORM_BACKUP_SERVICE_KEYS.some((service) => Boolean(recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]))) {
|
|
225
|
+
blocked.backupExecutorMissing = true;
|
|
226
|
+
}
|
|
201
227
|
return blocked;
|
|
202
228
|
}
|
|
203
229
|
/**
|
|
@@ -271,6 +297,12 @@ export class FoundryPlatformStepExecutor {
|
|
|
271
297
|
if (action.system === 'coolify' && action.kind === 'jobs') {
|
|
272
298
|
return this.coolifyJobs(action, context);
|
|
273
299
|
}
|
|
300
|
+
if (action.system === 'coolify' && action.kind === 'backup') {
|
|
301
|
+
if (!this.backup) {
|
|
302
|
+
throw new Error(`platform:backup:${action.key} needs FoundryPlatformStepExecutor to be constructed with its backup dependencies (#257)`);
|
|
303
|
+
}
|
|
304
|
+
return provisionPlatformBackup(action, context, this.backup);
|
|
305
|
+
}
|
|
274
306
|
if (action.system === 'sfu' && action.kind === 'livekit') {
|
|
275
307
|
return this.executeSfu();
|
|
276
308
|
}
|
|
@@ -464,7 +496,7 @@ const AUTH_BROKER_PORT = '3000';
|
|
|
464
496
|
* falls through to the login app's own ingress rule below (login v2 answers
|
|
465
497
|
* everything else, including the root).
|
|
466
498
|
*/
|
|
467
|
-
const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|ui/console|\\.well-known)(/|$)';
|
|
499
|
+
const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|debug/healthz|ui/console|\\.well-known)(/|$)';
|
|
468
500
|
/**
|
|
469
501
|
* `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`
|
|
470
502
|
* (`packages/auth/src/config.ts`) -- `deploy/proxy/Caddyfile`'s own
|
|
@@ -484,6 +516,14 @@ const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
|
|
|
484
516
|
/** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
|
|
485
517
|
const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
|
|
486
518
|
const PLATFORM_AUTH_ORG_NAME = 'foundry';
|
|
519
|
+
/**
|
|
520
|
+
* #283: the ref the new instance's IAM_OWNER machine token (a PAT) is
|
|
521
|
+
* delivered under, once. A host reads it back to build `zitadelForInstance`
|
|
522
|
+
* (see {@link provisionPlatformAuth}'s doc comment). Distinct from the admin
|
|
523
|
+
* key delivered under `zitadel:platform:admin`.
|
|
524
|
+
*/
|
|
525
|
+
export const PLATFORM_AUTH_INSTANCE_BEARER_REF = 'zitadel:platform:instanceBearer';
|
|
526
|
+
const PLATFORM_AUTH_BEARER_USER_NAME = 'foundry-instance-owner';
|
|
487
527
|
/**
|
|
488
528
|
* Retries `action` up to `attempts` times, sleeping increasingly between
|
|
489
529
|
* tries -- the same shape the admin machine user's own delivery retry (just
|
|
@@ -727,8 +767,24 @@ async function liveLoginClientTokenHash(coolify, appId) {
|
|
|
727
767
|
* first), so it will not start (`parseApps` throws on an empty list) until
|
|
728
768
|
* then either. Safe to run again: the next apply that registers an app
|
|
729
769
|
* redeploys the broker with a non-empty list.
|
|
730
|
-
* - Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
|
|
731
|
-
* bearer token for the new instance, which foundry cannot read from it
|
|
770
|
+
* - ~~Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
|
|
771
|
+
* bearer token for the new instance, which foundry cannot read from it.~~
|
|
772
|
+
* Closed by #283: see "The instance bearer" below.
|
|
773
|
+
*
|
|
774
|
+
* The instance bearer (#283): the container creates no instance of its own
|
|
775
|
+
* (`ZITADEL_FIRSTINSTANCE_SKIP`). foundry generates an RSA keypair, puts the
|
|
776
|
+
* public half in `ZITADEL_SYSTEMAPIUSERS` (a system API user with
|
|
777
|
+
* SYSTEM_OWNER only), and once the container answers calls ZITADEL's system
|
|
778
|
+
* API `CreateInstance` with a machine owner (IAM_OWNER of the new instance)
|
|
779
|
+
* signed by the private half, which lives in memory for that run only. The
|
|
780
|
+
* owner's PAT comes back once and is handed to
|
|
781
|
+
* {@link PlatformAuthDeps.deliverSecret} under
|
|
782
|
+
* {@link PLATFORM_AUTH_INSTANCE_BEARER_REF}, before anything else uses it. A
|
|
783
|
+
* host builds `zitadelForInstance` as `(url) => createZitadelAdminMinter({
|
|
784
|
+
* url, token: <the secret delivered under that ref> })` plus its own org
|
|
785
|
+
* calls; `zitadelForInstance` is first called after the delivery. No human
|
|
786
|
+
* admin is created: a person who needs console access is added by the host
|
|
787
|
+
* through that bearer.
|
|
732
788
|
*
|
|
733
789
|
* #197: a `cloudflared` connector for this tunnel is run
|
|
734
790
|
* ({@link ensureCloudflaredConnector}), so `hostname` answers.
|
|
@@ -809,6 +865,15 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
809
865
|
await deps.coolify.updateApplication(zitadelAppId, {
|
|
810
866
|
custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
|
|
811
867
|
});
|
|
868
|
+
// #251: only on create, never unconditionally -- nothing here patches
|
|
869
|
+
// `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
|
|
870
|
+
// already running, so that is the only moment this tag is actually live.
|
|
871
|
+
// Recording it every run, the way the app id above is, would make
|
|
872
|
+
// `versionNotAppliedLine` compare a bumped `VERSIONS.zitadel` against a
|
|
873
|
+
// value that was never applied to the running app.
|
|
874
|
+
if (zitadelApp.justCreated) {
|
|
875
|
+
await record(PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, AUTH_ZITADEL_IMAGE_TAG);
|
|
876
|
+
}
|
|
812
877
|
// ---- the zitadel-login app -----------------------------------------------
|
|
813
878
|
const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
|
|
814
879
|
const server = await resolvePlatformServer(deps.coolify);
|
|
@@ -859,7 +924,19 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
859
924
|
// same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
|
|
860
925
|
// this package mints itself rather than receiving from a provider.
|
|
861
926
|
const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
|
|
862
|
-
|
|
927
|
+
// #283: the instance is created once, with the system API, by a platform
|
|
928
|
+
// whose admin machine user is not recorded yet (one that already has it was
|
|
929
|
+
// built from a first instance and keeps it). The keypair is regenerated on
|
|
930
|
+
// any run that still has to create it, so the private half never needs to
|
|
931
|
+
// be held anywhere between runs.
|
|
932
|
+
const lostInstance = recorded[PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY];
|
|
933
|
+
if (lostInstance &&
|
|
934
|
+
!recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
|
|
935
|
+
!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
936
|
+
throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`));
|
|
937
|
+
}
|
|
938
|
+
const needsInstance = !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] && !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY];
|
|
939
|
+
const systemKeys = needsInstance ? generateSystemApiKeypair() : undefined;
|
|
863
940
|
const zitadelEnvs = [
|
|
864
941
|
{ key: 'ZITADEL_PORT', value: AUTH_ZITADEL_PORT },
|
|
865
942
|
{ key: 'ZITADEL_EXTERNALDOMAIN', value: hostname },
|
|
@@ -867,8 +944,7 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
867
944
|
{ key: 'ZITADEL_EXTERNALSECURE', value: 'true' },
|
|
868
945
|
{ key: 'ZITADEL_TLS_ENABLED', value: 'false' },
|
|
869
946
|
{ key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
|
|
870
|
-
{ key: '
|
|
871
|
-
{ key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED', value: 'false' },
|
|
947
|
+
{ key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
|
|
872
948
|
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
|
|
873
949
|
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI', value: `https://${hostname}/` },
|
|
874
950
|
{ key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2', value: `https://${hostname}/login?authRequest=` },
|
|
@@ -881,8 +957,12 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
881
957
|
];
|
|
882
958
|
if (masterkey)
|
|
883
959
|
zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
|
|
884
|
-
if (
|
|
885
|
-
zitadelEnvs.push({
|
|
960
|
+
if (systemKeys) {
|
|
961
|
+
zitadelEnvs.push({
|
|
962
|
+
key: 'ZITADEL_SYSTEMAPIUSERS',
|
|
963
|
+
value: systemApiUsersEnv(systemKeys.publicKey),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
886
966
|
const loginEnvs = [
|
|
887
967
|
{
|
|
888
968
|
key: 'ZITADEL_API_URL',
|
|
@@ -1011,6 +1091,50 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
1011
1091
|
// a caller that reaches the step anyway.
|
|
1012
1092
|
throw new Error(`platform:auth: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
1013
1093
|
}
|
|
1094
|
+
if (systemKeys) {
|
|
1095
|
+
// ZITADEL answers /debug/healthz without an instance; the openid
|
|
1096
|
+
// document below only exists once the instance does.
|
|
1097
|
+
await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep);
|
|
1098
|
+
// Sent exactly once. Anything that may have reached ZITADEL leaves a
|
|
1099
|
+
// marker first, so the next run refuses instead of creating again.
|
|
1100
|
+
let created;
|
|
1101
|
+
try {
|
|
1102
|
+
created = await createZitadelInstance({
|
|
1103
|
+
customDomain: hostname,
|
|
1104
|
+
privateKey: systemKeys.privateKey,
|
|
1105
|
+
instanceName: PLATFORM_AUTH_ORG_NAME,
|
|
1106
|
+
firstOrgName: PLATFORM_AUTH_ORG_NAME,
|
|
1107
|
+
owner: { userName: PLATFORM_AUTH_BEARER_USER_NAME, name: 'Foundry (instance owner)' },
|
|
1108
|
+
}, { fetch: runtime.fetch, now: runtime.now ?? Date.now });
|
|
1109
|
+
}
|
|
1110
|
+
catch (error) {
|
|
1111
|
+
if (error instanceof CreateInstanceError && error.kind !== 'rejected') {
|
|
1112
|
+
await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, 'unknown');
|
|
1113
|
+
}
|
|
1114
|
+
throw error;
|
|
1115
|
+
}
|
|
1116
|
+
const { instanceId, pat } = created;
|
|
1117
|
+
// Recorded before the PAT leaves this function, like the admin key below.
|
|
1118
|
+
await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, instanceId);
|
|
1119
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
1120
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1121
|
+
try {
|
|
1122
|
+
await deps.deliverSecret(PLATFORM_AUTH_INSTANCE_BEARER_REF, pat);
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
catch (error) {
|
|
1126
|
+
if (attempt >= attempts) {
|
|
1127
|
+
const reason = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
1128
|
+
pat,
|
|
1129
|
+
]);
|
|
1130
|
+
throw new Error(`platform:auth: the owner token of ZITADEL instance ${instanceId} was created but could not be delivered after ${attempts} tries (${reason}); ${instanceWithoutBearerMessage(hostname, `instance ${instanceId}`)}`);
|
|
1131
|
+
}
|
|
1132
|
+
await sleep(1000 * attempt);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
// The marker stays recorded but is ignored once this is set.
|
|
1136
|
+
await record(PLATFORM_AUTH_INSTANCE_SCOPE_KEY, instanceId);
|
|
1137
|
+
}
|
|
1014
1138
|
// Minting needs a running instance behind the tunnel: the first apply
|
|
1015
1139
|
// gets here right after the deploy.
|
|
1016
1140
|
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
|
|
@@ -1520,12 +1644,17 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1520
1644
|
await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
|
|
1521
1645
|
return { resourceId: appId };
|
|
1522
1646
|
}
|
|
1523
|
-
/**
|
|
1647
|
+
/**
|
|
1648
|
+
* Why the Stalwart image tag cannot be used, or `null`. #248: a thin wrapper
|
|
1649
|
+
* over the estate-wide {@link floatingTagBlocked} -- the mail step's own
|
|
1650
|
+
* message, naming `gitRef` since that is the declared field this tag
|
|
1651
|
+
* actually comes from.
|
|
1652
|
+
*/
|
|
1524
1653
|
function mailImageTagBlocked(tag) {
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1654
|
+
const reason = floatingTagBlocked('Stalwart', tag, 'v0.16.24');
|
|
1655
|
+
return reason
|
|
1656
|
+
? `the platform's mail server is blocked, not planned: ${reason} as the mail gitRef`
|
|
1657
|
+
: null;
|
|
1529
1658
|
}
|
|
1530
1659
|
/** Why the server's address cannot be the MX A record's content, or `null`. */
|
|
1531
1660
|
function mailServerIpBlocked(ip) {
|
|
@@ -1933,7 +2062,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1933
2062
|
// response was lost, or one made for another hostname: its client id
|
|
1934
2063
|
// cannot be read back (`listApps` does not carry it), so it is deleted and
|
|
1935
2064
|
// made again, as `zitadelApp` does.
|
|
1936
|
-
const orphan = (await zitadel.listApps(org.id, project.id)).find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
|
|
2065
|
+
const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
|
|
1937
2066
|
a.appType === 'web' &&
|
|
1938
2067
|
a.state !== 'APP_STATE_REMOVED');
|
|
1939
2068
|
if (orphan) {
|
|
@@ -3597,10 +3726,6 @@ async function ensureMasterkey(coolify, appId, databaseInUse) {
|
|
|
3597
3726
|
}
|
|
3598
3727
|
return randomBytes(24).toString('base64');
|
|
3599
3728
|
}
|
|
3600
|
-
/** `docs/deploy.md`: "ZITADEL_ADMIN_PASSWORD: 8+, upper, lower, digit, symbol". */
|
|
3601
|
-
function generateAdminPassword() {
|
|
3602
|
-
return `${randomBytes(18).toString('base64')}Aa1!`;
|
|
3603
|
-
}
|
|
3604
3729
|
/**
|
|
3605
3730
|
* #171: how the host signs a files management request with the key this step
|
|
3606
3731
|
* delivers under {@link FILES_MANAGEMENT_KEY_REF} (files'
|
|
@@ -3836,7 +3961,7 @@ async function provisionPlatformFiles(context, deps) {
|
|
|
3836
3961
|
// An app by this name here has no recorded secret (no undelivered id, or
|
|
3837
3962
|
// the throw above would have fired): a crash between the create and the
|
|
3838
3963
|
// record. Nothing was delivered, so it is deleted and made again.
|
|
3839
|
-
const stale = (await zitadel.listApps(org.id, project.id)).filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
|
|
3964
|
+
const stale = (await zitadel.listApps(org.id, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
|
|
3840
3965
|
for (const app of stale) {
|
|
3841
3966
|
try {
|
|
3842
3967
|
await zitadel.deleteApp(org.id, project.id, app.id);
|
|
@@ -4339,6 +4464,61 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
|
|
|
4339
4464
|
await sleep(interval);
|
|
4340
4465
|
}
|
|
4341
4466
|
}
|
|
4467
|
+
/** The name every platform database's backup is registered against -- one shared R2 bucket, one storage entry. */
|
|
4468
|
+
const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
|
|
4469
|
+
/** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
|
|
4470
|
+
const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
|
|
4471
|
+
/** Coolify's own name for "once a day" -- `createDatabaseBackup`'s `frequency`. */
|
|
4472
|
+
const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
|
|
4473
|
+
/**
|
|
4474
|
+
* #257: schedules a nightly R2 backup of one platform database --
|
|
4475
|
+
* `planBackups` (`platform-diff.ts`) plans this step once per service, each
|
|
4476
|
+
* carrying that service's own database id as `action.desired.databaseId`
|
|
4477
|
+
* (recorded by that service's own `coolify:app` step; never minted here).
|
|
4478
|
+
* The R2 bucket is registered as a Coolify S3 storage once, by name, and
|
|
4479
|
+
* its uuid recorded under {@link PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY} so
|
|
4480
|
+
* every service's own backup step -- and a rerun of this one -- reuses it
|
|
4481
|
+
* rather than registering a second storage entry under the same name.
|
|
4482
|
+
* `createDatabaseBackup` itself is the idempotency boundary for the
|
|
4483
|
+
* schedule: an existing enabled, `save_s3` schedule is left alone rather
|
|
4484
|
+
* than replaced, since Coolify has no "upsert" for this call and a second
|
|
4485
|
+
* schedule would dump the same database twice a night.
|
|
4486
|
+
*/
|
|
4487
|
+
async function provisionPlatformBackup(action, context, deps) {
|
|
4488
|
+
const { coolify, r2 } = deps;
|
|
4489
|
+
const databaseId = action.desired.databaseId;
|
|
4490
|
+
const recorded = context.recordedIds();
|
|
4491
|
+
let storageUuid = recorded[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY];
|
|
4492
|
+
if (!storageUuid) {
|
|
4493
|
+
const found = (await coolify.listS3Storages()).find((storage) => storage.name === PLATFORM_BACKUP_S3_STORAGE_NAME);
|
|
4494
|
+
if (found) {
|
|
4495
|
+
storageUuid = found.uuid;
|
|
4496
|
+
}
|
|
4497
|
+
else {
|
|
4498
|
+
const created = await coolify.createS3Storage({
|
|
4499
|
+
name: PLATFORM_BACKUP_S3_STORAGE_NAME,
|
|
4500
|
+
endpoint: r2.endpoint,
|
|
4501
|
+
bucket: r2.bucket,
|
|
4502
|
+
region: r2.region ?? 'auto',
|
|
4503
|
+
key: r2.accessKeyId,
|
|
4504
|
+
secret: r2.secretAccessKey,
|
|
4505
|
+
is_usable: true,
|
|
4506
|
+
});
|
|
4507
|
+
storageUuid = created.uuid;
|
|
4508
|
+
}
|
|
4509
|
+
context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, storageUuid);
|
|
4510
|
+
}
|
|
4511
|
+
const existing = (await coolify.listDatabaseBackups(databaseId)).find((backup) => backup.enabled && backup.save_s3);
|
|
4512
|
+
if (!existing) {
|
|
4513
|
+
await coolify.createDatabaseBackup(databaseId, {
|
|
4514
|
+
frequency: deps.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
|
|
4515
|
+
enabled: true,
|
|
4516
|
+
save_s3: true,
|
|
4517
|
+
s3_storage_uuid: storageUuid,
|
|
4518
|
+
});
|
|
4519
|
+
}
|
|
4520
|
+
return undefined;
|
|
4521
|
+
}
|
|
4342
4522
|
/**
|
|
4343
4523
|
* The real {@link PlatformCloudflareReader}: the platform's apex zone as
|
|
4344
4524
|
* `planApexZone` expects to see it, and nothing else. The zone's DNS records
|
|
@@ -4393,23 +4573,55 @@ export class ProvidersPlatformCoolifyReader {
|
|
|
4393
4573
|
this.platformStore = platformStore;
|
|
4394
4574
|
}
|
|
4395
4575
|
async listResources() {
|
|
4396
|
-
const
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
{
|
|
4576
|
+
const recorded = await this.platformStore.getServiceIds();
|
|
4577
|
+
const resources = [];
|
|
4578
|
+
const recordedAppId = recorded[JOBS_APP_KEY];
|
|
4579
|
+
if (recordedAppId) {
|
|
4580
|
+
try {
|
|
4581
|
+
const app = await this.client.getApplication(recordedAppId);
|
|
4582
|
+
resources.push({
|
|
4403
4583
|
system: 'coolify',
|
|
4404
4584
|
kind: 'jobs',
|
|
4405
4585
|
key: JOBS_SCHEDULER_KEY,
|
|
4406
4586
|
config: { uuid: app.uuid, status: app.status },
|
|
4407
|
-
}
|
|
4408
|
-
|
|
4587
|
+
});
|
|
4588
|
+
}
|
|
4589
|
+
catch {
|
|
4590
|
+
// A recorded id Coolify no longer has reads as not-live (see this
|
|
4591
|
+
// class's own doc comment); the scheduler, not every other resource
|
|
4592
|
+
// this reader checks.
|
|
4593
|
+
}
|
|
4409
4594
|
}
|
|
4410
|
-
|
|
4411
|
-
|
|
4595
|
+
// #257: one backup-schedule resource per service whose database is
|
|
4596
|
+
// recorded, found the only way a schedule can be: listed off that
|
|
4597
|
+
// database itself, since Coolify names no schedule of its own.
|
|
4598
|
+
for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
|
|
4599
|
+
const databaseId = recorded[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]];
|
|
4600
|
+
if (!databaseId)
|
|
4601
|
+
continue;
|
|
4602
|
+
try {
|
|
4603
|
+
const backups = await this.client.listDatabaseBackups(databaseId);
|
|
4604
|
+
const scheduled = backups.find((backup) => backup.enabled && backup.save_s3);
|
|
4605
|
+
if (scheduled) {
|
|
4606
|
+
resources.push({
|
|
4607
|
+
system: 'coolify',
|
|
4608
|
+
kind: 'backup',
|
|
4609
|
+
key: service,
|
|
4610
|
+
config: { uuid: scheduled.uuid, frequency: scheduled.frequency },
|
|
4611
|
+
});
|
|
4612
|
+
}
|
|
4613
|
+
}
|
|
4614
|
+
catch {
|
|
4615
|
+
// A Coolify error (or a recorded database id Coolify no longer has)
|
|
4616
|
+
// reads as not-live, the same as the jobs app above -- `databaseId`
|
|
4617
|
+
// is still recorded, so `planBackups` plans the step again rather
|
|
4618
|
+
// than reporting it blocked; `provisionPlatformBackup`'s own
|
|
4619
|
+
// `listDatabaseBackups` check (run again there) is what keeps a
|
|
4620
|
+
// transient failure here from scheduling a second backup once the
|
|
4621
|
+
// first one did succeed.
|
|
4622
|
+
}
|
|
4412
4623
|
}
|
|
4624
|
+
return resources;
|
|
4413
4625
|
}
|
|
4414
4626
|
}
|
|
4415
4627
|
/**
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #283: foundry's way into a brand-new ZITADEL instance. ZITADEL's system API
|
|
3
|
+
* (`POST /system/v1/instances/_create`, permission `system.instance.write`,
|
|
4
|
+
* https://github.com/zitadel/zitadel/blob/main/proto/zitadel/system.proto)
|
|
5
|
+
* creates the instance with a machine owner (IAM_OWNER of that instance) and
|
|
6
|
+
* returns that owner's personal access token (`pat`) once, inline. foundry
|
|
7
|
+
* authenticates to the system API with a short-lived RS256 JWT signed by a
|
|
8
|
+
* keypair it generates: the public half goes into the container as a system
|
|
9
|
+
* API user, the private half stays in memory for one run. The calls follow
|
|
10
|
+
* ZITADEL's source and proto and have not been run against a live instance.
|
|
11
|
+
*/
|
|
12
|
+
/** The system API user's name; it is the JWT's `iss` and `sub`. */
|
|
13
|
+
export declare const SYSTEM_API_USER_NAME = "foundry";
|
|
14
|
+
/** A fresh RSA keypair: the public key (PEM) for ZITADEL, the private key (PKCS#8 PEM) for signing. */
|
|
15
|
+
export declare function generateSystemApiKeypair(): {
|
|
16
|
+
publicKey: string;
|
|
17
|
+
privateKey: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The value of `ZITADEL_SYSTEMAPIUSERS`: JSON in ZITADEL's `SystemAPIUsers`
|
|
21
|
+
* shape (`cmd/defaults.yaml`), `KeyData` being the base64 of the public key
|
|
22
|
+
* PEM. With no `Memberships` ZITADEL grants exactly System/SYSTEM_OWNER.
|
|
23
|
+
*/
|
|
24
|
+
export declare function systemApiUsersEnv(publicKeyPem: string): string;
|
|
25
|
+
/** A 1-minute RS256 JWT for the system API: `iss` = `sub` = the user name, `aud` = the instance URL. */
|
|
26
|
+
export declare function signSystemApiJwt(privateKeyPem: string, audience: string, nowMs: number): string;
|
|
27
|
+
/**
|
|
28
|
+
* How a create failed. `rejected`: ZITADEL refused it for good before doing
|
|
29
|
+
* anything (bad key, bad request), so nothing exists. `exists`: an instance
|
|
30
|
+
* for the domain already exists. `uncertain`: the request may have reached
|
|
31
|
+
* ZITADEL (timeout, network error, 5xx, an unreadable answer), so an instance
|
|
32
|
+
* may exist. The caller records a marker for the last two.
|
|
33
|
+
*/
|
|
34
|
+
export declare class CreateInstanceError extends Error {
|
|
35
|
+
readonly kind: 'rejected' | 'exists' | 'uncertain';
|
|
36
|
+
constructor(kind: 'rejected' | 'exists' | 'uncertain', message: string);
|
|
37
|
+
}
|
|
38
|
+
/** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
|
|
39
|
+
export declare function instanceWithoutBearerMessage(hostname: string, detail: string): string;
|
|
40
|
+
export interface CreateInstanceRuntime {
|
|
41
|
+
fetch?: typeof fetch;
|
|
42
|
+
now: () => number;
|
|
43
|
+
/** Fetch timeout for the one create call (default 30s). */
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Creates the platform's ZITADEL instance on `customDomain` with one machine
|
|
48
|
+
* owner and returns its id and the owner's PAT. Sends exactly one request,
|
|
49
|
+
* never retries, and signs a fresh 60-second JWT for it. The PAT is returned
|
|
50
|
+
* to the caller only, never logged or put in an error.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createZitadelInstance(input: {
|
|
53
|
+
customDomain: string;
|
|
54
|
+
privateKey: string;
|
|
55
|
+
instanceName: string;
|
|
56
|
+
firstOrgName: string;
|
|
57
|
+
owner: {
|
|
58
|
+
userName: string;
|
|
59
|
+
name: string;
|
|
60
|
+
};
|
|
61
|
+
}, runtime: CreateInstanceRuntime): Promise<{
|
|
62
|
+
instanceId: string;
|
|
63
|
+
pat: string;
|
|
64
|
+
}>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createSign, generateKeyPairSync } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* #283: foundry's way into a brand-new ZITADEL instance. ZITADEL's system API
|
|
4
|
+
* (`POST /system/v1/instances/_create`, permission `system.instance.write`,
|
|
5
|
+
* https://github.com/zitadel/zitadel/blob/main/proto/zitadel/system.proto)
|
|
6
|
+
* creates the instance with a machine owner (IAM_OWNER of that instance) and
|
|
7
|
+
* returns that owner's personal access token (`pat`) once, inline. foundry
|
|
8
|
+
* authenticates to the system API with a short-lived RS256 JWT signed by a
|
|
9
|
+
* keypair it generates: the public half goes into the container as a system
|
|
10
|
+
* API user, the private half stays in memory for one run. The calls follow
|
|
11
|
+
* ZITADEL's source and proto and have not been run against a live instance.
|
|
12
|
+
*/
|
|
13
|
+
/** The system API user's name; it is the JWT's `iss` and `sub`. */
|
|
14
|
+
export const SYSTEM_API_USER_NAME = 'foundry';
|
|
15
|
+
/** A fresh RSA keypair: the public key (PEM) for ZITADEL, the private key (PKCS#8 PEM) for signing. */
|
|
16
|
+
export function generateSystemApiKeypair() {
|
|
17
|
+
return generateKeyPairSync('rsa', {
|
|
18
|
+
modulusLength: 2048,
|
|
19
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
20
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The value of `ZITADEL_SYSTEMAPIUSERS`: JSON in ZITADEL's `SystemAPIUsers`
|
|
25
|
+
* shape (`cmd/defaults.yaml`), `KeyData` being the base64 of the public key
|
|
26
|
+
* PEM. With no `Memberships` ZITADEL grants exactly System/SYSTEM_OWNER.
|
|
27
|
+
*/
|
|
28
|
+
export function systemApiUsersEnv(publicKeyPem) {
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
[SYSTEM_API_USER_NAME]: { KeyData: Buffer.from(publicKeyPem).toString('base64') },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const b64url = (input) => Buffer.from(input).toString('base64url');
|
|
34
|
+
/** A 1-minute RS256 JWT for the system API: `iss` = `sub` = the user name, `aud` = the instance URL. */
|
|
35
|
+
export function signSystemApiJwt(privateKeyPem, audience, nowMs) {
|
|
36
|
+
const iat = Math.floor(nowMs / 1000);
|
|
37
|
+
const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
38
|
+
const body = b64url(JSON.stringify({
|
|
39
|
+
iss: SYSTEM_API_USER_NAME,
|
|
40
|
+
sub: SYSTEM_API_USER_NAME,
|
|
41
|
+
aud: audience,
|
|
42
|
+
iat,
|
|
43
|
+
exp: iat + 60,
|
|
44
|
+
}));
|
|
45
|
+
const signature = createSign('RSA-SHA256').update(`${head}.${body}`).sign(privateKeyPem);
|
|
46
|
+
return `${head}.${body}.${b64url(signature)}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* How a create failed. `rejected`: ZITADEL refused it for good before doing
|
|
50
|
+
* anything (bad key, bad request), so nothing exists. `exists`: an instance
|
|
51
|
+
* for the domain already exists. `uncertain`: the request may have reached
|
|
52
|
+
* ZITADEL (timeout, network error, 5xx, an unreadable answer), so an instance
|
|
53
|
+
* may exist. The caller records a marker for the last two.
|
|
54
|
+
*/
|
|
55
|
+
export class CreateInstanceError extends Error {
|
|
56
|
+
kind;
|
|
57
|
+
constructor(kind, message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.kind = kind;
|
|
60
|
+
this.name = 'CreateInstanceError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
|
|
64
|
+
export function instanceWithoutBearerMessage(hostname, detail) {
|
|
65
|
+
return `platform:auth: a ZITADEL instance may already exist for ${hostname} without a delivered bearer (${detail}). To recover, delete the instance with the system API (DELETE /system/v1/instances/{id}) or redeploy the ZITADEL database, remove the recorded key zitadel_platform_instance_undelivered, then apply again`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Creates the platform's ZITADEL instance on `customDomain` with one machine
|
|
69
|
+
* owner and returns its id and the owner's PAT. Sends exactly one request,
|
|
70
|
+
* never retries, and signs a fresh 60-second JWT for it. The PAT is returned
|
|
71
|
+
* to the caller only, never logged or put in an error.
|
|
72
|
+
*/
|
|
73
|
+
export async function createZitadelInstance(input, runtime) {
|
|
74
|
+
const fetchImpl = runtime.fetch ?? globalThis.fetch;
|
|
75
|
+
const url = `https://${input.customDomain}`;
|
|
76
|
+
let response;
|
|
77
|
+
try {
|
|
78
|
+
response = await fetchImpl(`${url}/system/v1/instances/_create`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
signal: AbortSignal.timeout(runtime.timeoutMs ?? 30_000),
|
|
81
|
+
headers: {
|
|
82
|
+
authorization: `Bearer ${signSystemApiJwt(input.privateKey, url, runtime.now())}`,
|
|
83
|
+
'content-type': 'application/json',
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
instanceName: input.instanceName,
|
|
87
|
+
firstOrgName: input.firstOrgName,
|
|
88
|
+
customDomain: input.customDomain,
|
|
89
|
+
machine: {
|
|
90
|
+
userName: input.owner.userName,
|
|
91
|
+
name: input.owner.name,
|
|
92
|
+
personalAccessToken: {
|
|
93
|
+
expirationDate: new Date(runtime.now() + 10 * 365 * 86_400_000).toISOString(),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the create request timed out or failed'));
|
|
101
|
+
}
|
|
102
|
+
if (response.status === 401) {
|
|
103
|
+
throw new CreateInstanceError('rejected', 'platform:auth: system API key rejected (HTTP 401); nothing was created');
|
|
104
|
+
}
|
|
105
|
+
if (response.status === 409) {
|
|
106
|
+
throw new CreateInstanceError('exists', instanceWithoutBearerMessage(input.customDomain, 'ZITADEL answered 409, already exists'));
|
|
107
|
+
}
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
// A 4xx is a refusal before anything was created; a 5xx may not be.
|
|
110
|
+
throw new CreateInstanceError(response.status < 500 ? 'rejected' : 'uncertain', response.status < 500
|
|
111
|
+
? `platform:auth: ZITADEL refused to create the instance (HTTP ${response.status}); nothing was created`
|
|
112
|
+
: instanceWithoutBearerMessage(input.customDomain, `ZITADEL answered HTTP ${response.status}`));
|
|
113
|
+
}
|
|
114
|
+
let body = {};
|
|
115
|
+
try {
|
|
116
|
+
body = (await response.json());
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Handled below.
|
|
120
|
+
}
|
|
121
|
+
if (!body.instanceId || !body.pat) {
|
|
122
|
+
throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the answer had no instance id or token'));
|
|
123
|
+
}
|
|
124
|
+
return { instanceId: body.instanceId, pat: body.pat };
|
|
125
|
+
}
|