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
+2 -3
View File
@@ -8,8 +8,7 @@ import { db, schema } from "./db/index.js";
import { runMigrations } from "./db/migrate.js";
import { startCleanupCron } from "./lib/cleanup.js";
import { shutdownWorkerPool } from "./lib/worker-pool.js";
import { requirePermission } from "./permissions.js";
import { authMiddleware, authRoutes, ensureDefaultAdmin } from "./plugins/auth.js";
import { authMiddleware, authRoutes, ensureDefaultAdmin, requireAdmin } from "./plugins/auth.js";
import { registerStatic } from "./plugins/static.js";
import { registerUpload } from "./plugins/upload.js";
import { apiKeyRoutes } from "./routes/api-keys.js";
@@ -137,7 +136,7 @@ app.get("/api/v1/health", async (_request, reply) => {
// Admin health check (full diagnostics)
app.get("/api/v1/admin/health", async (request, reply) => {
const admin = requirePermission("settings:read")(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
let dbOk = false;
+23 -38
View File
@@ -85,6 +85,17 @@ export function requireAuth(request: FastifyRequest, reply: FastifyReply): AuthU
return user;
}
/** Require an admin user, sending 403 if not admin. */
export function requireAdmin(request: FastifyRequest, reply: FastifyReply): AuthUser | null {
const user = requireAuth(request, reply);
if (!user) return null;
if (user.role !== "admin") {
reply.status(403).send({ error: "Admin access required", code: "FORBIDDEN" });
return null;
}
return user;
}
// ── Session helpers ────────────────────────────────────────────────
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -93,18 +104,6 @@ function createSessionToken(): string {
return randomUUID();
}
// ── Team name resolution ──────────────────────────────────────────
/** Resolve a user's team column to a display name.
* The column may hold a team UUID (normal users) or the literal "Default"
* (legacy / initial admin). */
function resolveTeamName(teamValue: string): string {
const teamById = db.select().from(schema.teams).where(eq(schema.teams.id, teamValue)).get();
if (teamById) return teamById.name;
// Legacy default — the column contains the literal name
return teamValue;
}
// ── Default admin creation ─────────────────────────────────────────
export async function ensureDefaultAdmin(): Promise<void> {
@@ -200,15 +199,17 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username });
const teamRow = db.select().from(schema.teams).where(eq(schema.teams.id, user.team)).get();
return reply.send({
token,
user: {
id: user.id,
username: user.username,
role: user.role,
teamName: resolveTeamName(user.team),
permissions: getPermissions(user.role as "admin" | "user"),
mustChangePassword: user.mustChangePassword,
permissions: getPermissions(user.role as "admin" | "user"),
teamName: teamRow?.name ?? user.team,
},
expiresAt: expiresAt.toISOString(),
});
@@ -254,9 +255,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
id: user.id,
username: user.username,
role: user.role,
teamName: resolveTeamName(user.team),
permissions: getPermissions(user.role as "admin" | "user"),
mustChangePassword: user.mustChangePassword,
permissions: getPermissions(user.role as "admin" | "user"),
},
expiresAt: session.expiresAt.toISOString(),
});
@@ -330,11 +330,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// GET /api/auth/users (admin only)
app.get("/api/auth/users", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requireAuth(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
if (!getPermissions(admin.role as "admin" | "user").includes("users:manage")) {
return reply.status(403).send({ error: "Insufficient permissions", code: "FORBIDDEN" });
}
const users = db
.select({
@@ -363,11 +360,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// POST /api/auth/register (admin only)
app.post("/api/auth/register", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requireAuth(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
if (!getPermissions(admin.role as "admin" | "user").includes("users:manage")) {
return reply.status(403).send({ error: "Insufficient permissions", code: "FORBIDDEN" });
}
const body = request.body as {
username?: string;
@@ -482,15 +476,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
});
});
// PUT /api/auth/users/:id (admin only -- update role/team)
// PUT /api/auth/users/:id (admin only update role/team)
app.put(
"/api/auth/users/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requireAuth(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
if (!getPermissions(admin.role as "admin" | "user").includes("users:manage")) {
return reply.status(403).send({ error: "Insufficient permissions", code: "FORBIDDEN" });
}
const { id } = request.params;
const body = request.body as { role?: string; team?: string } | null;
@@ -549,11 +540,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post(
"/api/auth/users/:id/reset-password",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requireAuth(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
if (!getPermissions(admin.role as "admin" | "user").includes("users:manage")) {
return reply.status(403).send({ error: "Insufficient permissions", code: "FORBIDDEN" });
}
const { id } = request.params;
const body = request.body as { newPassword?: string } | null;
@@ -606,11 +594,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
app.delete(
"/api/auth/users/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requireAuth(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
if (!getPermissions(admin.role as "admin" | "user").includes("users:manage")) {
return reply.status(403).send({ error: "Insufficient permissions", code: "FORBIDDEN" });
}
const { id } = request.params;
@@ -677,7 +662,7 @@ function isPublicRoute(url: string): boolean {
export async function authMiddleware(app: FastifyInstance): Promise<void> {
app.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => {
// When auth is disabled, attach the first admin user so requireAuth/requirePermission pass
// When auth is disabled, attach the first admin user so requireAuth/requireAdmin pass
if (!env.AUTH_ENABLED) {
const adminUser = db.select().from(schema.users).where(eq(schema.users.role, "admin")).get();
if (adminUser) {
+20 -25
View File
@@ -6,18 +6,16 @@
* DELETE /api/v1/api-keys/:id — Delete an API key
*/
import { randomBytes, randomUUID } from "node:crypto";
import type { Role } from "@stirling-image/shared";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { hasPermission, requirePermission } from "../permissions.js";
import { computeKeyPrefix, hashPassword } from "../plugins/auth.js";
import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js";
export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/api-keys — Generate a new API key
app.post("/api/v1/api-keys", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("apikeys:own")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const body = request.body as { name?: string } | null;
@@ -59,21 +57,23 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/api-keys — List user's API keys (never returns the key itself)
app.get("/api/v1/api-keys", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("apikeys:own")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const canSeeAll = hasPermission(user.role as Role, "apikeys:all");
const query = db
.select({
id: schema.apiKeys.id,
name: schema.apiKeys.name,
createdAt: schema.apiKeys.createdAt,
lastUsedAt: schema.apiKeys.lastUsedAt,
})
.from(schema.apiKeys);
const keys = canSeeAll ? query.all() : query.where(eq(schema.apiKeys.userId, user.id)).all();
const selectFields = {
id: schema.apiKeys.id,
name: schema.apiKeys.name,
createdAt: schema.apiKeys.createdAt,
lastUsedAt: schema.apiKeys.lastUsedAt,
};
const keys =
user.role === "admin"
? db.select(selectFields).from(schema.apiKeys).all()
: db
.select(selectFields)
.from(schema.apiKeys)
.where(eq(schema.apiKeys.userId, user.id))
.all();
return reply.send({
apiKeys: keys.map((k) => ({
@@ -89,21 +89,16 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
app.delete(
"/api/v1/api-keys/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requirePermission("apikeys:own")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const { id } = request.params;
// Admin can delete any key; regular users can only delete their own
const canDeleteAll = hasPermission(user.role as Role, "apikeys:all");
// Ensure the key belongs to the requesting user
const existing = db
.select()
.from(schema.apiKeys)
.where(
canDeleteAll
? eq(schema.apiKeys.id, id)
: and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.userId, user.id)),
)
.where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.userId, user.id)))
.get();
if (!existing) {
-4
View File
@@ -16,7 +16,6 @@ import { autoOrient } from "../lib/auto-orient.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js";
import { requirePermission } from "../permissions.js";
import { type JobProgress, updateJobProgress } from "./progress.js";
import { getToolConfig } from "./tool-factory.js";
@@ -29,9 +28,6 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
app.post(
"/api/v1/tools/:toolId/batch",
async (request: FastifyRequest<{ Params: { toolId: string } }>, reply: FastifyReply) => {
const user = requirePermission("tools:use")(request, reply);
if (!user) return;
const { toolId } = request.params;
// Look up the tool config from the registry
+3 -3
View File
@@ -12,7 +12,7 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { db, schema } from "../db/index.js";
import { requirePermission } from "../permissions.js";
import { requireAdmin } from "../plugins/auth.js";
const BRANDING_DIR = join(process.cwd(), "data", "branding");
const LOGO_PATH = join(BRANDING_DIR, "logo.png");
@@ -33,7 +33,7 @@ function upsertSetting(key: string, value: string): void {
export async function brandingRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/settings/logo — Upload logo (admin only)
app.post("/api/v1/settings/logo", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requirePermission("branding:manage")(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
const file = await request.file();
@@ -86,7 +86,7 @@ export async function brandingRoutes(app: FastifyInstance): Promise<void> {
// DELETE /api/v1/settings/logo — Remove logo (admin only)
app.delete("/api/v1/settings/logo", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requirePermission("branding:manage")(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
if (existsSync(LOGO_PATH)) {
-4
View File
@@ -5,7 +5,6 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
import { requirePermission } from "../permissions.js";
/**
* Guard against path traversal in URL params.
@@ -22,9 +21,6 @@ function isPathTraversal(segment: string): boolean {
export async function fileRoutes(app: FastifyInstance): Promise<void> {
// ── POST /api/v1/upload ────────────────────────────────────────
app.post("/api/v1/upload", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("tools:use")(request, reply);
if (!user) return;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const inputDir = join(workspacePath, "input");
+14 -20
View File
@@ -9,7 +9,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { Role } from "@stirling-image/shared";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
@@ -18,7 +17,7 @@ import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js";
import { createWorkspace } from "../lib/workspace.js";
import { hasPermission, requirePermission } from "../permissions.js";
import { requireAuth } from "../plugins/auth.js";
import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js";
/** Schema for a single pipeline step. */
@@ -58,9 +57,6 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
* Returns the final processed image for download.
*/
app.post("/api/v1/pipeline/execute", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("tools:use")(request, reply);
if (!user) return;
let fileBuffer: Buffer | null = null;
let filename = "image";
let pipelineRaw: string | null = null;
@@ -223,7 +219,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
* Save a named pipeline definition for later reuse.
*/
app.post("/api/v1/pipeline/save", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("pipelines:own")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const body = request.body as unknown;
@@ -278,16 +274,15 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
* List all saved pipelines.
*/
app.get("/api/v1/pipeline/list", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("pipelines:own")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
// Admins (pipelines:all) see everything; users see own + legacy (no owner)
const canSeeAll = hasPermission(user.role as Role, "pipelines:all");
const rows = db
.select()
.from(schema.pipelines)
.all()
.filter((row) => canSeeAll || !row.userId || row.userId === user.id);
// Admins see all pipelines; regular users see their own + legacy (no owner)
const allRows = db.select().from(schema.pipelines).all();
const rows =
user.role === "admin"
? allRows
: allRows.filter((row) => !row.userId || row.userId === user.id);
const pipelines = rows.map((row) => ({
id: row.id,
@@ -308,7 +303,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
app.delete(
"/api/v1/pipeline/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requirePermission("pipelines:own")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const { id } = request.params;
@@ -316,13 +311,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
const existing = db.select().from(schema.pipelines).where(eq(schema.pipelines.id, id)).get();
if (!existing) {
return reply.status(404).send({ error: "Pipeline not found", code: "NOT_FOUND" });
return reply.status(404).send({ error: "Pipeline not found" });
}
// Only the owner (or pipelines:all) can delete; legacy pipelines (no owner) can be deleted by anyone
const canDeleteAll = hasPermission(user.role as Role, "pipelines:all");
if (existing.userId && existing.userId !== user.id && !canDeleteAll) {
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
if (existing.userId && existing.userId !== user.id && user.role !== "admin") {
return reply.status(403).send({ error: "Not authorized to delete this pipeline" });
}
db.delete(schema.pipelines).where(eq(schema.pipelines.id, id)).run();
+4 -4
View File
@@ -10,14 +10,14 @@ import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js";
import { requirePermission } from "../permissions.js";
import { requireAdmin, requireAuth } from "../plugins/auth.js";
const HTML_TAG_PATTERN = /<[a-z/!][^>]*>/i;
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/settings — Get all settings as a key-value object
app.get("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("settings:read")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const rows = db.select().from(schema.settings).all();
@@ -35,7 +35,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
// PUT /api/v1/settings — Save settings (admin only)
app.put("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requirePermission("settings:write")(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
const body = request.body as Record<string, unknown> | null;
@@ -89,7 +89,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
app.get(
"/api/v1/settings/:key",
async (request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) => {
const user = requirePermission("settings:read")(request, reply);
const user = requireAuth(request, reply);
if (!user) return;
const { key } = request.params;
+6 -6
View File
@@ -11,7 +11,7 @@ import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js";
import { requirePermission } from "../permissions.js";
import { requireAdmin, requireAuth } from "../plugins/auth.js";
function validateTeamName(name: unknown): string | null {
if (typeof name !== "string") return "Team name is required";
@@ -22,9 +22,9 @@ function validateTeamName(name: unknown): string | null {
}
export async function teamsRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/teams — List all teams with member count
// GET /api/v1/teams — List all teams with member count (admin only)
app.get("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("teams:manage")(request, reply);
const user = requireAdmin(request, reply);
if (!user) return;
const teams = db
@@ -47,7 +47,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/teams — Create team (admin only)
app.post("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requirePermission("teams:manage")(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
const body = request.body as { name?: string } | null;
@@ -81,7 +81,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
app.put(
"/api/v1/teams/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requirePermission("teams:manage")(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
const { id } = request.params;
@@ -122,7 +122,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
app.delete(
"/api/v1/teams/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requirePermission("teams:manage")(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
const { id } = request.params;
-4
View File
@@ -14,7 +14,6 @@ import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
import { sanitizeSvg } from "../lib/svg-sanitize.js";
import { getWorkerPool } from "../lib/worker-pool.js";
import { createWorkspace } from "../lib/workspace.js";
import { requirePermission } from "../permissions.js";
export interface ToolRouteConfig<T> {
/** Unique tool identifier, used as the URL path segment. */
@@ -103,9 +102,6 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
app.post(
`/api/v1/tools/${config.toolId}`,
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("tools:use")(request, reply);
if (!user) return;
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
+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
+17 -28
View File
@@ -10,6 +10,21 @@ interface AuthState {
permissions: string[];
}
const ALL_PERMISSIONS = [
"tools:use",
"files:own",
"files:all",
"apikeys:own",
"apikeys:all",
"pipelines:own",
"pipelines:all",
"settings:read",
"settings:write",
"users:manage",
"teams:manage",
"branding:manage",
];
export function useAuth() {
const [state, setState] = useState<AuthState>({
loading: true,
@@ -34,20 +49,7 @@ export function useAuth() {
isAuthenticated: true,
mustChangePassword: false,
role: "admin",
permissions: [
"tools:use",
"files:own",
"files:all",
"apikeys:own",
"apikeys:all",
"pipelines:own",
"pipelines:all",
"settings:read",
"settings:write",
"users:manage",
"teams:manage",
"branding:manage",
],
permissions: ALL_PERMISSIONS,
});
return;
}
@@ -100,20 +102,7 @@ export function useAuth() {
isAuthenticated: true,
mustChangePassword: false,
role: "admin",
permissions: [
"tools:use",
"files:own",
"files:all",
"apikeys:own",
"apikeys:all",
"pipelines:own",
"pipelines:all",
"settings:read",
"settings:write",
"users:manage",
"teams:manage",
"branding:manage",
],
permissions: ALL_PERMISSIONS,
});
}
}
+8 -4
View File
@@ -29,8 +29,12 @@ import Fastify from "fastify";
import { env } from "../../apps/api/src/config.js";
import { db, schema } from "../../apps/api/src/db/index.js";
import { runMigrations } from "../../apps/api/src/db/migrate.js";
import { requirePermission } from "../../apps/api/src/permissions.js";
import { authMiddleware, authRoutes, ensureDefaultAdmin } from "../../apps/api/src/plugins/auth.js";
import {
authMiddleware,
authRoutes,
ensureDefaultAdmin,
requireAdmin,
} from "../../apps/api/src/plugins/auth.js";
import { registerUpload } from "../../apps/api/src/plugins/upload.js";
import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
@@ -85,7 +89,7 @@ export async function buildTestApp(): Promise<TestApp> {
// File upload/download routes
await fileRoutes(app);
// User file library routes
// User file library routes (persistent file management with versioning)
await userFileRoutes(app);
// Tool routes
@@ -123,7 +127,7 @@ export async function buildTestApp(): Promise<TestApp> {
// Admin health check (full diagnostics)
app.get("/api/v1/admin/health", async (request, reply) => {
const admin = requirePermission("settings:read")(request, reply);
const admin = requireAdmin(request, reply);
if (!admin) return;
let dbOk = false;
+9 -1
View File
@@ -6,7 +6,15 @@
*/
import type { Role } from "@stirling-image/shared";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
// Mock the auth plugin to avoid transitively opening a SQLite connection
// (permissions.ts -> auth.ts -> db/index.ts), which causes lock contention
// when running in parallel with other DB-using test files like cleanup.test.ts.
vi.mock("../../../apps/api/src/plugins/auth.js", () => ({
getAuthUser: () => null,
}));
import { getPermissions, hasPermission } from "../../../apps/api/src/permissions.js";
describe("permissions", () => {