improvement(access-controls): ui/ux improvements (#5190)

* improvement(access-controls): ui/ux improvements

* remove unused col
This commit is contained in:
Vikhyath Mondreti
2026-06-23 17:35:41 -07:00
committed by GitHub
parent 43fa5eaa19
commit 8b5d746fe2
14 changed files with 16883 additions and 232 deletions
@@ -145,7 +145,7 @@ A workspace-scoped group applies to **all members of its workspaces by default**
A user is governed by one group per workspace, so adding a user is rejected when it would conflict with another of their groups on a shared workspace (skipped rather than added in bulk). The default group ignores members entirely — it always governs everyone not covered by a workspace group.
Manage which workspaces a group governs from the **Workspaces** list in the group's **Details** view (Add and Remove). A non-default group must always target at least one workspace.
Manage which workspaces a group governs from the **Workspaces** list in the group's **Details** view (Add and Remove). A non-default group is created targeting at least one workspace, but you can later remove all of them — a group with no workspaces simply governs nothing until you add one back.
External workspace members (people who have access to a workspace but belong to a different organization) can't be added as named members, but a workspace-scoped group with no members — and the organization default group — still governs them.
@@ -281,7 +281,7 @@ export const DELETE = withRouteHandler(
throw new Error('MEMBER_NOT_FOUND')
}
if (!lockedGroup.isDefault && !lockedGroup.appliesToAllWorkspaces) {
if (!lockedGroup.isDefault) {
const [memberCountRow] = await tx
.select({ value: count() })
.from(permissionGroupMember)
@@ -48,7 +48,7 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
const workspaces = group.appliesToAllWorkspaces ? [] : await getGroupWorkspaces(id)
const workspaces = group.isDefault ? [] : await getGroupWorkspaces(id)
return NextResponse.json({
permissionGroup: {
@@ -117,44 +117,30 @@ export const PUT = withRouteHandler(
// 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
// (which a non-default group otherwise requires) against the per-group cap.
// sends only `isDefault: false`, so this never forwards a workspace list.
const demotingDefaultToInert =
group.isDefault &&
updates.isDefault === false &&
updates.appliesToAllWorkspaces === undefined &&
updates.workspaceIds === undefined
// Resolve the target workspace scope. Setting the group as default forces
// all-workspaces; otherwise an explicit `appliesToAllWorkspaces` wins, and
// supplying `workspaceIds` alone implies a specific scope.
const scopeProvided =
demotingDefaultToInert ||
updates.appliesToAllWorkspaces !== undefined ||
updates.workspaceIds !== undefined ||
updates.isDefault === true
const resolvedAppliesToAll = demotingDefaultToInert
? false
: updates.isDefault === true
? true
: updates.appliesToAllWorkspaces !== undefined
? updates.appliesToAllWorkspaces
: updates.workspaceIds !== undefined
? false
: group.appliesToAllWorkspaces
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
if (effectiveIsDefault && !resolvedAppliesToAll) {
// 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 must apply to all workspaces' },
{ status: 400 }
)
}
if (!effectiveIsDefault && resolvedAppliesToAll) {
return NextResponse.json(
{ error: 'Non-default groups must target specific workspaces' },
{
error: 'The default group governs all workspaces and cannot target specific workspaces',
},
{ status: 400 }
)
}
@@ -164,14 +150,11 @@ export const PUT = withRouteHandler(
// ("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 (!resolvedAppliesToAll && updates.workspaceIds !== undefined) {
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))
if (providedWorkspaceIds.length === 0) {
return NextResponse.json(
{ error: 'Select at least one workspace when the group targets specific workspaces' },
{ status: 400 }
)
}
const invalid = await findWorkspacesNotInOrganization(providedWorkspaceIds, organizationId)
if (invalid.length > 0) {
return NextResponse.json(
@@ -197,12 +180,12 @@ export const PUT = withRouteHandler(
if (scopeProvided) {
await acquirePermissionGroupOrgLock(tx, organizationId)
if (!resolvedAppliesToAll) {
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)
if (resolvedWorkspaceIds.length === 0 && !demotingDefaultToInert) {
throw new Error('NO_WORKSPACES')
}
}
const members = await tx
@@ -225,7 +208,7 @@ export const PUT = withRouteHandler(
// With no explicit members the group governs all members of its
// workspaces; reject when another all-members group already does.
if (!resolvedAppliesToAll && members.length === 0) {
if (!effectiveIsDefault && members.length === 0) {
const conflict = await findAllMembersWorkspaceConflict(
{ organizationId, excludeGroupId: id, workspaceIds: resolvedWorkspaceIds },
tx
@@ -238,12 +221,12 @@ export const PUT = withRouteHandler(
}
if (updates.isDefault === true) {
// Demote the prior default to a non-default group. It must also drop
// the all-workspaces scope (only the default may be org-wide); it ends
// up with no workspaces (inert) until an admin re-scopes it.
// 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, appliesToAllWorkspaces: false, updatedAt: now })
.set({ isDefault: false, updatedAt: now })
.where(
and(
eq(permissionGroup.organizationId, organizationId),
@@ -258,7 +241,6 @@ export const PUT = withRouteHandler(
...(updates.name !== undefined && { name: updates.name }),
...(updates.description !== undefined && { description: updates.description }),
...(updates.isDefault !== undefined && { isDefault: updates.isDefault }),
...(scopeProvided && { appliesToAllWorkspaces: resolvedAppliesToAll }),
config: newConfig,
updatedAt: now,
})
@@ -268,7 +250,7 @@ export const PUT = withRouteHandler(
await tx
.delete(permissionGroupWorkspace)
.where(eq(permissionGroupWorkspace.permissionGroupId, id))
if (!resolvedAppliesToAll && resolvedWorkspaceIds.length > 0) {
if (!effectiveIsDefault && resolvedWorkspaceIds.length > 0) {
await tx.insert(permissionGroupWorkspace).values(
resolvedWorkspaceIds.map((workspaceId) => ({
id: generateId(),
@@ -288,7 +270,7 @@ export const PUT = withRouteHandler(
.where(eq(permissionGroup.id, id))
.limit(1)
const finalWorkspaceIds = updated.appliesToAllWorkspaces
const finalWorkspaceIds = updated.isDefault
? []
: (await getGroupWorkspaces(id)).map((ws) => ws.id)
@@ -334,12 +316,6 @@ export const PUT = withRouteHandler(
{ status: 409 }
)
}
if (error instanceof Error && error.message === 'NO_WORKSPACES') {
return NextResponse.json(
{ error: 'Select at least one workspace when the group targets specific workspaces' },
{ status: 400 }
)
}
if (getPostgresErrorCode(error) === '23505') {
const constraint = getPostgresConstraintName(error)
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) {
@@ -55,7 +55,6 @@ export const GET = withRouteHandler(
createdAt: permissionGroup.createdAt,
updatedAt: permissionGroup.updatedAt,
isDefault: permissionGroup.isDefault,
appliesToAllWorkspaces: permissionGroup.appliesToAllWorkspaces,
creatorName: user.name,
creatorEmail: user.email,
})
@@ -114,21 +113,20 @@ export const POST = withRouteHandler(
const { name, description, config, isDefault } = parsed.data.body
// Only the organization default group is org-wide; every other group
// targets specific workspaces (the contract rejects all-workspaces scope
// for non-default groups).
const appliesToAllWorkspaces = isDefault === true
const workspaceIds = appliesToAllWorkspaces
// targets specific workspaces. "Org-wide" is definitionally `isDefault`.
const isDefaultGroup = isDefault === true
const workspaceIds = isDefaultGroup
? []
: Array.from(new Set(parsed.data.body.workspaceIds ?? []))
if (!appliesToAllWorkspaces && workspaceIds.length === 0) {
if (!isDefaultGroup && workspaceIds.length === 0) {
return NextResponse.json(
{ error: 'Select at least one workspace when the group targets specific workspaces' },
{ status: 400 }
)
}
if (!appliesToAllWorkspaces) {
if (!isDefaultGroup) {
const invalid = await findWorkspacesNotInOrganization(workspaceIds, organizationId)
if (invalid.length > 0) {
return NextResponse.json(
@@ -169,7 +167,6 @@ export const POST = withRouteHandler(
createdAt: now,
updatedAt: now,
isDefault: isDefault || false,
appliesToAllWorkspaces,
}
await db.transaction(async (tx) => {
@@ -177,7 +174,7 @@ export const POST = withRouteHandler(
// 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 (!appliesToAllWorkspaces) {
if (!isDefaultGroup) {
const conflict = await findAllMembersWorkspaceConflict(
{ organizationId, excludeGroupId: newGroup.id, workspaceIds },
tx
@@ -189,12 +186,12 @@ export const POST = withRouteHandler(
}
if (isDefault) {
// Demote the prior default to a non-default group. It must also drop
// the all-workspaces scope (only the default may be org-wide); it ends
// up with no workspaces (inert) until an admin re-scopes it.
// 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, appliesToAllWorkspaces: false, updatedAt: now })
.set({ isDefault: false, updatedAt: now })
.where(
and(
eq(permissionGroup.organizationId, organizationId),
@@ -220,7 +217,6 @@ export const POST = withRouteHandler(
permissionGroupId: newGroup.id,
organizationId,
userId: session.user.id,
appliesToAllWorkspaces,
workspaceCount: workspaceIds.length,
})
@@ -236,7 +232,6 @@ export const POST = withRouteHandler(
metadata: {
organizationId,
isDefault: isDefault || false,
appliesToAllWorkspaces,
workspaceCount: workspaceIds.length,
},
request: req,
@@ -88,7 +88,6 @@ export async function loadGroupInOrganization(
createdAt: permissionGroup.createdAt,
updatedAt: permissionGroup.updatedAt,
isDefault: permissionGroup.isDefault,
appliesToAllWorkspaces: permissionGroup.appliesToAllWorkspaces,
})
.from(permissionGroup)
.where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId)))
@@ -282,7 +282,8 @@ function WorkspaceSelect({
<ChipDropdown
multiple
searchable
matchTriggerWidth={false}
align={fullWidth ? 'start' : 'end'}
matchTriggerWidth={fullWidth}
options={options}
value={workspaceIds}
onChange={onChange}
@@ -519,7 +520,7 @@ export function AccessControl() {
const [showCreateModal, setShowCreateModal] = useState(false)
const [viewingGroup, setViewingGroup] = useState<PermissionGroup | null>(null)
// Monotonic token for scope-affecting writes (workspace select + default
// toggle, which both set appliesToAllWorkspaces/workspaces). Only the most
// toggle, which both change the group's workspace scope). Only the most
// recent write may reconcile or revert the local viewingGroup, so rapid
// multi-select toggles can't settle on a stale, out-of-order response.
const scopeWriteSeqRef = useRef(0)
@@ -797,17 +798,15 @@ export function AccessControl() {
const handleCreatePermissionGroup = useCallback(async () => {
if (!newGroupName.trim() || !organizationId) return
setCreateError(null)
// Only the default group is organization-wide; every other group targets
// specific workspaces.
const appliesToAllWorkspaces = newGroupIsDefault
try {
await createPermissionGroup.mutateAsync({
organizationId,
name: newGroupName.trim(),
description: newGroupDescription.trim() || undefined,
isDefault: newGroupIsDefault,
appliesToAllWorkspaces,
workspaceIds: appliesToAllWorkspaces ? undefined : newGroupWorkspaceIds,
// Only the default group is organization-wide; every other group targets
// specific workspaces (omitted for the default group).
workspaceIds: newGroupIsDefault ? undefined : newGroupWorkspaceIds,
})
setShowCreateModal(false)
setNewGroupName('')
@@ -970,12 +969,9 @@ export function AccessControl() {
const handleScopeChange = useCallback(
async (workspaceIds: string[]) => {
if (!viewingGroup || !organizationId) return
if (workspaceIds.length === 0) {
toast.error("Can't remove the last workspace", {
description: 'A group must target at least one workspace. Delete the group instead.',
})
return
}
// Zero workspaces is allowed: the group then governs nothing (the resolver
// inner-joins on the workspace link table, so an empty group never matches
// any workspace). Re-add a workspace to make it active again.
const previous = viewingGroup
const seq = ++scopeWriteSeqRef.current
@@ -983,7 +979,6 @@ export function AccessControl() {
prev
? {
...prev,
appliesToAllWorkspaces: false,
workspaces: organizationWorkspaces.filter((ws) => workspaceIds.includes(ws.id)),
}
: null
@@ -992,7 +987,6 @@ export function AccessControl() {
const result = await updatePermissionGroup.mutateAsync({
id: viewingGroup.id,
organizationId,
appliesToAllWorkspaces: false,
workspaceIds,
})
@@ -1001,7 +995,6 @@ export function AccessControl() {
prev
? {
...prev,
appliesToAllWorkspaces: result.permissionGroup.appliesToAllWorkspaces,
workspaces: organizationWorkspaces.filter((ws) =>
result.permissionGroup.workspaceIds.includes(ws.id)
),
@@ -1043,8 +1036,7 @@ export function AccessControl() {
? {
...prev,
isDefault: result.permissionGroup.isDefault,
appliesToAllWorkspaces: result.permissionGroup.appliesToAllWorkspaces,
workspaces: result.permissionGroup.appliesToAllWorkspaces
workspaces: result.permissionGroup.isDefault
? []
: organizationWorkspaces.filter((ws) =>
result.permissionGroup.workspaceIds.includes(ws.id)
@@ -1294,7 +1286,7 @@ export function AccessControl() {
{viewingGroup.workspaces.length === 0
? 'Applies to no one yet — add workspaces below to choose who this group governs.'
: members.length === 0
? 'Applies to all members of its workspaces, including external members.'
? 'Applies to all members of its workspaces.'
: `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}.`}
</p>
)}
@@ -1376,6 +1368,7 @@ export function AccessControl() {
}}
srTitle='Configure Permissions'
size='xl'
className='h-[84vh]'
>
<ChipModalHeader
onClose={() => {
@@ -1405,7 +1398,7 @@ export function AccessControl() {
}
/>
{configTab === 'members' && !viewingGroup.isDefault && (
<div className='flex flex-col gap-3'>
<div className='flex min-h-0 flex-1 flex-col gap-3'>
<div className='flex items-center justify-between gap-3'>
<span className='text-[var(--text-body)] text-sm'>
{members.length === 0
@@ -1431,9 +1424,11 @@ export function AccessControl() {
))}
</div>
) : members.length === 0 ? (
<div className='py-6 text-center text-[var(--text-muted)] text-sm'>
This group applies to everyone in its workspaces, including external members.
Add members to restrict it to specific people.
<div className='flex flex-1 items-center justify-center px-6 text-center'>
<span className='max-w-md text-[var(--text-muted)] text-sm'>
This group applies to everyone in its workspaces, including external members.
Add members to restrict it to specific people.
</span>
</div>
) : (
<div className='-mx-2 flex flex-col gap-y-0.5'>
@@ -111,7 +111,6 @@ export interface CreatePermissionGroupData {
description?: string
config?: Partial<PermissionGroupConfig>
isDefault?: boolean
appliesToAllWorkspaces?: boolean
workspaceIds?: string[]
}
@@ -140,7 +139,6 @@ export interface UpdatePermissionGroupData {
description?: string | null
config?: Partial<PermissionGroupConfig>
isDefault?: boolean
appliesToAllWorkspaces?: boolean
workspaceIds?: string[]
}
@@ -13,72 +13,34 @@ describe('createPermissionGroupBodySchema', () => {
expect(result.success).toBe(true)
})
it('rejects a non-default group set to all workspaces', () => {
it('accepts a default group', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Engineering',
appliesToAllWorkspaces: true,
name: 'Baseline',
isDefault: true,
})
expect(result.success).toBe(false)
expect(result.success).toBe(true)
})
it('accepts a specific-scope group with at least one workspace', () => {
it('accepts a specific-scope group with workspaces', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Contractors',
appliesToAllWorkspaces: false,
workspaceIds: ['ws-1'],
})
expect(result.success).toBe(true)
})
it('rejects a specific-scope group with no workspaces', () => {
it('accepts a name + empty workspaces (the create route enforces at least one)', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Contractors',
appliesToAllWorkspaces: false,
workspaceIds: [],
})
expect(result.success).toBe(false)
})
it('rejects a specific-scope group that omits workspaceIds', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Contractors',
appliesToAllWorkspaces: false,
})
expect(result.success).toBe(false)
expect(result.success).toBe(true)
})
it('rejects a default group that targets specific workspaces', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Baseline',
isDefault: true,
appliesToAllWorkspaces: false,
workspaceIds: ['ws-1'],
})
expect(result.success).toBe(false)
})
it('accepts a default group that applies to all workspaces', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Baseline',
isDefault: true,
appliesToAllWorkspaces: true,
})
expect(result.success).toBe(true)
})
it('rejects a default group with workspaceIds (appliesToAllWorkspaces omitted)', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Baseline',
isDefault: true,
workspaceIds: ['ws-1'],
})
expect(result.success).toBe(false)
})
it('rejects an all-workspaces group that also names specific workspaces', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Engineering',
appliesToAllWorkspaces: true,
workspaceIds: ['ws-1'],
})
expect(result.success).toBe(false)
@@ -94,51 +56,27 @@ describe('updatePermissionGroupBodySchema', () => {
expect(updatePermissionGroupBodySchema.safeParse({ isDefault: false }).success).toBe(true)
})
it('rejects switching to specific scope with no workspaces', () => {
const result = updatePermissionGroupBodySchema.safeParse({
appliesToAllWorkspaces: false,
workspaceIds: [],
})
expect(result.success).toBe(false)
it('accepts emptying scope (group then governs nothing)', () => {
const result = updatePermissionGroupBodySchema.safeParse({ workspaceIds: [] })
expect(result.success).toBe(true)
})
it('accepts switching to specific scope with workspaces', () => {
it('accepts a specific scope with workspaces', () => {
const result = updatePermissionGroupBodySchema.safeParse({
appliesToAllWorkspaces: false,
workspaceIds: ['ws-1', 'ws-2'],
})
expect(result.success).toBe(true)
})
it('rejects making a specific-scope group the default', () => {
const result = updatePermissionGroupBodySchema.safeParse({
isDefault: true,
appliesToAllWorkspaces: false,
workspaceIds: ['ws-1'],
})
expect(result.success).toBe(false)
it('accepts promoting a group to the default', () => {
expect(updatePermissionGroupBodySchema.safeParse({ isDefault: true }).success).toBe(true)
})
it('rejects workspaceIds when making the group the default', () => {
it('rejects promoting to the default while naming workspaces', () => {
const result = updatePermissionGroupBodySchema.safeParse({
isDefault: true,
workspaceIds: ['ws-1'],
})
expect(result.success).toBe(false)
})
it('rejects workspaceIds on an all-workspaces update', () => {
const result = updatePermissionGroupBodySchema.safeParse({
appliesToAllWorkspaces: true,
workspaceIds: ['ws-1'],
})
expect(result.success).toBe(false)
})
it('rejects setting a non-default group to all workspaces', () => {
const result = updatePermissionGroupBodySchema.safeParse({
appliesToAllWorkspaces: true,
})
expect(result.success).toBe(false)
})
})
+19 -44
View File
@@ -65,9 +65,10 @@ export const permissionGroupSchema = z.object({
creatorEmail: z.string().nullable(),
memberCount: z.number(),
isDefault: z.boolean(),
/** When true the group governs every workspace; when false only `workspaces`. */
appliesToAllWorkspaces: z.boolean(),
/** Workspaces targeted when `appliesToAllWorkspaces` is false (empty otherwise). */
/**
* Workspaces this group targets. Empty for the default group (which governs
* every workspace) and for a non-default group scoped to nothing.
*/
workspaces: z.array(permissionGroupWorkspaceRefSchema),
})
export type PermissionGroup = z.output<typeof permissionGroupSchema>
@@ -82,8 +83,7 @@ export const permissionGroupWriteSchema = z.object({
createdAt: z.string(),
updatedAt: z.string(),
isDefault: z.boolean(),
appliesToAllWorkspaces: z.boolean(),
/** Ids of targeted workspaces when `appliesToAllWorkspaces` is false. */
/** Ids of targeted workspaces (empty for the default group). */
workspaceIds: z.array(z.string()),
})
export type PermissionGroupWrite = z.output<typeof permissionGroupWriteSchema>
@@ -120,52 +120,29 @@ export const MAX_PERMISSION_GROUP_WORKSPACES = 500
const workspaceIdsSchema = z.array(z.string().min(1)).max(MAX_PERMISSION_GROUP_WORKSPACES)
/**
* Enforce the workspace-scope invariants shared by create and update. Only the
* organization default group is org-wide; every non-default group targets
* specific workspaces:
* - all-workspaces scope (`appliesToAllWorkspaces === true`) is allowed only
* when `isDefault === true`,
* - a specific-scope group (`appliesToAllWorkspaces === false`) must name at
* least one workspace and cannot be the default group, and
* - an all-workspaces or default group must not name specific workspaces
* (otherwise `workspaceIds` would be silently dropped server-side).
* The one cross-field scope rule shared by create and update: the organization
* default group governs every workspace, so it cannot also name specific
* workspaces (they would be silently dropped server-side). "Org-wide" is
* definitionally `isDefault` — there is no separate flag — so a default group
* with no `workspaceIds` is already the all-workspaces case and needs no
* assertion here.
*
* Everything else is left to the routes: a non-default group targets the
* workspaces in `workspaceIds` (empty is allowed on update — the group then
* governs nothing, since the resolver inner-joins the workspace link table), and
* the create route requires at least one workspace up front.
*/
function refineWorkspaceScope(
body: { appliesToAllWorkspaces?: boolean; workspaceIds?: string[]; isDefault?: boolean },
body: { workspaceIds?: string[]; isDefault?: boolean },
ctx: z.RefinementCtx
) {
const allWorkspaces = body.isDefault === true || body.appliesToAllWorkspaces === true
if (allWorkspaces && body.workspaceIds && body.workspaceIds.length > 0) {
if (body.isDefault === true && body.workspaceIds && body.workspaceIds.length > 0) {
ctx.addIssue({
code: 'custom',
path: ['workspaceIds'],
message: 'workspaceIds can only be set when the group targets specific workspaces',
message: 'The default group governs all workspaces and cannot target specific workspaces',
})
}
if (body.appliesToAllWorkspaces === true && body.isDefault !== true) {
ctx.addIssue({
code: 'custom',
path: ['appliesToAllWorkspaces'],
message:
'Only the default group can apply to all workspaces; non-default groups must target specific workspaces',
})
}
if (body.appliesToAllWorkspaces === false) {
if (!body.workspaceIds || body.workspaceIds.length === 0) {
ctx.addIssue({
code: 'custom',
path: ['workspaceIds'],
message: 'Select at least one workspace when the group targets specific workspaces',
})
}
if (body.isDefault === true) {
ctx.addIssue({
code: 'custom',
path: ['appliesToAllWorkspaces'],
message: 'The default group must apply to all workspaces',
})
}
}
}
export const createPermissionGroupBodySchema = z
@@ -174,7 +151,6 @@ export const createPermissionGroupBodySchema = z
description: z.string().max(500).optional(),
config: permissionGroupConfigSchema.optional(),
isDefault: z.boolean().optional(),
appliesToAllWorkspaces: z.boolean().optional(),
workspaceIds: workspaceIdsSchema.optional(),
})
.superRefine(refineWorkspaceScope)
@@ -185,7 +161,6 @@ export const updatePermissionGroupBodySchema = z
description: z.string().max(500).nullable().optional(),
config: permissionGroupConfigSchema.optional(),
isDefault: z.boolean().optional(),
appliesToAllWorkspaces: z.boolean().optional(),
workspaceIds: workspaceIdsSchema.optional(),
})
.superRefine(refineWorkspaceScope)
@@ -0,0 +1,2 @@
-- migration-safe: `applies_to_all_workspaces` was a derived mirror of `is_default` (create/update routes kept them equal; migration 0246 backfilled legacy rows) that no enforcement/execution path ever read — resolveWorkspaceGroup inner-joins permission_group_workspace and treats org-wide as definitionally `is_default`. All readers/writers are removed in this same change and only admin permission-group management routes touched it, so the sole exposure is a brief deploy-cutover window on those admin routes — accepted (no execution-path impact).
ALTER TABLE "permission_group" DROP COLUMN "applies_to_all_workspaces";
File diff suppressed because it is too large Load Diff
@@ -1737,6 +1737,13 @@
"when": 1782158722013,
"tag": "0248_limit_notifications",
"breakpoints": true
},
{
"idx": 249,
"version": "7",
"when": 1782260194924,
"tag": "0249_drop_permission_group_applies_to_all_workspaces",
"breakpoints": true
}
]
}
+7 -9
View File
@@ -3064,11 +3064,10 @@ export const credentialSetInvitation = pgTable(
* an organization.
*
* Scope invariant: the organization's single default group (`isDefault`) is
* org-wide (`appliesToAllWorkspaces = true`) and governs everyone not covered by
* another group. Every non-default group
* targets specific workspaces (`appliesToAllWorkspaces = false` with rows in
* `permission_group_workspace`) — the all-workspaces scope is reserved for the
* default group. Enforced by the API contracts/routes, not a DB constraint.
* org-wide and governs everyone not covered by another group. Every non-default
* group targets specific workspaces (rows in `permission_group_workspace`), and a
* non-default group with no rows governs nothing. Being org-wide is definitionally
* `isDefault` — there is no separate flag. Enforced by the API contracts/routes.
*
* Member invariant: a non-default group with no `permission_group_member` rows
* governs every member of its workspaces (including external members); adding
@@ -3090,7 +3089,6 @@ export const permissionGroup = pgTable(
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
isDefault: boolean('is_default').notNull().default(false),
appliesToAllWorkspaces: boolean('applies_to_all_workspaces').notNull().default(true),
},
(table) => ({
createdByIdx: index('permission_group_created_by_idx').on(table.createdBy),
@@ -3105,9 +3103,9 @@ export const permissionGroup = pgTable(
)
/**
* Workspaces a `permission_group` targets when `applies_to_all_workspaces` is
* false. Rows are absent for organization-wide groups. A group with zero rows
* while `applies_to_all_workspaces = false` governs no workspace.
* Workspaces a non-default `permission_group` targets. Rows are absent for the
* organization-wide default group; a non-default group with zero rows governs no
* workspace.
*/
export const permissionGroupWorkspace = pgTable(
'permission_group_workspace',
@@ -1027,7 +1027,6 @@ export const schemaMock = {
createdAt: 'createdAt',
updatedAt: 'updatedAt',
isDefault: 'isDefault',
appliesToAllWorkspaces: 'appliesToAllWorkspaces',
},
permissionGroupWorkspace: {
id: 'id',