@@ -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+
953969async 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+
14901541async 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