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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
776
packages/api/src/routers/permission.ts
Normal file
776
packages/api/src/routers/permission.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
295
packages/api/src/utils/permissions.ts
Normal file
295
packages/api/src/utils/permissions.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as permissionRepo from "@kan/db/repository/permission.repo";
|
||||
import type { Permission, Role } from "@kan/shared";
|
||||
import { canManageRole, getDefaultPermissions } from "@kan/shared";
|
||||
|
||||
/**
|
||||
* Get effective permissions for a member by combining role permissions with overrides
|
||||
*/
|
||||
export async function getMemberEffectivePermissions(
|
||||
db: dbClient,
|
||||
workspaceMemberId: number,
|
||||
roleId: number | null,
|
||||
roleName: string,
|
||||
): Promise<Permission[]> {
|
||||
let roleDefaults: Set<Permission>;
|
||||
|
||||
// Get role permissions from database or fallback to code defaults
|
||||
if (roleId) {
|
||||
const dbPermissions = await permissionRepo.getPermissionsByRoleId(
|
||||
db,
|
||||
roleId,
|
||||
);
|
||||
roleDefaults = new Set<Permission>(dbPermissions);
|
||||
} else {
|
||||
const codeDefaults = getDefaultPermissions(roleName as Role);
|
||||
roleDefaults = new Set<Permission>([...codeDefaults]);
|
||||
}
|
||||
|
||||
// Get and apply custom overrides
|
||||
const overrides = await permissionRepo.getMemberPermissionOverrides(
|
||||
db,
|
||||
workspaceMemberId,
|
||||
);
|
||||
|
||||
for (const override of overrides) {
|
||||
if (override.granted) {
|
||||
roleDefaults.add(override.permission as Permission);
|
||||
} else {
|
||||
roleDefaults.delete(override.permission as Permission);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(roleDefaults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a member has a specific permission
|
||||
*/
|
||||
export async function memberHasPermission(
|
||||
db: dbClient,
|
||||
workspaceMemberId: number,
|
||||
roleId: number | null,
|
||||
roleName: string,
|
||||
permission: Permission,
|
||||
): Promise<boolean> {
|
||||
let hasRoleDefault: boolean;
|
||||
|
||||
// Check role permission from database or fallback to code defaults
|
||||
if (roleId) {
|
||||
const dbPermissions = await permissionRepo.getPermissionsByRoleId(
|
||||
db,
|
||||
roleId,
|
||||
);
|
||||
hasRoleDefault = dbPermissions.includes(permission);
|
||||
} else {
|
||||
const codeDefaults = getDefaultPermissions(roleName as Role);
|
||||
hasRoleDefault = codeDefaults.includes(permission);
|
||||
}
|
||||
|
||||
// Check for override
|
||||
const override = await permissionRepo.getMemberPermissionOverride(
|
||||
db,
|
||||
workspaceMemberId,
|
||||
permission,
|
||||
);
|
||||
|
||||
// Override takes precedence
|
||||
if (override) {
|
||||
return override.granted;
|
||||
}
|
||||
|
||||
return hasRoleDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission in a workspace
|
||||
*/
|
||||
export async function hasPermission(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
): Promise<boolean> {
|
||||
const member = await permissionRepo.getMemberWithRole(db, userId, workspaceId);
|
||||
|
||||
if (!member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return memberHasPermission(
|
||||
db,
|
||||
member.id,
|
||||
member.roleId,
|
||||
member.role,
|
||||
permission,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a user in a workspace
|
||||
*/
|
||||
export async function getUserPermissions(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
): Promise<{
|
||||
permissions: Permission[];
|
||||
role: string;
|
||||
roleId: number | null;
|
||||
} | null> {
|
||||
const member = await permissionRepo.getMemberWithRole(db, userId, workspaceId);
|
||||
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissions = await getMemberEffectivePermissions(
|
||||
db,
|
||||
member.id,
|
||||
member.roleId,
|
||||
member.role,
|
||||
);
|
||||
|
||||
return {
|
||||
permissions,
|
||||
role: member.role,
|
||||
roleId: member.roleId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user has permission - throws FORBIDDEN if not
|
||||
*/
|
||||
export async function assertPermission(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
): Promise<void> {
|
||||
const hasIt = await hasPermission(db, userId, workspaceId, permission);
|
||||
|
||||
if (!hasIt) {
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to perform this action (${permission})`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can assign a specific role (based on hierarchy)
|
||||
*/
|
||||
export async function assertCanManageRole(
|
||||
db: dbClient,
|
||||
managerUserId: string,
|
||||
workspaceId: number,
|
||||
targetRoleName: string,
|
||||
): Promise<void> {
|
||||
const managerMember = await permissionRepo.getMemberWithRole(
|
||||
db,
|
||||
managerUserId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!managerMember) {
|
||||
throw new TRPCError({
|
||||
message: "You are not a member of this workspace",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const managerRole = managerMember.role;
|
||||
|
||||
if (!canManageRole(managerRole, targetRoleName as Role)) {
|
||||
throw new TRPCError({
|
||||
message: `You cannot assign the "${targetRoleName}" role`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can manage another member based on role hierarchy
|
||||
*/
|
||||
export async function assertCanManageMember(
|
||||
db: dbClient,
|
||||
managerUserId: string,
|
||||
workspaceId: number,
|
||||
targetMemberId: number,
|
||||
): Promise<void> {
|
||||
const managerMember = await permissionRepo.getMemberWithRole(
|
||||
db,
|
||||
managerUserId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!managerMember) {
|
||||
throw new TRPCError({
|
||||
message: "You are not a member of this workspace",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const targetMember = await memberRepo.getById(db, targetMemberId);
|
||||
|
||||
if (!targetMember) {
|
||||
throw new TRPCError({
|
||||
message: "Target member not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const managerRole = managerMember.role;
|
||||
const targetRole = targetMember.role;
|
||||
|
||||
if (!canManageRole(managerRole, targetRole)) {
|
||||
throw new TRPCError({
|
||||
message: "You cannot manage this member due to role hierarchy",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can delete an entity - either has the delete permission OR is the creator
|
||||
*/
|
||||
export async function assertCanDelete(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
createdBy: string | null,
|
||||
): Promise<void> {
|
||||
// Check if user has the general delete permission
|
||||
const hasDeletePermission = await hasPermission(db, userId, workspaceId, permission);
|
||||
|
||||
// If user has permission, allow deletion
|
||||
if (hasDeletePermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If user doesn't have permission, check if they are the creator
|
||||
if (createdBy && createdBy === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither condition met - deny deletion
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to delete this entity (${permission})`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can edit an entity - either has the edit permission OR is the creator
|
||||
*/
|
||||
export async function assertCanEdit(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
createdBy: string | null,
|
||||
): Promise<void> {
|
||||
// Check if user has the general edit permission
|
||||
const hasEditPermission = await hasPermission(db, userId, workspaceId, permission);
|
||||
|
||||
// If user has permission, allow editing
|
||||
if (hasEditPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If user doesn't have permission, check if they are the creator
|
||||
if (createdBy && createdBy === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither condition met - deny editing
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to edit this entity (${permission})`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
172
packages/db/migrations/20260126135909_AddWorkspaceRoles.sql
Normal file
172
packages/db/migrations/20260126135909_AddWorkspaceRoles.sql
Normal 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();
|
||||
3313
packages/db/migrations/meta/20260126135909_snapshot.json
Normal file
3313
packages/db/migrations/meta/20260126135909_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -162,6 +162,13 @@
|
||||
"when": 1768858977000,
|
||||
"tag": "20260119164257_AddShowEmailsToMembersToWorkspace",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1769435949476,
|
||||
"tag": "20260126135909_AddWorkspaceRoles",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
494
packages/db/src/repository/permission.repo.ts
Normal file
494
packages/db/src/repository/permission.repo.ts
Normal 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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from "./integrations";
|
||||
export * from "./workspaces";
|
||||
export * from "./subscriptions";
|
||||
export * from "./workspaceInviteLinks";
|
||||
export * from "./permissions";
|
||||
|
||||
98
packages/db/src/schema/permissions.ts
Normal file
98
packages/db/src/schema/permissions.ts
Normal 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();
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ export const name = "shared";
|
||||
|
||||
export * from "./constants";
|
||||
export * from "./utils";
|
||||
export * from "./permissions";
|
||||
|
||||
165
packages/shared/src/permissions.ts
Normal file
165
packages/shared/src/permissions.ts
Normal 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user