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:
Henry
2026-02-01 21:17:18 +00:00
committed by GitHub
parent 5f2d409773
commit 7f5a1ab513
83 changed files with 10568 additions and 1442 deletions

View File

@@ -9,6 +9,7 @@ import { integrationRouter } from "./routers/integration";
import { labelRouter } from "./routers/label";
import { listRouter } from "./routers/list";
import { memberRouter } from "./routers/member";
import { permissionRouter } from "./routers/permission";
import { userRouter } from "./routers/user";
import { workspaceRouter } from "./routers/workspace";
import { createTRPCRouter } from "./trpc";
@@ -24,6 +25,7 @@ export const appRouter = createTRPCRouter({
list: listRouter,
member: memberRouter,
import: importRouter,
permission: permissionRouter,
user: userRouter,
workspace: workspaceRouter,
integration: integrationRouter,

View File

@@ -8,7 +8,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
import { deleteObject, generateUploadUrl } from "../utils/s3";
export const attachmentRouter = createTRPCRouter({
@@ -55,8 +55,7 @@ export const attachmentRouter = createTRPCRouter({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
// Get workspace publicId
const workspace = await workspaceRepo.getById(ctx.db, card.workspaceId);
@@ -131,8 +130,7 @@ export const attachmentRouter = createTRPCRouter({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
const attachment = await cardAttachmentRepo.create(ctx.db, {
cardId: card.id,
@@ -186,8 +184,7 @@ export const attachmentRouter = createTRPCRouter({
});
const workspaceId = attachment.card.list.board.workspaceId;
await assertUserInWorkspace(ctx.db, userId, workspaceId);
await assertPermission(ctx.db, userId, workspaceId, "card:edit");
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
if (bucket) {

View File

@@ -15,7 +15,7 @@ import {
} from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const boardRouter = createTRPCRouter({
all: protectedProcedure
@@ -58,7 +58,7 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
await assertPermission(ctx.db, userId, workspace.id, "board:view");
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, {
type: input.type,
@@ -119,7 +119,7 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
await assertPermission(ctx.db, userId, board.workspaceId, "board:view");
// Convert semantic string filters to date ranges expected by the repo
const dueDateFilters = input.dueDateFilters
@@ -255,7 +255,7 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
await assertPermission(ctx.db, userId, workspace.id, "board:create");
// If sourceBoardPublicId is provided, clone the source board
if (input.sourceBoardPublicId) {
@@ -422,7 +422,13 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
await assertCanEdit(
ctx.db,
userId,
board.workspaceId,
"board:edit",
board.createdBy ?? null,
);
if (input.slug) {
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
@@ -491,7 +497,13 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
await assertCanDelete(
ctx.db,
userId,
board.workspaceId,
"board:delete",
board.createdBy ?? null,
);
const listIds = board.lists.map((list) => list.id);

View File

@@ -10,7 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities";
import { assertUserInWorkspace } from "../utils/auth";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateDownloadUrl } from "../utils/s3";
export const cardRouter = createTRPCRouter({
@@ -57,13 +57,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
await assertPermission(ctx.db, userId, list.workspaceId, "card:create");
const newCard = await cardRepo.create(ctx.db, {
title: input.title,
@@ -199,7 +193,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "comment:create");
const newComment = await cardCommentRepo.create(ctx.db, {
comment: input.comment,
@@ -262,8 +256,6 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
const existingComment = await cardCommentRepo.getByPublicId(
ctx.db,
input.commentPublicId,
@@ -275,11 +267,13 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
if (existingComment.createdBy !== userId)
throw new TRPCError({
message: `You do not have permission to update this comment`,
code: "FORBIDDEN",
});
await assertCanEdit(
ctx.db,
userId,
card.workspaceId,
"comment:edit",
existingComment.createdBy,
);
const updatedComment = await cardCommentRepo.update(ctx.db, {
id: existingComment.id,
@@ -340,8 +334,6 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
const existingComment = await cardCommentRepo.getByPublicId(
ctx.db,
input.commentPublicId,
@@ -353,6 +345,14 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertCanDelete(
ctx.db,
userId,
card.workspaceId,
"comment:delete",
existingComment.createdBy,
);
const deletedComment = await cardCommentRepo.softDelete(ctx.db, {
commentId: existingComment.id,
deletedAt: new Date(),
@@ -412,7 +412,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
@@ -504,7 +504,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
const member = await workspaceRepo.getMemberByPublicId(
ctx.db,
@@ -616,7 +616,7 @@ export const cardRouter = createTRPCRouter({
code: "UNAUTHORIZED",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:view");
}
const result = await cardRepo.getWithListAndMembersByPublicId(
@@ -725,7 +725,7 @@ export const cardRouter = createTRPCRouter({
code: "UNAUTHORIZED",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:view");
}
const cursor = input.cursor ? new Date(input.cursor) : undefined;
@@ -788,7 +788,13 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertCanEdit(
ctx.db,
userId,
card.workspaceId,
"card:edit",
card.createdBy,
);
const existingCard = await cardRepo.getByPublicId(
ctx.db,
@@ -958,7 +964,13 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertCanDelete(
ctx.db,
userId,
card.workspaceId,
"card:delete",
card.createdBy,
);
const deletedAt = new Date();

View File

@@ -6,7 +6,7 @@ import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as checklistRepo from "@kan/db/repository/checklist.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
const checklistSchema = z.object({
publicId: z.string().length(12),
@@ -57,8 +57,7 @@ export const checklistRouter = createTRPCRouter({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
const newChecklist = await checklistRepo.create(ctx.db, {
name: input.name,
@@ -106,11 +105,11 @@ export const checklistRouter = createTRPCRouter({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
await assertPermission(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
"card:edit",
);
const previousName = checklist.name;
@@ -166,11 +165,11 @@ export const checklistRouter = createTRPCRouter({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
await assertPermission(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
"card:edit",
);
await checklistRepo.softDeleteAllItemsByChecklistId(ctx.db, {
@@ -237,11 +236,11 @@ export const checklistRouter = createTRPCRouter({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
await assertPermission(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
"card:edit",
);
const newChecklistItem = await checklistRepo.createItem(ctx.db, {
@@ -304,11 +303,11 @@ export const checklistRouter = createTRPCRouter({
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
await assertPermission(
ctx.db,
userId,
item.checklist.card.list.board.workspace.id,
"card:edit",
);
const previousTitle = item.title;
@@ -394,11 +393,11 @@ export const checklistRouter = createTRPCRouter({
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
await assertPermission(
ctx.db,
userId,
item.checklist.card.list.board.workspace.id,
"card:edit",
);
const deleted = await checklistRepo.softDeleteItemById(ctx.db, {

View File

@@ -14,7 +14,7 @@ import { colours } from "@kan/shared/constants";
import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
import { apiKeys, urls } from "./integration";
export interface TrelloBoard {
@@ -180,8 +180,7 @@ export const importRouter = createTRPCRouter({
message: `Workspace with public ID ${input.workspacePublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
await assertPermission(ctx.db, userId, workspace.id, "board:create");
const newImport = await importRepo.create(ctx.db, {
source: "trello",

View File

@@ -6,7 +6,7 @@ import * as cardRepo from "@kan/db/repository/card.repo";
import * as labelRepo from "@kan/db/repository/label.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
const labelSchema = z.object({
publicId: z.string(),
@@ -47,8 +47,7 @@ export const labelRouter = createTRPCRouter({
message: `Label with public ID ${input.labelPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
await assertPermission(ctx.db, userId, label.workspaceId, "board:view");
const result = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
@@ -102,8 +101,7 @@ export const labelRouter = createTRPCRouter({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
await assertPermission(ctx.db, userId, board.workspaceId, "board:edit");
const result = await labelRepo.create(ctx.db, {
name: input.name,
@@ -162,8 +160,7 @@ export const labelRouter = createTRPCRouter({
message: `Label with public ID ${input.labelPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
await assertPermission(ctx.db, userId, label.workspaceId, "board:edit");
const result = await labelRepo.update(ctx.db, input);
@@ -211,8 +208,7 @@ export const labelRouter = createTRPCRouter({
message: `Label with public ID ${input.labelPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
await assertPermission(ctx.db, userId, label.workspaceId, "board:edit");
await cardRepo.hardDeleteAllCardLabelRelationships(ctx.db, label.id);

View File

@@ -7,7 +7,7 @@ import * as activityRepo from "@kan/db/repository/cardActivity.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const listRouter = createTRPCRouter({
create: protectedProcedure
@@ -48,7 +48,7 @@ export const listRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
await assertPermission(ctx.db, userId, board.workspaceId, "list:create");
const result = await listRepo.create(ctx.db, {
name: input.name,
@@ -101,7 +101,13 @@ export const listRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
await assertCanDelete(
ctx.db,
userId,
list.workspaceId,
"list:delete",
list.createdBy,
);
const deletedAt = new Date();
@@ -183,7 +189,13 @@ export const listRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
await assertCanEdit(
ctx.db,
userId,
list.workspaceId,
"list:edit",
list.createdBy,
);
let result: { name: string; publicId: string } | undefined;

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as permissionRepo from "@kan/db/repository/permission.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
@@ -11,11 +12,15 @@ import {
generateUID,
getSubscriptionByPlan,
hasUnlimitedSeats,
} from "@kan/shared/utils";
} from "@kan/shared";
import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import {
assertCanManageMember,
assertCanManageRole,
assertPermission,
} from "../utils/permissions";
export const memberRouter = createTRPCRouter({
invite: protectedProcedure
@@ -56,7 +61,7 @@ export const memberRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
await assertPermission(ctx.db, userId, workspace.id, "member:invite");
const isInvitedEmailAlreadyMember = workspace.members.some(
(member) => member.email === input.email,
@@ -112,12 +117,20 @@ export const memberRouter = createTRPCRouter({
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
// Get the workspace role to set roleId
const memberRole = await permissionRepo.getRoleByWorkspaceIdAndName(
ctx.db,
workspace.id,
"member",
);
const invite = await memberRepo.create(ctx.db, {
workspaceId: workspace.id,
email: input.email,
userId: existingUser?.id ?? null,
createdBy: userId,
role: "member",
roleId: memberRole?.id ?? null,
status: "invited",
});
@@ -190,7 +203,7 @@ export const memberRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
await assertPermission(ctx.db, userId, workspace.id, "member:remove");
const member = await memberRepo.getByPublicId(
ctx.db,
@@ -292,8 +305,8 @@ export const memberRouter = createTRPCRouter({
code: "NOT_FOUND",
});
// Check if user is in workspace
await assertUserInWorkspace(ctx.db, userId, workspace.id);
// Check if user can view members
await assertPermission(ctx.db, userId, workspace.id, "member:view");
// Get active invite link for this workspace
const activeInviteLink = await inviteLinkRepo.getActiveForWorkspace(
@@ -360,8 +373,8 @@ export const memberRouter = createTRPCRouter({
code: "NOT_FOUND",
});
// Check if user is in workspace
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
// Check if user can edit members (admin-equivalent)
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
// Check subscription for cloud environment
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
@@ -461,8 +474,8 @@ export const memberRouter = createTRPCRouter({
code: "NOT_FOUND",
});
// Check if user is in workspace
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
// Check if user can edit members (admin-equivalent)
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
// Deactivate all active invite links
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
@@ -631,12 +644,20 @@ export const memberRouter = createTRPCRouter({
}
}
// Get the workspace role to set roleId
const memberRole = await permissionRepo.getRoleByWorkspaceIdAndName(
ctx.db,
invite.workspaceId,
"member",
);
await memberRepo.create(ctx.db, {
workspaceId: invite.workspaceId,
email: user.email,
userId: user.id,
createdBy: user.id,
role: "member",
roleId: memberRole?.id ?? null,
status: "active",
});
@@ -646,4 +667,85 @@ export const memberRouter = createTRPCRouter({
workspaceSlug: workspace.slug,
};
}),
updateRole: protectedProcedure
.meta({
openapi: {
summary: "Update member role",
method: "PUT",
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/role",
description: "Updates a member's role in a workspace",
tags: ["Workspaces"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
memberPublicId: z.string().min(12),
role: z.enum(["admin", "member", "guest"]),
}),
)
.output(
z.object({
success: z.boolean(),
role: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
const member = await memberRepo.getByPublicId(
ctx.db,
input.memberPublicId,
);
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",
});
}
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
await assertCanManageRole(ctx.db, userId, workspace.id, input.role);
// Get the workspace role to set roleId
const workspaceRole = await permissionRepo.getRoleByWorkspaceIdAndName(
ctx.db,
workspace.id,
input.role,
);
await memberRepo.updateRole(ctx.db, {
memberId: member.id,
role: input.role,
roleId: workspaceRole?.id ?? null,
});
return {
success: true,
role: input.role,
};
}),
});

View File

@@ -0,0 +1,776 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as permissionRepo from "@kan/db/repository/permission.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import type { Permission } from "@kan/shared";
import { allPermissions } from "@kan/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import {
assertCanManageMember,
assertPermission,
getMemberEffectivePermissions,
getUserPermissions,
} from "../utils/permissions";
const permissionsList = [...allPermissions] as [string, ...string[]];
export const permissionRouter = createTRPCRouter({
getMyPermissions: protectedProcedure
.meta({
openapi: {
summary: "Get my permissions",
method: "GET",
path: "/workspaces/{workspacePublicId}/permissions/me",
description: "Get the current user's permissions in a workspace",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
}),
)
.output(
z.object({
permissions: z.array(z.string()),
role: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
const result = await getUserPermissions(ctx.db, userId, workspace.id);
if (!result) {
throw new TRPCError({
message: "You are not a member of this workspace",
code: "FORBIDDEN",
});
}
return result;
}),
getMemberPermissions: protectedProcedure
.meta({
openapi: {
summary: "Get member permissions",
method: "GET",
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions",
description: "Get a specific member's permissions in a workspace",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
memberPublicId: z.string().min(12),
}),
)
.output(
z.object({
memberPublicId: z.string(),
role: z.string(),
permissions: z.array(z.string()),
overrides: z.array(
z.object({
permission: z.string(),
granted: z.boolean(),
}),
),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
// Check user has permission to view member permissions
await assertPermission(ctx.db, userId, workspace.id, "member:view");
const member = await memberRepo.getByPublicId(
ctx.db,
input.memberPublicId,
);
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",
});
}
const effectivePermissions = await getMemberEffectivePermissions(
ctx.db,
member.id,
member.roleId ?? null,
member.role,
);
const overrides = await permissionRepo.getMemberPermissionOverrides(
ctx.db,
member.id,
);
return {
memberPublicId: member.publicId,
role: member.role,
permissions: effectivePermissions,
overrides: overrides.map((o) => ({
permission: o.permission,
granted: o.granted,
})),
};
}),
grantPermission: protectedProcedure
.meta({
openapi: {
summary: "Grant permission to member",
method: "POST",
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions/grant",
description: "Grant a specific permission to a member",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
memberPublicId: z.string().min(12),
permission: z.enum(permissionsList),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
const member = await memberRepo.getByPublicId(
ctx.db,
input.memberPublicId,
);
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",
});
}
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
await permissionRepo.grantPermission(
ctx.db,
member.id,
input.permission as Permission,
);
return { success: true };
}),
revokePermission: protectedProcedure
.meta({
openapi: {
summary: "Revoke permission from member",
method: "POST",
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions/revoke",
description: "Revoke a specific permission from a member",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
memberPublicId: z.string().min(12),
permission: z.enum(permissionsList),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
const member = await memberRepo.getByPublicId(
ctx.db,
input.memberPublicId,
);
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",
});
}
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
await permissionRepo.revokePermission(
ctx.db,
member.id,
input.permission as Permission,
);
return { success: true };
}),
resetMemberPermissions: protectedProcedure
.meta({
openapi: {
summary: "Reset member permissions to role defaults",
method: "POST",
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions/reset",
description:
"Clears all custom permission overrides for a member so their effective permissions come only from their role",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
memberPublicId: z.string().min(12),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
const member = await memberRepo.getByPublicId(
ctx.db,
input.memberPublicId,
);
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",
});
}
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
await permissionRepo.clearMemberPermissionOverrides(ctx.db, member.id);
return { success: true };
}),
resetWorkspaceMemberPermissions: protectedProcedure
.meta({
openapi: {
summary: "Reset all member permission overrides in a workspace",
method: "POST",
path: "/workspaces/{workspacePublicId}/members/permissions/reset",
description:
"Clears all custom permission overrides for all members in a workspace so their effective permissions come only from their roles",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
await permissionRepo.clearAllMemberPermissionOverridesForWorkspace(
ctx.db,
workspace.id,
);
return { success: true };
}),
getWorkspaceRoles: protectedProcedure
.meta({
openapi: {
summary: "Get workspace roles",
method: "GET",
path: "/workspaces/{workspacePublicId}/roles",
description: "Get all roles for a workspace",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
}),
)
.output(
z.object({
roles: z.array(
z.object({
publicId: z.string().min(12),
name: z.string(),
description: z.string().nullable(),
hierarchyLevel: z.number(),
isSystem: z.boolean(),
}),
),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
await assertPermission(ctx.db, userId, workspace.id, "member:view");
const roles = await permissionRepo.getRolesByWorkspaceId(
ctx.db,
workspace.id,
);
return {
roles: roles.map((role) => ({
publicId: role.publicId,
name: role.name,
description: role.description ?? null,
hierarchyLevel: role.hierarchyLevel,
isSystem: role.isSystem,
})),
};
}),
getWorkspaceRolePermissions: protectedProcedure
.meta({
openapi: {
summary: "Get workspace role permissions",
method: "GET",
path: "/workspaces/{workspacePublicId}/roles/permissions",
description:
"Get all roles for a workspace with their granted permissions",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
}),
)
.output(
z.object({
roles: z.array(
z.object({
publicId: z.string().min(12),
name: z.string(),
permissions: z.array(z.string()),
}),
),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
await assertPermission(ctx.db, userId, workspace.id, "member:view");
const roles = await permissionRepo.getRolesByWorkspaceId(
ctx.db,
workspace.id,
);
const rolesWithPermissions = await Promise.all(
roles.map(async (role) => {
const permissionsForRole = await permissionRepo.getPermissionsByRoleId(
ctx.db,
role.id,
);
return {
publicId: role.publicId,
name: role.name,
permissions: permissionsForRole,
};
}),
);
return {
roles: rolesWithPermissions,
};
}),
getRolePermissions: protectedProcedure
.meta({
openapi: {
summary: "Get role permissions",
method: "GET",
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions",
description: "Get permissions granted to a specific role in a workspace",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
rolePublicId: z.string().min(12),
}),
)
.output(
z.object({
rolePublicId: z.string(),
name: z.string(),
permissions: z.array(z.string()),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
await assertPermission(ctx.db, userId, workspace.id, "member:view");
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
ctx.db,
workspace.id,
input.rolePublicId,
);
if (!role) {
throw new TRPCError({
message: "Role not found",
code: "NOT_FOUND",
});
}
const permissionsForRole = await permissionRepo.getPermissionsByRoleId(
ctx.db,
role.id,
);
return {
rolePublicId: role.publicId,
name: role.name,
permissions: permissionsForRole,
};
}),
grantRolePermission: protectedProcedure
.meta({
openapi: {
summary: "Grant permission to role",
method: "POST",
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions/grant",
description: "Grant a specific permission to a role",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
rolePublicId: z.string().min(12),
permission: z.enum(permissionsList),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
// Require ability to edit members/roles
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
ctx.db,
workspace.id,
input.rolePublicId,
);
if (!role) {
throw new TRPCError({
message: "Role not found",
code: "NOT_FOUND",
});
}
if (role.name === "admin" && role.isSystem) {
throw new TRPCError({
message: "Admin role permissions cannot be modified",
code: "FORBIDDEN",
});
}
// Never allow non-admin roles to manage billing or delete workspace
if (
(input.permission === "workspace:manage" ||
input.permission === "workspace:delete") &&
role.name !== "admin"
) {
throw new TRPCError({
message:
"Only the admin role can manage billing or delete the workspace",
code: "FORBIDDEN",
});
}
await permissionRepo.grantRolePermission(
ctx.db,
role.id,
input.permission as Permission,
);
return { success: true };
}),
revokeRolePermission: protectedProcedure
.meta({
openapi: {
summary: "Revoke permission from role",
method: "POST",
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions/revoke",
description: "Revoke a specific permission from a role",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
rolePublicId: z.string().min(12),
permission: z.enum(permissionsList),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
ctx.db,
workspace.id,
input.rolePublicId,
);
if (!role) {
throw new TRPCError({
message: "Role not found",
code: "NOT_FOUND",
});
}
if (role.name === "admin" && role.isSystem) {
throw new TRPCError({
message: "Admin role permissions cannot be modified",
code: "FORBIDDEN",
});
}
await permissionRepo.revokeRolePermission(
ctx.db,
role.id,
input.permission as Permission,
);
return { success: true };
}),
});

View File

@@ -7,7 +7,7 @@ import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
export const workspaceRouter = createTRPCRouter({
all: protectedProcedure
@@ -74,8 +74,7 @@ export const workspaceRouter = createTRPCRouter({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, result.id);
await assertPermission(ctx.db, userId, result.id, "workspace:view");
// Check if user is an admin
const userMember = result.members.find(
@@ -84,7 +83,8 @@ export const workspaceRouter = createTRPCRouter({
const isAdmin = userMember?.role === "admin";
// Show emails if user is admin OR workspace setting allows it
const shouldShowEmails = isAdmin || result.showEmailsToMembers === true;
const shouldShowEmails =
isAdmin || result.showEmailsToMembers === true;
// If emails should be hidden, filter them out
if (!shouldShowEmails) {
@@ -164,8 +164,7 @@ export const workspaceRouter = createTRPCRouter({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, result.id);
await assertPermission(ctx.db, userId, result.id, "workspace:view");
return result;
}),
@@ -296,8 +295,7 @@ export const workspaceRouter = createTRPCRouter({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
await assertPermission(ctx.db, userId, workspace.id, "workspace:edit");
if (input.slug) {
const reservedOrPremiumWorkspaceSlug =
@@ -379,8 +377,7 @@ export const workspaceRouter = createTRPCRouter({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
const result = await workspaceRepo.hardDelete(
ctx.db,
@@ -518,8 +515,7 @@ export const workspaceRouter = createTRPCRouter({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
await assertPermission(ctx.db, userId, workspace.id, "workspace:view");
const result = await workspaceRepo.searchBoardsAndCards(
ctx.db,

View 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",
});
}

View File

@@ -0,0 +1,172 @@
CREATE TABLE IF NOT EXISTS "workspace_member_permissions" (
"id" bigserial PRIMARY KEY NOT NULL,
"workspaceMemberId" bigint NOT NULL,
"permission" varchar(64) NOT NULL,
"granted" boolean DEFAULT true NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
ALTER TABLE "workspace_member_permissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_role_permissions" (
"id" bigserial PRIMARY KEY NOT NULL,
"workspaceRoleId" bigint NOT NULL,
"permission" varchar(64) NOT NULL,
"granted" boolean DEFAULT true NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "workspace_role_permissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_roles" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"workspaceId" bigint NOT NULL,
"name" varchar(64) NOT NULL,
"description" varchar(255),
"hierarchyLevel" integer NOT NULL,
"isSystem" boolean DEFAULT false NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "workspace_roles_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "workspace_roles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "workspace_members" ADD COLUMN "roleId" bigint;--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean DEFAULT true NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_role_permissions" ADD CONSTRAINT "workspace_role_permissions_workspaceRoleId_workspace_roles_id_fk" FOREIGN KEY ("workspaceRoleId") REFERENCES "public"."workspace_roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_roles" ADD CONSTRAINT "workspace_roles_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "unique_member_permission" ON "workspace_member_permissions" USING btree ("workspaceMemberId","permission");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "permission_member_idx" ON "workspace_member_permissions" USING btree ("workspaceMemberId");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "unique_role_permission" ON "workspace_role_permissions" USING btree ("workspaceRoleId","permission");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "role_permissions_role_idx" ON "workspace_role_permissions" USING btree ("workspaceRoleId");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "unique_role_per_workspace" ON "workspace_roles" USING btree ("workspaceId","name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_roles_workspace_idx" ON "workspace_roles" USING btree ("workspaceId");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_roleId_workspace_roles_id_fk" FOREIGN KEY ("roleId") REFERENCES "public"."workspace_roles"("id") ON DELETE restrict ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
-- Helper function to generate 12-character public IDs
CREATE OR REPLACE FUNCTION generate_public_id() RETURNS varchar(12) AS $$
DECLARE
chars text := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
result varchar(12) := '';
i integer;
BEGIN
FOR i IN 1..12 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::integer, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql;
--> statement-breakpoint
-- Seed system roles for each existing workspace
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
SELECT generate_public_id(), w.id, 'admin', 'Full access to all workspace features', 100, true, NOW()
FROM "workspace" w
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_roles" wr
WHERE wr."workspaceId" = w.id AND wr."name" = 'admin'
);
--> statement-breakpoint
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
SELECT generate_public_id(), w.id, 'member', 'Standard member with create and edit permissions', 50, true, NOW()
FROM "workspace" w
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_roles" wr
WHERE wr."workspaceId" = w.id AND wr."name" = 'member'
);
--> statement-breakpoint
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
SELECT generate_public_id(), w.id, 'guest', 'View-only access', 10, true, NOW()
FROM "workspace" w
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_roles" wr
WHERE wr."workspaceId" = w.id AND wr."name" = 'guest'
);
--> statement-breakpoint
-- Seed admin role permissions (all permissions)
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
SELECT wr.id, p.permission, true, NOW()
FROM "workspace_roles" wr
CROSS JOIN (
VALUES
('workspace:view'), ('workspace:edit'), ('workspace:delete'), ('workspace:manage'),
('board:view'), ('board:create'), ('board:edit'), ('board:delete'),
('list:view'), ('list:create'), ('list:edit'), ('list:delete'),
('card:view'), ('card:create'), ('card:edit'), ('card:delete'),
('comment:view'), ('comment:create'), ('comment:edit'), ('comment:delete'),
('member:view'), ('member:invite'), ('member:edit'), ('member:remove')
) AS p(permission)
WHERE wr."name" = 'admin' AND wr."isSystem" = true
AND NOT EXISTS (
SELECT 1 FROM "workspace_role_permissions" wrp
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
);
--> statement-breakpoint
-- Seed member role permissions
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
SELECT wr.id, p.permission, true, NOW()
FROM "workspace_roles" wr
CROSS JOIN (
VALUES
('workspace:view'),
('board:view'), ('board:create'),
('list:view'), ('list:create'), ('list:edit'), ('list:delete'),
('card:view'), ('card:create'), ('card:edit'), ('card:delete'),
('comment:view'), ('comment:create'), ('comment:edit'), ('comment:delete'),
('member:view')
) AS p(permission)
WHERE wr."name" = 'member' AND wr."isSystem" = true
AND NOT EXISTS (
SELECT 1 FROM "workspace_role_permissions" wrp
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
);
--> statement-breakpoint
-- Seed guest role permissions (view only)
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
SELECT wr.id, p.permission, true, NOW()
FROM "workspace_roles" wr
CROSS JOIN (
VALUES
('workspace:view'),
('board:view'),
('list:view'),
('card:view'),
('comment:view'),
('member:view')
) AS p(permission)
WHERE wr."name" = 'guest' AND wr."isSystem" = true
AND NOT EXISTS (
SELECT 1 FROM "workspace_role_permissions" wrp
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
);
--> statement-breakpoint
-- Migrate existing workspace_members to use roleId
UPDATE "workspace_members" wm
SET "roleId" = wr.id
FROM "workspace_roles" wr
WHERE wm."workspaceId" = wr."workspaceId"
AND wm."role"::text = wr."name"
AND wm."roleId" IS NULL;
--> statement-breakpoint
-- Clean up helper function
DROP FUNCTION IF EXISTS generate_public_id();

File diff suppressed because it is too large Load Diff

View File

@@ -162,6 +162,13 @@
"when": 1768858977000,
"tag": "20260119164257_AddShowEmailsToMembersToWorkspace",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1769435949476,
"tag": "20260126135909_AddWorkspaceRoles",
"breakpoints": true
}
]
}

View File

@@ -518,6 +518,7 @@ export const getWithListIdsByPublicId = (
columns: {
id: true,
workspaceId: true,
createdBy: true,
},
with: {
lists: {
@@ -671,6 +672,7 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
columns: {
id: true,
workspaceId: true,
createdBy: true,
},
where: eq(boards.publicId, boardPublicId),
});

View File

@@ -424,6 +424,7 @@ export const getWithListAndMembersByPublicId = async (
title: true,
description: true,
dueDate: true,
createdBy: true,
},
with: {
labels: {
@@ -938,7 +939,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
cardPublicId: string,
) => {
const result = await db.query.cards.findFirst({
columns: { id: true },
columns: { id: true, createdBy: true },
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
with: {
list: {
@@ -958,6 +959,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
return result
? {
id: result.id,
createdBy: result.createdBy,
workspaceId: result.list.board.workspaceId,
workspaceVisibility: result.list.board.visibility,
}

View File

@@ -419,7 +419,7 @@ export const getWorkspaceAndListIdByListPublicId = async (
listPublicId: string,
) => {
const result = await db.query.lists.findFirst({
columns: { id: true },
columns: { id: true, createdBy: true },
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
with: {
board: {
@@ -431,6 +431,10 @@ export const getWorkspaceAndListIdByListPublicId = async (
});
return result
? { id: result.id, workspaceId: result.board.workspaceId }
? {
id: result.id,
createdBy: result.createdBy,
workspaceId: result.board.workspaceId,
}
: null;
};

View File

@@ -27,6 +27,7 @@ export const create = async (
workspaceId: number;
createdBy: string;
role: MemberRole;
roleId?: number | null;
status: MemberStatus;
},
) => {
@@ -39,6 +40,7 @@ export const create = async (
workspaceId: memberInput.workspaceId,
createdBy: memberInput.createdBy,
role: memberInput.role,
roleId: memberInput.roleId ?? null,
status: memberInput.status,
})
.returning({
@@ -55,6 +57,12 @@ export const getByPublicId = async (db: dbClient, publicId: string) => {
});
};
export const getById = async (db: dbClient, memberId: number) => {
return db.query.workspaceMembers.findFirst({
where: eq(workspaceMembers.id, memberId),
});
};
export const getByEmailAndStatus = async (
db: dbClient,
email: string,
@@ -133,3 +141,28 @@ export const pauseAllMembers = async (db: dbClient, workspaceId: number) => {
),
);
};
export const updateRole = async (
db: dbClient,
args: {
memberId: number;
role: MemberRole;
roleId: number | null;
},
) => {
const [result] = await db
.update(workspaceMembers)
.set({
role: args.role,
roleId: args.roleId,
updatedAt: new Date(),
})
.where(eq(workspaceMembers.id, args.memberId))
.returning({
id: workspaceMembers.id,
publicId: workspaceMembers.publicId,
role: workspaceMembers.role,
});
return result;
};

View File

@@ -0,0 +1,494 @@
import { and, eq, isNull, inArray } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import {
workspaceMemberPermissions,
workspaceMembers,
workspaceRolePermissions,
workspaceRoles,
} from "@kan/db/schema";
import type { Permission, Role } from "@kan/shared";
import { generateUID, getDefaultPermissions } from "@kan/shared";
/**
* Get permissions by role ID
*/
export const getPermissionsByRoleId = async (
db: dbClient,
roleId: number,
): Promise<Permission[]> => {
const permissions = await db
.select({ permission: workspaceRolePermissions.permission })
.from(workspaceRolePermissions)
.where(
and(
eq(workspaceRolePermissions.workspaceRoleId, roleId),
eq(workspaceRolePermissions.granted, true),
),
);
return permissions.map((p) => p.permission as Permission);
};
/**
* Get role by workspace ID and name
*/
export const getRoleByWorkspaceIdAndName = async (
db: dbClient,
workspaceId: number,
name: string,
) => {
const [role] = await db
.select()
.from(workspaceRoles)
.where(
and(
eq(workspaceRoles.workspaceId, workspaceId),
eq(workspaceRoles.name, name),
),
)
.limit(1);
return role;
};
/**
* Get role by workspace ID and publicId
*/
export const getRoleByWorkspaceIdAndPublicId = async (
db: dbClient,
workspaceId: number,
rolePublicId: string,
) => {
const [role] = await db
.select()
.from(workspaceRoles)
.where(
and(
eq(workspaceRoles.workspaceId, workspaceId),
eq(workspaceRoles.publicId, rolePublicId),
),
)
.limit(1);
return role;
};
/**
* Get all custom permission overrides for a workspace member
*/
export const getMemberPermissionOverrides = async (
db: dbClient,
workspaceMemberId: number,
) => {
return db
.select()
.from(workspaceMemberPermissions)
.where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId));
};
/**
* Get a single permission override for a member
*/
export const getMemberPermissionOverride = async (
db: dbClient,
workspaceMemberId: number,
permission: string,
) => {
const [override] = await db
.select()
.from(workspaceMemberPermissions)
.where(
and(
eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId),
eq(workspaceMemberPermissions.permission, permission),
),
)
.limit(1);
return override;
};
/**
* Get effective permissions for a workspace member
* Combines role template (from DB) with custom overrides
*/
export const getMemberEffectivePermissions = async (
db: dbClient,
workspaceMemberId: number,
roleId: number | null,
roleName: string,
): Promise<Permission[]> => {
let roleDefaults: Set<Permission>;
// Try to get role permissions from database first
if (roleId) {
const dbPermissions = await getPermissionsByRoleId(db, roleId);
roleDefaults = new Set<Permission>(dbPermissions);
} else {
// Fallback to code-based defaults if roleId not set
const codeDefaults = getDefaultPermissions(roleName as Role);
roleDefaults = new Set<Permission>([...codeDefaults]);
}
// Get custom overrides
const overrides = await getMemberPermissionOverrides(db, workspaceMemberId);
// Apply overrides
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 const memberHasPermission = async (
db: dbClient,
workspaceMemberId: number,
roleId: number | null,
roleName: string,
permission: Permission,
): Promise<boolean> => {
let hasRoleDefault: boolean;
// Check role permission from database first
if (roleId) {
const dbPermissions = await getPermissionsByRoleId(db, roleId);
hasRoleDefault = dbPermissions.includes(permission);
} else {
// Fallback to code-based defaults
const codeDefaults = getDefaultPermissions(roleName as Role);
hasRoleDefault = codeDefaults.includes(permission);
}
// Check for override
const [override] = await db
.select()
.from(workspaceMemberPermissions)
.where(
and(
eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId),
eq(workspaceMemberPermissions.permission, permission),
),
)
.limit(1);
// Override takes precedence
if (override) {
return override.granted;
}
return hasRoleDefault;
};
/**
* Grant a permission to a member
*/
export const grantPermission = async (
db: dbClient,
workspaceMemberId: number,
permission: Permission,
) => {
const [result] = await db
.insert(workspaceMemberPermissions)
.values({
workspaceMemberId,
permission,
granted: true,
})
.onConflictDoUpdate({
target: [
workspaceMemberPermissions.workspaceMemberId,
workspaceMemberPermissions.permission,
],
set: {
granted: true,
updatedAt: new Date(),
},
})
.returning();
return result;
};
/**
* Revoke a permission from a member
*/
export const revokePermission = async (
db: dbClient,
workspaceMemberId: number,
permission: Permission,
) => {
const [result] = await db
.insert(workspaceMemberPermissions)
.values({
workspaceMemberId,
permission,
granted: false,
})
.onConflictDoUpdate({
target: [
workspaceMemberPermissions.workspaceMemberId,
workspaceMemberPermissions.permission,
],
set: {
granted: false,
updatedAt: new Date(),
},
})
.returning();
return result;
};
/**
* Clear all permission overrides for a workspace member
*/
export const clearMemberPermissionOverrides = async (
db: dbClient,
workspaceMemberId: number,
) => {
await db
.delete(workspaceMemberPermissions)
.where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId));
};
/**
* Clear all permission overrides for all members in a workspace
*/
export const clearAllMemberPermissionOverridesForWorkspace = async (
db: dbClient,
workspaceId: number,
) => {
const memberIds = await db
.select({ id: workspaceMembers.id })
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, workspaceId),
isNull(workspaceMembers.deletedAt),
),
);
if (memberIds.length === 0) return;
const ids = memberIds.map((m) => m.id);
await db
.delete(workspaceMemberPermissions)
.where(inArray(workspaceMemberPermissions.workspaceMemberId, ids));
};
/**
* Get member with their role by userId and workspaceId
*/
export const getMemberWithRole = async (
db: dbClient,
userId: string,
workspaceId: number,
) => {
const [member] = await db
.select({
id: workspaceMembers.id,
publicId: workspaceMembers.publicId,
role: workspaceMembers.role,
roleId: workspaceMembers.roleId,
})
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.userId, userId),
eq(workspaceMembers.workspaceId, workspaceId),
isNull(workspaceMembers.deletedAt),
),
)
.limit(1);
return member;
};
/**
* Check if user has permission in workspace
*/
export const userHasPermissionInWorkspace = async (
db: dbClient,
userId: string,
workspaceId: number,
permission: Permission,
): Promise<boolean> => {
const member = await 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 const getUserPermissionsInWorkspace = async (
db: dbClient,
userId: string,
workspaceId: number,
): Promise<{
permissions: Permission[];
role: string;
roleId: number | null;
} | null> => {
const member = await 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,
};
};
/**
* Create a role for a workspace
*/
export const createRole = async (
db: dbClient,
args: {
workspaceId: number;
name: string;
description: string;
hierarchyLevel: number;
isSystem: boolean;
permissions: Permission[];
},
) => {
const [role] = await db
.insert(workspaceRoles)
.values({
publicId: generateUID(),
workspaceId: args.workspaceId,
name: args.name,
description: args.description,
hierarchyLevel: args.hierarchyLevel,
isSystem: args.isSystem,
})
.returning({
id: workspaceRoles.id,
name: workspaceRoles.name,
publicId: workspaceRoles.publicId,
});
if (role && args.permissions.length > 0) {
await db.insert(workspaceRolePermissions).values(
args.permissions.map((perm) => ({
workspaceRoleId: role.id,
permission: perm,
granted: true,
})),
);
}
return role;
};
/**
* Grant a permission to a role
*/
export const grantRolePermission = async (
db: dbClient,
roleId: number,
permission: Permission,
) => {
const [result] = await db
.insert(workspaceRolePermissions)
.values({
workspaceRoleId: roleId,
permission,
granted: true,
})
.onConflictDoUpdate({
target: [
workspaceRolePermissions.workspaceRoleId,
workspaceRolePermissions.permission,
],
set: {
granted: true,
},
})
.returning();
return result;
};
/**
* Revoke a permission from a role
*/
export const revokeRolePermission = async (
db: dbClient,
roleId: number,
permission: Permission,
) => {
const [result] = await db
.insert(workspaceRolePermissions)
.values({
workspaceRoleId: roleId,
permission,
granted: false,
})
.onConflictDoUpdate({
target: [
workspaceRolePermissions.workspaceRoleId,
workspaceRolePermissions.permission,
],
set: {
granted: false,
},
})
.returning();
return result;
};
/**
* Get all roles for a workspace
*/
export const getRolesByWorkspaceId = async (
db: dbClient,
workspaceId: number,
) => {
return db
.select({
id: workspaceRoles.id,
publicId: workspaceRoles.publicId,
name: workspaceRoles.name,
description: workspaceRoles.description,
hierarchyLevel: workspaceRoles.hierarchyLevel,
isSystem: workspaceRoles.isSystem,
})
.from(workspaceRoles)
.where(eq(workspaceRoles.workspaceId, workspaceId));
};

View File

@@ -18,7 +18,33 @@ import {
workspaceMembers,
workspaces,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
import type { Permission, Role } from "@kan/shared";
import { generateUID, getDefaultPermissions } from "@kan/shared";
import * as permissionRepo from "./permission.repo";
// System role definitions
const SYSTEM_ROLES: {
name: Role;
description: string;
hierarchyLevel: number;
}[] = [
{
name: "admin",
description: "Full access to all workspace features",
hierarchyLevel: 100,
},
{
name: "member",
description: "Standard member with create and edit permissions",
hierarchyLevel: 50,
},
{
name: "guest",
description: "View-only access",
hierarchyLevel: 10,
},
];
export const getCount = async (db: dbClient) => {
const result = await db
@@ -57,6 +83,22 @@ export const create = async (
});
if (workspace) {
// Create system roles for the workspace
let adminRoleId: number | null = null;
for (const roleData of SYSTEM_ROLES) {
const role = await permissionRepo.createRole(db, {
workspaceId: workspace.id,
name: roleData.name,
description: roleData.description,
hierarchyLevel: roleData.hierarchyLevel,
isSystem: true,
permissions: [...getDefaultPermissions(roleData.name)] as Permission[],
});
if (roleData.name === "admin" && role) {
adminRoleId = role.id;
}
}
await db.insert(workspaceMembers).values({
publicId: generateUID(),
userId: workspaceInput.createdBy,
@@ -64,6 +106,7 @@ export const create = async (
workspaceId: workspace.id,
createdBy: workspaceInput.createdBy,
role: "admin",
roleId: adminRoleId,
status: "active",
});
}
@@ -151,8 +194,13 @@ export const getByPublicIdWithMembers = (
email: true,
role: true,
status: true,
createdAt: true,
},
where: isNull(workspaceMembers.deletedAt),
orderBy: (member, { desc }) => [
desc(sql`CASE WHEN ${member.role} = 'admin' THEN 1 ELSE 0 END`),
desc(member.createdAt),
],
with: {
user: {
columns: {

View File

@@ -12,3 +12,4 @@ export * from "./integrations";
export * from "./workspaces";
export * from "./subscriptions";
export * from "./workspaceInviteLinks";
export * from "./permissions";

View File

@@ -0,0 +1,98 @@
import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
index,
integer,
pgTable,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
export const workspaceRoles = pgTable(
"workspace_roles",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
name: varchar("name", { length: 64 }).notNull(),
description: varchar("description", { length: 255 }),
hierarchyLevel: integer("hierarchyLevel").notNull(),
isSystem: boolean("isSystem").notNull().default(false),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
},
(table) => [
uniqueIndex("unique_role_per_workspace").on(table.workspaceId, table.name),
index("workspace_roles_workspace_idx").on(table.workspaceId),
],
).enableRLS();
export const workspaceRolesRelations = relations(
workspaceRoles,
({ one, many }) => ({
workspace: one(workspaces, {
fields: [workspaceRoles.workspaceId],
references: [workspaces.id],
relationName: "workspaceRoles",
}),
permissions: many(workspaceRolePermissions),
}),
);
export const workspaceRolePermissions = pgTable(
"workspace_role_permissions",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
workspaceRoleId: bigint("workspaceRoleId", { mode: "number" })
.notNull()
.references(() => workspaceRoles.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 64 }).notNull(),
granted: boolean("granted").notNull().default(true),
createdAt: timestamp("createdAt").defaultNow().notNull(),
},
(table) => [
uniqueIndex("unique_role_permission").on(
table.workspaceRoleId,
table.permission,
),
index("role_permissions_role_idx").on(table.workspaceRoleId),
],
).enableRLS();
export const workspaceRolePermissionsRelations = relations(
workspaceRolePermissions,
({ one }) => ({
role: one(workspaceRoles, {
fields: [workspaceRolePermissions.workspaceRoleId],
references: [workspaceRoles.id],
relationName: "rolePermissions",
}),
}),
);
export const workspaceMemberPermissions = pgTable(
"workspace_member_permissions",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" }).notNull(),
permission: varchar("permission", { length: 64 }).notNull(),
granted: boolean("granted").notNull().default(true),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
},
(table) => [
uniqueIndex("unique_member_permission").on(
table.workspaceMemberId,
table.permission,
),
index("permission_member_idx").on(table.workspaceMemberId),
],
).enableRLS();

View File

@@ -12,6 +12,7 @@ import {
} from "drizzle-orm/pg-core";
import { boards } from "./boards";
import { workspaceMemberPermissions, workspaceRoles } from "./permissions";
import { subscription } from "./subscriptions";
import { users } from "./users";
@@ -69,6 +70,7 @@ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
members: many(workspaceMembers),
boards: many(boards),
subscriptions: many(subscription),
roles: many(workspaceRoles),
}));
export const workspaceMembers = pgTable("workspace_members", {
@@ -86,13 +88,18 @@ export const workspaceMembers = pgTable("workspace_members", {
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
// Legacy role enum
role: memberRoleEnum("role").notNull(),
roleId: bigint("roleId", { mode: "number" }).references(
() => workspaceRoles.id,
{ onDelete: "restrict" },
),
status: memberStatusEnum("status").default("invited").notNull(),
}).enableRLS();
export const workspaceMembersRelations = relations(
workspaceMembers,
({ one }) => ({
({ one, many }) => ({
user: one(users, {
fields: [workspaceMembers.userId],
references: [users.id],
@@ -103,6 +110,23 @@ export const workspaceMembersRelations = relations(
references: [workspaces.id],
relationName: "workspaceMembersWorkspace",
}),
workspaceRole: one(workspaceRoles, {
fields: [workspaceMembers.roleId],
references: [workspaceRoles.id],
relationName: "workspaceMemberRole",
}),
permissions: many(workspaceMemberPermissions),
}),
);
export const workspaceMemberPermissionsRelations = relations(
workspaceMemberPermissions,
({ one }) => ({
member: one(workspaceMembers, {
fields: [workspaceMemberPermissions.workspaceMemberId],
references: [workspaceMembers.id],
relationName: "memberPermissions",
}),
}),
);

View File

@@ -2,3 +2,4 @@ export const name = "shared";
export * from "./constants";
export * from "./utils";
export * from "./permissions";

View File

@@ -0,0 +1,165 @@
export const permissionActions = [
"view",
"create",
"edit",
"delete",
"manage",
] as const;
export type PermissionAction = (typeof permissionActions)[number];
export const permissionResources = [
"workspace",
"board",
"list",
"card",
"comment",
"member",
] as const;
export type PermissionResource = (typeof permissionResources)[number];
export const allPermissions = [
"workspace:view",
"workspace:edit",
"workspace:delete",
"workspace:manage",
"board:view",
"board:create",
"board:edit",
"board:delete",
"list:view",
"list:create",
"list:edit",
"list:delete",
"card:view",
"card:create",
"card:edit",
"card:delete",
"comment:view",
"comment:create",
"comment:edit",
"comment:delete",
"member:view",
"member:invite",
"member:edit",
"member:remove",
] as const;
export type Permission = (typeof allPermissions)[number];
export const roleHierarchy = {
admin: 100,
member: 50,
guest: 10,
} as const;
export type Role = keyof typeof roleHierarchy;
export const roles = Object.keys(roleHierarchy) as Role[];
export const defaultRolePermissions: Record<Role, readonly Permission[]> = {
admin: allPermissions,
member: [
"workspace:view",
"board:view",
"board:create",
"list:view",
"list:create",
"list:edit",
"list:delete",
"card:view",
"card:create",
"card:edit",
"card:delete",
"comment:view",
"comment:create",
"comment:edit",
"comment:delete",
"member:view",
],
guest: [
"workspace:view",
"board:view",
"list:view",
"card:view",
"comment:view",
"member:view",
],
} as const;
export const permissionCategories = {
workspace: {
label: "Workspace",
permissions: [
"workspace:view",
"workspace:edit",
"workspace:delete",
"workspace:manage",
] as const,
},
board: {
label: "Boards",
permissions: [
"board:view",
"board:create",
"board:edit",
"board:delete",
] as const,
},
list: {
label: "Lists",
permissions: [
"list:view",
"list:create",
"list:edit",
"list:delete",
] as const,
},
card: {
label: "Cards",
permissions: [
"card:view",
"card:create",
"card:edit",
"card:delete",
] as const,
},
comment: {
label: "Comments",
permissions: [
"comment:view",
"comment:create",
"comment:edit",
"comment:delete",
] as const,
},
member: {
label: "Members",
permissions: [
"member:view",
"member:invite",
"member:edit",
"member:remove",
] as const,
},
} as const;
export function getDefaultPermissions(role: Role): readonly Permission[] {
return defaultRolePermissions[role];
}
export function getRoleLevel(role: Role): number {
return roleHierarchy[role];
}
export function canManageRole(managerRole: Role, targetRole: Role): boolean {
return roleHierarchy[managerRole] >= roleHierarchy[targetRole];
}
export function hasPermissionInDefaults(
role: Role,
permission: Permission,
): boolean {
return defaultRolePermissions[role].includes(permission);
}