chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,209 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, permissionGroupMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { bulkAddPermissionGroupMembersContract } from '@/lib/api/contracts/permission-groups'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types'
import {
acquirePermissionGroupOrgLock,
authorizeOrgAccessControl,
findScopeConflicts,
formatScopeConflictError,
getGroupWorkspaces,
loadGroupInOrganization,
type ScopeConflict,
} from '@/app/api/organizations/[id]/permission-groups/utils'
const logger = createLogger('OrganizationPermissionGroupBulkMembers')
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, groupId: id } = await context.params
// Populated inside the transaction when a scope conflict is detected, so the
// catch can format the 409 after the rollback.
let scopeConflicts: ScopeConflict[] = []
try {
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
if (denied) return denied
const group = await loadGroupInOrganization(id, organizationId)
if (!group) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
const parsed = await parseRequest(bulkAddPermissionGroupMembersContract, req, context, {
validationErrorResponse: (error) =>
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { userIds, addAllOrganizationMembers } = parsed.data.body
let targetUserIds: string[] = []
if (addAllOrganizationMembers) {
const orgMembers = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
targetUserIds = Array.from(new Set(orgMembers.map((m) => m.userId)))
} else if (userIds && userIds.length > 0) {
const uniqueUserIds = Array.from(new Set(userIds))
const validMembers = await db
.select({ userId: member.userId })
.from(member)
.where(
and(eq(member.organizationId, organizationId), inArray(member.userId, uniqueUserIds))
)
targetUserIds = Array.from(new Set(validMembers.map((m) => m.userId)))
}
if (targetUserIds.length === 0) {
return NextResponse.json({ added: 0, skipped: 0 })
}
const { addedUserIds } = await db.transaction(async (tx) => {
// Serialize all permission-group writes for this org so the conflict
// check and inserts are atomic against concurrent adds or scope changes.
await acquirePermissionGroupOrgLock(tx, organizationId)
// Re-read the group under the lock: a concurrent scope change may have
// changed its workspaces since the pre-transaction load, so the conflict
// check uses one consistent snapshot.
const lockedGroup = await loadGroupInOrganization(id, organizationId, tx)
if (!lockedGroup) {
throw new Error('GROUP_NOT_FOUND')
}
// Bulk add is all-or-nothing for conflicts: if any selected user is
// already an explicit member of another group sharing one of this group's
// workspaces, add nobody and surface the conflict so the admin can fix the
// selection. Members already in this group are no-ops.
const groupWorkspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
const conflicts = await findScopeConflicts(
{
organizationId,
excludeGroupId: id,
workspaceIds: groupWorkspaceIds,
candidateUserIds: targetUserIds,
},
tx
)
if (conflicts.length > 0) {
scopeConflicts = conflicts
throw new Error('SCOPE_CONFLICT')
}
const existingInGroup = await tx
.select({ userId: permissionGroupMember.userId })
.from(permissionGroupMember)
.where(
and(
eq(permissionGroupMember.permissionGroupId, id),
inArray(permissionGroupMember.userId, targetUserIds)
)
)
const alreadyInThisGroup = new Set(existingInGroup.map((m) => m.userId))
const usersToAdd = targetUserIds.filter((uid) => !alreadyInThisGroup.has(uid))
if (usersToAdd.length === 0) {
return { addedUserIds: [] as string[] }
}
const newMembers = usersToAdd.map((userId) => ({
id: generateId(),
permissionGroupId: id,
organizationId,
userId,
assignedBy: session.user.id,
assignedAt: new Date(),
}))
await tx.insert(permissionGroupMember).values(newMembers)
return { addedUserIds: usersToAdd }
})
const skipped = targetUserIds.length - addedUserIds.length
if (addedUserIds.length === 0) {
return NextResponse.json({ added: 0, skipped })
}
logger.info('Bulk added members to permission group', {
permissionGroupId: id,
organizationId,
addedCount: addedUserIds.length,
skipped,
assignedBy: session.user.id,
})
recordAudit({
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: id,
resourceName: group.name,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Bulk added ${addedUserIds.length} member(s) to permission group "${group.name}"`,
metadata: {
organizationId,
permissionGroupId: id,
addedUserIds,
skipped,
},
request: req,
})
return NextResponse.json({ added: addedUserIds.length, skipped })
} catch (error) {
if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
if (error instanceof Error && error.message === 'SCOPE_CONFLICT') {
return NextResponse.json(
{ error: formatScopeConflictError(scopeConflicts) },
{ status: 409 }
)
}
if (
getPostgresErrorCode(error) === '23505' &&
getPostgresConstraintName(error) === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser
) {
return NextResponse.json(
{
error:
'One or more users were concurrently added to this group. Please refresh and try again.',
},
{ status: 409 }
)
}
// Advisory lock wait exceeded (lock_timeout) — transient contention.
if (getPostgresErrorCode(error) === '55P03') {
return NextResponse.json(
{ error: 'This group is being updated by another request. Please try again.' },
{ status: 503 }
)
}
logger.error('Error bulk adding members to permission group', error)
return NextResponse.json({ error: 'Failed to add members' }, { status: 500 })
}
}
)
@@ -0,0 +1,360 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissionGroupMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, count, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission-groups'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types'
import { isOrganizationMember } from '@/lib/workspaces/permissions/utils'
import {
type AllMembersConflict,
acquirePermissionGroupOrgLock,
authorizeOrgAccessControl,
findAllMembersWorkspaceConflict,
findScopeConflicts,
formatAllMembersConflictError,
formatScopeConflictError,
getGroupWorkspaces,
loadGroupInOrganization,
type ScopeConflict,
} from '@/app/api/organizations/[id]/permission-groups/utils'
const logger = createLogger('OrganizationPermissionGroupMembers')
export const GET = withRouteHandler(
async (_req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, groupId: id } = await params
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
if (denied) return denied
const group = await loadGroupInOrganization(id, organizationId)
if (!group) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
const members = await db
.select({
id: permissionGroupMember.id,
userId: permissionGroupMember.userId,
assignedAt: permissionGroupMember.assignedAt,
userName: user.name,
userEmail: user.email,
userImage: user.image,
})
.from(permissionGroupMember)
.leftJoin(user, eq(permissionGroupMember.userId, user.id))
.where(eq(permissionGroupMember.permissionGroupId, id))
return NextResponse.json({ members })
}
)
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, groupId: id } = await context.params
// Populated inside the transaction when a scope conflict is detected, so the
// catch can format the 409 after the rollback.
let scopeConflicts: ScopeConflict[] = []
try {
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
if (denied) return denied
const group = await loadGroupInOrganization(id, organizationId)
if (!group) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
const parsed = await parseRequest(addPermissionGroupMemberContract, req, context, {
validationErrorResponse: (error) =>
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { userId } = parsed.data.body
const isMember = await isOrganizationMember(userId, organizationId)
if (!isMember) {
return NextResponse.json(
{ error: 'User is not a member of this organization' },
{ status: 400 }
)
}
const newMember = await db.transaction(async (tx) => {
// Serialize all permission-group writes for this org so the conflict
// check and insert are atomic. Without it, two concurrent adds (or a
// concurrent scope change) could both pass findScopeConflicts and place
// the user in two groups that overlap on a workspace.
await acquirePermissionGroupOrgLock(tx, organizationId)
// Re-read the group under the lock: a concurrent scope change may have
// changed its workspaces since the pre-transaction load, so the conflict
// check uses one consistent snapshot.
const lockedGroup = await loadGroupInOrganization(id, organizationId, tx)
if (!lockedGroup) {
throw new Error('GROUP_NOT_FOUND')
}
const [existingInGroup] = await tx
.select({ id: permissionGroupMember.id })
.from(permissionGroupMember)
.where(
and(
eq(permissionGroupMember.permissionGroupId, id),
eq(permissionGroupMember.userId, userId)
)
)
.limit(1)
if (existingInGroup) {
throw new Error('ALREADY_IN_GROUP')
}
// A user may belong to multiple groups, but only one may govern any given
// workspace. Reject when the user is already an explicit member of another
// group that shares one of this group's workspaces.
const groupWorkspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
const conflicts = await findScopeConflicts(
{
organizationId,
excludeGroupId: id,
workspaceIds: groupWorkspaceIds,
candidateUserIds: [userId],
},
tx
)
if (conflicts.length > 0) {
scopeConflicts = conflicts
throw new Error('SCOPE_CONFLICT')
}
const memberData = {
id: generateId(),
permissionGroupId: id,
organizationId,
userId,
assignedBy: session.user.id,
assignedAt: new Date(),
}
await tx.insert(permissionGroupMember).values(memberData)
return memberData
})
logger.info('Added member to permission group', {
permissionGroupId: id,
organizationId,
userId,
assignedBy: session.user.id,
})
recordAudit({
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: id,
resourceName: group.name,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Added member ${userId} to permission group "${group.name}"`,
metadata: {
organizationId,
targetUserId: userId,
permissionGroupId: id,
},
request: req,
})
return NextResponse.json({ member: newMember }, { status: 201 })
} catch (error) {
if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
if (error instanceof Error && error.message === 'ALREADY_IN_GROUP') {
return NextResponse.json(
{ error: 'User is already in this permission group' },
{ status: 409 }
)
}
if (error instanceof Error && error.message === 'SCOPE_CONFLICT') {
return NextResponse.json(
{ error: formatScopeConflictError(scopeConflicts) },
{ status: 409 }
)
}
if (
getPostgresErrorCode(error) === '23505' &&
getPostgresConstraintName(error) === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser
) {
return NextResponse.json(
{ error: 'User is already in this permission group' },
{ status: 409 }
)
}
// Advisory lock wait exceeded (lock_timeout) — transient contention.
if (getPostgresErrorCode(error) === '55P03') {
return NextResponse.json(
{ error: 'This group is being updated by another request. Please try again.' },
{ status: 503 }
)
}
logger.error('Error adding member to permission group', error)
return NextResponse.json({ error: 'Failed to add member' }, { status: 500 })
}
}
)
export const DELETE = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, groupId: id } = await params
const { searchParams } = new URL(req.url)
const memberId = searchParams.get('memberId')
if (!memberId) {
return NextResponse.json({ error: 'memberId is required' }, { status: 400 })
}
// Populated inside the transaction when an all-members scope conflict is
// detected, so the catch can format the 409 after the rollback.
let allMembersConflict: AllMembersConflict | null = null
try {
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
if (denied) return denied
const group = await loadGroupInOrganization(id, organizationId)
if (!group) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
const memberToRemove = await db.transaction(async (tx) => {
// Serialize permission-group writes for this org so the last-member check
// and the delete commit atomically: removing the last member turns a
// workspace group into an all-members group, which is unique per workspace.
await acquirePermissionGroupOrgLock(tx, organizationId)
const lockedGroup = await loadGroupInOrganization(id, organizationId, tx)
if (!lockedGroup) {
throw new Error('GROUP_NOT_FOUND')
}
const [member] = await tx
.select({
id: permissionGroupMember.id,
userId: permissionGroupMember.userId,
email: user.email,
})
.from(permissionGroupMember)
.innerJoin(user, eq(permissionGroupMember.userId, user.id))
.where(
and(
eq(permissionGroupMember.id, memberId),
eq(permissionGroupMember.permissionGroupId, id)
)
)
.limit(1)
if (!member) {
throw new Error('MEMBER_NOT_FOUND')
}
if (!lockedGroup.isDefault) {
const [memberCountRow] = await tx
.select({ value: count() })
.from(permissionGroupMember)
.where(eq(permissionGroupMember.permissionGroupId, id))
if ((memberCountRow?.value ?? 0) <= 1) {
const workspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
const conflict = await findAllMembersWorkspaceConflict(
{ organizationId, excludeGroupId: id, workspaceIds },
tx
)
if (conflict) {
allMembersConflict = conflict
throw new Error('ALL_MEMBERS_CONFLICT')
}
}
}
await tx.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId))
return member
})
logger.info('Removed member from permission group', {
permissionGroupId: id,
organizationId,
memberId,
userId: session.user.id,
})
recordAudit({
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: id,
resourceName: group.name,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Removed member ${memberToRemove.userId} from permission group "${group.name}"`,
metadata: {
organizationId,
targetUserId: memberToRemove.userId,
targetEmail: memberToRemove.email ?? undefined,
memberId,
permissionGroupId: id,
},
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
if (error instanceof Error && error.message === 'MEMBER_NOT_FOUND') {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
if (
error instanceof Error &&
error.message === 'ALL_MEMBERS_CONFLICT' &&
allMembersConflict
) {
return NextResponse.json(
{ error: formatAllMembersConflictError(allMembersConflict) },
{ status: 409 }
)
}
if (getPostgresErrorCode(error) === '55P03') {
return NextResponse.json(
{ error: 'This group is being updated by another request. Please try again.' },
{ status: 503 }
)
}
logger.error('Error removing member from permission group', error)
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
}
}
)
@@ -0,0 +1,408 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updatePermissionGroupContract } from '@/lib/api/contracts/permission-groups'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
PERMISSION_GROUP_CONSTRAINTS,
type PermissionGroupConfig,
parsePermissionGroupConfig,
} from '@/lib/permission-groups/types'
import {
type AllMembersConflict,
acquirePermissionGroupOrgLock,
authorizeOrgAccessControl,
findAllMembersWorkspaceConflict,
findScopeConflicts,
findWorkspacesNotInOrganization,
formatAllMembersConflictError,
formatScopeConflictError,
getGroupWorkspaces,
loadGroupInOrganization,
type ScopeConflict,
} from '@/app/api/organizations/[id]/permission-groups/utils'
const logger = createLogger('OrganizationPermissionGroup')
export const GET = withRouteHandler(
async (_req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, groupId: id } = await params
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
if (denied) return denied
const group = await loadGroupInOrganization(id, organizationId)
if (!group) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
const workspaces = group.isDefault ? [] : await getGroupWorkspaces(id)
return NextResponse.json({
permissionGroup: {
...group,
config: parsePermissionGroupConfig(group.config),
workspaces,
},
})
}
)
export const PUT = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, groupId: id } = await context.params
// Populated inside the transaction when a scope conflict is detected, so the
// catch can format the 409 after the rollback.
let scopeConflicts: ScopeConflict[] = []
let allMembersConflict: AllMembersConflict | null = null
try {
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
if (denied) return denied
const group = await loadGroupInOrganization(id, organizationId)
if (!group) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
const parsed = await parseRequest(updatePermissionGroupContract, req, context, {
validationErrorResponse: (error) =>
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const updates = parsed.data.body
if (updates.name) {
const existingGroup = await db
.select({ id: permissionGroup.id })
.from(permissionGroup)
.where(
and(
eq(permissionGroup.organizationId, organizationId),
eq(permissionGroup.name, updates.name)
)
)
.limit(1)
if (existingGroup.length > 0 && existingGroup[0].id !== id) {
return NextResponse.json(
{ error: 'A permission group with this name already exists' },
{ status: 409 }
)
}
}
const currentConfig = parsePermissionGroupConfig(group.config)
const newConfig: PermissionGroupConfig = updates.config
? { ...currentConfig, ...updates.config }
: currentConfig
// Demoting the org default with no new scope: it becomes a non-default
// group with no workspaces (inert) until an admin re-scopes it. The client
// sends only `isDefault: false`, so this never forwards a workspace list.
const demotingDefaultToInert =
group.isDefault && updates.isDefault === false && updates.workspaceIds === undefined
// "Org-wide" is definitionally `isDefault` (the default group), so the
// effective scope follows it: a default group targets no specific
// workspaces; a non-default group targets its `workspaceIds`.
const effectiveIsDefault =
updates.isDefault !== undefined ? updates.isDefault : group.isDefault
// Scope is rewritten when the group is promoted to default, demoted to
// inert, or handed an explicit workspace list.
const scopeProvided =
demotingDefaultToInert || updates.workspaceIds !== undefined || updates.isDefault === true
// The default group governs every workspace, so it can't also name specific
// ones. The contract rejects `isDefault: true` + workspaceIds, but a direct
// API caller can still send workspaceIds against a group that is already the
// default — reject rather than silently dropping them.
if (effectiveIsDefault && updates.workspaceIds !== undefined) {
return NextResponse.json(
{
error: 'The default group governs all workspaces and cannot target specific workspaces',
},
{ status: 400 }
)
}
// Resolve and validate explicitly-provided workspaceIds before the
// transaction. When the request omits them for a specific-scope group
// ("keep current"), they're read under the lock instead (see below) so the
// conflict check and the write share one consistent snapshot.
let providedWorkspaceIds: string[] | null = null
if (!effectiveIsDefault && updates.workspaceIds !== undefined) {
// Zero workspaces is allowed on update: the group then governs nothing
// (the resolver inner-joins on the link table, so an empty group never
// matches any workspace). No "at least one" floor here.
providedWorkspaceIds = Array.from(new Set(updates.workspaceIds))
const invalid = await findWorkspacesNotInOrganization(providedWorkspaceIds, organizationId)
if (invalid.length > 0) {
return NextResponse.json(
{ error: 'One or more selected workspaces do not belong to this organization' },
{ status: 400 }
)
}
}
const now = new Date()
await db.transaction(async (tx) => {
// For a specific-scope group the target workspaces are the request's
// explicit ids, or — when omitted ("keep current") — the group's current
// workspaces read under the lock so the conflict check and write share
// one snapshot.
let resolvedWorkspaceIds: string[] = []
// When the scope changes, serialize against other permission-group writes
// for this org and re-check membership conflicts atomically with the
// write, so a concurrent member add (or scope change) can't slip a user
// into two groups that overlap on a workspace.
if (scopeProvided) {
await acquirePermissionGroupOrgLock(tx, organizationId)
if (!effectiveIsDefault) {
// May resolve to an empty list — a non-default group is allowed to
// target zero workspaces (governs nothing). The write below deletes
// the old links and inserts none.
resolvedWorkspaceIds =
providedWorkspaceIds ?? (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
}
const members = await tx
.select({ userId: permissionGroupMember.userId })
.from(permissionGroupMember)
.where(eq(permissionGroupMember.permissionGroupId, id))
const conflicts = await findScopeConflicts(
{
organizationId,
excludeGroupId: id,
workspaceIds: resolvedWorkspaceIds,
candidateUserIds: members.map((m) => m.userId),
},
tx
)
if (conflicts.length > 0) {
scopeConflicts = conflicts
throw new Error('SCOPE_CONFLICT')
}
// With no explicit members the group governs all members of its
// workspaces; reject when another all-members group already does.
if (!effectiveIsDefault && members.length === 0) {
const conflict = await findAllMembersWorkspaceConflict(
{ organizationId, excludeGroupId: id, workspaceIds: resolvedWorkspaceIds },
tx
)
if (conflict) {
allMembersConflict = conflict
throw new Error('ALL_MEMBERS_CONFLICT')
}
}
}
if (updates.isDefault === true) {
// Demote the prior default to a non-default group (only the default may
// be org-wide); it ends up with no workspaces (inert) until an admin
// re-scopes it.
await tx
.update(permissionGroup)
.set({ isDefault: false, updatedAt: now })
.where(
and(
eq(permissionGroup.organizationId, organizationId),
eq(permissionGroup.isDefault, true)
)
)
}
await tx
.update(permissionGroup)
.set({
...(updates.name !== undefined && { name: updates.name }),
...(updates.description !== undefined && { description: updates.description }),
...(updates.isDefault !== undefined && { isDefault: updates.isDefault }),
config: newConfig,
updatedAt: now,
})
.where(eq(permissionGroup.id, id))
if (scopeProvided) {
await tx
.delete(permissionGroupWorkspace)
.where(eq(permissionGroupWorkspace.permissionGroupId, id))
if (!effectiveIsDefault && resolvedWorkspaceIds.length > 0) {
await tx.insert(permissionGroupWorkspace).values(
resolvedWorkspaceIds.map((workspaceId) => ({
id: generateId(),
permissionGroupId: id,
workspaceId,
organizationId,
createdAt: now,
}))
)
}
}
})
const [updated] = await db
.select()
.from(permissionGroup)
.where(eq(permissionGroup.id, id))
.limit(1)
const finalWorkspaceIds = updated.isDefault
? []
: (await getGroupWorkspaces(id)).map((ws) => ws.id)
recordAudit({
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_UPDATED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: updated.name,
description: `Updated permission group "${updated.name}"`,
metadata: {
organizationId,
updatedFields: Object.keys(updates).filter(
(k) => updates[k as keyof typeof updates] !== undefined
),
},
request: req,
})
return NextResponse.json({
permissionGroup: {
...updated,
config: parsePermissionGroupConfig(updated.config),
workspaceIds: finalWorkspaceIds,
},
})
} catch (error) {
if (error instanceof Error && error.message === 'SCOPE_CONFLICT') {
return NextResponse.json(
{ error: formatScopeConflictError(scopeConflicts) },
{ status: 409 }
)
}
if (
error instanceof Error &&
error.message === 'ALL_MEMBERS_CONFLICT' &&
allMembersConflict
) {
return NextResponse.json(
{ error: formatAllMembersConflictError(allMembersConflict) },
{ status: 409 }
)
}
if (getPostgresErrorCode(error) === '23505') {
const constraint = getPostgresConstraintName(error)
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) {
return NextResponse.json(
{ error: 'A permission group with this name already exists' },
{ status: 409 }
)
}
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationDefault) {
return NextResponse.json(
{
error:
'Another group was concurrently set as the default. Please refresh and try again.',
},
{ status: 409 }
)
}
}
// Advisory lock wait exceeded (lock_timeout) — transient contention.
if (getPostgresErrorCode(error) === '55P03') {
return NextResponse.json(
{ error: 'This group is being updated by another request. Please try again.' },
{ status: 503 }
)
}
logger.error('Error updating permission group', error)
return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 })
}
}
)
export const DELETE = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, groupId: id } = await params
try {
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
if (denied) return denied
const group = await loadGroupInOrganization(id, organizationId)
if (!group) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
await db.transaction(async (tx) => {
await acquirePermissionGroupOrgLock(tx, organizationId)
await tx
.delete(permissionGroupMember)
.where(eq(permissionGroupMember.permissionGroupId, id))
await tx.delete(permissionGroup).where(eq(permissionGroup.id, id))
})
logger.info('Deleted permission group', {
permissionGroupId: id,
organizationId,
userId: session.user.id,
})
recordAudit({
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_DELETED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: group.name,
description: `Deleted permission group "${group.name}"`,
metadata: { organizationId },
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
// Advisory lock wait exceeded (lock_timeout) — transient contention.
if (getPostgresErrorCode(error) === '55P03') {
return NextResponse.json(
{ error: 'This group is being updated by another request. Please try again.' },
{ status: 503 }
)
}
logger.error('Error deleting permission group', error)
return NextResponse.json({ error: 'Failed to delete permission group' }, { status: 500 })
}
}
)