mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user