@wtfalch/foundry 0.23.0 → 0.24.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/declare.js +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0048_hosting_gate_limit.sql +7 -0
- package/dist/org.d.ts +27 -23
- package/dist/org.js +73 -29
- package/dist/plan/diff.js +36 -28
- package/dist/plan/edge-access.d.ts +34 -1
- package/dist/plan/edge-access.js +30 -1
- package/dist/plan/executor.d.ts +36 -16
- package/dist/plan/executor.js +93 -47
- package/dist/plan/gates.d.ts +42 -0
- package/dist/plan/gates.js +126 -0
- package/dist/plan/health-targets.js +3 -4
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +8 -1
- package/dist/plan/platform-diff.d.ts +14 -0
- package/dist/plan/platform-diff.js +78 -0
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -0
- package/dist/plan/platform-providers.d.ts +24 -0
- package/dist/plan/platform-providers.js +110 -2
- package/dist/store/postgres.js +27 -5
- package/dist/store/schema.d.ts +17 -0
- package/dist/store/schema.js +2 -0
- package/package.json +2 -2
package/dist/plan/executor.js
CHANGED
|
@@ -11,7 +11,7 @@ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
|
11
11
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
12
12
|
import { waitForNewDeployment, } from './deploy-health.js';
|
|
13
13
|
import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
|
|
14
|
-
import { createEdgeAccess, } from './edge-access.js';
|
|
14
|
+
import { createEdgeAccess, isNotFound as isEdgeNotFound, } from './edge-access.js';
|
|
15
15
|
import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
|
|
16
16
|
import { createEdgeRateLimits, } from './edge-ratelimits.js';
|
|
17
17
|
import { createEdgeTunnels } from './edge-tunnels.js';
|
|
@@ -19,6 +19,7 @@ import { createEdgeZones } from './edge-zones.js';
|
|
|
19
19
|
import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
|
|
20
20
|
import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
21
21
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
22
|
+
import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
|
|
22
23
|
import { healthCheckSettings } from './health-check-settings.js';
|
|
23
24
|
import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
|
|
24
25
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
@@ -184,18 +185,14 @@ function mailOrgFor(org, usesDomain, mode) {
|
|
|
184
185
|
* value.
|
|
185
186
|
*/
|
|
186
187
|
const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
|
|
187
|
-
/** The one Access policy foundry puts on
|
|
188
|
+
/** The one Access policy foundry puts on a platform Access app (#84). */
|
|
188
189
|
export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
|
|
189
190
|
/**
|
|
190
191
|
* The Access policy name for each `hosting.access` rule kind (#246),
|
|
191
192
|
* `cloudflareAccess`'s way of telling its own policy apart from a foreign
|
|
192
|
-
* one on the same app
|
|
193
|
-
* the `app` entry's own email gate, which keeps that exact name so a
|
|
194
|
-
* re-run against an org applied before #246 still recognises its own
|
|
195
|
-
* policy.
|
|
193
|
+
* one on the same app.
|
|
196
194
|
*/
|
|
197
195
|
const ACCESS_RULE_POLICY_NAMES = {
|
|
198
|
-
gate: ACCESS_POLICY_NAME,
|
|
199
196
|
bypass: 'foundry: bypass',
|
|
200
197
|
'service-token': 'foundry: service token',
|
|
201
198
|
};
|
|
@@ -721,13 +718,10 @@ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
721
718
|
* redirect or 403.
|
|
722
719
|
*/
|
|
723
720
|
function publicHealthCheck(org, recorded, hostname, path, options = {}) {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
(app !== undefined &&
|
|
727
|
-
app.accessEmails.length > 0 &&
|
|
728
|
-
entryHostnames(org, app).includes(hostname));
|
|
729
|
-
if (behindAccess)
|
|
721
|
+
if ((org.hosting.access ?? []).some((rule) => rule.hostname === hostname) ||
|
|
722
|
+
isBehindGate(org, hostname)) {
|
|
730
723
|
return { kind: 'deployment-only' };
|
|
724
|
+
}
|
|
731
725
|
const routed = (host) => !org.domain ||
|
|
732
726
|
!(host === org.domain || host.endsWith(`.${org.domain}`)) ||
|
|
733
727
|
(recorded[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY] ?? '').split(',').includes(host);
|
|
@@ -798,6 +792,8 @@ export class FoundryStepExecutor {
|
|
|
798
792
|
return this.cloudflareZone(action, org, context);
|
|
799
793
|
case 'cloudflare:access':
|
|
800
794
|
return this.cloudflareAccess(action, org);
|
|
795
|
+
case 'cloudflare:gate':
|
|
796
|
+
return this.cloudflareGate(action, org, context);
|
|
801
797
|
case 'cloudflare:ratelimit':
|
|
802
798
|
return this.cloudflareRateLimit(org);
|
|
803
799
|
case 'cloudflare:worker':
|
|
@@ -843,40 +839,27 @@ export class FoundryStepExecutor {
|
|
|
843
839
|
}
|
|
844
840
|
}
|
|
845
841
|
/**
|
|
846
|
-
* `plan/diff.ts`'s `planAccess` (#
|
|
847
|
-
* behind a Cloudflare Access app
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
* against an org applied before #246 still finds the app it already
|
|
855
|
-
* created; a rule's own app is named `foundry:<orgId>:<hostname>` instead,
|
|
856
|
-
* since an org may declare more than one. Either way foundry reuses only
|
|
857
|
-
* an app with its own name (so a re-run after a crash does not duplicate
|
|
858
|
-
* it), refuses to adopt a hostname another Access app already holds
|
|
859
|
-
* (destroy would later delete it), and refuses when its own app carries
|
|
860
|
-
* any policy but foundry's, since that policy could let others in.
|
|
842
|
+
* `plan/diff.ts`'s `planAccess` (#246): puts a `hosting.access` rule's
|
|
843
|
+
* hostname behind a Cloudflare Access app named
|
|
844
|
+
* `foundry:<orgId>:<hostname>`, since an org may declare more than one.
|
|
845
|
+
* Foundry reuses only an app with its own name (so a re-run after a crash
|
|
846
|
+
* does not duplicate it), refuses to adopt a hostname another Access app
|
|
847
|
+
* already holds (destroy would later delete it), and refuses when its own
|
|
848
|
+
* app carries any policy but foundry's, since that policy could let others
|
|
849
|
+
* in.
|
|
861
850
|
*/
|
|
862
851
|
async cloudflareAccess(action, org) {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
|
|
868
|
-
if (action.key === appStepKey(org, entry.key)) {
|
|
852
|
+
const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
|
|
853
|
+
const owned = action.desired.ownedBypass;
|
|
854
|
+
if (!rule && owned && appStepKey(org, owned) === action.key) {
|
|
855
|
+
// A name foundry owns under a '*' gate: the same bypass a declared rule makes.
|
|
869
856
|
return this.cloudflareAccessApp(org, {
|
|
870
|
-
appName: `foundry:${org.id}`,
|
|
871
|
-
domain:
|
|
872
|
-
policyName:
|
|
873
|
-
policy: {
|
|
874
|
-
decision: 'allow',
|
|
875
|
-
include: entry.accessEmails.map((email) => ({ email: { email } })),
|
|
876
|
-
},
|
|
857
|
+
appName: `foundry:${org.id}:${owned}`,
|
|
858
|
+
domain: owned,
|
|
859
|
+
policyName: ACCESS_RULE_POLICY_NAMES.bypass,
|
|
860
|
+
policy: await this.accessPolicyFor(org, { kind: 'bypass', hostname: owned }),
|
|
877
861
|
});
|
|
878
862
|
}
|
|
879
|
-
const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
|
|
880
863
|
if (!rule) {
|
|
881
864
|
throw new Error(`cloudflare:access for ${org.slug}: no hosting.access rule matches step key '${action.key}'`);
|
|
882
865
|
}
|
|
@@ -887,14 +870,74 @@ export class FoundryStepExecutor {
|
|
|
887
870
|
policy: await this.accessPolicyFor(org, rule),
|
|
888
871
|
});
|
|
889
872
|
}
|
|
873
|
+
/**
|
|
874
|
+
* `plan/diff.ts`'s `planGates`: makes, changes or deletes one declared
|
|
875
|
+
* gate through edge. A create needs the platform's recorded Access provider,
|
|
876
|
+
* the company's sign-in organisation and an owner with an email, and fails
|
|
877
|
+
* plainly without edge being called when one is missing. The create seeds
|
|
878
|
+
* the list of people with the owners' emails; nothing here sets people
|
|
879
|
+
* again. The gate id is recorded from the return value, the hostnames it
|
|
880
|
+
* has last been made with as a scope id.
|
|
881
|
+
*/
|
|
882
|
+
async cloudflareGate(action, org, context) {
|
|
883
|
+
const gateKey = action.key.slice(org.id.length + 1);
|
|
884
|
+
const at = `cloudflare:gate for ${org.slug} gate '${gateKey}'`;
|
|
885
|
+
const desired = action.desired;
|
|
886
|
+
const recorded = context.recordedIds();
|
|
887
|
+
const gateId = recorded[gateIdRecordKey(org.id, gateKey)];
|
|
888
|
+
const hostnamesKey = gateHostnamesScopeKey(gateKey);
|
|
889
|
+
if (desired.mode === 'delete') {
|
|
890
|
+
if (gateId)
|
|
891
|
+
await this.accessPort().deleteGate(gateId);
|
|
892
|
+
if (context.forgetId) {
|
|
893
|
+
context.forgetId(gateIdRecordKey(org.id, gateKey));
|
|
894
|
+
context.forgetId(hostnamesKey);
|
|
895
|
+
}
|
|
896
|
+
return {};
|
|
897
|
+
}
|
|
898
|
+
const hostnames = desired.hostnames ?? [];
|
|
899
|
+
if (gateId) {
|
|
900
|
+
try {
|
|
901
|
+
await this.accessPort().replaceGateHostnames(gateId, hostnames);
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
if (!isEdgeNotFound(error))
|
|
905
|
+
throw error;
|
|
906
|
+
// The gate is gone at edge: forget it, so the next plan creates it again.
|
|
907
|
+
context.forgetId?.(gateIdRecordKey(org.id, gateKey));
|
|
908
|
+
context.forgetId?.(hostnamesKey);
|
|
909
|
+
return {};
|
|
910
|
+
}
|
|
911
|
+
context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
|
|
912
|
+
return {};
|
|
913
|
+
}
|
|
914
|
+
if (!desired.providerId) {
|
|
915
|
+
throw new Error(`${at}: the platform's sign-in provider step has not been applied yet, so there is no Access provider to require`);
|
|
916
|
+
}
|
|
917
|
+
const claimValue = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
918
|
+
if (!claimValue) {
|
|
919
|
+
throw new Error(`${at}: ${org.slug} has no sign-in organisation recorded yet`);
|
|
920
|
+
}
|
|
921
|
+
const emails = org.owners.map((owner) => owner.email);
|
|
922
|
+
if (emails.length === 0) {
|
|
923
|
+
throw new Error(`${at}: ${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`);
|
|
924
|
+
}
|
|
925
|
+
const created = await this.accessPort().createGate({
|
|
926
|
+
organisationId: org.id,
|
|
927
|
+
key: gateKey,
|
|
928
|
+
entry: desired.entry,
|
|
929
|
+
hostnames,
|
|
930
|
+
providerId: desired.providerId,
|
|
931
|
+
claimName: GATE_CLAIM_NAME,
|
|
932
|
+
claimValue,
|
|
933
|
+
emails,
|
|
934
|
+
});
|
|
935
|
+
context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
|
|
936
|
+
return { resourceId: created.id };
|
|
937
|
+
}
|
|
890
938
|
/** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
|
|
891
939
|
async accessPolicyFor(org, rule) {
|
|
892
940
|
switch (rule.kind) {
|
|
893
|
-
case 'gate':
|
|
894
|
-
return {
|
|
895
|
-
decision: 'allow',
|
|
896
|
-
include: rule.emails.map((email) => ({ email: { email } })),
|
|
897
|
-
};
|
|
898
941
|
case 'bypass':
|
|
899
942
|
// A bypass policy still needs an `include` rule to match against;
|
|
900
943
|
// `everyone` is Cloudflare's own shape for "match every visitor".
|
|
@@ -5996,6 +6039,9 @@ export class FoundryStepExecutor {
|
|
|
5996
6039
|
case 'cloudflare:access':
|
|
5997
6040
|
await this.accessPort().deleteAccessApp(target.id);
|
|
5998
6041
|
return;
|
|
6042
|
+
case 'cloudflare:gate':
|
|
6043
|
+
await this.accessPort().deleteGate(target.id);
|
|
6044
|
+
return;
|
|
5999
6045
|
case 'ai:key': {
|
|
6000
6046
|
// F6a: an entry's key (`<orgId>:<entry>`). REVOKE SEAM (ai#157): a
|
|
6001
6047
|
// port with no `revokeKey` leaves it live until it expires.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* A company's declared gates (`HostingEntry.gates`) as Cloudflare Access gates
|
|
5
|
+
* made through edge. Foundry sets who passes once, on create, from the
|
|
6
|
+
* company's owners; it never writes the list of people again, so a company's
|
|
7
|
+
* admin app owns it from then on.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The platform's recorded id of the Access provider that signs people in
|
|
11
|
+
* through the platform's ZITADEL. The platform's sign-in provider step writes
|
|
12
|
+
* it; a gate is never made without it. A flat scope key, no `:`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ACCESS_PROVIDER_RECORD_KEY = "access_provider_id";
|
|
15
|
+
/** The claim every gate requires: the company's ZITADEL organisation id, on every token ZITADEL issues. */
|
|
16
|
+
export declare const GATE_CLAIM_NAME = "urn:zitadel:iam:user:resourceowner:id";
|
|
17
|
+
/** The gate id edge returned, recorded by `runSteps` from the step's `resourceId`. */
|
|
18
|
+
export declare const gateIdRecordKey: (orgId: string, gateKey: string) => string;
|
|
19
|
+
/** The resolved, sorted hostnames the gate was last made or changed with, as JSON. */
|
|
20
|
+
export declare const gateHostnamesScopeKey: (gateKey: string) => string;
|
|
21
|
+
/** `'@'` is the domain, `'*'` is `*.<domain>`, a label is `<label>.<domain>`. Sorted. */
|
|
22
|
+
export declare function resolveGateHostnames(domain: string, names: readonly string[]): string[];
|
|
23
|
+
/** True when a declared gate resolves to `*.<domain>`. */
|
|
24
|
+
export declare function hasWildcardGate(org: DeclaredOrg): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* True when a declared gate covers `hostname`, exactly or through a gated
|
|
27
|
+
* wildcard, unless a `bypass` rule names it exactly: that rule wins. A name
|
|
28
|
+
* foundry owns is never behind a gate: the plan bypasses it under a wildcard.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isBehindGate(org: DeclaredOrg, hostname: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* One step per declared gate whose recorded id or hostnames differ from what
|
|
33
|
+
* is declared (a create, or a hostname change), and one destroying step per
|
|
34
|
+
* recorded gate no longer declared. `providerId` is the platform's recorded
|
|
35
|
+
* provider. A create needs it, the company's sign-in organisation and an
|
|
36
|
+
* owner with an email: when one is missing no create is planned and
|
|
37
|
+
* `blocked` says why (CLAUDE.md rule 2).
|
|
38
|
+
*/
|
|
39
|
+
export declare function planGates(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, providerId: string | null): {
|
|
40
|
+
steps: DraftStep[];
|
|
41
|
+
blocked: string | null;
|
|
42
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
2
|
+
import { lockedHostnames } from './locked-hostnames.js';
|
|
3
|
+
import { appStepKey } from './platform-diff.js';
|
|
4
|
+
/**
|
|
5
|
+
* A company's declared gates (`HostingEntry.gates`) as Cloudflare Access gates
|
|
6
|
+
* made through edge. Foundry sets who passes once, on create, from the
|
|
7
|
+
* company's owners; it never writes the list of people again, so a company's
|
|
8
|
+
* admin app owns it from then on.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The platform's recorded id of the Access provider that signs people in
|
|
12
|
+
* through the platform's ZITADEL. The platform's sign-in provider step writes
|
|
13
|
+
* it; a gate is never made without it. A flat scope key, no `:`.
|
|
14
|
+
*/
|
|
15
|
+
export const ACCESS_PROVIDER_RECORD_KEY = 'access_provider_id';
|
|
16
|
+
/** The claim every gate requires: the company's ZITADEL organisation id, on every token ZITADEL issues. */
|
|
17
|
+
export const GATE_CLAIM_NAME = 'urn:zitadel:iam:user:resourceowner:id';
|
|
18
|
+
/** The gate id edge returned, recorded by `runSteps` from the step's `resourceId`. */
|
|
19
|
+
export const gateIdRecordKey = (orgId, gateKey) => `cloudflare:gate:${orgId}:${gateKey}`;
|
|
20
|
+
/** The resolved, sorted hostnames the gate was last made or changed with, as JSON. */
|
|
21
|
+
export const gateHostnamesScopeKey = (gateKey) => `gate_hostnames_${gateKey}`;
|
|
22
|
+
/** `'@'` is the domain, `'*'` is `*.<domain>`, a label is `<label>.<domain>`. Sorted. */
|
|
23
|
+
export function resolveGateHostnames(domain, names) {
|
|
24
|
+
return names
|
|
25
|
+
.map((name) => (name === '@' ? domain : name === '*' ? `*.${domain}` : `${name}.${domain}`))
|
|
26
|
+
.sort();
|
|
27
|
+
}
|
|
28
|
+
/** Every declared gate's key with its entry's key and resolved hostnames; none without hosting or a domain. */
|
|
29
|
+
function declaredGates(org) {
|
|
30
|
+
const gates = new Map();
|
|
31
|
+
if (!org.hosting.enabled || !org.domain)
|
|
32
|
+
return gates;
|
|
33
|
+
for (const entry of org.hosting.entries) {
|
|
34
|
+
for (const gate of entry.gates) {
|
|
35
|
+
gates.set(gate.key, {
|
|
36
|
+
entry: entry.key,
|
|
37
|
+
hostnames: resolveGateHostnames(org.domain, gate.hostnames),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return gates;
|
|
42
|
+
}
|
|
43
|
+
/** True when a declared gate resolves to `*.<domain>`. */
|
|
44
|
+
export function hasWildcardGate(org) {
|
|
45
|
+
return [...declaredGates(org).values()].some(({ hostnames }) => hostnames.some((name) => name.startsWith('*.')));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* True when a declared gate covers `hostname`, exactly or through a gated
|
|
49
|
+
* wildcard, unless a `bypass` rule names it exactly: that rule wins. A name
|
|
50
|
+
* foundry owns is never behind a gate: the plan bypasses it under a wildcard.
|
|
51
|
+
*/
|
|
52
|
+
export function isBehindGate(org, hostname) {
|
|
53
|
+
if (lockedHostnames(org, []).has(hostname))
|
|
54
|
+
return false;
|
|
55
|
+
if ((org.hosting.access ?? []).some((r) => r.kind === 'bypass' && r.hostname === hostname)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return [...declaredGates(org).values()].some(({ hostnames }) => hostnames.some((name) => name.startsWith('*.') ? hostname.endsWith(name.slice(1)) : name === hostname));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* One step per declared gate whose recorded id or hostnames differ from what
|
|
62
|
+
* is declared (a create, or a hostname change), and one destroying step per
|
|
63
|
+
* recorded gate no longer declared. `providerId` is the platform's recorded
|
|
64
|
+
* provider. A create needs it, the company's sign-in organisation and an
|
|
65
|
+
* owner with an email: when one is missing no create is planned and
|
|
66
|
+
* `blocked` says why (CLAUDE.md rule 2).
|
|
67
|
+
*/
|
|
68
|
+
export function planGates(org, recordedIds, providerId) {
|
|
69
|
+
const steps = [];
|
|
70
|
+
let blocked = null;
|
|
71
|
+
const missing = !providerId
|
|
72
|
+
? "the platform's sign-in provider step has not been applied yet, so there is no Access provider to require"
|
|
73
|
+
: !recordedIds[ZITADEL_ORG_SCOPE_KEY]
|
|
74
|
+
? `${org.slug} has no sign-in organisation recorded yet`
|
|
75
|
+
: org.owners.length === 0
|
|
76
|
+
? `${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`
|
|
77
|
+
: null;
|
|
78
|
+
const declared = declaredGates(org);
|
|
79
|
+
const prefix = gateIdRecordKey(org.id, '');
|
|
80
|
+
for (const [name, id] of Object.entries(recordedIds)) {
|
|
81
|
+
if (!id || !name.startsWith(prefix))
|
|
82
|
+
continue;
|
|
83
|
+
const gateKey = name.slice(prefix.length);
|
|
84
|
+
if (declared.has(gateKey))
|
|
85
|
+
continue;
|
|
86
|
+
steps.push({
|
|
87
|
+
action: {
|
|
88
|
+
system: 'cloudflare',
|
|
89
|
+
kind: 'gate',
|
|
90
|
+
key: appStepKey(org, gateKey),
|
|
91
|
+
desired: { mode: 'delete' },
|
|
92
|
+
},
|
|
93
|
+
step: {
|
|
94
|
+
description: `delete ${org.slug}'s '${gateKey}' Cloudflare Access gate: it is no longer declared`,
|
|
95
|
+
destroys: true,
|
|
96
|
+
migrates: false,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
for (const [gateKey, { entry, hostnames }] of declared) {
|
|
101
|
+
const recorded = recordedIds[gateIdRecordKey(org.id, gateKey)];
|
|
102
|
+
if (recorded && recordedIds[gateHostnamesScopeKey(gateKey)] === JSON.stringify(hostnames)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!recorded && missing) {
|
|
106
|
+
blocked = missing;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
steps.push({
|
|
110
|
+
action: {
|
|
111
|
+
system: 'cloudflare',
|
|
112
|
+
kind: 'gate',
|
|
113
|
+
key: appStepKey(org, gateKey),
|
|
114
|
+
desired: { mode: 'apply', entry, hostnames, providerId },
|
|
115
|
+
},
|
|
116
|
+
step: {
|
|
117
|
+
description: recorded
|
|
118
|
+
? `change the hostnames of ${org.slug}'s '${gateKey}' Cloudflare Access gate to ${hostnames.join(', ')}`
|
|
119
|
+
: `put ${hostnames.join(', ')} (${org.slug}'s '${gateKey}' gate) behind Cloudflare Access`,
|
|
120
|
+
destroys: false,
|
|
121
|
+
migrates: false,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return { steps, blocked };
|
|
126
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { appEntry } from '../org.js';
|
|
2
2
|
import { agoraHostnameFor } from './agora.js';
|
|
3
3
|
import { bouleHostnameFor } from './boule.js';
|
|
4
4
|
import { callsHostnameFor, cmsHostnameFor } from './executor.js';
|
|
5
|
+
import { isBehindGate } from './gates.js';
|
|
5
6
|
import { entryHostnames } from './hostnames.js';
|
|
6
7
|
import { loginHostnameFor } from './login-host.js';
|
|
7
8
|
function okExpect() {
|
|
@@ -182,9 +183,7 @@ function hostingEntryTarget(org, entry) {
|
|
|
182
183
|
expect: okExpect(),
|
|
183
184
|
depth: entry.buildPack === 'static' ? 'page' : entry.database ? 'deep' : 'shallow',
|
|
184
185
|
restartSafe: true,
|
|
185
|
-
|
|
186
|
-
// (`org.ts`'s own `HostingEntry.accessEmails` doc comment).
|
|
187
|
-
behindAccess: entry.key === APP_ENTRY_KEY && entry.accessEmails.length > 0,
|
|
186
|
+
behindAccess: isBehindGate(org, hostname),
|
|
188
187
|
};
|
|
189
188
|
}
|
|
190
189
|
/**
|
|
@@ -216,6 +216,8 @@ export interface PlatformStepExecutor {
|
|
|
216
216
|
ai?: string;
|
|
217
217
|
archon?: string;
|
|
218
218
|
mail?: string;
|
|
219
|
+
/** #604: see `PlanPlatformOptions.blocked.accessProvider` (`platform-diff.ts`). */
|
|
220
|
+
accessProvider?: string;
|
|
219
221
|
/** #257: see `PlanPlatformOptions.blocked.backupExecutorMissing` (`platform-diff.ts`). */
|
|
220
222
|
backupExecutorMissing?: boolean;
|
|
221
223
|
/** #540: see `PlanPlatformOptions.blocked.hc` (`platform-diff.ts`). */
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -98,6 +98,7 @@ const HOSTNAME_STEPS = new Set([
|
|
|
98
98
|
'coolify:database',
|
|
99
99
|
'cloudflare:tunnel',
|
|
100
100
|
'cloudflare:access',
|
|
101
|
+
'cloudflare:gate',
|
|
101
102
|
'jobs:registration',
|
|
102
103
|
// #318 (F8): the route is itself hostname-shaped, validated against the
|
|
103
104
|
// org's own domain by `validateEntryWorker`.
|
|
@@ -1149,6 +1150,7 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1149
1150
|
'zitadel:login-host:',
|
|
1150
1151
|
'cloudflare:tunnel:',
|
|
1151
1152
|
'cloudflare:access:',
|
|
1153
|
+
'cloudflare:gate:',
|
|
1152
1154
|
// #318: an entry's Worker and its route.
|
|
1153
1155
|
'cloudflare:worker:',
|
|
1154
1156
|
'coolify:database:',
|
|
@@ -1311,7 +1313,8 @@ const STEP_FIELDS = {
|
|
|
1311
1313
|
'coolify:database': 'hosting.database',
|
|
1312
1314
|
'cloudflare:tunnel': 'hosting.tunnel',
|
|
1313
1315
|
'cloudflare:zone': 'hosting.usesDomain',
|
|
1314
|
-
'cloudflare:access': 'hosting.
|
|
1316
|
+
'cloudflare:access': 'hosting.access',
|
|
1317
|
+
'cloudflare:gate': 'hosting.entries.gates',
|
|
1315
1318
|
'cloudflare:ratelimit': 'hosting.usesDomain',
|
|
1316
1319
|
'cloudflare:worker': 'hosting.entries.worker',
|
|
1317
1320
|
};
|
|
@@ -1330,6 +1333,10 @@ function stepField(action) {
|
|
|
1330
1333
|
if (action.system === 'zitadel' && action.kind === 'app') {
|
|
1331
1334
|
return `hosting.issuerApps.${action.key}`;
|
|
1332
1335
|
}
|
|
1336
|
+
// A bypass on a name foundry owns serves the entry gates, not `hosting.access`.
|
|
1337
|
+
if (action.system === 'cloudflare' && action.kind === 'access' && action.desired.ownedBypass) {
|
|
1338
|
+
return 'hosting.entries.gates';
|
|
1339
|
+
}
|
|
1333
1340
|
return STEP_FIELDS[`${action.system}:${action.kind}`] ?? `${action.system}.${action.kind}`;
|
|
1334
1341
|
}
|
|
1335
1342
|
/**
|
|
@@ -694,6 +694,18 @@ export declare const PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF = "cloudflare:edge:apiTo
|
|
|
694
694
|
* reports it and never mints again. Neither key holds the secret itself.
|
|
695
695
|
*/
|
|
696
696
|
export declare const PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY = "edge_platform_key_undelivered";
|
|
697
|
+
/**
|
|
698
|
+
* #604: the ZITADEL client Cloudflare Access signs people in through. Its
|
|
699
|
+
* secret exists once, at creation, and goes to edge's `ensureProvider`. The
|
|
700
|
+
* client's app id is recorded here and persisted before the secret leaves;
|
|
701
|
+
* the guard, `ACCESS_PROVIDER_RECORD_KEY` (`gates.ts`), is recorded only
|
|
702
|
+
* after edge returned the provider's id. An undelivered id with no guard
|
|
703
|
+
* means the secret is lost: the planner reports it and never mints again.
|
|
704
|
+
*/
|
|
705
|
+
export declare const PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY = "access_provider_client_undelivered";
|
|
706
|
+
/** The sign-in hostname and the Access team address the provider was applied with. */
|
|
707
|
+
export declare const PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY = "access_provider_applied_hostname";
|
|
708
|
+
export declare const PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY = "access_provider_applied_auth_domain";
|
|
697
709
|
export declare const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = "edge_platform_key_set";
|
|
698
710
|
/**
|
|
699
711
|
* #478: the hostname and `gitRef` the last successful names run applied.
|
|
@@ -850,6 +862,8 @@ export interface PlanPlatformOptions {
|
|
|
850
862
|
mail?: string;
|
|
851
863
|
/** #478: why the edge host is blocked, or absent when it is not. */
|
|
852
864
|
edge?: string;
|
|
865
|
+
/** #604: why the Access sign-in provider cannot be made yet (no dependencies, or edge cannot confirm Access is ready), or absent. */
|
|
866
|
+
accessProvider?: string;
|
|
853
867
|
/**
|
|
854
868
|
* #257: true once a service's database exists but the host built the
|
|
855
869
|
* platform executor with no backup dependencies -- every such service's
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { VERSIONS } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY } from '../org.js';
|
|
3
3
|
import { VERSION_CHANGE_KIND, } from '../plan.js';
|
|
4
|
+
import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
|
|
4
5
|
import { legacyAppHostname } from './hostnames.js';
|
|
5
6
|
import { withStepSystem } from './step-system.js';
|
|
6
7
|
/**
|
|
@@ -771,6 +772,18 @@ export const PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF = 'cloudflare:edge:apiToken';
|
|
|
771
772
|
* reports it and never mints again. Neither key holds the secret itself.
|
|
772
773
|
*/
|
|
773
774
|
export const PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY = 'edge_platform_key_undelivered';
|
|
775
|
+
/**
|
|
776
|
+
* #604: the ZITADEL client Cloudflare Access signs people in through. Its
|
|
777
|
+
* secret exists once, at creation, and goes to edge's `ensureProvider`. The
|
|
778
|
+
* client's app id is recorded here and persisted before the secret leaves;
|
|
779
|
+
* the guard, `ACCESS_PROVIDER_RECORD_KEY` (`gates.ts`), is recorded only
|
|
780
|
+
* after edge returned the provider's id. An undelivered id with no guard
|
|
781
|
+
* means the secret is lost: the planner reports it and never mints again.
|
|
782
|
+
*/
|
|
783
|
+
export const PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY = 'access_provider_client_undelivered';
|
|
784
|
+
/** The sign-in hostname and the Access team address the provider was applied with. */
|
|
785
|
+
export const PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY = 'access_provider_applied_hostname';
|
|
786
|
+
export const PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY = 'access_provider_applied_auth_domain';
|
|
774
787
|
export const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = 'edge_platform_key_set';
|
|
775
788
|
/**
|
|
776
789
|
* #478: the hostname and `gitRef` the last successful names run applied.
|
|
@@ -999,6 +1012,11 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
999
1012
|
const ai = planAi(platform, recordedIds, blocked.ai);
|
|
1000
1013
|
if (ai.step)
|
|
1001
1014
|
plannedSteps.push(ai.step);
|
|
1015
|
+
// #604: after edge and sign-in, whose applied ids it reads; before Archon,
|
|
1016
|
+
// whose redeploy can end the run.
|
|
1017
|
+
const accessProvider = planAccessProvider(platform, recordedIds, blocked.accessProvider);
|
|
1018
|
+
if (accessProvider.step)
|
|
1019
|
+
plannedSteps.push(accessProvider.step);
|
|
1002
1020
|
// #175: last, on purpose. Archon's sign-in client lives in the sign-in
|
|
1003
1021
|
// `planAuth` builds, and its env reads what every other service's step
|
|
1004
1022
|
// records or delivers, so in one apply it runs after all of them.
|
|
@@ -1081,6 +1099,9 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
1081
1099
|
unmanaged.push({ description: ai.blocked, key: 'ai' });
|
|
1082
1100
|
if (edge.blocked)
|
|
1083
1101
|
unmanaged.push({ description: edge.blocked, key: 'edge' });
|
|
1102
|
+
if (accessProvider.blocked) {
|
|
1103
|
+
unmanaged.push({ description: accessProvider.blocked, key: 'access-provider' });
|
|
1104
|
+
}
|
|
1084
1105
|
if (hc.blocked)
|
|
1085
1106
|
unmanaged.push({ description: hc.blocked, key: 'hc' });
|
|
1086
1107
|
if (archon.blocked)
|
|
@@ -2027,6 +2048,63 @@ function planEdge(platform, recordedIds, executorBlocked) {
|
|
|
2027
2048
|
blocked: null,
|
|
2028
2049
|
};
|
|
2029
2050
|
}
|
|
2051
|
+
/**
|
|
2052
|
+
* #604: the platform's ZITADEL as a sign-in provider in Cloudflare Access: a
|
|
2053
|
+
* ZITADEL client for Cloudflare, then edge's `ensureProvider`. One
|
|
2054
|
+
* `cloudflare:access-provider` step. It needs edge and the platform's sign-in
|
|
2055
|
+
* applied (it reads the Access team address through the one and makes the
|
|
2056
|
+
* client in the other). Done once `ACCESS_PROVIDER_RECORD_KEY` is recorded.
|
|
2057
|
+
*
|
|
2058
|
+
* Blocked, not planned: edge or sign-in not applied, a client minted but never
|
|
2059
|
+
* delivered (its secret cannot be shown again), or `executorBlocked` (no
|
|
2060
|
+
* dependencies, or edge cannot confirm Access is ready).
|
|
2061
|
+
*/
|
|
2062
|
+
function planAccessProvider(platform, recordedIds, executorBlocked) {
|
|
2063
|
+
if (!platform.edge.enabled || !platform.auth.enabled)
|
|
2064
|
+
return { step: null, blocked: null };
|
|
2065
|
+
if (recordedIds[ACCESS_PROVIDER_RECORD_KEY]) {
|
|
2066
|
+
// The secret is not stored, so a changed address cannot be re-sent: a person remakes the client.
|
|
2067
|
+
const appliedHostname = recordedIds[PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY];
|
|
2068
|
+
if (appliedHostname && appliedHostname !== platform.auth.hostname) {
|
|
2069
|
+
return {
|
|
2070
|
+
step: null,
|
|
2071
|
+
blocked: `the Access sign-in provider is blocked, not planned: it was applied with the sign-in hostname ${appliedHostname} and ${platform.auth.hostname} is declared now; its secret is not stored, so delete the ZITADEL client cloudflare-access, remove the recorded keys ${ACCESS_PROVIDER_RECORD_KEY}, ${PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY} and ${PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY}, then apply again`,
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
return { step: null, blocked: null };
|
|
2075
|
+
}
|
|
2076
|
+
const undelivered = recordedIds[PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY];
|
|
2077
|
+
if (undelivered) {
|
|
2078
|
+
return {
|
|
2079
|
+
step: null,
|
|
2080
|
+
blocked: `the Access sign-in provider is blocked, not planned: its ZITADEL client (${undelivered}) was made and its secret may or may not have reached edge; it cannot be shown again, so delete that client in ZITADEL and remove the recorded keys ${PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY} and ${ACCESS_PROVIDER_RECORD_KEY}, then apply again to make both anew`,
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
const applied = Boolean(recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) &&
|
|
2084
|
+
Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]) &&
|
|
2085
|
+
// The sibling ZITADEL steps need the delivered admin key (or the instance it came from).
|
|
2086
|
+
(Boolean(recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) ||
|
|
2087
|
+
Boolean(recordedIds[PLATFORM_AUTH_INSTANCE_SCOPE_KEY]));
|
|
2088
|
+
if (!applied) {
|
|
2089
|
+
return {
|
|
2090
|
+
step: null,
|
|
2091
|
+
blocked: 'the Access sign-in provider is blocked, not planned: edge and the platform sign-in are not both applied yet',
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
if (executorBlocked)
|
|
2095
|
+
return { step: null, blocked: executorBlocked };
|
|
2096
|
+
return {
|
|
2097
|
+
step: {
|
|
2098
|
+
action: { system: 'cloudflare', kind: 'access-provider', key: 'platform', desired: {} },
|
|
2099
|
+
step: {
|
|
2100
|
+
description: "make the platform's ZITADEL the sign-in provider in Cloudflare Access, for company gates",
|
|
2101
|
+
destroys: false,
|
|
2102
|
+
migrates: false,
|
|
2103
|
+
},
|
|
2104
|
+
},
|
|
2105
|
+
blocked: null,
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2030
2108
|
/**
|
|
2031
2109
|
* #540: a self-hosted heartbeat server (Healthchecks) for the platform, for
|
|
2032
2110
|
* now -- a Coolify Postgres plus one Docker-image app
|
|
@@ -87,6 +87,8 @@ export interface OrgPlatformReadiness {
|
|
|
87
87
|
callsReady: boolean;
|
|
88
88
|
/** The platform's own sign-in (#168), which every org's own ZITADEL org lives inside. */
|
|
89
89
|
authReady: boolean;
|
|
90
|
+
/** The Access provider id the platform recorded (`ACCESS_PROVIDER_RECORD_KEY`), which every gate requires. `null` or absent until the platform has recorded it. */
|
|
91
|
+
accessProviderId?: string | null;
|
|
90
92
|
/** The jobs scheduler (#173) is enabled, applied, has recorded its internal address, and its admin token is held. */
|
|
91
93
|
jobsReady: boolean;
|
|
92
94
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
|
|
1
2
|
import { JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
|
|
2
3
|
import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
|
|
3
4
|
/**
|
|
@@ -129,6 +130,9 @@ edgePortPresent = false) {
|
|
|
129
130
|
aiReady: aiApplied && missingKeys.ai === null,
|
|
130
131
|
aiUrl: aiApplied ? (platformRecordedIds[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] ?? null) : null,
|
|
131
132
|
callsReady: platform.sfu.enabled,
|
|
133
|
+
...(platformRecordedIds[ACCESS_PROVIDER_RECORD_KEY]
|
|
134
|
+
? { accessProviderId: platformRecordedIds[ACCESS_PROVIDER_RECORD_KEY] }
|
|
135
|
+
: {}),
|
|
132
136
|
authReady: platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY),
|
|
133
137
|
jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
|
|
134
138
|
...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),
|