feat(core-shared): auth-gate mutating feature procedures

Adds a shared requireAuthenticated tRPC middleware (reads the server-
resolved ctx.user from createTrpcContext) and applies it to every
mutating feature procedure — blog.createArticle and media.deleteMedia
were anonymous-callable (audit finding B7). Read-only queries stay
public; features compose <x>ProtectedProcedure from their error-mapped
base procedure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:55:27 +02:00
parent 49241845b5
commit d09b3e2cdd
9 changed files with 175 additions and 11 deletions

View File

@@ -1,5 +1,6 @@
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { requireAuthenticated } from "@repo/core-shared/trpc/require-authenticated";
import { ArticleNotFoundError } from "../../entities/errors/article";
import { InputParseError } from "../../entities/errors/common";
@@ -10,3 +11,10 @@ export const blogProcedure = t.procedure.use(
[ArticleNotFoundError, "NOT_FOUND"],
]),
);
/**
* Base procedure for MUTATING blog routes (audit finding B7): anonymous
* callers are rejected with UNAUTHORIZED before the controller runs.
* Read-only queries stay on `blogProcedure`.
*/
export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);

View File

@@ -30,7 +30,10 @@ describe("blogRouter", () => {
});
it("createArticle then articleBySlug returns the article", async () => {
const caller = blogRouter.createCaller({});
// Mutations are auth-gated (B7) — provide a server-resolved ctx.user.
const caller = blogRouter.createCaller({
user: { id: "u1", roles: [] },
});
const created = await caller.createArticle({
title: "Router Test Article",
@@ -45,6 +48,34 @@ describe("blogRouter", () => {
});
});
describe("blogRouter authorization (B7)", () => {
beforeEach(() => {
blogContainer.unbindAll();
blogContainer.load(BlogModule);
});
afterEach(() => {
blogContainer.unbindAll();
});
it("createArticle rejects anonymous callers with UNAUTHORIZED", async () => {
const caller = blogRouter.createCaller({});
await expect(
caller.createArticle({
title: "Nope",
content: null,
authorId: "u1",
slug: "nope",
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("read-only queries stay public", async () => {
const caller = blogRouter.createCaller({});
await expect(caller.listArticles({})).resolves.toEqual([]);
});
});
describe("blogRouter error mapping", () => {
beforeEach(() => {
blogContainer.unbindAll();

View File

@@ -11,7 +11,7 @@ import type { IGetArticlesController } from "../../interface-adapters/controller
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
import { blogProcedure } from "./procedures";
import { blogProcedure, blogProtectedProcedure } from "./procedures";
export const blogRouter = router({
articleBySlug: blogProcedure
@@ -32,7 +32,8 @@ export const blogRouter = router({
return ctrl(input);
}),
createArticle: blogProcedure
// Mutations require an authenticated caller (B7).
createArticle: blogProtectedProcedure
.input(createArticleInputSchema)
.mutation(({ input }) => {
const ctrl = blogContainer.get<ICreateArticleController>(

View File

@@ -16,6 +16,7 @@
"./payload": "./src/payload/index.ts",
"./trpc/init": "./src/trpc/init.ts",
"./trpc/context": "./src/trpc/context.ts",
"./trpc/require-authenticated": "./src/trpc/require-authenticated.ts",
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
"./instrumentation": "./src/instrumentation/index.ts",
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { t } from "@/trpc/init";
import {
requireAuthenticated,
protectedProcedure,
} from "@/trpc/require-authenticated";
const echoRouter = t.router({
publicEcho: t.procedure
.input(z.object({ value: z.string() }).strict())
.query(({ input }) => input.value),
protectedEcho: protectedProcedure
.input(z.object({ value: z.string() }).strict())
.mutation(({ input, ctx }) => ({
value: input.value,
userId: (ctx as { user: { id: string } }).user.id,
})),
composedEcho: t.procedure
.use(requireAuthenticated)
.input(z.object({}).strict())
.mutation(() => "ok"),
});
describe("requireAuthenticated middleware (B7)", () => {
it("rejects anonymous callers with UNAUTHORIZED", async () => {
const caller = echoRouter.createCaller({});
await expect(caller.protectedEcho({ value: "x" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
await expect(caller.composedEcho({})).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("passes through authenticated callers and exposes ctx.user", async () => {
const caller = echoRouter.createCaller({
user: { id: "user-1", roles: [] },
});
await expect(caller.protectedEcho({ value: "x" })).resolves.toEqual({
value: "x",
userId: "user-1",
});
});
it("leaves public procedures untouched", async () => {
const caller = echoRouter.createCaller({});
await expect(caller.publicEcho({ value: "hi" })).resolves.toBe("hi");
});
});

View File

@@ -0,0 +1,32 @@
import { TRPCError } from "@trpc/server";
import { t } from "./init";
import type { TrpcSessionUser } from "./context";
/**
* Shared authentication guard for MUTATING tRPC procedures (audit finding
* B7). Reads the server-resolved `ctx.user` (attached by `createTrpcContext`
* via the app's `resolveUser`) and rejects anonymous callers with
* UNAUTHORIZED. Read-only queries stay public; each feature opts its
* mutations in by composing this middleware into its procedure chain:
*
* ```ts
* export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);
* ```
*
* The shared `t` is context-untyped, so the middleware narrows at runtime
* (same cast pattern as the dsr/audit routers) and re-publishes `user` into
* the downstream ctx with a non-optional type.
*/
export const requireAuthenticated = t.middleware(({ ctx, next }) => {
const user = (ctx as { user?: TrpcSessionUser }).user;
if (!user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required",
});
}
return next({ ctx: { ...ctx, user } });
});
/** Convenience base procedure for apps composing ad-hoc protected routes. */
export const protectedProcedure = t.procedure.use(requireAuthenticated);

View File

@@ -1,5 +1,6 @@
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { requireAuthenticated } from "@repo/core-shared/trpc/require-authenticated";
import { MediaNotFoundError } from "../../entities/errors/media";
import { InputParseError } from "../../entities/errors/common";
@@ -10,3 +11,10 @@ export const mediaProcedure = t.procedure.use(
[MediaNotFoundError, "NOT_FOUND"],
]),
);
/**
* Base procedure for MUTATING media routes (audit finding B7): anonymous
* callers are rejected with UNAUTHORIZED before the controller runs.
* Read-only queries stay on `mediaProcedure`.
*/
export const mediaProtectedProcedure = mediaProcedure.use(requireAuthenticated);

View File

@@ -36,6 +36,29 @@ describe("mediaRouter", () => {
});
});
describe("mediaRouter authorization (B7)", () => {
beforeEach(() => {
mediaContainer.unbindAll();
mediaContainer.load(MediaModule);
});
afterEach(() => {
mediaContainer.unbindAll();
});
it("deleteMedia rejects anonymous callers with UNAUTHORIZED", async () => {
const caller = mediaRouter.createCaller({});
await expect(caller.deleteMedia({ id: "some-id" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("read-only queries stay public", async () => {
const caller = mediaRouter.createCaller({});
await expect(caller.listMedia({})).resolves.toEqual([]);
});
});
describe("mediaRouter error mapping", () => {
beforeEach(() => {
mediaContainer.unbindAll();
@@ -69,7 +92,8 @@ describe("mediaRouter error mapping", () => {
});
it("translates MediaNotFoundError → NOT_FOUND on deleteMedia with missing id", async () => {
const caller = mediaRouter.createCaller({});
// Mutations are auth-gated (B7) — provide a server-resolved ctx.user.
const caller = mediaRouter.createCaller({ user: { id: "u1", roles: [] } });
try {
await caller.deleteMedia({ id: "nonexistent-id" });
throw new Error("expected throw");

View File

@@ -11,21 +11,30 @@ import type { IGetMediaController } from "../../interface-adapters/controllers/g
import type { IListMediaController } from "../../interface-adapters/controllers/list-media.controller";
import type { IDeleteMediaController } from "../../interface-adapters/controllers/delete-media.controller";
import { mediaProcedure } from "./procedures";
import { mediaProcedure, mediaProtectedProcedure } from "./procedures";
export const mediaRouter = router({
getMedia: mediaProcedure.input(getMediaInputSchema).query(({ input }) => {
const ctrl = mediaContainer.get<IGetMediaController>(MEDIA_SYMBOLS.IGetMediaController);
const ctrl = mediaContainer.get<IGetMediaController>(
MEDIA_SYMBOLS.IGetMediaController,
);
return ctrl(input);
}),
listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => {
const ctrl = mediaContainer.get<IListMediaController>(MEDIA_SYMBOLS.IListMediaController);
return ctrl(input);
}),
deleteMedia: mediaProcedure.input(deleteMediaInputSchema).mutation(({ input }) => {
const ctrl = mediaContainer.get<IDeleteMediaController>(MEDIA_SYMBOLS.IDeleteMediaController);
const ctrl = mediaContainer.get<IListMediaController>(
MEDIA_SYMBOLS.IListMediaController,
);
return ctrl(input);
}),
// Mutations require an authenticated caller (B7).
deleteMedia: mediaProtectedProcedure
.input(deleteMediaInputSchema)
.mutation(({ input }) => {
const ctrl = mediaContainer.get<IDeleteMediaController>(
MEDIA_SYMBOLS.IDeleteMediaController,
);
return ctrl(input);
}),
});
export type MediaRouter = typeof mediaRouter;