Skip to content

Commit aa9b415

Browse files
committed
feat(projects): create projects with their initial environment
1 parent 00a83d0 commit aa9b415

12 files changed

Lines changed: 371 additions & 22 deletions

File tree

‎apps/sim/app/api/projects/route.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { listProjectsContract } from '@/lib/api/contracts/projects'
1+
import { createProjectContract, listProjectsContract } from '@/lib/api/contracts/projects'
22
import {
33
defineInternalJsonRoute,
44
internalOrchestrationErrorPolicy,
55
internalRateLimits,
66
internalSessionAuth,
77
} from '@/lib/api/server/routes'
8-
import { listProjects, projectOperations } from '@/lib/projects/application'
8+
import { createProject, listProjects, projectOperations } from '@/lib/projects/application'
99

1010
export const GET = defineInternalJsonRoute({
1111
contract: listProjectsContract,
@@ -16,3 +16,13 @@ export const GET = defineInternalJsonRoute({
1616
mapInput: ({ query }) => query,
1717
useCase: listProjects,
1818
})
19+
20+
export const POST = defineInternalJsonRoute({
21+
contract: createProjectContract,
22+
auth: internalSessionAuth,
23+
operation: projectOperations.create,
24+
rateLimit: internalRateLimits.user({ bucketName: 'projects.write' }),
25+
errorPolicy: internalOrchestrationErrorPolicy,
26+
mapInput: ({ body }) => body,
27+
useCase: createProject,
28+
})

‎apps/sim/lib/api/contracts/projects.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from 'zod'
22
import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives'
33
import { defineRouteContract } from '@/lib/api/contracts/types'
4+
import { createProjectInputSchema } from '@/lib/projects/create-input'
45

56
export const projectEnvironmentSchema = z.object({
67
id: nonEmptyIdSchema,
@@ -84,3 +85,17 @@ export const getWorkspaceProjectContract = defineRouteContract({
8485
params: workspaceProjectParamsSchema,
8586
response: { mode: 'json', schema: getProjectResponseSchema },
8687
})
88+
89+
export const createProjectBodySchema = createProjectInputSchema
90+
export type CreateProjectBody = z.input<typeof createProjectBodySchema>
91+
export const createProjectResponseSchema = z.object({
92+
project: z.object({ id: nonEmptyIdSchema, name: z.string() }),
93+
initialEnvironment: z.object({ id: nonEmptyIdSchema, name: z.string() }),
94+
})
95+
export type CreateProjectResponse = z.output<typeof createProjectResponseSchema>
96+
export const createProjectContract = defineRouteContract({
97+
method: 'POST',
98+
path: '/api/projects',
99+
body: createProjectBodySchema,
100+
response: { mode: 'json', status: 201, schema: createProjectResponseSchema },
101+
})

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

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
user,
1212
userStats,
1313
workflow,
14+
workflowBlocks,
1415
workspace,
1516
workspaceForkBlockMap,
1617
workspaceForkDependentValue,
@@ -21,14 +22,15 @@ import {
2122
createSessionPrincipal,
2223
createWorkspaceApiKeyPrincipal,
2324
} from '@sim/testing/factories/principal.factory'
24-
import { getErrorMessage } from '@sim/utils/errors'
25+
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
2526
import { generateId } from '@sim/utils/id'
26-
import { and, eq, inArray } from 'drizzle-orm'
27+
import { and, eq, inArray, sql } from 'drizzle-orm'
2728
import { afterAll, describe, expect, it } from 'vitest'
2829
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
2930
import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion'
3031
import {
3132
archiveProject,
33+
createProject,
3234
getProject,
3335
getProjectIssueAccess,
3436
getWorkspaceProject,
@@ -180,6 +182,172 @@ afterAll(async () => {
180182
})
181183

182184
describe('Project foundation at the database and application boundary', () => {
185+
check(
186+
'Project creation commits its named first environment and starter workflow in the requested scope',
187+
async () => {
188+
for (const personal of [false, true]) {
189+
const f = await fixture(true, 1)
190+
const organizationId = personal ? null : f.organizationId
191+
if (!f.organizationId) throw new Error('Missing organization fixture')
192+
await db.insert(member).values({
193+
id: generateId(),
194+
organizationId: f.organizationId,
195+
userId: f.teammateId,
196+
role: 'admin',
197+
createdAt: new Date(),
198+
})
199+
const result = await createProject.execute({
200+
principal: f.teammate,
201+
input: {
202+
organizationId,
203+
name: 'Customer support',
204+
initialEnvironment: { name: 'Production' },
205+
},
206+
request,
207+
})
208+
environments.push(result.initialEnvironment.id)
209+
const [created] = await db.select().from(project).where(eq(project.id, result.project.id))
210+
expect(created).toMatchObject({
211+
name: 'Customer support',
212+
organizationId,
213+
ownerId: f.teammateId,
214+
})
215+
const [env] = await db
216+
.select()
217+
.from(workspace)
218+
.where(eq(workspace.id, result.initialEnvironment.id))
219+
expect(env).toMatchObject({
220+
name: 'Production',
221+
organizationId,
222+
ownerId: f.teammateId,
223+
billedAccountUserId: personal ? f.teammateId : f.ownerId,
224+
})
225+
expect(
226+
await db
227+
.select({
228+
projectId: projectWorkspace.projectId,
229+
workspaceId: projectWorkspace.workspaceId,
230+
})
231+
.from(projectWorkspace)
232+
.where(eq(projectWorkspace.projectId, created.id))
233+
).toEqual([{ projectId: created.id, workspaceId: env.id }])
234+
const grants = await db.select().from(permissions).where(eq(permissions.entityId, env.id))
235+
expect(
236+
grants.map((grant) => ({ userId: grant.userId, permissionType: grant.permissionType }))
237+
).toEqual(
238+
expect.arrayContaining([
239+
{ userId: f.teammateId, permissionType: 'admin' },
240+
...(personal ? [] : [{ userId: f.ownerId, permissionType: 'admin' }]),
241+
])
242+
)
243+
expect(grants).toHaveLength(personal ? 1 : 2)
244+
const workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, env.id))
245+
expect(workflows).toHaveLength(1)
246+
const blocks = await db
247+
.select()
248+
.from(workflowBlocks)
249+
.where(eq(workflowBlocks.workflowId, workflows[0].id))
250+
expect(blocks.length).toBeGreaterThan(0)
251+
}
252+
}
253+
)
254+
255+
check(
256+
'Project creation refuses workspace keys and foreign organizations without creating resources',
257+
async () => {
258+
const f = await fixture(true, 1)
259+
const foreign = await fixture(true, 1)
260+
const input = {
261+
organizationId: foreign.organizationId,
262+
name: 'Forbidden project',
263+
initialEnvironment: { name: 'Production' },
264+
}
265+
await expect(
266+
createProject.execute({ principal: f.owner, input, request })
267+
).rejects.toMatchObject({ code: 'forbidden' })
268+
await expect(
269+
createProject.execute({
270+
principal: createWorkspaceApiKeyPrincipal({ workspaceId: f.ids[0] }),
271+
input: { ...input, organizationId: f.organizationId },
272+
request,
273+
})
274+
).rejects.toThrow('cannot perform operation')
275+
expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength(1)
276+
expect(
277+
await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))
278+
).toHaveLength(1)
279+
}
280+
)
281+
282+
check(
283+
'Project creation cannot bypass the workspace-creation restriction through personal scope',
284+
async () => {
285+
const f = await fixture(true, 1)
286+
if (!f.organizationId) throw new Error('Missing organization fixture')
287+
await db.insert(permissionGroup).values({
288+
id: generateId(),
289+
organizationId: f.organizationId,
290+
name: 'Creation disabled',
291+
createdBy: f.ownerId,
292+
isDefault: true,
293+
config: { disableWorkspaceCreation: true },
294+
})
295+
for (const organizationId of [f.organizationId, null]) {
296+
await expect(
297+
createProject.execute({
298+
principal: f.owner,
299+
input: {
300+
organizationId,
301+
name: 'Forbidden project',
302+
initialEnvironment: { name: 'Production' },
303+
},
304+
request,
305+
})
306+
).rejects.toMatchObject({ detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' })
307+
}
308+
expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength(1)
309+
expect(
310+
await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))
311+
).toHaveLength(1)
312+
}
313+
)
314+
315+
check(
316+
'A starter-workflow failure rolls back the Project, environment and permissions together',
317+
async () => {
318+
const f = await fixture(false, 1)
319+
const constraint = sql.identifier(`project_create_test_${generateId().replaceAll('-', '')}`)
320+
const before = await db.select().from(permissions).where(eq(permissions.userId, f.ownerId))
321+
await db.execute(
322+
sql`ALTER TABLE ${workflow} ADD CONSTRAINT ${constraint} CHECK (${workflow.userId} <> ${f.ownerId}) NOT VALID`.inlineParams()
323+
)
324+
try {
325+
await expect(
326+
createProject.execute({
327+
principal: f.owner,
328+
input: {
329+
organizationId: null,
330+
name: 'Rollback project',
331+
initialEnvironment: { name: 'Rollback environment' },
332+
},
333+
request,
334+
})
335+
).rejects.toSatisfy((error: unknown) => getPostgresErrorCode(error) === '23514')
336+
expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength(
337+
1
338+
)
339+
expect(
340+
await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))
341+
).toHaveLength(1)
342+
expect(
343+
await db.select().from(permissions).where(eq(permissions.userId, f.ownerId))
344+
).toEqual(before)
345+
} finally {
346+
await db.execute(sql`ALTER TABLE ${workflow} DROP CONSTRAINT ${constraint}`)
347+
}
348+
}
349+
)
350+
183351
check('workspace creation and forks commit exactly one Project membership', async () => {
184352
const f = await fixture(false, 1)
185353
const source = await db.transaction((tx) =>

‎apps/sim/lib/projects/application/authorization.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { lockProject } from '@/lib/projects/membership'
1313

1414
export function requireProjectPrincipal(
1515
principal: Principal,
16-
operation: ProjectOperation
16+
operation: Pick<ProjectOperation, 'id' | 'principalKinds'>
1717
): asserts principal is SessionPrincipal {
1818
if (principal.kind !== 'session')
1919
throw new PrincipalKindAuthorizationError(principal.kind, operation.id)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { AuditAction, AuditResourceType } from '@sim/audit'
2+
import { db } from '@sim/db'
3+
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
4+
import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case'
5+
import type { OperationUseCase } from '@/lib/core/application/operation'
6+
import { OrchestrationError } from '@/lib/core/orchestration/types'
7+
import { refuseCapability } from '@/lib/permission-groups/capabilities'
8+
import { requireProjectPrincipal } from '@/lib/projects/application/authorization'
9+
import { projectOperations } from '@/lib/projects/application/operations'
10+
import { type CreateProjectInput, createProjectInputSchema } from '@/lib/projects/create-input'
11+
import {
12+
createWorkspaceWithProjectInTransaction,
13+
emitWorkspaceCreatedPlatformEvent,
14+
} from '@/lib/workspaces/create'
15+
import {
16+
getWorkspaceCreationPolicy,
17+
WorkspaceCreationCapabilityWithheldError,
18+
WorkspaceCreationContextChangedError,
19+
} from '@/lib/workspaces/policy'
20+
21+
interface CreatedProject {
22+
project: { id: string; name: string }
23+
initialEnvironment: { id: string; name: string }
24+
}
25+
26+
export const createProject: OperationUseCase<
27+
typeof projectOperations.create,
28+
CreateProjectInput,
29+
CreatedProject
30+
> = {
31+
operation: projectOperations.create,
32+
async execute({ principal, input, request }) {
33+
requireProjectPrincipal(principal, projectOperations.create)
34+
const parsed = createProjectInputSchema.safeParse(input)
35+
if (!parsed.success)
36+
throw new OrchestrationError(
37+
'validation',
38+
'A scope, Project name and initial environment name are required'
39+
)
40+
const { organizationId, name, initialEnvironment } = parsed.data
41+
const policy = await getWorkspaceCreationPolicy({
42+
userId: principal.userId,
43+
activeOrganizationId: organizationId,
44+
pinOrganization: true,
45+
})
46+
if (!policy.canCreate) {
47+
if (policy.blockedReasonCode === 'permission-group-denied')
48+
refuseCapability('workspace.create')
49+
throw new OrchestrationError('forbidden', policy.reason ?? 'Project creation is not allowed')
50+
}
51+
if (policy.organizationId !== organizationId) {
52+
throw new OrchestrationError(
53+
'forbidden',
54+
'The requested organization cannot create environments under its current subscription'
55+
)
56+
}
57+
let created: Awaited<ReturnType<typeof createWorkspaceWithProjectInTransaction>>
58+
try {
59+
created = await db.transaction((tx) =>
60+
createWorkspaceWithProjectInTransaction(tx, {
61+
userId: principal.userId,
62+
name: initialEnvironment.name,
63+
projectName: name,
64+
organizationId,
65+
workspaceMode: policy.workspaceMode,
66+
billedAccountUserId: policy.billedAccountUserId,
67+
observedOrganizationId: policy.observedOrganizationId,
68+
governingPermissionGroupOrganizationId: policy.governingPermissionGroupOrganizationId,
69+
})
70+
)
71+
} catch (error) {
72+
if (error instanceof WorkspaceCreationCapabilityWithheldError)
73+
refuseCapability('workspace.create')
74+
if (error instanceof WorkspaceCreationContextChangedError)
75+
throw new OrchestrationError(
76+
'conflict',
77+
'Organization membership changed; retry Project creation'
78+
)
79+
if (getPostgresErrorCode(error) === '55P03')
80+
throw new OrchestrationError(
81+
'locked',
82+
'This organization is being updated; retry Project creation'
83+
)
84+
if (
85+
getPostgresErrorCode(error) === '23503' &&
86+
getPostgresConstraintName(error) === 'workspace_owner_id_user_id_fk'
87+
)
88+
throw new OrchestrationError('unauthorized', 'Unauthorized')
89+
throw error
90+
}
91+
const environment = created.workspace
92+
emitWorkspaceCreatedPlatformEvent({
93+
workspaceId: environment.id,
94+
userId: principal.userId,
95+
name: environment.name,
96+
})
97+
recordProjectedUseCaseAuditEntries(
98+
projectOperations.create,
99+
environment.id,
100+
principal,
101+
request,
102+
[
103+
{
104+
action: AuditAction.PROJECT_CREATED,
105+
resourceType: AuditResourceType.PROJECT,
106+
resourceId: created.projectId,
107+
resourceName: name,
108+
description: `Created Project "${name}"`,
109+
},
110+
{
111+
action: AuditAction.WORKSPACE_CREATED,
112+
resourceType: AuditResourceType.WORKSPACE,
113+
resourceId: environment.id,
114+
resourceName: environment.name,
115+
description: `Created workspace "${environment.name}"`,
116+
metadata: { name: environment.name, workspaceMode: environment.workspaceMode },
117+
},
118+
],
119+
organizationId ?? undefined
120+
)
121+
return {
122+
project: { id: created.projectId, name },
123+
initialEnvironment: { id: environment.id, name: environment.name },
124+
}
125+
},
126+
}

‎apps/sim/lib/projects/application/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { createProject } from '@/lib/projects/application/create-project'
12
export { projectOperations } from '@/lib/projects/application/operations'
23
export {
34
archiveProject,

0 commit comments

Comments
 (0)