feat: customisable workspace role permissions (#345)
* feat: setup schema for workspace roles * chore: regen migration * feat: add publicId to workspace roles * feat: setup default permissions * feat: add repo funcs * feat: setup basic router interactions * feat: add card permissions * feat: assert permissions for lists * feat: assert board permissions * feat: assert permission for remaining routes * feat: add permissions page to settings * feat: enable updating member roles * feat: order members by role and createdAt * feat: allow editing individual permissions * feat: reset role defaults * feat: clear all permission overrides * feat: allow users to delete entities they have created * feat: set roleId when inviting new members * feat: disable UI elements if user does not have permissions * feat: allow admins to assign the admin role to other users * feat: allow delete:list as default * refactor: centre permissions modal * chore: translations
This commit is contained in:
295
packages/api/src/utils/permissions.ts
Normal file
295
packages/api/src/utils/permissions.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as permissionRepo from "@kan/db/repository/permission.repo";
|
||||
import type { Permission, Role } from "@kan/shared";
|
||||
import { canManageRole, getDefaultPermissions } from "@kan/shared";
|
||||
|
||||
/**
|
||||
* Get effective permissions for a member by combining role permissions with overrides
|
||||
*/
|
||||
export async function getMemberEffectivePermissions(
|
||||
db: dbClient,
|
||||
workspaceMemberId: number,
|
||||
roleId: number | null,
|
||||
roleName: string,
|
||||
): Promise<Permission[]> {
|
||||
let roleDefaults: Set<Permission>;
|
||||
|
||||
// Get role permissions from database or fallback to code defaults
|
||||
if (roleId) {
|
||||
const dbPermissions = await permissionRepo.getPermissionsByRoleId(
|
||||
db,
|
||||
roleId,
|
||||
);
|
||||
roleDefaults = new Set<Permission>(dbPermissions);
|
||||
} else {
|
||||
const codeDefaults = getDefaultPermissions(roleName as Role);
|
||||
roleDefaults = new Set<Permission>([...codeDefaults]);
|
||||
}
|
||||
|
||||
// Get and apply custom overrides
|
||||
const overrides = await permissionRepo.getMemberPermissionOverrides(
|
||||
db,
|
||||
workspaceMemberId,
|
||||
);
|
||||
|
||||
for (const override of overrides) {
|
||||
if (override.granted) {
|
||||
roleDefaults.add(override.permission as Permission);
|
||||
} else {
|
||||
roleDefaults.delete(override.permission as Permission);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(roleDefaults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a member has a specific permission
|
||||
*/
|
||||
export async function memberHasPermission(
|
||||
db: dbClient,
|
||||
workspaceMemberId: number,
|
||||
roleId: number | null,
|
||||
roleName: string,
|
||||
permission: Permission,
|
||||
): Promise<boolean> {
|
||||
let hasRoleDefault: boolean;
|
||||
|
||||
// Check role permission from database or fallback to code defaults
|
||||
if (roleId) {
|
||||
const dbPermissions = await permissionRepo.getPermissionsByRoleId(
|
||||
db,
|
||||
roleId,
|
||||
);
|
||||
hasRoleDefault = dbPermissions.includes(permission);
|
||||
} else {
|
||||
const codeDefaults = getDefaultPermissions(roleName as Role);
|
||||
hasRoleDefault = codeDefaults.includes(permission);
|
||||
}
|
||||
|
||||
// Check for override
|
||||
const override = await permissionRepo.getMemberPermissionOverride(
|
||||
db,
|
||||
workspaceMemberId,
|
||||
permission,
|
||||
);
|
||||
|
||||
// Override takes precedence
|
||||
if (override) {
|
||||
return override.granted;
|
||||
}
|
||||
|
||||
return hasRoleDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission in a workspace
|
||||
*/
|
||||
export async function hasPermission(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
): Promise<boolean> {
|
||||
const member = await permissionRepo.getMemberWithRole(db, userId, workspaceId);
|
||||
|
||||
if (!member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return memberHasPermission(
|
||||
db,
|
||||
member.id,
|
||||
member.roleId,
|
||||
member.role,
|
||||
permission,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a user in a workspace
|
||||
*/
|
||||
export async function getUserPermissions(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
): Promise<{
|
||||
permissions: Permission[];
|
||||
role: string;
|
||||
roleId: number | null;
|
||||
} | null> {
|
||||
const member = await permissionRepo.getMemberWithRole(db, userId, workspaceId);
|
||||
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissions = await getMemberEffectivePermissions(
|
||||
db,
|
||||
member.id,
|
||||
member.roleId,
|
||||
member.role,
|
||||
);
|
||||
|
||||
return {
|
||||
permissions,
|
||||
role: member.role,
|
||||
roleId: member.roleId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user has permission - throws FORBIDDEN if not
|
||||
*/
|
||||
export async function assertPermission(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
): Promise<void> {
|
||||
const hasIt = await hasPermission(db, userId, workspaceId, permission);
|
||||
|
||||
if (!hasIt) {
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to perform this action (${permission})`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can assign a specific role (based on hierarchy)
|
||||
*/
|
||||
export async function assertCanManageRole(
|
||||
db: dbClient,
|
||||
managerUserId: string,
|
||||
workspaceId: number,
|
||||
targetRoleName: string,
|
||||
): Promise<void> {
|
||||
const managerMember = await permissionRepo.getMemberWithRole(
|
||||
db,
|
||||
managerUserId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!managerMember) {
|
||||
throw new TRPCError({
|
||||
message: "You are not a member of this workspace",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const managerRole = managerMember.role;
|
||||
|
||||
if (!canManageRole(managerRole, targetRoleName as Role)) {
|
||||
throw new TRPCError({
|
||||
message: `You cannot assign the "${targetRoleName}" role`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can manage another member based on role hierarchy
|
||||
*/
|
||||
export async function assertCanManageMember(
|
||||
db: dbClient,
|
||||
managerUserId: string,
|
||||
workspaceId: number,
|
||||
targetMemberId: number,
|
||||
): Promise<void> {
|
||||
const managerMember = await permissionRepo.getMemberWithRole(
|
||||
db,
|
||||
managerUserId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!managerMember) {
|
||||
throw new TRPCError({
|
||||
message: "You are not a member of this workspace",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const targetMember = await memberRepo.getById(db, targetMemberId);
|
||||
|
||||
if (!targetMember) {
|
||||
throw new TRPCError({
|
||||
message: "Target member not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const managerRole = managerMember.role;
|
||||
const targetRole = targetMember.role;
|
||||
|
||||
if (!canManageRole(managerRole, targetRole)) {
|
||||
throw new TRPCError({
|
||||
message: "You cannot manage this member due to role hierarchy",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can delete an entity - either has the delete permission OR is the creator
|
||||
*/
|
||||
export async function assertCanDelete(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
createdBy: string | null,
|
||||
): Promise<void> {
|
||||
// Check if user has the general delete permission
|
||||
const hasDeletePermission = await hasPermission(db, userId, workspaceId, permission);
|
||||
|
||||
// If user has permission, allow deletion
|
||||
if (hasDeletePermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If user doesn't have permission, check if they are the creator
|
||||
if (createdBy && createdBy === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither condition met - deny deletion
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to delete this entity (${permission})`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can edit an entity - either has the edit permission OR is the creator
|
||||
*/
|
||||
export async function assertCanEdit(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
createdBy: string | null,
|
||||
): Promise<void> {
|
||||
// Check if user has the general edit permission
|
||||
const hasEditPermission = await hasPermission(db, userId, workspaceId, permission);
|
||||
|
||||
// If user has permission, allow editing
|
||||
if (hasEditPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If user doesn't have permission, check if they are the creator
|
||||
if (createdBy && createdBy === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither condition met - deny editing
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to edit this entity (${permission})`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user