fix(comment): align mention audience with entity visibility

This commit is contained in:
2026-03-30 18:50:36 +02:00
parent 34067f1576
commit dd71e8f80b
7 changed files with 616 additions and 97 deletions
@@ -145,6 +145,267 @@ describe("comment router authorization", () => {
});
});
it("returns estimate mention candidates only for the controller audience", async () => {
const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" });
const userFindMany = vi.fn().mockResolvedValue([
{ id: "user_admin", name: "Admin User", email: "admin@example.com" },
{ id: "user_controller", name: "Controller User", email: "controller@example.com" },
]);
const caller = createCaller(createContext({
estimate: {
findUnique: estimateFindUnique,
},
user: {
findMany: userFindMany,
},
}, { role: SystemRole.CONTROLLER }));
const result = await caller.listMentionCandidates({
entityType: "estimate",
entityId: "est_1",
query: "con",
});
expect(result).toEqual([
{ id: "user_admin", name: "Admin User", email: "admin@example.com" },
{ id: "user_controller", name: "Controller User", email: "controller@example.com" },
]);
expect(estimateFindUnique).toHaveBeenCalledTimes(1);
expect(userFindMany).toHaveBeenCalledWith({
where: {
systemRole: { in: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER] },
OR: [
{ name: { contains: "con", mode: "insensitive" } },
{ email: { contains: "con", mode: "insensitive" } },
],
},
select: {
id: true,
name: true,
email: true,
},
orderBy: [{ name: "asc" }, { email: "asc" }],
take: 20,
});
});
it("forbids plain users from reading estimate mention candidates", async () => {
const estimateFindUnique = vi.fn();
const userFindMany = vi.fn();
const caller = createCaller(createContext({
estimate: {
findUnique: estimateFindUnique,
},
user: {
findMany: userFindMany,
},
}));
await expect(caller.listMentionCandidates({
entityType: "estimate",
entityId: "est_1",
query: "con",
})).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Controller access required",
});
expect(estimateFindUnique).not.toHaveBeenCalled();
expect(userFindMany).not.toHaveBeenCalled();
});
it("allows users to list, count, and create comments on their own resource", async () => {
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "res_1" });
const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" });
const commentFindMany = vi.fn().mockResolvedValue([]);
const commentCount = vi.fn().mockResolvedValue(1);
const commentCreate = vi.fn().mockResolvedValue({
id: "comment_resource_1",
body: "Please update my profile summary.",
author: { id: "user_1", name: "Resource User", email: "user@example.com", image: null },
});
const caller = createCaller(createContext({
resource: {
findUnique: resourceFindUnique,
findFirst: resourceFindFirst,
},
comment: {
findMany: commentFindMany,
count: commentCount,
create: commentCreate,
},
notification: {
create: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
}));
const listResult = await caller.list({ entityType: "resource", entityId: "res_1" });
const countResult = await caller.count({ entityType: "resource", entityId: "res_1" });
const createResult = await caller.create({
entityType: "resource",
entityId: "res_1",
body: "Please update my profile summary.",
});
expect(listResult).toEqual([]);
expect(countResult).toBe(1);
expect(createResult.id).toBe("comment_resource_1");
expect(resourceFindUnique).toHaveBeenCalledTimes(3);
expect(resourceFindFirst).toHaveBeenCalledTimes(3);
expect(commentCreate).toHaveBeenCalledWith({
data: {
entityType: "resource",
entityId: "res_1",
authorId: "user_1",
body: "Please update my profile summary.",
mentions: [],
},
include: {
author: { select: { id: true, name: true, email: true, image: true } },
},
});
});
it("returns resource mention candidates for the own-resource audience only", async () => {
const resourceFindUnique = vi
.fn()
.mockResolvedValueOnce({ id: "res_1" })
.mockResolvedValueOnce({ userId: "user_1" });
const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" });
const userFindMany = vi.fn().mockResolvedValue([
{
id: "user_1",
name: "Resource User",
email: "user@example.com",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
{
id: "manager_1",
name: "Manager User",
email: "manager@example.com",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
{
id: "viewer_1",
name: "Viewer User",
email: "viewer@example.com",
systemRole: SystemRole.VIEWER,
permissionOverrides: null,
},
{
id: "user_2",
name: "Override Staff",
email: "override@example.com",
systemRole: SystemRole.USER,
permissionOverrides: { granted: ["viewAllResources"] },
},
]);
const caller = createCaller(createContext({
resource: {
findUnique: resourceFindUnique,
findFirst: resourceFindFirst,
},
user: {
findMany: userFindMany,
},
}));
const result = await caller.listMentionCandidates({
entityType: "resource",
entityId: "res_1",
});
expect(result).toEqual([
{ id: "user_1", name: "Resource User", email: "user@example.com" },
{ id: "manager_1", name: "Manager User", email: "manager@example.com" },
{ id: "user_2", name: "Override Staff", email: "override@example.com" },
]);
expect(resourceFindUnique).toHaveBeenCalledTimes(2);
expect(resourceFindFirst).toHaveBeenCalledTimes(1);
expect(userFindMany).toHaveBeenCalledWith({
where: undefined,
select: {
id: true,
name: true,
email: true,
systemRole: true,
permissionOverrides: true,
},
orderBy: [{ name: "asc" }, { email: "asc" }],
});
});
it("forbids users from reading or creating comments on foreign resources", async () => {
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "res_2" });
const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" });
const commentFindMany = vi.fn();
const commentCount = vi.fn();
const commentCreate = vi.fn();
const caller = createCaller(createContext({
resource: {
findUnique: resourceFindUnique,
findFirst: resourceFindFirst,
},
comment: {
findMany: commentFindMany,
count: commentCount,
create: commentCreate,
},
}));
await expect(caller.list({ entityType: "resource", entityId: "res_2" })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You can only comment on your own resource unless you have staff access",
});
await expect(caller.count({ entityType: "resource", entityId: "res_2" })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You can only comment on your own resource unless you have staff access",
});
await expect(caller.create({
entityType: "resource",
entityId: "res_2",
body: "This should not work.",
})).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You can only comment on your own resource unless you have staff access",
});
expect(commentFindMany).not.toHaveBeenCalled();
expect(commentCount).not.toHaveBeenCalled();
expect(commentCreate).not.toHaveBeenCalled();
});
it("forbids users from reading mention candidates on foreign resources", async () => {
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "res_2" });
const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" });
const userFindMany = vi.fn();
const caller = createCaller(createContext({
resource: {
findUnique: resourceFindUnique,
findFirst: resourceFindFirst,
},
user: {
findMany: userFindMany,
},
}));
await expect(caller.listMentionCandidates({
entityType: "resource",
entityId: "res_2",
query: "staff",
})).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You can only comment on your own resource unless you have staff access",
});
expect(userFindMany).not.toHaveBeenCalled();
});
it("rejects unsupported comment entity types before touching the database", async () => {
const estimateFindUnique = vi.fn();
const commentFindMany = vi.fn();
@@ -0,0 +1,226 @@
import { TRPCError } from "@trpc/server";
import {
COMMENT_ENTITY_LABELS,
COMMENT_ENTITY_TYPE_VALUES,
PermissionKey,
resolvePermissions,
SystemRole,
type PermissionOverrides,
} from "@capakraken/shared";
import type { CommentEntityType } from "@capakraken/shared";
import { z } from "zod";
import type { TRPCContext } from "../trpc.js";
import { assertCanReadResource } from "./resource-access.js";
export const CommentEntityTypeSchema = z.enum(COMMENT_ENTITY_TYPE_VALUES);
export type CommentMentionCandidate = {
id: string;
name: string | null;
email: string;
};
type CommentEntityPolicy = {
assertAccess: (ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">, entityId: string) => Promise<void>;
buildLink: (entityId: string) => string;
listMentionCandidates: (
ctx: Pick<TRPCContext, "db" | "roleDefaults">,
entityId: string,
query?: string,
) => Promise<CommentMentionCandidate[]>;
};
const CONTROLLER_COMMENT_ROLES = new Set<SystemRole>([
SystemRole.ADMIN,
SystemRole.MANAGER,
SystemRole.CONTROLLER,
]);
const RESOURCE_STAFF_PERMISSION_KEYS = new Set<PermissionKey>([
PermissionKey.VIEW_ALL_RESOURCES,
PermissionKey.MANAGE_RESOURCES,
]);
const MENTION_CANDIDATE_LIMIT = 20;
function buildMentionSearchWhere(query?: string) {
if (!query) {
return undefined;
}
return {
OR: [
{ name: { contains: query, mode: "insensitive" as const } },
{ email: { contains: query, mode: "insensitive" as const } },
],
};
}
async function listEstimateMentionCandidates(
ctx: Pick<TRPCContext, "db">,
_entityId: string,
query?: string,
): Promise<CommentMentionCandidate[]> {
return ctx.db.user.findMany({
where: {
systemRole: { in: Array.from(CONTROLLER_COMMENT_ROLES) },
...buildMentionSearchWhere(query),
},
select: {
id: true,
name: true,
email: true,
},
orderBy: [{ name: "asc" }, { email: "asc" }],
take: MENTION_CANDIDATE_LIMIT,
});
}
async function listResourceMentionCandidates(
ctx: Pick<TRPCContext, "db" | "roleDefaults">,
entityId: string,
query?: string,
): Promise<CommentMentionCandidate[]> {
const resource = await ctx.db.resource.findUnique({
where: { id: entityId },
select: { userId: true },
});
if (!resource) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
const mentionWhere = buildMentionSearchWhere(query);
const users = await ctx.db.user.findMany({
...(mentionWhere ? { where: mentionWhere } : {}),
select: {
id: true,
name: true,
email: true,
systemRole: true,
permissionOverrides: true,
},
orderBy: [{ name: "asc" }, { email: "asc" }],
});
return users
.filter((user) => {
if (user.id === resource.userId) {
return true;
}
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
);
return Array.from(RESOURCE_STAFF_PERMISSION_KEYS).some((permission) =>
permissions.has(permission),
);
})
.slice(0, MENTION_CANDIDATE_LIMIT)
.map(({ id, name, email }) => ({ id, name, email }));
}
async function assertEstimateCommentAccess(
ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">,
entityId: string,
) {
const role = ctx.dbUser?.systemRole as SystemRole | undefined;
if (!role || !CONTROLLER_COMMENT_ROLES.has(role)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Controller access required",
});
}
const estimate = await ctx.db.estimate.findUnique({
where: { id: entityId },
select: { id: true },
});
if (!estimate) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Estimate not found",
});
}
}
async function assertResourceCommentAccess(
ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">,
entityId: string,
) {
const resource = await ctx.db.resource.findUnique({
where: { id: entityId },
select: { id: true },
});
if (!resource) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
await assertCanReadResource(
ctx,
entityId,
"You can only comment on your own resource unless you have staff access",
);
}
const COMMENT_ENTITY_POLICIES: Record<CommentEntityType, CommentEntityPolicy> = {
estimate: {
assertAccess: assertEstimateCommentAccess,
buildLink: (entityId) => `/estimates/${entityId}?tab=comments`,
listMentionCandidates: listEstimateMentionCandidates,
},
resource: {
assertAccess: assertResourceCommentAccess,
buildLink: (entityId) => `/resources/${entityId}#comments`,
listMentionCandidates: listResourceMentionCandidates,
},
};
export function getSupportedCommentEntityTypes(): CommentEntityType[] {
return [...COMMENT_ENTITY_TYPE_VALUES];
}
export function getSupportedCommentEntityLabelList(): string {
return getSupportedCommentEntityTypes()
.map((entityType) => COMMENT_ENTITY_LABELS[entityType])
.join(", ");
}
export function getCommentToolEntityDescription(): string {
return `Supported entity type. One of: ${getSupportedCommentEntityLabelList()}.`;
}
export function getCommentToolScopeSentence(): string {
return `Supported comment entities: ${getSupportedCommentEntityLabelList()}. Estimate comments require controller/manager/admin visibility; resource comments follow resource detail visibility.`;
}
export function isSupportedCommentEntityType(value: string): value is CommentEntityType {
return (COMMENT_ENTITY_TYPE_VALUES as readonly string[]).includes(value);
}
export function getCommentEntityPolicy(entityType: string): CommentEntityPolicy {
if (!isSupportedCommentEntityType(entityType)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Comments are not supported for entity type: ${entityType}`,
});
}
return COMMENT_ENTITY_POLICIES[entityType];
}
export async function assertCommentEntityAccess(
ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">,
entityType: string,
entityId: string,
) {
const policy = getCommentEntityPolicy(entityType);
await policy.assertAccess(ctx, entityId);
return policy;
}
+19 -72
View File
@@ -1,9 +1,9 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure, type TRPCContext } from "../trpc.js";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createNotification } from "../lib/create-notification.js";
import { createAuditEntry } from "../lib/audit.js";
import { assertCommentEntityAccess, CommentEntityTypeSchema } from "../lib/comment-entity-registry.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -22,74 +22,6 @@ function parseMentions(body: string): string[] {
return Array.from(ids);
}
const COMMENT_ENTITY_TYPE_VALUES = ["estimate"] as const;
const CommentEntityTypeSchema = z.enum(COMMENT_ENTITY_TYPE_VALUES);
type CommentEntityType = z.infer<typeof CommentEntityTypeSchema>;
type CommentEntityPolicy = {
assertAccess: (ctx: Pick<TRPCContext, "db" | "dbUser">, entityId: string) => Promise<void>;
buildLink: (entityId: string) => string;
};
const CONTROLLER_COMMENT_ROLES = new Set<SystemRole>([
SystemRole.ADMIN,
SystemRole.MANAGER,
SystemRole.CONTROLLER,
]);
async function assertEstimateCommentAccess(ctx: Pick<TRPCContext, "db" | "dbUser">, entityId: string) {
const role = ctx.dbUser?.systemRole as SystemRole | undefined;
if (!role || !CONTROLLER_COMMENT_ROLES.has(role)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Controller access required",
});
}
const estimate = await ctx.db.estimate.findUnique({
where: { id: entityId },
select: { id: true },
});
if (!estimate) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Estimate not found",
});
}
}
const COMMENT_ENTITY_POLICIES: Record<CommentEntityType, CommentEntityPolicy> = {
estimate: {
assertAccess: assertEstimateCommentAccess,
buildLink: (entityId) => `/estimates/${entityId}?tab=comments`,
},
};
function isSupportedCommentEntityType(value: string): value is CommentEntityType {
return (COMMENT_ENTITY_TYPE_VALUES as readonly string[]).includes(value);
}
function getCommentEntityPolicy(entityType: string): CommentEntityPolicy {
if (!isSupportedCommentEntityType(entityType)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Comments are not supported for entity type: ${entityType}`,
});
}
return COMMENT_ENTITY_POLICIES[entityType];
}
async function assertCommentEntityAccess(
ctx: Pick<TRPCContext, "db" | "dbUser">,
entityType: string,
entityId: string,
) {
const policy = getCommentEntityPolicy(entityType);
await policy.assertAccess(ctx, entityId);
return policy;
}
// ─── Router ───────────────────────────────────────────────────────────────────
export const commentRouter = createTRPCRouter({
@@ -123,6 +55,21 @@ export const commentRouter = createTRPCRouter({
});
}),
/** List mention candidates that match the audience of the backing comment entity */
listMentionCandidates: protectedProcedure
.input(
z.object({
entityType: CommentEntityTypeSchema,
entityId: z.string(),
query: z.string().trim().max(100).optional(),
}),
)
.query(async ({ ctx, input }) => {
const normalizedQuery = input.query && input.query.length > 0 ? input.query : undefined;
const policy = await assertCommentEntityAccess(ctx, input.entityType, input.entityId);
return policy.listMentionCandidates(ctx, input.entityId, normalizedQuery);
}),
/** Count comments for a given entity (used for badge) */
count: protectedProcedure
.input(
@@ -253,7 +200,7 @@ export const commentRouter = createTRPCRouter({
await assertCommentEntityAccess(ctx, existing.entityType, existing.entityId);
// Only the author or an admin can resolve
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
const isAdmin = dbUser?.systemRole === "ADMIN";
if (existing.authorId !== userId && !isAdmin) {
throw new TRPCError({
code: "FORBIDDEN",
@@ -302,7 +249,7 @@ export const commentRouter = createTRPCRouter({
await assertCommentEntityAccess(ctx, existing.entityType, existing.entityId);
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
const isAdmin = dbUser?.systemRole === "ADMIN";
if (existing.authorId !== userId && !isAdmin) {
throw new TRPCError({
code: "FORBIDDEN",