feat: setup basic router interactions
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
296
packages/api/src/routers/permission.ts
Normal file
296
packages/api/src/routers/permission.ts
Normal file
@@ -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 };
|
||||
}),
|
||||
});
|
||||
235
packages/api/src/utils/permissions.ts
Normal file
235
packages/api/src/utils/permissions.ts
Normal file
@@ -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<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 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<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 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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" }),
|
||||
|
||||
@@ -2,3 +2,4 @@ export const name = "shared";
|
||||
|
||||
export * from "./constants";
|
||||
export * from "./utils";
|
||||
export * from "./permissions";
|
||||
|
||||
Reference in New Issue
Block a user