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