Skip to content

Commit 5121347

Browse files
committed
feat(projects): enforce membership after the staged backfill
1 parent a14b448 commit 5121347

14 files changed

Lines changed: 30444 additions & 53 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env python3
2+
"""Read-only, fail-closed ECS retirement check for the Project contract migration.
3+
4+
The expected digest is the operator's release-scoped acknowledgment that Project
5+
writers are enabled, relevant old worker jobs are drained, and backfill verification
6+
passed. AWS checks below independently verify ECS retirement, not those assertions.
7+
"""
8+
import argparse
9+
import json
10+
import re
11+
import subprocess
12+
import sys
13+
14+
15+
def aws(region, *args):
16+
result = subprocess.run(
17+
['aws', '--region', region, '--no-cli-pager', '--cli-connect-timeout', '10', '--cli-read-timeout', '30', *args, '--output', 'json'],
18+
capture_output=True, text=True, timeout=90, check=False,
19+
)
20+
if result.returncode:
21+
raise RuntimeError('AWS preflight read failed; check deployment-read permissions')
22+
return json.loads(result.stdout)
23+
24+
25+
def verify(environment, region, digest):
26+
if not re.fullmatch(r'sha256:[0-9a-f]{64}', digest):
27+
raise RuntimeError('Set the environment-specific PROJECT_ENFORCEMENT_READY_IMAGE_DIGEST after reviewing rollout and backfill evidence')
28+
pipeline = f'sim-{environment}-{region}-app-deployment'
29+
executions = aws(region, 'codepipeline', 'list-pipeline-executions', '--pipeline-name', pipeline).get('pipelineExecutionSummaries', [])
30+
if not executions or executions[0].get('status') != 'Succeeded':
31+
raise RuntimeError('The latest app pipeline has not completed; traffic cutover alone is insufficient')
32+
execution_id = executions[0]['pipelineExecutionId']
33+
group = aws(region, 'deploy', 'get-deployment-group', '--application-name', f'sim-{environment}-{region}-ecs-app',
34+
'--deployment-group-name', f'sim-{environment}-{region}-app-dg')['deploymentGroupInfo']
35+
services = group.get('ecsServices', [])
36+
if len(services) != 1:
37+
raise RuntimeError('Expected exactly one application ECS service')
38+
cluster, service = services[0]['clusterName'], services[0]['serviceName']
39+
description = aws(region, 'ecs', 'describe-services', '--cluster', cluster, '--services', service)
40+
if description.get('failures') or len(description.get('services', [])) != 1:
41+
raise RuntimeError('Cannot inspect the application ECS service')
42+
record = description['services'][0]
43+
if record.get('desiredCount', 0) < 1 or record.get('runningCount') != record['desiredCount'] or record.get('pendingCount') != 0:
44+
raise RuntimeError('Application ECS service is not stable')
45+
arns = set()
46+
for status in ('RUNNING', 'STOPPED'):
47+
arns.update(aws(region, 'ecs', 'list-tasks', '--cluster', cluster, '--service-name', service,
48+
'--desired-status', status).get('taskArns', []))
49+
live = []
50+
ordered = sorted(arns)
51+
for start in range(0, len(ordered), 100):
52+
response = aws(region, 'ecs', 'describe-tasks', '--cluster', cluster, '--tasks', *ordered[start:start + 100])
53+
if response.get('failures'):
54+
raise RuntimeError('Cannot account for every ECS task')
55+
if len(response.get('tasks', [])) != len(ordered[start:start + 100]):
56+
raise RuntimeError('Incomplete ECS task response')
57+
live.extend(task for task in response['tasks'] if task.get('lastStatus') != 'STOPPED')
58+
if len(live) != record['desiredCount']:
59+
raise RuntimeError('Old, stopping, or pending ECS tasks remain')
60+
for task in live:
61+
app = [container for container in task.get('containers', []) if container.get('name') == 'app']
62+
if task.get('lastStatus') != 'RUNNING' or task.get('desiredStatus') != 'RUNNING' or len(app) != 1 or app[0].get('imageDigest') != digest:
63+
raise RuntimeError('A live ECS task does not match the acknowledged compatible release')
64+
latest = aws(region, 'codepipeline', 'list-pipeline-executions', '--pipeline-name', pipeline).get('pipelineExecutionSummaries', [])
65+
if not latest or latest[0].get('pipelineExecutionId') != execution_id or latest[0].get('status') != 'Succeeded':
66+
raise RuntimeError('Application deployment changed during preflight')
67+
print(json.dumps({'ecsRetired': True, 'expectedImageDigest': digest, 'pipelineExecutionId': execution_id,
68+
'operatorAcknowledgedWritersWorkersAndBackfill': True}))
69+
70+
71+
if __name__ == '__main__':
72+
parser = argparse.ArgumentParser(description=__doc__)
73+
parser.add_argument('--environment', required=True, choices=['production', 'staging'])
74+
parser.add_argument('--region', required=True)
75+
parser.add_argument('--expected-image-digest', required=True)
76+
args = parser.parse_args()
77+
try:
78+
verify(args.environment, args.region, args.expected_image_digest)
79+
except (RuntimeError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as error:
80+
print(f'Project rollout preflight refused: {error}', file=sys.stderr)
81+
sys.exit(1)

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ jobs:
116116
# in place before the new app version deploys (replaces the removed ECS
117117
# migration sidecar)
118118
migrate:
119+
permissions:
120+
contents: read
121+
id-token: write
119122
name: Migrate DB
120123
needs: [test-build]
121124
# Explicit need results instead of the implicit success(): a skipped job
@@ -134,6 +137,9 @@ jobs:
134137

135138
# Same ordering for dev (schema push before the dev image lands in ECR)
136139
migrate-dev:
140+
permissions:
141+
contents: read
142+
id-token: write
137143
name: Migrate Dev DB
138144
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
139145
uses: ./.github/workflows/migrations.yml

‎.github/workflows/migrations.yml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ on:
2020

2121
permissions:
2222
contents: read
23+
id-token: write
2324

2425
jobs:
2526
migrate:
@@ -51,6 +52,32 @@ jobs:
5152
- name: Install dependencies
5253
run: bun install --frozen-lockfile --ignore-scripts
5354

55+
- name: Check whether Project enforcement needs rollout evidence
56+
id: project-contract
57+
if: inputs.environment != 'dev'
58+
working-directory: ./packages/db
59+
env:
60+
DATABASE_URL: ${{ inputs.environment == 'production' && secrets.DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_DATABASE_URL || '' }}
61+
MIGRATION_DATABASE_URL: ${{ inputs.environment == 'production' && secrets.MIGRATION_DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_MIGRATION_DATABASE_URL || '' }}
62+
run: bun --no-env-file scripts/project-contract-required.ts >> "$GITHUB_OUTPUT"
63+
64+
- name: Configure AWS for Project retirement verification
65+
if: steps.project-contract.outputs.required == 'true'
66+
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b
67+
with:
68+
role-to-assume: ${{ inputs.environment == 'production' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
69+
aws-region: ${{ inputs.environment == 'production' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
70+
71+
# Set this release-scoped acknowledgment only after writers are enabled everywhere,
72+
# relevant old Trigger.dev runs are drained, and the reviewed backfill verifies ready.
73+
# The preflight independently checks ECS retirement; it does not inspect worker runs.
74+
- name: Require completed compatible rollout before Project enforcement
75+
if: steps.project-contract.outputs.required == 'true'
76+
env:
77+
ENVIRONMENT: ${{ inputs.environment }}
78+
EXPECTED_IMAGE_DIGEST: ${{ inputs.environment == 'production' && vars.PROJECT_ENFORCEMENT_READY_IMAGE_DIGEST_PRODUCTION || inputs.environment == 'staging' && vars.PROJECT_ENFORCEMENT_READY_IMAGE_DIGEST_STAGING || '' }}
79+
run: python3 .github/scripts/check-project-rollout.py --environment "$ENVIRONMENT" --region "$AWS_REGION" --expected-image-digest "$EXPECTED_IMAGE_DIGEST"
80+
5481
# The expression maps the explicit environment input to exactly one repo
5582
# secret, so the job never holds another environment's database URL. An
5683
# unknown environment resolves to empty and the guard below fails the job.

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,6 @@ export const env = createEnv({
669669

670670
// SSO Configuration (for script-based registration)
671671
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
672-
PROJECT_WRITES_ENABLED: z.boolean().optional(), // Activate new Project assignments after old writers drain
673672
PROJECT_API_ENABLED: z.boolean().optional(), // Expose Projects after backfill and contract enforcement
674673
SCIM_ENABLED: z.boolean().optional(), // Enable SCIM directory provisioning
675674
USAGE_MONITORING_ENABLED: z.boolean().optional(), // Enable organization usage monitoring on self-hosted (bypasses hosted requirements)

‎apps/sim/lib/projects/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A Project groups environments. An environment is an existing `workspace` record; there is no separate environment table. Every newly created Project starts with an environment.
44

5-
Project APIs return HTTP 503 until the deployment enables `PROJECT_API_ENABLED`. Automatic assignment on legacy workspace creation requires `PROJECT_WRITES_ENABLED`; both server controls default off. The API requires writers to be enabled. Existing assigned Projects always retain their lifecycle protections, including fork inheritance and disconnect behavior, even if activation is disabled.
5+
Project APIs return HTTP 503 until the deployment enables `PROJECT_API_ENABLED`. Workspace creation always assigns a Project atomically. The API control defaults off and does not disable assignment. Existing assigned Projects always retain their lifecycle protections, including fork inheritance and disconnect behavior, even if activation is disabled.
66

77
## Choose the creation flow
88

‎apps/sim/lib/projects/__integration__/foundation.integration.ts‎

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, writeFile } from 'node:fs/promises'
1+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
22
import { dirname, resolve } from 'node:path'
33
import { db } from '@sim/db'
44
import {
@@ -18,6 +18,7 @@ import {
1818
workspaceForkPromoteRun,
1919
workspaceForkResourceMap,
2020
} from '@sim/db/schema'
21+
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
2122
import {
2223
createSessionPrincipal,
2324
createWorkspaceApiKeyPrincipal,
@@ -27,6 +28,7 @@ import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
2728
import { sleep } from '@sim/utils/helpers'
2829
import { generateId } from '@sim/utils/id'
2930
import { and, eq, inArray, sql } from 'drizzle-orm'
31+
import postgres from 'postgres'
3032
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
3133
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
3234
import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion'
@@ -194,7 +196,79 @@ afterAll(async () => {
194196

195197
describe('Project foundation at the database and application boundary', () => {
196198
check(
197-
'disabled rollout preserves legacy creation and fork/disconnect without Project rows',
199+
'application creation, disconnect, organization deletion and archive commit with SQL enforcement',
200+
async () => {
201+
const client = postgres(readTestDatabaseUrl(), { max: 1, onnotice: () => undefined })
202+
try {
203+
await client.unsafe(
204+
await readFile(
205+
new URL(
206+
'../../../../../packages/db/migrations/0394_project_membership_enforcement.sql',
207+
import.meta.url
208+
),
209+
'utf8'
210+
)
211+
)
212+
const f = await fixture(true, 1)
213+
const organizationId = f.organizationId
214+
if (!organizationId) throw new Error('Missing organization fixture')
215+
const created = await createProject.execute({
216+
principal: f.owner,
217+
input: {
218+
organizationId: f.organizationId,
219+
name: 'Enforced',
220+
initialEnvironment: { name: 'Production' },
221+
},
222+
request,
223+
})
224+
environments.push(created.initialEnvironment.id)
225+
const source = await getWorkspaceWithOwner(created.initialEnvironment.id)
226+
if (!source) throw new Error('Missing source environment')
227+
const fork = await createFork({
228+
source,
229+
policy: await getWorkspaceCreationPolicy({ userId: f.ownerId }),
230+
userId: f.ownerId,
231+
name: 'Staging',
232+
})
233+
environments.push(fork.workspace.id)
234+
await unlinkForkEdge({ parentWorkspaceId: source.id, childWorkspaceId: fork.workspace.id })
235+
const [detached] = await db
236+
.select()
237+
.from(projectWorkspace)
238+
.where(eq(projectWorkspace.workspaceId, fork.workspace.id))
239+
expect(detached.projectId).not.toBe(created.project.id)
240+
await db.transaction(async (tx) => {
241+
await detachOrganizationWorkspacesTx(tx, organizationId)
242+
await tx.delete(organization).where(eq(organization.id, organizationId))
243+
})
244+
await archiveProject.execute({
245+
principal: f.owner,
246+
input: { projectId: created.project.id },
247+
request,
248+
})
249+
const [archived] = await db.select().from(project).where(eq(project.id, created.project.id))
250+
expect(archived.organizationId).toBeNull()
251+
expect(archived.archivedAt).not.toBeNull()
252+
expect(
253+
await db
254+
.select()
255+
.from(workflow)
256+
.where(and(eq(workflow.workspaceId, source.id), sql`${workflow.archivedAt} IS NULL`))
257+
).toHaveLength(0)
258+
} finally {
259+
await client.unsafe('ROLLBACK')
260+
for (const table of ['project', 'project_workspace', 'workspace', 'workflow']) {
261+
await client.unsafe(
262+
`DROP TRIGGER IF EXISTS project_contract_lock ON ${table}; DROP TRIGGER IF EXISTS project_contract_check ON ${table}`
263+
)
264+
}
265+
await client.end()
266+
}
267+
}
268+
)
269+
270+
check(
271+
'workspace creation and fork/disconnect assign Projects even with the retired writer flag off',
198272
async () => {
199273
vi.stubEnv('PROJECT_WRITES_ENABLED', 'false')
200274
vi.stubEnv('PROJECT_API_ENABLED', 'false')
@@ -214,7 +288,7 @@ describe('Project foundation at the database and application boundary', () => {
214288
environments.push(source.id)
215289
expect(
216290
await db.select().from(projectWorkspace).where(eq(projectWorkspace.workspaceId, source.id))
217-
).toEqual([])
291+
).toHaveLength(1)
218292
const parent = await getWorkspaceWithOwner(source.id)
219293
if (!parent) throw new Error('Missing source fixture')
220294
const fork = await createFork({
@@ -229,11 +303,11 @@ describe('Project foundation at the database and application boundary', () => {
229303
.select()
230304
.from(projectWorkspace)
231305
.where(eq(projectWorkspace.workspaceId, fork.workspace.id))
232-
).toEqual([])
306+
).toHaveLength(1)
233307
await unlinkForkEdge({ parentWorkspaceId: source.id, childWorkspaceId: fork.workspace.id })
234308
const [child] = await db.select().from(workspace).where(eq(workspace.id, fork.workspace.id))
235309
expect(child.forkedFromWorkspaceId).toBeNull()
236-
expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength(1)
310+
expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength(3)
237311
}
238312
)
239313

@@ -316,7 +390,7 @@ describe('Project foundation at the database and application boundary', () => {
316390
}
317391
)
318392

319-
check('writer activation assigns new workspaces while Project APIs remain disabled', async () => {
393+
check('new workspaces receive Projects while Project APIs remain disabled', async () => {
320394
vi.stubEnv('PROJECT_API_ENABLED', 'false')
321395
const f = await fixture(false, 1)
322396
const created = await db.transaction((tx) =>
@@ -335,18 +409,6 @@ describe('Project foundation at the database and application boundary', () => {
335409
expect(
336410
await db.select().from(projectWorkspace).where(eq(projectWorkspace.workspaceId, created.id))
337411
).toHaveLength(1)
338-
vi.stubEnv('PROJECT_WRITES_ENABLED', 'false')
339-
vi.stubEnv('PROJECT_API_ENABLED', 'true')
340-
await expect(
341-
createProject.execute({
342-
principal: f.owner,
343-
input: { organizationId: null, name: 'Invalid', initialEnvironment: { name: 'First' } },
344-
request,
345-
})
346-
).rejects.toThrow('PROJECT_API_ENABLED requires PROJECT_WRITES_ENABLED')
347-
expect(await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))).toHaveLength(
348-
2
349-
)
350412
})
351413

352414
check('legacy fork and disconnect refuse a partially assigned subtree', async () => {
Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
import { envBoolean, getEnv } from '@/lib/core/config/env'
22
import { HttpError } from '@/lib/core/utils/http-error'
33

4-
/** Deployment-wide controls; existing Project lifecycle maintenance never depends on these. */
5-
export function getProjectRollout() {
6-
const writesEnabled = envBoolean(getEnv('PROJECT_WRITES_ENABLED')) ?? false
7-
const apiEnabled = envBoolean(getEnv('PROJECT_API_ENABLED')) ?? false
8-
if (apiEnabled && !writesEnabled)
9-
throw new Error('PROJECT_API_ENABLED requires PROJECT_WRITES_ENABLED')
10-
return { writesEnabled, apiEnabled }
11-
}
12-
134
class ProjectUnavailableError extends HttpError {
145
readonly statusCode = 503
156
constructor() {
@@ -18,5 +9,5 @@ class ProjectUnavailableError extends HttpError {
189
}
1910

2011
export function requireProjectApiEnabled(): void {
21-
if (!getProjectRollout().apiEnabled) throw new ProjectUnavailableError()
12+
if (!(envBoolean(getEnv('PROJECT_API_ENABLED')) ?? false)) throw new ProjectUnavailableError()
2213
}

0 commit comments

Comments
 (0)