diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 531fe87d..21493c5b 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -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, diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts index 8bf51d37..35625f2f 100644 --- a/packages/api/src/routers/member.ts +++ b/packages/api/src/routers/member.ts @@ -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,16 @@ 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 @@ -646,4 +652,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, + }; + }), }); diff --git a/packages/api/src/routers/permission.ts b/packages/api/src/routers/permission.ts new file mode 100644 index 00000000..ce5093a5 --- /dev/null +++ b/packages/api/src/routers/permission.ts @@ -0,0 +1,296 @@ +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 }; + }), +}); diff --git a/packages/api/src/utils/permissions.ts b/packages/api/src/utils/permissions.ts new file mode 100644 index 00000000..ca2dbca1 --- /dev/null +++ b/packages/api/src/utils/permissions.ts @@ -0,0 +1,235 @@ +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 { + let roleDefaults: Set; + + // Get role permissions from database or fallback to code defaults + if (roleId) { + const dbPermissions = await permissionRepo.getPermissionsByRoleId( + db, + roleId, + ); + roleDefaults = new Set(dbPermissions); + } else { + const codeDefaults = getDefaultPermissions(roleName as Role); + roleDefaults = new Set([...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 { + 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 { + 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 { + 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 { + 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 as 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 { + 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 as Role; + const targetRole = targetMember.role as Role; + + if (!canManageRole(managerRole, targetRole)) { + throw new TRPCError({ + message: "You cannot manage this member due to role hierarchy", + code: "FORBIDDEN", + }); + } +} diff --git a/packages/db/src/schema/permissions.ts b/packages/db/src/schema/permissions.ts index 6394a809..a271271c 100644 --- a/packages/db/src/schema/permissions.ts +++ b/packages/db/src/schema/permissions.ts @@ -17,6 +17,7 @@ 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" }), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3df3f1aa..b9716389 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,3 +2,4 @@ export const name = "shared"; export * from "./constants"; export * from "./utils"; +export * from "./permissions";