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
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:
+209
@@ -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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,280 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
permissionGroup,
|
||||
permissionGroupMember,
|
||||
permissionGroupWorkspace,
|
||||
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, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createPermissionGroupContract } 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 {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
PERMISSION_GROUP_CONSTRAINTS,
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import {
|
||||
type AllMembersConflict,
|
||||
acquirePermissionGroupOrgLock,
|
||||
authorizeOrgAccessControl,
|
||||
findAllMembersWorkspaceConflict,
|
||||
findWorkspacesNotInOrganization,
|
||||
formatAllMembersConflictError,
|
||||
getWorkspacesForGroups,
|
||||
} from '@/app/api/organizations/[id]/permission-groups/utils'
|
||||
|
||||
const logger = createLogger('OrganizationPermissionGroups')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const groups = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
config: permissionGroup.config,
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
isDefault: permissionGroup.isDefault,
|
||||
creatorName: user.name,
|
||||
creatorEmail: user.email,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
|
||||
.where(eq(permissionGroup.organizationId, organizationId))
|
||||
.orderBy(desc(permissionGroup.createdAt))
|
||||
|
||||
const groupIds = groups.map((group) => group.id)
|
||||
const memberCounts = groupIds.length
|
||||
? await db
|
||||
.select({
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
count: count(),
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.where(inArray(permissionGroupMember.permissionGroupId, groupIds))
|
||||
.groupBy(permissionGroupMember.permissionGroupId)
|
||||
: []
|
||||
const countByGroupId = new Map(memberCounts.map((row) => [row.permissionGroupId, row.count]))
|
||||
const workspacesByGroupId = await getWorkspacesForGroups(groupIds)
|
||||
|
||||
const groupsWithCounts = groups.map((group) => ({
|
||||
...group,
|
||||
config: parsePermissionGroupConfig(group.config),
|
||||
memberCount: countByGroupId.get(group.id) ?? 0,
|
||||
workspaces: workspacesByGroupId.get(group.id) ?? [],
|
||||
}))
|
||||
|
||||
return NextResponse.json({ permissionGroups: groupsWithCounts })
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
// 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 parsed = await parseRequest(createPermissionGroupContract, req, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name, description, config, isDefault } = parsed.data.body
|
||||
|
||||
// Only the organization default group is org-wide; every other group
|
||||
// targets specific workspaces. "Org-wide" is definitionally `isDefault`.
|
||||
const isDefaultGroup = isDefault === true
|
||||
const workspaceIds = isDefaultGroup
|
||||
? []
|
||||
: Array.from(new Set(parsed.data.body.workspaceIds ?? []))
|
||||
|
||||
if (!isDefaultGroup && workspaceIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Select at least one workspace when the group targets specific workspaces' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDefaultGroup) {
|
||||
const invalid = await findWorkspacesNotInOrganization(workspaceIds, organizationId)
|
||||
if (invalid.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'One or more selected workspaces do not belong to this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const existingGroup = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.name, name))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingGroup.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const groupConfig: PermissionGroupConfig = {
|
||||
...DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
...config,
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const newGroup = {
|
||||
id: generateId(),
|
||||
organizationId,
|
||||
name,
|
||||
description: description || null,
|
||||
config: groupConfig,
|
||||
createdBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDefault: isDefault || false,
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await acquirePermissionGroupOrgLock(tx, organizationId)
|
||||
|
||||
// A new non-default group has no members, so it governs all members of
|
||||
// its workspaces; reject when another all-members group already does.
|
||||
if (!isDefaultGroup) {
|
||||
const conflict = await findAllMembersWorkspaceConflict(
|
||||
{ organizationId, excludeGroupId: newGroup.id, workspaceIds },
|
||||
tx
|
||||
)
|
||||
if (conflict) {
|
||||
allMembersConflict = conflict
|
||||
throw new Error('ALL_MEMBERS_CONFLICT')
|
||||
}
|
||||
}
|
||||
|
||||
if (isDefault) {
|
||||
// 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.insert(permissionGroup).values(newGroup)
|
||||
if (workspaceIds.length > 0) {
|
||||
await tx.insert(permissionGroupWorkspace).values(
|
||||
workspaceIds.map((workspaceId) => ({
|
||||
id: generateId(),
|
||||
permissionGroupId: newGroup.id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
createdAt: now,
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Created permission group', {
|
||||
permissionGroupId: newGroup.id,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
workspaceCount: workspaceIds.length,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_CREATED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
resourceId: newGroup.id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: name,
|
||||
description: `Created permission group "${name}"`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
isDefault: isDefault || false,
|
||||
workspaceCount: workspaceIds.length,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json({ permissionGroup: { ...newGroup, workspaceIds } }, { status: 201 })
|
||||
} catch (error) {
|
||||
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 organization is being updated by another request. Please try again.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
logger.error('Error creating permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to create permission group' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockIsOrganizationAdminOrOwner,
|
||||
mockIsOrganizationOnEnterprisePlan,
|
||||
mockConflictRows,
|
||||
mockAllMembersRows,
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsOrganizationAdminOrOwner: vi.fn<() => Promise<boolean>>(),
|
||||
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
|
||||
mockConflictRows: {
|
||||
value: [] as Array<{
|
||||
userId: string
|
||||
userName: string | null
|
||||
userEmail: string | null
|
||||
otherGroupId: string
|
||||
otherGroupName: string
|
||||
}>,
|
||||
},
|
||||
mockAllMembersRows: {
|
||||
value: [] as Array<{
|
||||
conflictingGroupId: string
|
||||
conflictingGroupName: string
|
||||
workspaceName: string
|
||||
}>,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
isOrganizationAdminOrOwner: mockIsOrganizationAdminOrOwner,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(() => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.from = vi.fn(() => chain)
|
||||
chain.innerJoin = vi.fn(() => chain)
|
||||
chain.leftJoin = vi.fn(() => chain)
|
||||
chain.where = vi.fn(() => chain)
|
||||
chain.orderBy = vi.fn(() => chain)
|
||||
// findAllMembersWorkspaceConflict ends in `.limit(1)`; findScopeConflicts
|
||||
// awaits the builder directly after `.where()`.
|
||||
chain.limit = vi.fn(() => Promise.resolve(mockAllMembersRows.value))
|
||||
chain.then = (onFulfilled: (rows: unknown) => unknown) =>
|
||||
Promise.resolve(mockConflictRows.value).then(onFulfilled)
|
||||
return chain
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
permissionGroup: {},
|
||||
permissionGroupMember: {},
|
||||
permissionGroupWorkspace: {},
|
||||
user: {},
|
||||
workspace: {},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn(),
|
||||
asc: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
ne: vi.fn(),
|
||||
sql: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
authorizeOrgAccessControl,
|
||||
findAllMembersWorkspaceConflict,
|
||||
findScopeConflicts,
|
||||
} from './utils'
|
||||
|
||||
describe('authorizeOrgAccessControl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns a 403 when the user is not an organization admin/owner', async () => {
|
||||
mockIsOrganizationAdminOrOwner.mockResolvedValue(false)
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
|
||||
|
||||
const response = await authorizeOrgAccessControl('user-1', 'org-1')
|
||||
|
||||
expect(response).not.toBeNull()
|
||||
expect(response?.status).toBe(403)
|
||||
await expect(response?.json()).resolves.toEqual({ error: 'Admin permissions required' })
|
||||
// Entitlement is only checked after the admin gate passes.
|
||||
expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a 403 when the organization is not on an enterprise plan', async () => {
|
||||
mockIsOrganizationAdminOrOwner.mockResolvedValue(true)
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
|
||||
|
||||
const response = await authorizeOrgAccessControl('user-1', 'org-1')
|
||||
|
||||
expect(response?.status).toBe(403)
|
||||
await expect(response?.json()).resolves.toEqual({
|
||||
error: 'Access Control is an Enterprise feature',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the user is an admin and the org is entitled', async () => {
|
||||
mockIsOrganizationAdminOrOwner.mockResolvedValue(true)
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
|
||||
|
||||
const response = await authorizeOrgAccessControl('user-1', 'org-1')
|
||||
|
||||
expect(response).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findScopeConflicts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConflictRows.value = []
|
||||
})
|
||||
|
||||
const baseParams = {
|
||||
organizationId: 'org-1',
|
||||
excludeGroupId: 'group-1',
|
||||
workspaceIds: ['ws-1'],
|
||||
candidateUserIds: ['user-1'],
|
||||
}
|
||||
|
||||
const conflictRow = (userId: string, otherGroupName = 'Marketing') => ({
|
||||
userId,
|
||||
userName: 'User One',
|
||||
userEmail: `${userId}@example.com`,
|
||||
otherGroupId: 'group-2',
|
||||
otherGroupName,
|
||||
})
|
||||
|
||||
it('returns no conflicts when there are no candidate users', async () => {
|
||||
mockConflictRows.value = [conflictRow('user-1')]
|
||||
|
||||
const conflicts = await findScopeConflicts({ ...baseParams, candidateUserIds: [] })
|
||||
|
||||
expect(conflicts).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no conflicts when there are no target workspaces', async () => {
|
||||
mockConflictRows.value = [conflictRow('user-1')]
|
||||
|
||||
const conflicts = await findScopeConflicts({ ...baseParams, workspaceIds: [] })
|
||||
|
||||
expect(conflicts).toEqual([])
|
||||
})
|
||||
|
||||
it('flags a candidate already in another group that shares a workspace', async () => {
|
||||
mockConflictRows.value = [conflictRow('user-1')]
|
||||
|
||||
const conflicts = await findScopeConflicts(baseParams)
|
||||
|
||||
expect(conflicts.map((c) => c.userId)).toEqual(['user-1'])
|
||||
expect(conflicts[0].conflictingGroupName).toBe('Marketing')
|
||||
})
|
||||
|
||||
it('returns at most one conflict per user', async () => {
|
||||
mockConflictRows.value = [conflictRow('user-1', 'Marketing'), conflictRow('user-1', 'Sales')]
|
||||
|
||||
const conflicts = await findScopeConflicts(baseParams)
|
||||
|
||||
expect(conflicts).toHaveLength(1)
|
||||
expect(conflicts[0].conflictingGroupName).toBe('Marketing')
|
||||
})
|
||||
|
||||
it('returns no conflicts when the query finds no overlapping memberships', async () => {
|
||||
mockConflictRows.value = []
|
||||
|
||||
const conflicts = await findScopeConflicts(baseParams)
|
||||
|
||||
expect(conflicts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('findAllMembersWorkspaceConflict', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAllMembersRows.value = []
|
||||
})
|
||||
|
||||
const baseParams = {
|
||||
organizationId: 'org-1',
|
||||
excludeGroupId: 'group-1',
|
||||
workspaceIds: ['ws-1', 'ws-2'],
|
||||
}
|
||||
|
||||
it('returns null when there are no target workspaces', async () => {
|
||||
mockAllMembersRows.value = [
|
||||
{ conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' },
|
||||
]
|
||||
|
||||
const conflict = await findAllMembersWorkspaceConflict({ ...baseParams, workspaceIds: [] })
|
||||
|
||||
expect(conflict).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the conflicting all-members group sharing a workspace', async () => {
|
||||
mockAllMembersRows.value = [
|
||||
{ conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' },
|
||||
]
|
||||
|
||||
const conflict = await findAllMembersWorkspaceConflict(baseParams)
|
||||
|
||||
expect(conflict).toEqual({
|
||||
conflictingGroupId: 'group-2',
|
||||
conflictingGroupName: 'Marketing',
|
||||
workspaceName: 'Acme',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when no other all-members group targets the workspaces', async () => {
|
||||
mockAllMembersRows.value = []
|
||||
|
||||
const conflict = await findAllMembersWorkspaceConflict(baseParams)
|
||||
|
||||
expect(conflict).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
permissionGroup,
|
||||
permissionGroupMember,
|
||||
permissionGroupWorkspace,
|
||||
user,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
/** A workspace reference (id + display name). */
|
||||
export interface WorkspaceRef {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize an organization-scoped access-control management request. The caller
|
||||
* must be an organization owner/admin and the organization must be entitled to
|
||||
* the Access Control (Permission Groups) enterprise feature. Returns a
|
||||
* `NextResponse` to short-circuit on failure, or `null` when authorized.
|
||||
*/
|
||||
export async function authorizeOrgAccessControl(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<NextResponse | null> {
|
||||
const isAdmin = await isOrganizationAdminOrOwner(userId, organizationId)
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const entitled = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json({ error: 'Access Control is an Enterprise feature' }, { status: 403 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* Serialize all permission-group membership and scope writes for an organization
|
||||
* via a transaction-scoped Postgres advisory lock. Callers acquire it at the top
|
||||
* of the transaction that both checks (`findScopeConflicts`) and mutates, so a
|
||||
* concurrent member add or scope change can't commit in the check-to-write
|
||||
* window and leave a user governed by two groups on the same workspace.
|
||||
*
|
||||
* The invariant (one effective group per user per workspace) spans users and
|
||||
* groups in ways a unique constraint can't express, and these are low-frequency
|
||||
* admin writes, so a single org-scoped lock is simpler and more obviously
|
||||
* correct than fine-grained per-user/per-group locks with acquire-ordering.
|
||||
*
|
||||
* `pg_advisory_xact_lock` auto-releases at transaction end (safe on pooled
|
||||
* connections), and `lock_timeout` bounds the wait (raising SQLSTATE 55P03)
|
||||
* instead of hanging if a holder is stuck.
|
||||
*/
|
||||
export async function acquirePermissionGroupOrgLock(
|
||||
tx: DbOrTx,
|
||||
organizationId: string
|
||||
): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)`
|
||||
)
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`permission_group:${organizationId}`}, 0))`
|
||||
)
|
||||
}
|
||||
|
||||
/** Load a permission group only if it belongs to the given organization. */
|
||||
export async function loadGroupInOrganization(
|
||||
groupId: string,
|
||||
organizationId: string,
|
||||
executor: DbOrTx = db
|
||||
) {
|
||||
const [group] = await executor
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
config: permissionGroup.config,
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
isDefault: permissionGroup.isDefault,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
return group ?? null
|
||||
}
|
||||
|
||||
/** The workspaces ({id, name}) a specific-scope group targets. */
|
||||
export async function getGroupWorkspaces(
|
||||
groupId: string,
|
||||
executor: DbOrTx = db
|
||||
): Promise<WorkspaceRef[]> {
|
||||
return executor
|
||||
.select({ id: workspace.id, name: workspace.name })
|
||||
.from(permissionGroupWorkspace)
|
||||
.innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id))
|
||||
.where(eq(permissionGroupWorkspace.permissionGroupId, groupId))
|
||||
.orderBy(asc(workspace.name))
|
||||
}
|
||||
|
||||
/** Batched map of `groupId -> targeted workspaces` for a list of groups. */
|
||||
export async function getWorkspacesForGroups(
|
||||
groupIds: string[]
|
||||
): Promise<Map<string, WorkspaceRef[]>> {
|
||||
const byGroup = new Map<string, WorkspaceRef[]>()
|
||||
if (groupIds.length === 0) return byGroup
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
groupId: permissionGroupWorkspace.permissionGroupId,
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
})
|
||||
.from(permissionGroupWorkspace)
|
||||
.innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id))
|
||||
.where(inArray(permissionGroupWorkspace.permissionGroupId, groupIds))
|
||||
.orderBy(asc(workspace.name))
|
||||
|
||||
for (const row of rows) {
|
||||
const list = byGroup.get(row.groupId) ?? []
|
||||
list.push({ id: row.id, name: row.name })
|
||||
byGroup.set(row.groupId, list)
|
||||
}
|
||||
return byGroup
|
||||
}
|
||||
|
||||
/** Returns the subset of `workspaceIds` that do NOT belong to the organization. */
|
||||
export async function findWorkspacesNotInOrganization(
|
||||
workspaceIds: string[],
|
||||
organizationId: string
|
||||
): Promise<string[]> {
|
||||
if (workspaceIds.length === 0) return []
|
||||
const rows = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(inArray(workspace.id, workspaceIds), eq(workspace.organizationId, organizationId)))
|
||||
const valid = new Set(rows.map((row) => row.id))
|
||||
return workspaceIds.filter((id) => !valid.has(id))
|
||||
}
|
||||
|
||||
/** List an organization's workspaces ({id, name}), ordered by name. */
|
||||
export async function listOrganizationWorkspaces(organizationId: string): Promise<WorkspaceRef[]> {
|
||||
return db
|
||||
.select({ id: workspace.id, name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.organizationId, organizationId))
|
||||
.orderBy(asc(workspace.name))
|
||||
}
|
||||
|
||||
/** A member whose other group membership would conflict with a candidate scope. */
|
||||
export interface ScopeConflict {
|
||||
userId: string
|
||||
userName: string | null
|
||||
userEmail: string | null
|
||||
/** The group the member already belongs to that causes the conflict. */
|
||||
conflictingGroupId: string
|
||||
conflictingGroupName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of `candidateUserIds` would be governed by two groups on the same
|
||||
* workspace: each is already an explicit member of another non-default group
|
||||
* that shares one of `workspaceIds`. The candidate group (`excludeGroupId`) and
|
||||
* the org default group are ignored — the default never governs through
|
||||
* membership. Returns at most one conflict per user.
|
||||
*/
|
||||
export async function findScopeConflicts(
|
||||
params: {
|
||||
organizationId: string
|
||||
excludeGroupId: string
|
||||
workspaceIds: string[]
|
||||
candidateUserIds: string[]
|
||||
},
|
||||
executor: DbOrTx = db
|
||||
): Promise<ScopeConflict[]> {
|
||||
const { organizationId, excludeGroupId, workspaceIds, candidateUserIds } = params
|
||||
if (candidateUserIds.length === 0 || workspaceIds.length === 0) return []
|
||||
|
||||
const rows = await executor
|
||||
.select({
|
||||
userId: permissionGroupMember.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
otherGroupId: permissionGroup.id,
|
||||
otherGroupName: permissionGroup.name,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id))
|
||||
.innerJoin(
|
||||
permissionGroupWorkspace,
|
||||
eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id)
|
||||
)
|
||||
.leftJoin(user, eq(permissionGroupMember.userId, user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.organizationId, organizationId),
|
||||
inArray(permissionGroupMember.userId, candidateUserIds),
|
||||
ne(permissionGroupMember.permissionGroupId, excludeGroupId),
|
||||
eq(permissionGroup.isDefault, false),
|
||||
inArray(permissionGroupWorkspace.workspaceId, workspaceIds)
|
||||
)
|
||||
)
|
||||
|
||||
const conflictByUser = new Map<string, ScopeConflict>()
|
||||
for (const row of rows) {
|
||||
if (conflictByUser.has(row.userId)) continue
|
||||
conflictByUser.set(row.userId, {
|
||||
userId: row.userId,
|
||||
userName: row.userName,
|
||||
userEmail: row.userEmail,
|
||||
conflictingGroupId: row.otherGroupId,
|
||||
conflictingGroupName: row.otherGroupName,
|
||||
})
|
||||
}
|
||||
return Array.from(conflictByUser.values())
|
||||
}
|
||||
|
||||
/** An existing all-members group that already governs everyone in a shared workspace. */
|
||||
export interface AllMembersConflict {
|
||||
conflictingGroupId: string
|
||||
conflictingGroupName: string
|
||||
workspaceName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* For a group that will govern *all members* of `workspaceIds` (a non-default
|
||||
* group with no explicit members), return the first other non-default
|
||||
* all-members group already targeting one of those workspaces, or `null`. Two
|
||||
* all-members groups on one workspace would both claim everyone there, so this
|
||||
* is rejected at assignment time. The candidate group (`excludeGroupId`) is
|
||||
* ignored.
|
||||
*/
|
||||
export async function findAllMembersWorkspaceConflict(
|
||||
params: { organizationId: string; excludeGroupId: string; workspaceIds: string[] },
|
||||
executor: DbOrTx = db
|
||||
): Promise<AllMembersConflict | null> {
|
||||
const { organizationId, excludeGroupId, workspaceIds } = params
|
||||
if (workspaceIds.length === 0) return null
|
||||
|
||||
const [row] = await executor
|
||||
.select({
|
||||
conflictingGroupId: permissionGroup.id,
|
||||
conflictingGroupName: permissionGroup.name,
|
||||
workspaceName: workspace.name,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.innerJoin(
|
||||
permissionGroupWorkspace,
|
||||
eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id)
|
||||
)
|
||||
.innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
eq(permissionGroup.isDefault, false),
|
||||
ne(permissionGroup.id, excludeGroupId),
|
||||
inArray(permissionGroupWorkspace.workspaceId, workspaceIds),
|
||||
sql`not exists (
|
||||
select 1 from ${permissionGroupMember}
|
||||
where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id}
|
||||
)`
|
||||
)
|
||||
)
|
||||
.orderBy(asc(workspace.name))
|
||||
.limit(1)
|
||||
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable 409 message for a scope/membership conflict, naming the member
|
||||
* and the group they already belong to that overlaps the requested workspaces.
|
||||
*/
|
||||
export function formatScopeConflictError(conflicts: ScopeConflict[]): string {
|
||||
const [first] = conflicts
|
||||
if (!first) {
|
||||
return 'A member would be governed by two groups for the same workspace. Resolve their group memberships first.'
|
||||
}
|
||||
const who = first.userName || first.userEmail || 'A member'
|
||||
if (conflicts.length === 1) {
|
||||
return `${who} is already in the group "${first.conflictingGroupName}", which targets one of these workspaces. Remove them from one group first.`
|
||||
}
|
||||
const others = conflicts.length - 1
|
||||
return `${who} and ${others} other member${others === 1 ? '' : 's'} already belong to groups that target these workspaces (e.g. "${first.conflictingGroupName}"). Resolve their group memberships first.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable 409 message when another group already governs everyone in a
|
||||
* workspace this group would also apply to all members of.
|
||||
*/
|
||||
export function formatAllMembersConflictError(conflict: AllMembersConflict): string {
|
||||
return `The group "${conflict.conflictingGroupName}" already applies to everyone in "${conflict.workspaceName}". Two groups can't both govern all members of the same workspace — add members to one of them, or remove that workspace from one group first.`
|
||||
}
|
||||
Reference in New Issue
Block a user