777 lines
20 KiB
TypeScript
777 lines
20 KiB
TypeScript
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 || member.workspaceId !== workspace.id) {
|
|
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 || member.workspaceId !== workspace.id) {
|
|
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 || member.workspaceId !== workspace.id) {
|
|
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 || member.workspaceId !== workspace.id) {
|
|
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 };
|
|
}),
|
|
});
|