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:
@@ -1,5 +1,6 @@
|
|||||||
import { t } from "@repo/core-shared/trpc/init";
|
import { t } from "@repo/core-shared/trpc/init";
|
||||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
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 { ArticleNotFoundError } from "../../entities/errors/article";
|
||||||
import { InputParseError } from "../../entities/errors/common";
|
import { InputParseError } from "../../entities/errors/common";
|
||||||
@@ -10,3 +11,10 @@ export const blogProcedure = t.procedure.use(
|
|||||||
[ArticleNotFoundError, "NOT_FOUND"],
|
[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);
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ describe("blogRouter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("createArticle then articleBySlug returns the article", async () => {
|
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({
|
const created = await caller.createArticle({
|
||||||
title: "Router Test Article",
|
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", () => {
|
describe("blogRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
blogContainer.unbindAll();
|
blogContainer.unbindAll();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { IGetArticlesController } from "../../interface-adapters/controller
|
|||||||
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
|
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
|
||||||
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.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({
|
export const blogRouter = router({
|
||||||
articleBySlug: blogProcedure
|
articleBySlug: blogProcedure
|
||||||
@@ -32,7 +32,8 @@ export const blogRouter = router({
|
|||||||
return ctrl(input);
|
return ctrl(input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createArticle: blogProcedure
|
// Mutations require an authenticated caller (B7).
|
||||||
|
createArticle: blogProtectedProcedure
|
||||||
.input(createArticleInputSchema)
|
.input(createArticleInputSchema)
|
||||||
.mutation(({ input }) => {
|
.mutation(({ input }) => {
|
||||||
const ctrl = blogContainer.get<ICreateArticleController>(
|
const ctrl = blogContainer.get<ICreateArticleController>(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"./payload": "./src/payload/index.ts",
|
"./payload": "./src/payload/index.ts",
|
||||||
"./trpc/init": "./src/trpc/init.ts",
|
"./trpc/init": "./src/trpc/init.ts",
|
||||||
"./trpc/context": "./src/trpc/context.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",
|
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
|
||||||
"./instrumentation": "./src/instrumentation/index.ts",
|
"./instrumentation": "./src/instrumentation/index.ts",
|
||||||
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
|
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
|
||||||
|
|||||||
50
packages/core-shared/src/trpc/require-authenticated.test.ts
Normal file
50
packages/core-shared/src/trpc/require-authenticated.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
32
packages/core-shared/src/trpc/require-authenticated.ts
Normal file
32
packages/core-shared/src/trpc/require-authenticated.ts
Normal 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);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { t } from "@repo/core-shared/trpc/init";
|
import { t } from "@repo/core-shared/trpc/init";
|
||||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
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 { MediaNotFoundError } from "../../entities/errors/media";
|
||||||
import { InputParseError } from "../../entities/errors/common";
|
import { InputParseError } from "../../entities/errors/common";
|
||||||
@@ -10,3 +11,10 @@ export const mediaProcedure = t.procedure.use(
|
|||||||
[MediaNotFoundError, "NOT_FOUND"],
|
[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);
|
||||||
|
|||||||
@@ -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", () => {
|
describe("mediaRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mediaContainer.unbindAll();
|
mediaContainer.unbindAll();
|
||||||
@@ -69,7 +92,8 @@ describe("mediaRouter error mapping", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("translates MediaNotFoundError → NOT_FOUND on deleteMedia with missing id", async () => {
|
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 {
|
try {
|
||||||
await caller.deleteMedia({ id: "nonexistent-id" });
|
await caller.deleteMedia({ id: "nonexistent-id" });
|
||||||
throw new Error("expected throw");
|
throw new Error("expected throw");
|
||||||
|
|||||||
@@ -11,21 +11,30 @@ import type { IGetMediaController } from "../../interface-adapters/controllers/g
|
|||||||
import type { IListMediaController } from "../../interface-adapters/controllers/list-media.controller";
|
import type { IListMediaController } from "../../interface-adapters/controllers/list-media.controller";
|
||||||
import type { IDeleteMediaController } from "../../interface-adapters/controllers/delete-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({
|
export const mediaRouter = router({
|
||||||
getMedia: mediaProcedure.input(getMediaInputSchema).query(({ input }) => {
|
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);
|
return ctrl(input);
|
||||||
}),
|
}),
|
||||||
listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => {
|
listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => {
|
||||||
const ctrl = mediaContainer.get<IListMediaController>(MEDIA_SYMBOLS.IListMediaController);
|
const ctrl = mediaContainer.get<IListMediaController>(
|
||||||
return ctrl(input);
|
MEDIA_SYMBOLS.IListMediaController,
|
||||||
}),
|
);
|
||||||
deleteMedia: mediaProcedure.input(deleteMediaInputSchema).mutation(({ input }) => {
|
|
||||||
const ctrl = mediaContainer.get<IDeleteMediaController>(MEDIA_SYMBOLS.IDeleteMediaController);
|
|
||||||
return ctrl(input);
|
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;
|
export type MediaRouter = typeof mediaRouter;
|
||||||
|
|||||||
Reference in New Issue
Block a user