mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add permission checks and admin override to pipeline routes
Replace requireAuth with requirePermission("pipelines:own") on pipeline
save/list/delete routes. Admin users with pipelines:all can see and
delete all pipelines. Unauthorized delete returns 404 to avoid leaking
resource existence.
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { writeFile } from "node:fs/promises";
|
import { writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type { Role } from "@stirling-image/shared";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -17,8 +18,7 @@ import { validateImageBuffer } from "../lib/file-validation.js";
|
|||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
import { decodeHeic } from "../lib/heic-converter.js";
|
import { decodeHeic } from "../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../lib/workspace.js";
|
import { createWorkspace } from "../lib/workspace.js";
|
||||||
import { requirePermission } from "../permissions.js";
|
import { hasPermission, requirePermission } from "../permissions.js";
|
||||||
import { requireAuth } from "../plugins/auth.js";
|
|
||||||
import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js";
|
import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js";
|
||||||
|
|
||||||
/** Schema for a single pipeline step. */
|
/** Schema for a single pipeline step. */
|
||||||
@@ -223,7 +223,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
* Save a named pipeline definition for later reuse.
|
* Save a named pipeline definition for later reuse.
|
||||||
*/
|
*/
|
||||||
app.post("/api/v1/pipeline/save", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post("/api/v1/pipeline/save", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const user = requireAuth(request, reply);
|
const user = requirePermission("pipelines:own")(request, reply);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
const body = request.body as unknown;
|
const body = request.body as unknown;
|
||||||
@@ -278,15 +278,16 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
* List all saved pipelines.
|
* List all saved pipelines.
|
||||||
*/
|
*/
|
||||||
app.get("/api/v1/pipeline/list", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.get("/api/v1/pipeline/list", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const user = requireAuth(request, reply);
|
const user = requirePermission("pipelines:own")(request, reply);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
// Users see their own pipelines + legacy pipelines (no owner)
|
// Admins (pipelines:all) see everything; users see own + legacy (no owner)
|
||||||
|
const canSeeAll = hasPermission(user.role as Role, "pipelines:all");
|
||||||
const rows = db
|
const rows = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.pipelines)
|
.from(schema.pipelines)
|
||||||
.all()
|
.all()
|
||||||
.filter((row) => !row.userId || row.userId === user.id);
|
.filter((row) => canSeeAll || !row.userId || row.userId === user.id);
|
||||||
|
|
||||||
const pipelines = rows.map((row) => ({
|
const pipelines = rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -307,7 +308,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
app.delete(
|
app.delete(
|
||||||
"/api/v1/pipeline/:id",
|
"/api/v1/pipeline/:id",
|
||||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||||
const user = requireAuth(request, reply);
|
const user = requirePermission("pipelines:own")(request, reply);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
@@ -315,12 +316,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
const existing = db.select().from(schema.pipelines).where(eq(schema.pipelines.id, id)).get();
|
const existing = db.select().from(schema.pipelines).where(eq(schema.pipelines.id, id)).get();
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return reply.status(404).send({ error: "Pipeline not found" });
|
return reply.status(404).send({ error: "Pipeline not found", code: "NOT_FOUND" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the owner (or admin) can delete; legacy pipelines (no owner) can be deleted by anyone
|
// Only the owner (or pipelines:all) can delete; legacy pipelines (no owner) can be deleted by anyone
|
||||||
if (existing.userId && existing.userId !== user.id && user.role !== "admin") {
|
const canDeleteAll = hasPermission(user.role as Role, "pipelines:all");
|
||||||
return reply.status(403).send({ error: "Not authorized to delete this pipeline" });
|
if (existing.userId && existing.userId !== user.id && !canDeleteAll) {
|
||||||
|
return reply.status(404).send({ error: "Pipeline not found", code: "NOT_FOUND" });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.delete(schema.pipelines).where(eq(schema.pipelines.id, id)).run();
|
db.delete(schema.pipelines).where(eq(schema.pipelines.id, id)).run();
|
||||||
|
|||||||
@@ -514,3 +514,57 @@ describe("file ownership scoping", () => {
|
|||||||
expect(body.deleted).toBe(0);
|
expect(body.deleted).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("pipeline ownership scoping", () => {
|
||||||
|
let userToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await testApp.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/register",
|
||||||
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
|
payload: { username: "pipeuser", password: "TestPass1", role: "user" },
|
||||||
|
});
|
||||||
|
db.update(schema.users)
|
||||||
|
.set({ mustChangePassword: false })
|
||||||
|
.where(eq(schema.users.username, "pipeuser"))
|
||||||
|
.run();
|
||||||
|
const loginRes = await testApp.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { username: "pipeuser", password: "TestPass1" },
|
||||||
|
});
|
||||||
|
userToken = JSON.parse(loginRes.body).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("user can save and list their own pipelines", async () => {
|
||||||
|
const saveRes = await testApp.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/pipeline/save",
|
||||||
|
headers: { authorization: `Bearer ${userToken}` },
|
||||||
|
payload: {
|
||||||
|
name: "My Pipeline",
|
||||||
|
steps: [{ toolId: "rotate", settings: { angle: 90 } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(saveRes.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const listRes = await testApp.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/pipeline/list",
|
||||||
|
headers: { authorization: `Bearer ${userToken}` },
|
||||||
|
});
|
||||||
|
const body = JSON.parse(listRes.body);
|
||||||
|
expect(body.pipelines.some((p: any) => p.name === "My Pipeline")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin can see all users' pipelines", async () => {
|
||||||
|
const listRes = await testApp.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/pipeline/list",
|
||||||
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
const body = JSON.parse(listRes.body);
|
||||||
|
expect(body.pipelines.some((p: any) => p.name === "My Pipeline")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user