Skip to content

Commit 8ea85aa

Browse files
committed
fix(platform): preserve public bypasses during Zero Trust disable
1 parent c0fd4c1 commit 8ea85aa

5 files changed

Lines changed: 591 additions & 20 deletions

File tree

‎platform/meta/design-docs/0081-project-zero-trust.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,12 @@ over stale Worker variables and KV reads, including scheduled retries.
139139

140140
Project deletion treats Access application deletion as critical because the
141141
project row is the durable handle. The database cascade occurs only after those
142-
deletes succeed. Platform disablement removes route enforcement and every
143-
Void-managed application before recording the feature disabled.
142+
deletes succeed. Platform disablement clears route enforcement and protected
143+
custom-domain applications first, retaining public bypasses until the wildcard
144+
is deleted. Wildcard deletion checkpoints enforcement as disabled and advances
145+
to bounded bypass cleanup. The saved disable operation remains pending until
146+
every retained bypass is removed, so failures can resume without interrupting
147+
previously public applications or repeating completed routing writes.
144148

145149
## Independent dispatch verification
146150

‎platform/packages/api/src/zero-trust/configuration.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export type ZeroTrustOperation = Readonly<{
2020
/** Stable across retries; separates completion even when database clocks coincide. */
2121
id?: string;
2222
kind: 'configure' | 'reconcile' | 'disable';
23-
phase: 'seed' | 'public-bypasses' | 'wildcard' | 'projects' | 'wildcard-delete';
23+
phase: 'seed' | 'public-bypasses' | 'wildcard' | 'projects' | 'wildcard-delete' | 'bypass-delete';
2424
owner: string | null;
2525
leaseExpiresAt: string | null;
2626
startedAt: string;
@@ -132,9 +132,14 @@ function parseOperation(value: unknown): ZeroTrustOperation | null {
132132
throw new ZeroTrustConfigurationError('Zero Trust operation kind is invalid');
133133
}
134134
if (
135-
!['seed', 'public-bypasses', 'wildcard', 'projects', 'wildcard-delete'].includes(
136-
String(input.phase),
137-
)
135+
![
136+
'seed',
137+
'public-bypasses',
138+
'wildcard',
139+
'projects',
140+
'wildcard-delete',
141+
'bypass-delete',
142+
].includes(String(input.phase))
138143
) {
139144
throw new ZeroTrustConfigurationError('Zero Trust operation phase is invalid');
140145
}
@@ -157,8 +162,9 @@ function parseOperation(value: unknown): ZeroTrustOperation | null {
157162
const kind = input.kind as ZeroTrustOperation['kind'];
158163
const phase = input.phase as ZeroTrustOperation['phase'];
159164
if (
160-
(kind === 'disable' && !['seed', 'projects', 'wildcard-delete'].includes(phase)) ||
161-
(kind !== 'disable' && phase === 'wildcard-delete')
165+
(kind === 'disable' &&
166+
!['seed', 'projects', 'wildcard-delete', 'bypass-delete'].includes(phase)) ||
167+
(kind !== 'disable' && ['wildcard-delete', 'bypass-delete'].includes(phase))
162168
) {
163169
throw new ZeroTrustConfigurationError('Zero Trust operation phase is inconsistent');
164170
}
@@ -254,6 +260,9 @@ function parseZeroTrustConfiguration(value: unknown): ZeroTrustConfiguration | n
254260
const wildcardApplication = parseApplication(input.wildcardApplication);
255261
const operation = parseOperation(input.operation);
256262
const status = input.status as ZeroTrustConfiguration['status'];
263+
if (operation?.phase === 'bypass-delete' && (input.enabled || wildcardApplication)) {
264+
throw new ZeroTrustConfigurationError('Zero Trust bypass cleanup requires wildcard removal');
265+
}
257266
if (
258267
(status === 'ready' && operation !== null) ||
259268
(status === 'configuring' && (!operation || operation.kind === 'disable')) ||

‎platform/packages/api/src/zero-trust/service.ts‎

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -939,8 +939,6 @@ async function disableCandidates(
939939
sql`${projectZeroTrust.reconciled_operation} IS DISTINCT FROM ${operationCompletionKey(operation)}`,
940940
eq(projectZeroTrust.effective_protected, true),
941941
sql`${projectZeroTrust.state} <> 'public'`,
942-
sql`${projectZeroTrust.bypass_application_id} IS NOT NULL`,
943-
sql`${projectZeroTrust.bypass_application_name} IS NOT NULL`,
944942
sql`${projectZeroTrust.protected_application_id} IS NOT NULL`,
945943
sql`${projectZeroTrust.protected_application_name} IS NOT NULL`,
946944
),
@@ -950,6 +948,24 @@ async function disableCandidates(
950948
.limit(limit);
951949
}
952950

951+
async function bypassCleanupCandidates(
952+
db: Db,
953+
limit: number,
954+
): Promise<ReadonlyArray<ProjectCandidate>> {
955+
return candidateQuery(db)
956+
.where(
957+
and(
958+
eq(projects.status, 'active'),
959+
or(
960+
sql`${projectZeroTrust.bypass_application_id} IS NOT NULL`,
961+
sql`${projectZeroTrust.bypass_application_name} IS NOT NULL`,
962+
),
963+
),
964+
)
965+
.orderBy(projects.created_at, projects.id)
966+
.limit(limit);
967+
}
968+
953969
async function setInitialProjectProtection(
954970
db: Db,
955971
projectId: string,
@@ -1100,7 +1116,7 @@ function isDeletedProject(error: unknown): boolean {
11001116
return error instanceof ZeroTrustUnavailableError && error.message === 'Project is unavailable.';
11011117
}
11021118

1103-
async function mutateWildcardApplication(
1119+
async function withZeroTrustOperationLock(
11041120
db: Db,
11051121
configuration: ZeroTrustConfiguration,
11061122
mutate: (lockedDb: Db) => Promise<ZeroTrustConfiguration>,
@@ -1345,7 +1361,7 @@ async function runConfigureOperation(
13451361
throw new Error('The platform Access application checkpoint is missing.');
13461362
}
13471363
const name = configuration.wildcardApplicationName;
1348-
configuration = await mutateWildcardApplication(db, configuration, async (lockedDb) => {
1364+
configuration = await withZeroTrustOperationLock(db, configuration, async (lockedDb) => {
13491365
const wildcard = await client.ensureApplication(
13501366
{
13511367
name,
@@ -1459,7 +1475,6 @@ async function disableProjectZeroTrust(
14591475
return;
14601476
}
14611477
if (row) {
1462-
await client.deleteApplication(row.bypass_application_id, row.bypass_application_name);
14631478
await client.deleteApplication(
14641479
row.protected_application_id,
14651480
row.protected_application_name,
@@ -1470,8 +1485,8 @@ async function disableProjectZeroTrust(
14701485
effective_protected: false,
14711486
state: 'public',
14721487
reconciled_operation: operationCompletionKey(saved.operation),
1473-
bypass_application_id: null,
1474-
bypass_application_name: null,
1488+
// Public exceptions must outlive the wildcard, including when a later
1489+
// project or wildcard deletion fails. The final phase clears these handles.
14751490
protected_application_id: null,
14761491
protected_application_name: null,
14771492
protected_application_audience: null,
@@ -1487,6 +1502,42 @@ async function disableProjectZeroTrust(
14871502
}
14881503
}
14891504

1505+
async function deleteProjectZeroTrustBypass(
1506+
db: Db,
1507+
projectId: string,
1508+
client: ZeroTrustCloudflareClient,
1509+
configuration: ZeroTrustConfiguration,
1510+
): Promise<void> {
1511+
let operationError: unknown;
1512+
await db.transaction(async (tx) => {
1513+
await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`);
1514+
await lockZeroTrustConfiguration(tx);
1515+
const saved = await readZeroTrustConfigurationStrict(tx);
1516+
if (!sameConfiguration(configuration, saved) || saved.operation?.phase !== 'bypass-delete') {
1517+
throw new ZeroTrustUnavailableError('The Zero Trust disable operation was replaced.');
1518+
}
1519+
const row = await getProjectZeroTrustState(tx, projectId);
1520+
if (!row) {
1521+
return;
1522+
}
1523+
try {
1524+
await client.deleteApplication(row.bypass_application_id, row.bypass_application_name);
1525+
await updateProjectState(tx, projectId, {
1526+
bypass_application_id: null,
1527+
bypass_application_name: null,
1528+
state: 'public',
1529+
last_error: null,
1530+
});
1531+
} catch (error) {
1532+
await markProjectError(tx, projectId, error);
1533+
operationError = error;
1534+
}
1535+
});
1536+
if (operationError !== undefined) {
1537+
throw operationError;
1538+
}
1539+
}
1540+
14901541
async function runDisableOperation(
14911542
env: ApiEnv['Bindings'],
14921543
db: Db,
@@ -1538,7 +1589,7 @@ async function runDisableOperation(
15381589
continue;
15391590
}
15401591
if (operation.phase === 'wildcard-delete') {
1541-
configuration = await mutateWildcardApplication(db, configuration, async (lockedDb) => {
1592+
configuration = await withZeroTrustOperationLock(db, configuration, async (lockedDb) => {
15421593
// A resumed cleanup must drain late/legacy project work before losing
15431594
// the wildcard checkpoint, including work left by an errored request.
15441595
if ((await disableCandidates(lockedDb, operation, 1)).length > 0) {
@@ -1548,10 +1599,38 @@ async function runDisableOperation(
15481599
configuration.wildcardApplication?.id,
15491600
configuration.wildcardApplicationName ?? configuration.wildcardApplication?.name,
15501601
);
1551-
return patchConfiguration(lockedDb, configuration, {
1602+
return advanceOperation(lockedDb, configuration, 'bypass-delete', {
15521603
enabled: false,
1553-
status: 'ready',
15541604
wildcardApplication: null,
1605+
lastError: null,
1606+
});
1607+
});
1608+
continue;
1609+
}
1610+
if (operation.phase === 'bypass-delete') {
1611+
const candidates = await bypassCleanupCandidates(
1612+
db,
1613+
MAX_PROJECTS_PER_RUN - projectsProcessed,
1614+
);
1615+
for (const candidate of candidates) {
1616+
await deleteProjectZeroTrustBypass(db, candidate.project.id, client, configuration);
1617+
projectsProcessed += 1;
1618+
configuration = await heartbeatConfiguration(db, configuration);
1619+
}
1620+
if (projectsProcessed >= MAX_PROJECTS_PER_RUN) {
1621+
return releaseOperation(db, configuration);
1622+
}
1623+
configuration = await withZeroTrustOperationLock(db, configuration, async (lockedDb) => {
1624+
// Recheck late project work under the lock used by project mutations
1625+
// before releasing the disable operation and allowing configuration again.
1626+
if ((await disableCandidates(lockedDb, operation, 1)).length > 0) {
1627+
return advanceOperation(lockedDb, configuration, 'projects');
1628+
}
1629+
if ((await bypassCleanupCandidates(lockedDb, 1)).length > 0) {
1630+
return configuration;
1631+
}
1632+
return patchConfiguration(lockedDb, configuration, {
1633+
status: 'ready',
15551634
operation: null,
15561635
lastError: null,
15571636
});

0 commit comments

Comments
 (0)