fix: complete RBAC implementation lost during merge

Several RBAC features from feat/rbac-permissions were silently lost
during the merge into main. This restores and completes them:

- Add permissions and teamName to login/session API responses
- Export Permission and Role types from shared package
- Filter settings tabs by user permissions in frontend
- Extend useAuth hook with role, permissions, and hasPermission
- Restrict teams listing to admin only
- Add admin override for API keys, files, and pipelines listing
- Add ownership scoping to file access, download, and delete routes
- Register userFileRoutes in integration test server
- Mock auth import in unit permissions test to avoid SQLite lock
This commit is contained in:
Siddharth Kumar Sah
2026-04-10 21:25:30 +08:00
parent 6c6fb113fa
commit cc8a27239b
14 changed files with 126 additions and 194 deletions
+20 -50
View File
@@ -12,7 +12,6 @@
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { extname } from "node:path";
import type { Role } from "@stirling-image/shared";
import { and, desc, eq, like, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
@@ -28,7 +27,7 @@ import {
} from "../lib/file-storage.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { hasPermission, requirePermission } from "../permissions.js";
import { getAuthUser, requireAuth } from "../plugins/auth.js";
// ── Helpers ────────────────────────────────────────────────────────
@@ -98,9 +97,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
}>,
reply: FastifyReply,
) => {
const user = requirePermission("files:own")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const canSeeAll = hasPermission(user.role as Role, "files:all");
const limit = Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 200);
const offset = parseInt(request.query.offset ?? "0", 10) || 0;
@@ -115,7 +113,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
// Build the where clauses
const conditions = [latestCondition];
if (!canSeeAll) {
// Non-admin users only see their own files; admins see all
if (user.role !== "admin") {
conditions.push(eq(schema.userFiles.userId, user.id));
}
@@ -155,9 +154,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
* Validates each (magic bytes + dimensions), stores to disk, creates DB record.
*/
app.post("/api/v1/files/upload", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("files:own")(request, reply);
if (!user) return;
const userId = user.id;
const user = getAuthUser(request);
const userId = user?.id ?? null;
const created: ReturnType<typeof serializeFile>[] = [];
@@ -234,19 +232,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
app.get(
"/api/v1/files/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requirePermission("files:own")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const canSeeAll = hasPermission(user.role as Role, "files:all");
const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (!file) {
return reply.status(404).send({ error: "File not found" });
}
if (!canSeeAll && file.userId !== user.id) {
if (!file || (user.role !== "admin" && file.userId !== user.id)) {
return reply.status(404).send({ error: "File not found" });
}
@@ -319,19 +312,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
app.get(
"/api/v1/files/:id/download",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requirePermission("files:own")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const canSeeAll = hasPermission(user.role as Role, "files:all");
const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (!file) {
return reply.status(404).send({ error: "File not found" });
}
if (!canSeeAll && file.userId !== user.id) {
if (!file || (user.role !== "admin" && file.userId !== user.id)) {
return reply.status(404).send({ error: "File not found" });
}
@@ -360,10 +348,6 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
app.get(
"/api/v1/files/:id/thumbnail",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requirePermission("files:own")(request, reply);
if (!user) return;
const canSeeAll = hasPermission(user.role as Role, "files:all");
const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
@@ -372,10 +356,6 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
return reply.status(404).send({ error: "File not found" });
}
if (!canSeeAll && file.userId !== user.id) {
return reply.status(404).send({ error: "File not found" });
}
// Serve from disk cache if available
const cached = await getCachedThumbnail(file.storedName);
if (cached) {
@@ -413,9 +393,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
* For each id, deletes the entire version chain (all ancestors and descendants).
*/
app.delete("/api/v1/files", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("files:own")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const canDeleteAll = hasPermission(user.role as Role, "files:all");
const body = request.body as { ids?: unknown } | null;
@@ -433,15 +412,17 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
interface DeleteChainRow {
id: string;
stored_name: string;
user_id: string | null;
}
for (const id of ids) {
// Ownership check: non-admin users can only delete their own files
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (!file || (user.role !== "admin" && file.userId !== user.id)) continue;
// Collect all files in the chain using a recursive CTE
const chainRows = sqlite
.prepare(`
WITH RECURSIVE chain(id, stored_name, user_id) AS (
SELECT f.id, f.stored_name, f.user_id
WITH RECURSIVE chain(id, stored_name) AS (
SELECT f.id, f.stored_name
FROM user_files f
WHERE f.id = (
WITH RECURSIVE ancestors(id, parent_id) AS (
@@ -453,19 +434,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1
)
UNION ALL
SELECT child.id, child.stored_name, child.user_id
SELECT child.id, child.stored_name
FROM user_files child
INNER JOIN chain c ON child.parent_id = c.id
)
SELECT id, stored_name, user_id FROM chain
SELECT id, stored_name FROM chain
`)
.all(id) as DeleteChainRow[];
// Check ownership of the root file (first in chain)
if (chainRows.length > 0 && !canDeleteAll && chainRows[0].user_id !== user.id) {
continue;
}
for (const row of chainRows) {
await deleteStoredFile(row.stored_name);
await deleteThumbnail(row.stored_name);
@@ -489,10 +465,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
* toolId — the tool that produced this result
*/
app.post("/api/v1/files/save-result", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("files:own")(request, reply);
if (!user) return;
const canSeeAll = hasPermission(user.role as Role, "files:all");
const userId = user.id;
const user = getAuthUser(request);
const userId = user?.id ?? null;
let fileBuffer: Buffer | null = null;
let filename = "result";
@@ -542,10 +516,6 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
return reply.status(404).send({ error: "Parent file not found" });
}
if (!canSeeAll && parent.userId !== user.id) {
return reply.status(404).send({ error: "Parent file not found" });
}
const nextVersion = parent.version + 1;
// Build the tool chain: append the new toolId to the parent's chain