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 { runMigrations } from "./db/migrate.js";
import { startCleanupCron } from "./lib/cleanup.js"; import { startCleanupCron } from "./lib/cleanup.js";
import { shutdownWorkerPool } from "./lib/worker-pool.js"; import { shutdownWorkerPool } from "./lib/worker-pool.js";
import { requirePermission } from "./permissions.js"; import { authMiddleware, authRoutes, ensureDefaultAdmin, requireAdmin } from "./plugins/auth.js";
import { authMiddleware, authRoutes, ensureDefaultAdmin } from "./plugins/auth.js";
import { registerStatic } from "./plugins/static.js"; import { registerStatic } from "./plugins/static.js";
import { registerUpload } from "./plugins/upload.js"; import { registerUpload } from "./plugins/upload.js";
import { apiKeyRoutes } from "./routes/api-keys.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) // Admin health check (full diagnostics)
app.get("/api/v1/admin/health", async (request, reply) => { app.get("/api/v1/admin/health", async (request, reply) => {
const admin = requirePermission("settings:read")(request, reply); const admin = requireAdmin(request, reply);
if (!admin) return; if (!admin) return;
let dbOk = false; let dbOk = false;
+23 -38
View File
@@ -85,6 +85,17 @@ export function requireAuth(request: FastifyRequest, reply: FastifyReply): AuthU
return user; 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 ──────────────────────────────────────────────── // ── Session helpers ────────────────────────────────────────────────
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -93,18 +104,6 @@ function createSessionToken(): string {
return randomUUID(); 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 ───────────────────────────────────────── // ── Default admin creation ─────────────────────────────────────────
export async function ensureDefaultAdmin(): Promise<void> { 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 }); 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({ return reply.send({
token, token,
user: { user: {
id: user.id, id: user.id,
username: user.username, username: user.username,
role: user.role, role: user.role,
teamName: resolveTeamName(user.team),
permissions: getPermissions(user.role as "admin" | "user"),
mustChangePassword: user.mustChangePassword, mustChangePassword: user.mustChangePassword,
permissions: getPermissions(user.role as "admin" | "user"),
teamName: teamRow?.name ?? user.team,
}, },
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
}); });
@@ -254,9 +255,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
id: user.id, id: user.id,
username: user.username, username: user.username,
role: user.role, role: user.role,
teamName: resolveTeamName(user.team),
permissions: getPermissions(user.role as "admin" | "user"),
mustChangePassword: user.mustChangePassword, mustChangePassword: user.mustChangePassword,
permissions: getPermissions(user.role as "admin" | "user"),
}, },
expiresAt: session.expiresAt.toISOString(), expiresAt: session.expiresAt.toISOString(),
}); });
@@ -330,11 +330,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// GET /api/auth/users (admin only) // GET /api/auth/users (admin only)
app.get("/api/auth/users", async (request: FastifyRequest, reply: FastifyReply) => { app.get("/api/auth/users", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requireAuth(request, reply); const admin = requireAdmin(request, reply);
if (!admin) return; 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 const users = db
.select({ .select({
@@ -363,11 +360,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// POST /api/auth/register (admin only) // POST /api/auth/register (admin only)
app.post("/api/auth/register", async (request: FastifyRequest, reply: FastifyReply) => { app.post("/api/auth/register", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requireAuth(request, reply); const admin = requireAdmin(request, reply);
if (!admin) return; 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 { const body = request.body as {
username?: string; 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( app.put(
"/api/auth/users/:id", "/api/auth/users/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requireAuth(request, reply); const admin = requireAdmin(request, reply);
if (!admin) return; 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 { id } = request.params;
const body = request.body as { role?: string; team?: string } | null; const body = request.body as { role?: string; team?: string } | null;
@@ -549,11 +540,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post( app.post(
"/api/auth/users/:id/reset-password", "/api/auth/users/:id/reset-password",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requireAuth(request, reply); const admin = requireAdmin(request, reply);
if (!admin) return; 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 { id } = request.params;
const body = request.body as { newPassword?: string } | null; const body = request.body as { newPassword?: string } | null;
@@ -606,11 +594,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
app.delete( app.delete(
"/api/auth/users/:id", "/api/auth/users/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requireAuth(request, reply); const admin = requireAdmin(request, reply);
if (!admin) return; 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 { id } = request.params;
@@ -677,7 +662,7 @@ function isPublicRoute(url: string): boolean {
export async function authMiddleware(app: FastifyInstance): Promise<void> { export async function authMiddleware(app: FastifyInstance): Promise<void> {
app.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => { 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) { if (!env.AUTH_ENABLED) {
const adminUser = db.select().from(schema.users).where(eq(schema.users.role, "admin")).get(); const adminUser = db.select().from(schema.users).where(eq(schema.users.role, "admin")).get();
if (adminUser) { if (adminUser) {
+16 -21
View File
@@ -6,18 +6,16 @@
* DELETE /api/v1/api-keys/:id — Delete an API key * DELETE /api/v1/api-keys/:id — Delete an API key
*/ */
import { randomBytes, randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import type { Role } from "@stirling-image/shared";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js"; import { auditLog } from "../lib/audit.js";
import { hasPermission, requirePermission } from "../permissions.js"; import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js";
import { computeKeyPrefix, hashPassword } from "../plugins/auth.js";
export async function apiKeyRoutes(app: FastifyInstance): Promise<void> { export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/api-keys — Generate a new API key // POST /api/v1/api-keys — Generate a new API key
app.post("/api/v1/api-keys", async (request: FastifyRequest, reply: FastifyReply) => { 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; if (!user) return;
const body = request.body as { name?: string } | null; 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) // 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) => { 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; if (!user) return;
const canSeeAll = hasPermission(user.role as Role, "apikeys:all"); const selectFields = {
const query = db
.select({
id: schema.apiKeys.id, id: schema.apiKeys.id,
name: schema.apiKeys.name, name: schema.apiKeys.name,
createdAt: schema.apiKeys.createdAt, createdAt: schema.apiKeys.createdAt,
lastUsedAt: schema.apiKeys.lastUsedAt, lastUsedAt: schema.apiKeys.lastUsedAt,
}) };
.from(schema.apiKeys); const keys =
user.role === "admin"
const keys = canSeeAll ? query.all() : query.where(eq(schema.apiKeys.userId, user.id)).all(); ? db.select(selectFields).from(schema.apiKeys).all()
: db
.select(selectFields)
.from(schema.apiKeys)
.where(eq(schema.apiKeys.userId, user.id))
.all();
return reply.send({ return reply.send({
apiKeys: keys.map((k) => ({ apiKeys: keys.map((k) => ({
@@ -89,21 +89,16 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
app.delete( app.delete(
"/api/v1/api-keys/:id", "/api/v1/api-keys/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requirePermission("apikeys:own")(request, reply); const user = requireAuth(request, reply);
if (!user) return; if (!user) return;
const { id } = request.params; const { id } = request.params;
// Admin can delete any key; regular users can only delete their own // Ensure the key belongs to the requesting user
const canDeleteAll = hasPermission(user.role as Role, "apikeys:all");
const existing = db const existing = db
.select() .select()
.from(schema.apiKeys) .from(schema.apiKeys)
.where( .where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.userId, user.id)))
canDeleteAll
? eq(schema.apiKeys.id, id)
: and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.userId, user.id)),
)
.get(); .get();
if (!existing) { if (!existing) {
-4
View File
@@ -16,7 +16,6 @@ import { autoOrient } from "../lib/auto-orient.js";
import { validateImageBuffer } from "../lib/file-validation.js"; 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 { requirePermission } from "../permissions.js";
import { type JobProgress, updateJobProgress } from "./progress.js"; import { type JobProgress, updateJobProgress } from "./progress.js";
import { getToolConfig } from "./tool-factory.js"; import { getToolConfig } from "./tool-factory.js";
@@ -29,9 +28,6 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
app.post( app.post(
"/api/v1/tools/:toolId/batch", "/api/v1/tools/:toolId/batch",
async (request: FastifyRequest<{ Params: { toolId: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { toolId: string } }>, reply: FastifyReply) => {
const user = requirePermission("tools:use")(request, reply);
if (!user) return;
const { toolId } = request.params; const { toolId } = request.params;
// Look up the tool config from the registry // 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 type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp"; import sharp from "sharp";
import { db, schema } from "../db/index.js"; 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 BRANDING_DIR = join(process.cwd(), "data", "branding");
const LOGO_PATH = join(BRANDING_DIR, "logo.png"); 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> { export async function brandingRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/settings/logo — Upload logo (admin only) // POST /api/v1/settings/logo — Upload logo (admin only)
app.post("/api/v1/settings/logo", async (request: FastifyRequest, reply: FastifyReply) => { 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; if (!admin) return;
const file = await request.file(); 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) // DELETE /api/v1/settings/logo — Remove logo (admin only)
app.delete("/api/v1/settings/logo", async (request: FastifyRequest, reply: FastifyReply) => { 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 (!admin) return;
if (existsSync(LOGO_PATH)) { 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 { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js"; import { sanitizeFilename } from "../lib/filename.js";
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js"; import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
import { requirePermission } from "../permissions.js";
/** /**
* Guard against path traversal in URL params. * Guard against path traversal in URL params.
@@ -22,9 +21,6 @@ function isPathTraversal(segment: string): boolean {
export async function fileRoutes(app: FastifyInstance): Promise<void> { export async function fileRoutes(app: FastifyInstance): Promise<void> {
// ── POST /api/v1/upload ──────────────────────────────────────── // ── POST /api/v1/upload ────────────────────────────────────────
app.post("/api/v1/upload", async (request: FastifyRequest, reply: FastifyReply) => { app.post("/api/v1/upload", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("tools:use")(request, reply);
if (!user) return;
const jobId = randomUUID(); const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId); const workspacePath = await createWorkspace(jobId);
const inputDir = join(workspacePath, "input"); const inputDir = join(workspacePath, "input");
+14 -20
View File
@@ -9,7 +9,6 @@
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";
@@ -18,7 +17,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 { 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. */
@@ -58,9 +57,6 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
* Returns the final processed image for download. * Returns the final processed image for download.
*/ */
app.post("/api/v1/pipeline/execute", async (request: FastifyRequest, reply: FastifyReply) => { 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 fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let pipelineRaw: string | null = null; 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. * 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 = requirePermission("pipelines:own")(request, reply); const user = requireAuth(request, reply);
if (!user) return; if (!user) return;
const body = request.body as unknown; const body = request.body as unknown;
@@ -278,16 +274,15 @@ 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 = requirePermission("pipelines:own")(request, reply); const user = requireAuth(request, reply);
if (!user) return; if (!user) return;
// Admins (pipelines:all) see everything; users see own + legacy (no owner) // Admins see all pipelines; regular users see their own + legacy (no owner)
const canSeeAll = hasPermission(user.role as Role, "pipelines:all"); const allRows = db.select().from(schema.pipelines).all();
const rows = db const rows =
.select() user.role === "admin"
.from(schema.pipelines) ? allRows
.all() : allRows.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,
@@ -308,7 +303,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 = requirePermission("pipelines:own")(request, reply); const user = requireAuth(request, reply);
if (!user) return; if (!user) return;
const { id } = request.params; 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(); 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", 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 // Only the owner (or admin) 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 && user.role !== "admin") {
if (existing.userId && existing.userId !== user.id && !canDeleteAll) { return reply.status(403).send({ error: "Not authorized to delete this pipeline" });
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();
+4 -4
View File
@@ -10,14 +10,14 @@ import { PYTHON_SIDECAR_TOOLS } 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 { db, schema } from "../db/index.js"; 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; const HTML_TAG_PATTERN = /<[a-z/!][^>]*>/i;
export async function settingsRoutes(app: FastifyInstance): Promise<void> { export async function settingsRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/settings — Get all settings as a key-value object // GET /api/v1/settings — Get all settings as a key-value object
app.get("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => { 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; if (!user) return;
const rows = db.select().from(schema.settings).all(); 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) // PUT /api/v1/settings — Save settings (admin only)
app.put("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => { 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; if (!admin) return;
const body = request.body as Record<string, unknown> | null; const body = request.body as Record<string, unknown> | null;
@@ -89,7 +89,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
app.get( app.get(
"/api/v1/settings/:key", "/api/v1/settings/:key",
async (request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) => {
const user = requirePermission("settings:read")(request, reply); const user = requireAuth(request, reply);
if (!user) return; if (!user) return;
const { key } = request.params; const { key } = request.params;
+6 -6
View File
@@ -11,7 +11,7 @@ import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js"; 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 { function validateTeamName(name: unknown): string | null {
if (typeof name !== "string") return "Team name is required"; 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> { 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) => { 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; if (!user) return;
const teams = db const teams = db
@@ -47,7 +47,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/teams — Create team (admin only) // POST /api/v1/teams — Create team (admin only)
app.post("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => { 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; if (!admin) return;
const body = request.body as { name?: string } | null; const body = request.body as { name?: string } | null;
@@ -81,7 +81,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
app.put( app.put(
"/api/v1/teams/:id", "/api/v1/teams/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requirePermission("teams:manage")(request, reply); const admin = requireAdmin(request, reply);
if (!admin) return; if (!admin) return;
const { id } = request.params; const { id } = request.params;
@@ -122,7 +122,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
app.delete( app.delete(
"/api/v1/teams/:id", "/api/v1/teams/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requirePermission("teams:manage")(request, reply); const admin = requireAdmin(request, reply);
if (!admin) return; if (!admin) return;
const { id } = request.params; 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 { sanitizeSvg } from "../lib/svg-sanitize.js";
import { getWorkerPool } from "../lib/worker-pool.js"; import { getWorkerPool } from "../lib/worker-pool.js";
import { createWorkspace } from "../lib/workspace.js"; import { createWorkspace } from "../lib/workspace.js";
import { requirePermission } from "../permissions.js";
export interface ToolRouteConfig<T> { export interface ToolRouteConfig<T> {
/** Unique tool identifier, used as the URL path segment. */ /** Unique tool identifier, used as the URL path segment. */
@@ -103,9 +102,6 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
app.post( app.post(
`/api/v1/tools/${config.toolId}`, `/api/v1/tools/${config.toolId}`,
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("tools:use")(request, reply);
if (!user) return;
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
+20 -50
View File
@@ -12,7 +12,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { extname } from "node:path"; import { extname } from "node:path";
import type { Role } from "@stirling-image/shared";
import { and, desc, eq, like, sql } from "drizzle-orm"; import { and, desc, eq, like, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp"; import sharp from "sharp";
@@ -28,7 +27,7 @@ import {
} from "../lib/file-storage.js"; } from "../lib/file-storage.js";
import { validateImageBuffer } from "../lib/file-validation.js"; import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js"; import { sanitizeFilename } from "../lib/filename.js";
import { hasPermission, requirePermission } from "../permissions.js"; import { getAuthUser, requireAuth } from "../plugins/auth.js";
// ── Helpers ──────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────
@@ -98,9 +97,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
}>, }>,
reply: FastifyReply, reply: FastifyReply,
) => { ) => {
const user = requirePermission("files:own")(request, reply); const user = requireAuth(request, reply);
if (!user) return; 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 limit = Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 200);
const offset = parseInt(request.query.offset ?? "0", 10) || 0; 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 // Build the where clauses
const conditions = [latestCondition]; 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)); 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. * Validates each (magic bytes + dimensions), stores to disk, creates DB record.
*/ */
app.post("/api/v1/files/upload", async (request: FastifyRequest, reply: FastifyReply) => { app.post("/api/v1/files/upload", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("files:own")(request, reply); const user = getAuthUser(request);
if (!user) return; const userId = user?.id ?? null;
const userId = user.id;
const created: ReturnType<typeof serializeFile>[] = []; const created: ReturnType<typeof serializeFile>[] = [];
@@ -234,19 +232,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
app.get( app.get(
"/api/v1/files/:id", "/api/v1/files/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requirePermission("files:own")(request, reply); const user = requireAuth(request, reply);
if (!user) return; if (!user) return;
const canSeeAll = hasPermission(user.role as Role, "files:all");
const { id } = request.params; const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (!file) { if (!file || (user.role !== "admin" && file.userId !== user.id)) {
return reply.status(404).send({ error: "File not found" });
}
if (!canSeeAll && file.userId !== user.id) {
return reply.status(404).send({ error: "File not found" }); return reply.status(404).send({ error: "File not found" });
} }
@@ -319,19 +312,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
app.get( app.get(
"/api/v1/files/:id/download", "/api/v1/files/:id/download",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requirePermission("files:own")(request, reply); const user = requireAuth(request, reply);
if (!user) return; if (!user) return;
const canSeeAll = hasPermission(user.role as Role, "files:all");
const { id } = request.params; const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (!file) { if (!file || (user.role !== "admin" && file.userId !== user.id)) {
return reply.status(404).send({ error: "File not found" });
}
if (!canSeeAll && file.userId !== user.id) {
return reply.status(404).send({ error: "File not found" }); return reply.status(404).send({ error: "File not found" });
} }
@@ -360,10 +348,6 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
app.get( app.get(
"/api/v1/files/:id/thumbnail", "/api/v1/files/:id/thumbnail",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { 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 { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); 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" }); 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 // Serve from disk cache if available
const cached = await getCachedThumbnail(file.storedName); const cached = await getCachedThumbnail(file.storedName);
if (cached) { 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). * For each id, deletes the entire version chain (all ancestors and descendants).
*/ */
app.delete("/api/v1/files", async (request: FastifyRequest, reply: FastifyReply) => { 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; if (!user) return;
const canDeleteAll = hasPermission(user.role as Role, "files:all");
const body = request.body as { ids?: unknown } | null; const body = request.body as { ids?: unknown } | null;
@@ -433,15 +412,17 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
interface DeleteChainRow { interface DeleteChainRow {
id: string; id: string;
stored_name: string; stored_name: string;
user_id: string | null;
} }
for (const id of ids) { 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 // Collect all files in the chain using a recursive CTE
const chainRows = sqlite const chainRows = sqlite
.prepare(` .prepare(`
WITH RECURSIVE chain(id, stored_name, user_id) AS ( WITH RECURSIVE chain(id, stored_name) AS (
SELECT f.id, f.stored_name, f.user_id SELECT f.id, f.stored_name
FROM user_files f FROM user_files f
WHERE f.id = ( WHERE f.id = (
WITH RECURSIVE ancestors(id, parent_id) AS ( 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 SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1
) )
UNION ALL UNION ALL
SELECT child.id, child.stored_name, child.user_id SELECT child.id, child.stored_name
FROM user_files child FROM user_files child
INNER JOIN chain c ON child.parent_id = c.id 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[]; .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) { for (const row of chainRows) {
await deleteStoredFile(row.stored_name); await deleteStoredFile(row.stored_name);
await deleteThumbnail(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 * toolId the tool that produced this result
*/ */
app.post("/api/v1/files/save-result", async (request: FastifyRequest, reply: FastifyReply) => { app.post("/api/v1/files/save-result", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("files:own")(request, reply); const user = getAuthUser(request);
if (!user) return; const userId = user?.id ?? null;
const canSeeAll = hasPermission(user.role as Role, "files:all");
const userId = user.id;
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "result"; 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" }); 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; const nextVersion = parent.version + 1;
// Build the tool chain: append the new toolId to the parent's chain // 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[]; 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() { export function useAuth() {
const [state, setState] = useState<AuthState>({ const [state, setState] = useState<AuthState>({
loading: true, loading: true,
@@ -34,20 +49,7 @@ export function useAuth() {
isAuthenticated: true, isAuthenticated: true,
mustChangePassword: false, mustChangePassword: false,
role: "admin", role: "admin",
permissions: [ permissions: 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",
],
}); });
return; return;
} }
@@ -100,20 +102,7 @@ export function useAuth() {
isAuthenticated: true, isAuthenticated: true,
mustChangePassword: false, mustChangePassword: false,
role: "admin", role: "admin",
permissions: [ permissions: 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",
],
}); });
} }
} }
+8 -4
View File
@@ -29,8 +29,12 @@ import Fastify from "fastify";
import { env } from "../../apps/api/src/config.js"; import { env } from "../../apps/api/src/config.js";
import { db, schema } from "../../apps/api/src/db/index.js"; import { db, schema } from "../../apps/api/src/db/index.js";
import { runMigrations } from "../../apps/api/src/db/migrate.js"; import { runMigrations } from "../../apps/api/src/db/migrate.js";
import { requirePermission } from "../../apps/api/src/permissions.js"; import {
import { authMiddleware, authRoutes, ensureDefaultAdmin } from "../../apps/api/src/plugins/auth.js"; authMiddleware,
authRoutes,
ensureDefaultAdmin,
requireAdmin,
} from "../../apps/api/src/plugins/auth.js";
import { registerUpload } from "../../apps/api/src/plugins/upload.js"; import { registerUpload } from "../../apps/api/src/plugins/upload.js";
import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js"; import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js"; import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
@@ -85,7 +89,7 @@ export async function buildTestApp(): Promise<TestApp> {
// File upload/download routes // File upload/download routes
await fileRoutes(app); await fileRoutes(app);
// User file library routes // User file library routes (persistent file management with versioning)
await userFileRoutes(app); await userFileRoutes(app);
// Tool routes // Tool routes
@@ -123,7 +127,7 @@ export async function buildTestApp(): Promise<TestApp> {
// Admin health check (full diagnostics) // Admin health check (full diagnostics)
app.get("/api/v1/admin/health", async (request, reply) => { app.get("/api/v1/admin/health", async (request, reply) => {
const admin = requirePermission("settings:read")(request, reply); const admin = requireAdmin(request, reply);
if (!admin) return; if (!admin) return;
let dbOk = false; let dbOk = false;
+9 -1
View File
@@ -6,7 +6,15 @@
*/ */
import type { Role } from "@stirling-image/shared"; 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"; import { getPermissions, hasPermission } from "../../../apps/api/src/permissions.js";
describe("permissions", () => { describe("permissions", () => {