From b75aa3baf7225a9d08ad7aeb87b9bc61b885ef66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Tue, 3 Nov 2026 14:56:55 +0530 Subject: [PATCH 1/4] feat(security): add role-based access control --- README.md | 5 ++ docs/rbac.md | 36 ++++++++++++++ drizzle/0004_add_user_roles.sql | 12 +++++ drizzle/meta/_journal.json | 3 +- src/__tests__/roles.test.ts | 36 ++++++++++++++ src/auth/middleware.ts | 42 ++++------------ src/auth/roles.ts | 37 +++++++++++++++ src/controllers/appSettings.controller.ts | 4 ++ src/controllers/system.controller.ts | 52 +++----------------- src/controllers/user.controller.ts | 35 ++++++++++++++ src/db/schema.ts | 1 + src/http/constants.ts | 9 +--- src/middleware/apiAuthGuard.ts | 58 ++++++++--------------- src/routes/createApp.ts | 47 ++++++------------ 14 files changed, 218 insertions(+), 159 deletions(-) create mode 100644 docs/rbac.md create mode 100644 drizzle/0004_add_user_roles.sql create mode 100644 src/__tests__/roles.test.ts create mode 100644 src/auth/roles.ts create mode 100644 src/controllers/user.controller.ts diff --git a/README.md b/README.md index 72efdc9..06f874d 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,8 @@ Domain detail pages support an on-demand public check. The refresh endpoint runs - Run `bun run db:migrate` before starting an upgraded deployment. The first version checks the domain’s DNS records, TLS certificate and existing registration/host enrichment. It does not run a separate HTTP status probe yet; scheduled portfolio-wide checks and webhook notifications are tracked separately. + + +## Role-based access control + +ProxyDeck supports **admin**, **operator**, and **viewer** roles enforced by the API. The first account in an existing installation is promoted to admin by the role migration; new accounts default to viewer. Administrators can inspect and change roles through the user-management API. See [RBAC documentation](docs/rbac.md) for the capability matrix, endpoints, migration behavior, and security model. diff --git a/docs/rbac.md b/docs/rbac.md new file mode 100644 index 0000000..3b52d9b --- /dev/null +++ b/docs/rbac.md @@ -0,0 +1,36 @@ +# Role-based access control + +Roles are instance-wide, persisted on the Better Auth user record, and enforced server-side by the API guard. Client-side navigation visibility is never the authorization boundary. + +## Built-in roles + +| Capability | Admin | Operator | Viewer | +|---|:---:|:---:|:---:| +| Read domains and server inventory | Yes | Yes | Yes | +| Create/update/delete/import/export domains | Yes | Yes | No | +| Create/update/delete/import/export servers | Yes | Yes | No | +| Read proxy configuration, status, certificates and logs | Yes | Yes | Yes | +| Validate/apply/rollback proxy configuration | Yes | Yes | No | +| Read application settings | Yes | No | Yes | +| Change SMTP/authentication settings | Yes | No | No | +| List users and change roles | Yes | No | No | + +Admin has every permission. Operator manages operational resources but not global settings or users. Viewer can inspect operational data but cannot mutate it. + +## Migration / first admin + +Run `bun run db:migrate` during deployment. Migration `0004_add_user_roles` promotes the existing earliest-created account to `admin`; other existing users and all new users default to `viewer`. Never trust role data supplied at signup. + +## User management + +Only admins can access: +- `GET /api/users` — list users, effective roles and permissions. +- `PATCH /api/users/:id/role` with `{"role":"operator"}` — set `admin`, `operator` or `viewer`. + +The last admin cannot demote themselves. Roles are read from the database on each authenticated API request so changes apply immediately. + +## Authorization behavior + +API methods GET/HEAD/OPTIONS require read permissions; POST/PUT/PATCH/DELETE require write permissions. Unknown API paths fail closed with 403 until a policy is explicitly added. The public exception list is limited to health, signup capability, authentication capability and auth routes. Proxy status is no longer public. + +Domain and server data continues to use existing owner-scoped service/repository queries. Roles grant feature access, not access to another user's private portfolio records. diff --git a/drizzle/0004_add_user_roles.sql b/drizzle/0004_add_user_roles.sql new file mode 100644 index 0000000..f74bb81 --- /dev/null +++ b/drizzle/0004_add_user_roles.sql @@ -0,0 +1,12 @@ +ALTER TABLE "user" + ADD COLUMN "role" text NOT NULL DEFAULT 'viewer'; + +-- Preserve the first existing account as the instance administrator. +WITH first_user AS ( + SELECT "id" FROM "user" ORDER BY "createdAt" ASC, "id" ASC LIMIT 1 +) +UPDATE "user" SET "role" = 'admin' +WHERE "id" IN (SELECT "id" FROM first_user); + +ALTER TABLE "user" + ADD CONSTRAINT "user_role_check" CHECK ("role" IN ('admin','operator','viewer')); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ae42a0c..c8380b9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,6 +5,7 @@ { "idx": 0, "version": "7", "when": 1774095492703, "tag": "0000_spooky_odin", "breakpoints": true }, { "idx": 1, "version": "7", "when": 1774437795200, "tag": "0001_strange_jasper_sitwell", "breakpoints": true }, { "idx": 2, "version": "7", "when": 1775055276544, "tag": "0002_outstanding_bishop", "breakpoints": true }, - { "idx": 3, "version": "7", "when": 1793550000000, "tag": "0003_domain_check_runs", "breakpoints": true } + { "idx": 3, "version": "7", "when": 1793550000000, "tag": "0003_domain_check_runs", "breakpoints": true }, + { "idx": 4, "version": "7", "when": 1793707200000, "tag": "0004_add_user_roles", "breakpoints": true } ] } diff --git a/src/__tests__/roles.test.ts b/src/__tests__/roles.test.ts new file mode 100644 index 0000000..687cfbc --- /dev/null +++ b/src/__tests__/roles.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { hasPermission, permissionForApiRequest } from "../auth/roles"; + +describe("RBAC", () => { + test("admin can do everything", () => { + for (const p of ["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"] as const) + expect(hasPermission("admin",p)).toBe(true); + }); + test("operator manages domain/server/proxy but not global settings or users", () => { + expect(hasPermission("operator","domains:write")).toBe(true); + expect(hasPermission("operator","servers:write")).toBe(true); + expect(hasPermission("operator","proxy:write")).toBe(true); + expect(hasPermission("operator","settings:write")).toBe(false); + expect(hasPermission("operator","users:write")).toBe(false); + }); + test("viewer is read-only", () => { + expect(hasPermission("viewer","domains:read")).toBe(true); + expect(hasPermission("viewer","domains:write")).toBe(false); + expect(hasPermission("viewer","proxy:write")).toBe(false); + }); + test("resource API methods map to read/write permissions", () => { + expect(permissionForApiRequest("GET","/api/domains")).toBe("domains:read"); + expect(permissionForApiRequest("POST","/api/domains")).toBe("domains:write"); + expect(permissionForApiRequest("DELETE","/api/servers/id")).toBe("servers:write"); + expect(permissionForApiRequest("POST","/api/config/apply")).toBe("proxy:write"); + expect(permissionForApiRequest("PUT","/api/settings/app")).toBe("settings:write"); + expect(permissionForApiRequest("GET","/api/logs")).toBe("logs:read"); + }); + test("unknown API routes are denied by default", () => { + expect(permissionForApiRequest("GET","/api/not-classified")).toBe("admin:only"); + }); + test("public health/auth capability paths do not require a role", () => { + expect(permissionForApiRequest("GET","/api/health")).toBeNull(); + expect(permissionForApiRequest("POST","/api/auth/sign-out")).toBeNull(); + }); +}); diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index ab64d5c..63e7bd3 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -5,58 +5,32 @@ import { session, user } from "../db/schema"; const PUBLIC_PATHS = ["/login", "/signup", "/api/auth", "/api/allow-signup"]; const STATIC_PREFIX = "/assets"; const SESSION_COOKIE_NAME = "better-auth.session_token"; - function isPublicPath(pathname: string): boolean { if (pathname.startsWith(STATIC_PREFIX)) return true; return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/")); } - function getCookie(headers: Headers, name: string): string | null { const cookie = headers.get("cookie") ?? headers.get("Cookie") ?? ""; const match = cookie.match(new RegExp(`${name}=([^;]+)`)); return match ? decodeURIComponent(match[1].trim()) : null; } - -/** Resolve session from DB using cookie token. */ +/** Resolve session and live role from DB on every request so role changes take effect immediately. */ async function getSessionFromDb(cookieValue: string): Promise<{ user: unknown } | null> { const tokenPart = cookieValue.split(".")[0]; - const rows = await db - .select({ - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified, - username: user.username, - }) - .from(session) - .innerJoin(user, eq(session.userId, user.id)) - .where( - and( - or(eq(session.token, cookieValue), eq(session.token, tokenPart)), - gt(session.expiresAt, sql`now()`) - ) - ) + const rows = await db.select({ + id: user.id, name: user.name, email: user.email, emailVerified: user.emailVerified, + username: user.username, role: user.role, + }).from(session).innerJoin(user, eq(session.userId, user.id)) + .where(and(or(eq(session.token,cookieValue),eq(session.token,tokenPart)),gt(session.expiresAt,sql`now()`))) .limit(1); const row = rows[0]; if (!row) return null; - return { - user: { - id: row.id, - name: row.name, - email: row.email, - emailVerified: row.emailVerified, - username: row.username, - }, - }; + return { user: { id:row.id,name:row.name,email:row.email,emailVerified:row.emailVerified,username:row.username,role:row.role } }; } - export async function getSession(requestOrHeaders: Request | Headers) { const headers = requestOrHeaders instanceof Request ? requestOrHeaders.headers : requestOrHeaders; const cookieValue = getCookie(headers, SESSION_COOKIE_NAME); if (!cookieValue) return null; return getSessionFromDb(cookieValue); } - -export function isProtectedPath(pathname: string): boolean { - return !isPublicPath(pathname); -} +export function isProtectedPath(pathname: string): boolean { return !isPublicPath(pathname); } diff --git a/src/auth/roles.ts b/src/auth/roles.ts new file mode 100644 index 0000000..fe97796 --- /dev/null +++ b/src/auth/roles.ts @@ -0,0 +1,37 @@ +export const USER_ROLES = ["admin", "operator", "viewer"] as const; +export type UserRole = (typeof USER_ROLES)[number]; +export type Permission = + | "domains:read" | "domains:write" | "servers:read" | "servers:write" + | "proxy:read" | "proxy:write" | "logs:read" + | "settings:read" | "settings:write" | "users:read" | "users:write"; + +const ROLE_PERMISSIONS: Record = { + admin: ["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"], + operator: ["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read"], + viewer: ["domains:read","servers:read","proxy:read","logs:read","settings:read"], +}; +export function isUserRole(v: unknown): v is UserRole { + return typeof v === "string" && (USER_ROLES as readonly string[]).includes(v); +} +export function hasPermission(role: UserRole, permission: Permission): boolean { + return ROLE_PERMISSIONS[role].includes(permission); +} +export function permissionsForRole(role: UserRole): readonly Permission[] { + return ROLE_PERMISSIONS[role]; +} +/** Fail closed: any new API route must be classified before it is accessible. */ +export function permissionForApiRequest(method: string, pathname: string): Permission | null { + const verb = method.toUpperCase(); + const write = ["POST","PUT","PATCH","DELETE"].includes(verb); + if (pathname === "/api/health" || pathname === "/api/allow-signup" || + pathname === "/api/auth-capabilities" || pathname.startsWith("/api/auth/") || + pathname === "/api/docs" || pathname.startsWith("/api/docs/")) return null; + if (pathname.startsWith("/api/users")) return "users:read"; + if (pathname === "/api/settings/app") return write ? "settings:write" : "settings:read"; + if (pathname.startsWith("/api/domains")) return write ? "domains:write" : "domains:read"; + if (pathname.startsWith("/api/servers")) return write ? "servers:write" : "servers:read"; + if (pathname.startsWith("/api/config")) return write ? "proxy:write" : "proxy:read"; + if (pathname === "/api/proxy/status" || pathname === "/api/certificates") return "proxy:read"; + if (pathname === "/api/logs") return "logs:read"; + return "admin:only" as Permission; +} diff --git a/src/controllers/appSettings.controller.ts b/src/controllers/appSettings.controller.ts index 48d5109..6fd97fb 100644 --- a/src/controllers/appSettings.controller.ts +++ b/src/controllers/appSettings.controller.ts @@ -1,6 +1,7 @@ import { Elysia } from "elysia"; import { jsonResponse, readJsonBody, toResponse } from "../http/json"; import { getUserIdFromRequest } from "../http/sessionUser"; +import { getSession } from "../auth/middleware"; import { type AppSettingsUpdateBody, getAppSettingsPublic, @@ -55,6 +56,9 @@ export const appSettingsRoutes = new Elysia().group("/api/settings", (app) => async ({ request }) => { const userId = await getUserIdFromRequest(request); if (!userId) return jsonResponse({ error: "Unauthorized" }, 401); + const session = await getSession(request); + const role = (session?.user as { role?: string } | undefined)?.role; + if (role !== "admin") return jsonResponse({ error: "Only administrators can update application settings" }, 403); const raw = await readJsonBody(request); if (raw === null) return jsonResponse({ error: "Expected application/json body" }, 400); try { diff --git a/src/controllers/system.controller.ts b/src/controllers/system.controller.ts index d702cfb..a486a20 100644 --- a/src/controllers/system.controller.ts +++ b/src/controllers/system.controller.ts @@ -5,49 +5,11 @@ import { allowSignup } from "../auth/allow-signup"; import { getEmailCapabilities } from "../services/appSettings.service"; import { getProxyStatusSafe } from "../services/proxyStatus.service"; import { readProxyLogTail } from "../services/logs.service"; - -const systemOpenapi = { - tags: ["system"], -}; - +const systemOpenapi = { tags: ["system"] }; export const systemRoutes = new Elysia() - .get("/api/health", () => jsonResponse({ ok: true, version: appVersion }), { - detail: { - ...systemOpenapi, - summary: "Health check", - }, - }) - .get("/api/allow-signup", async () => jsonResponse({ allowSignup: await allowSignup() }), { - detail: { - ...systemOpenapi, - summary: "Signup allowed?", - description: "Whether new account registration is permitted.", - }, - }) - .get("/api/auth-capabilities", async () => jsonResponse({ email: await getEmailCapabilities() }), { - detail: { - ...systemOpenapi, - summary: "Auth-related feature flags", - description: - "Public snapshot of which email-backed auth features are active (no secrets). Use for UI (e.g. show forgot-password link).", - }, - }) - .get("/api/proxy/status", async () => jsonResponse(await getProxyStatusSafe()), { - detail: { - ...systemOpenapi, - summary: "Proxy detection status", - }, - }) - .get("/api/certificates", () => jsonResponse([]), { - detail: { - ...systemOpenapi, - summary: "Certificates (stub)", - description: "Placeholder; returns an empty list until implemented.", - }, - }) - .get("/api/logs", () => jsonResponse({ lines: readProxyLogTail() }), { - detail: { - ...systemOpenapi, - summary: "Recent proxy log lines", - }, - }); + .get("/api/health", () => jsonResponse({ ok:true, version:appVersion }), { detail:{...systemOpenapi,summary:"Health check"} }) + .get("/api/allow-signup", async () => jsonResponse({allowSignup:await allowSignup()}), { detail:{...systemOpenapi,summary:"Signup allowed?"} }) + .get("/api/auth-capabilities", async () => jsonResponse({email:await getEmailCapabilities()}), { detail:{...systemOpenapi,summary:"Auth-related feature flags"} }) + .get("/api/proxy/status", async () => jsonResponse(await getProxyStatusSafe()), { detail:{...systemOpenapi,summary:"Proxy detection status"} }) + .get("/api/certificates", () => jsonResponse([]), { detail:{...systemOpenapi,summary:"Certificates (stub)"} }) + .get("/api/logs", () => jsonResponse({lines:readProxyLogTail()}), { detail:{...systemOpenapi,summary:"Recent proxy log lines"} }); diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts new file mode 100644 index 0000000..81ecccc --- /dev/null +++ b/src/controllers/user.controller.ts @@ -0,0 +1,35 @@ +import { Elysia } from "elysia"; +import { eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { user } from "../db/schema"; +import { getUserIdFromRequest } from "../http/sessionUser"; +import { isUserRole, permissionsForRole, USER_ROLES, type UserRole } from "../auth/roles"; +import { jsonResponse } from "../http/json"; + +export const userRoutes = new Elysia().group("/api/users", (app) => + app.get("/", async ({ request }) => { + const actorId = await getUserIdFromRequest(request); + if (!actorId) return jsonResponse({ error: "Unauthorized" }, 401); + const rows = await db.select({ + id:user.id,name:user.name,email:user.email,username:user.username,role:user.role,createdAt:user.createdAt, + }).from(user).orderBy(user.createdAt); + return jsonResponse({ roles:USER_ROLES, users:rows.map((entry) => ({ + ...entry, permissions:permissionsForRole(entry.role as UserRole), + })) }); + }).patch("/:id/role", async ({ request, params }) => { + const actorId = await getUserIdFromRequest(request); + if (!actorId) return jsonResponse({ error:"Unauthorized" },401); + let body: unknown; + try { body = await request.json(); } catch { return jsonResponse({ error:"Invalid JSON body" },400); } + const role = body && typeof body === "object" ? (body as { role?: unknown }).role : null; + if (!isUserRole(role)) return jsonResponse({ error:"role must be admin, operator, or viewer" },400); + if (params.id === actorId && role !== "admin") { + const admins = await db.select({ id:user.id }).from(user).where(eq(user.role,"admin")); + if (admins.length <= 1) return jsonResponse({ error:"The last administrator cannot demote themselves" },409); + } + const result = await db.update(user).set({role,updatedAt:new Date()}) + .where(eq(user.id,params.id)).returning({id:user.id,role:user.role}); + if (!result.length) return jsonResponse({error:"User not found"},404); + return jsonResponse({user:result[0],permissions:permissionsForRole(role)}); + }) +); diff --git a/src/db/schema.ts b/src/db/schema.ts index 5a6e474..758eeb0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -17,6 +17,7 @@ export const user = pgTable("user", { email: text("email").notNull().unique(), emailVerified: boolean("emailVerified").notNull().default(false), image: text("image"), + role: text("role").$type<"admin" | "operator" | "viewer">().notNull().default("viewer"), username: text("username").unique(), displayUsername: text("displayUsername"), createdAt: timestamp("createdAt", { mode: "date", precision: 3 }) diff --git a/src/http/constants.ts b/src/http/constants.ts index 0f8d24b..94ef7e4 100644 --- a/src/http/constants.ts +++ b/src/http/constants.ts @@ -1,9 +1,4 @@ -/** API paths that skip session checks (see `middleware/apiAuthGuard`). */ +/** Public API endpoints; keep the unauthenticated surface deliberately small. */ export const PUBLIC_API_PATHS = [ - "/api/auth", - "/api/docs", - "/api/health", - "/api/allow-signup", - "/api/auth-capabilities", - "/api/proxy/status", + "/api/auth", "/api/docs", "/api/health", "/api/allow-signup", "/api/auth-capabilities", ] as const; diff --git a/src/middleware/apiAuthGuard.ts b/src/middleware/apiAuthGuard.ts index 28aed52..22844ef 100644 --- a/src/middleware/apiAuthGuard.ts +++ b/src/middleware/apiAuthGuard.ts @@ -1,51 +1,31 @@ import { getSession } from "../auth/middleware"; -import { PUBLIC_API_PATHS } from "../http/constants"; +import { hasPermission, permissionForApiRequest, type Permission, type UserRole } from "../auth/roles"; import { getAppSettingsRowCached, rowRequiresVerifiedSignIn } from "../services/appSettings.service"; -/** API prefixes reachable before email verification (when that policy is on). */ -const API_OK_BEFORE_EMAIL_VERIFIED = [ - "/api/auth", - "/api/health", - "/api/allow-signup", - "/api/auth-capabilities", - "/api/docs", - "/api/settings/app", -] as const; - -function isOkBeforeEmailVerified(pathname: string): boolean { - return API_OK_BEFORE_EMAIL_VERIFIED.some((p) => pathname === p || pathname.startsWith(`${p}/`)); +const API_OK_BEFORE_EMAIL_VERIFIED = ["/api/auth","/api/health","/api/allow-signup","/api/auth-capabilities","/api/docs"] as const; +function jsonError(status: number, error: string, code: string): Response { + return new Response(JSON.stringify({ error, code }), { status, headers: { "Content-Type": "application/json" } }); } - export async function apiAuthGuard({ request }: { request: Request }): Promise { const pathname = new URL(request.url).pathname; if (!pathname.startsWith("/api")) return; - const isPublic = PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); - if (isPublic) return; const session = await getSession(request); - if (!session) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } + if (!session) return jsonError(401, "Unauthorized", "UNAUTHORIZED"); + const currentUser = session.user as { emailVerified?: boolean; role?: unknown }; const settings = await getAppSettingsRowCached(); - if (rowRequiresVerifiedSignIn(settings)) { - const u = session.user; - if (u && typeof u === "object" && "emailVerified" in u && u.emailVerified === false) { - if (!isOkBeforeEmailVerified(pathname)) { - return new Response( - JSON.stringify({ - error: "Forbidden", - code: "EMAIL_NOT_VERIFIED", - message: "Verify your email to use this API.", - }), - { - status: 403, - headers: { "Content-Type": "application/json" }, - }, - ); - } - } + if (rowRequiresVerifiedSignIn(settings) && currentUser.emailVerified === false && + !API_OK_BEFORE_EMAIL_VERIFIED.some((p) => pathname === p || pathname.startsWith(p + "/"))) { + return jsonError(403, "Verify your email to use this API.", "EMAIL_NOT_VERIFIED"); + } + const permission = permissionForApiRequest(request.method, pathname); + if (permission === null) return; + if (permission === "admin:only") return jsonError(403, "No authorization policy is defined for this API route.", "ROLE_POLICY_MISSING"); + const role = currentUser.role; + if (role !== "admin" && role !== "operator" && role !== "viewer") { + return jsonError(403, "User role is missing or invalid.", "INVALID_ROLE"); + } + if (!hasPermission(role as UserRole, permission as Permission)) { + return jsonError(403, "Your role does not allow this action.", "FORBIDDEN"); } return; } diff --git a/src/routes/createApp.ts b/src/routes/createApp.ts index 2f5bb26..75bc096 100644 --- a/src/routes/createApp.ts +++ b/src/routes/createApp.ts @@ -6,43 +6,24 @@ import { domainRoutes } from "../controllers/domain.controller"; import { configApiRoutes } from "../controllers/configApi.controller"; import { infrastructureServerRoutes } from "../controllers/infrastructureServer.controller"; import { appSettingsRoutes } from "../controllers/appSettings.controller"; +import { userRoutes } from "../controllers/user.controller"; import { systemRoutes } from "../controllers/system.controller"; import { openapiDocsPlugin } from "../plugins/openapiDocs"; import { createSpaStaticHandler } from "../static/spaStatic"; - -/** - * Composes HTTP middleware, API route plugins, auth catch-all, and SPA static fallback. - */ export function createApp(frontendDistDir: string) { const serveStatic = createSpaStaticHandler(frontendDistDir); - const forwardAuth = (request: Request) => getAuth().handler(request); - - return ( - new Elysia() - .use(requestLogPlugin()) - .onBeforeHandle(apiAuthGuard) - .use(domainRoutes) - .use(infrastructureServerRoutes) - .use(configApiRoutes) - .use(appSettingsRoutes) - .use(systemRoutes) - .use(openapiDocsPlugin()) - // Elysia matches `.get("/*")` before `.all("/api/auth/*")` for GET requests, which - // returned an empty body for get-session and broke the SPA session check. Register - // auth paths with explicit methods so they win over the SPA catch-all. - // `parse: "none"` keeps Elysia from consuming `request.json()` before Better Auth runs - // (avoids ERR_BODY_ALREADY_USED on sign-in/sign-up POSTs). - .get("/api/auth/*", ({ request }) => forwardAuth(request), { parse: "none" }) - .post("/api/auth/*", ({ request }) => forwardAuth(request), { parse: "none" }) - .options("/api/auth/*", ({ request }) => forwardAuth(request), { parse: "none" }) - .get("/*", ({ request }) => { - const pathname = new URL(request.url).pathname; - if (pathname.startsWith("/api")) return; - const res = serveStatic(pathname); - if (res) return res; - const indexRes = serveStatic("/index.html"); - return indexRes ?? new Response("Not Found", { status: 404 }); - }) - ); + return new Elysia().use(requestLogPlugin()).onBeforeHandle(apiAuthGuard) + .use(domainRoutes).use(infrastructureServerRoutes).use(configApiRoutes) + .use(appSettingsRoutes).use(userRoutes).use(systemRoutes).use(openapiDocsPlugin()) + .get("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) + .post("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) + .options("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) + .get("/*",({request})=>{ + const pathname=new URL(request.url).pathname; + if(pathname.startsWith("/api")) return; + const res=serveStatic(pathname); if(res) return res; + const indexRes=serveStatic("/index.html"); + return indexRes ?? new Response("Not Found",{status:404}); + }); } From d07b5b19b83c96606f6d3503e6637351eb34a8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Tue, 3 Nov 2026 14:59:09 +0530 Subject: [PATCH 2/4] feat(security): add role-based access control --- docs/rbac.md | 28 ++++++------- drizzle/0004_add_user_roles.sql | 17 +++----- src/__tests__/roles.test.ts | 44 ++++--------------- src/auth/roles.ts | 51 +++++++++-------------- src/controllers/appSettings.controller.ts | 3 +- src/controllers/system.controller.ts | 16 +++---- src/http/constants.ts | 4 +- src/middleware/apiAuthGuard.ts | 8 +--- src/routes/createApp.ts | 25 ++++------- 9 files changed, 67 insertions(+), 129 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index 3b52d9b..5204235 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -1,8 +1,8 @@ # Role-based access control -Roles are instance-wide, persisted on the Better Auth user record, and enforced server-side by the API guard. Client-side navigation visibility is never the authorization boundary. +Roles are stored on each Better Auth user record and enforced server-side for all authenticated API requests. -## Built-in roles +## Roles | Capability | Admin | Operator | Viewer | |---|:---:|:---:|:---:| @@ -12,25 +12,23 @@ Roles are instance-wide, persisted on the Better Auth user record, and enforced | Read proxy configuration, status, certificates and logs | Yes | Yes | Yes | | Validate/apply/rollback proxy configuration | Yes | Yes | No | | Read application settings | Yes | No | Yes | -| Change SMTP/authentication settings | Yes | No | No | +| Change SMTP and authentication settings | Yes | No | No | | List users and change roles | Yes | No | No | -Admin has every permission. Operator manages operational resources but not global settings or users. Viewer can inspect operational data but cannot mutate it. +Operators manage operational resources but cannot change global settings or users. Viewers have read-only access to operational data. -## Migration / first admin +## Migration and first administrator -Run `bun run db:migrate` during deployment. Migration `0004_add_user_roles` promotes the existing earliest-created account to `admin`; other existing users and all new users default to `viewer`. Never trust role data supplied at signup. +Run `bun run db:migrate` during deployment. Migration `0004_add_user_roles` promotes the earliest-created existing account to `admin`. Other existing accounts and new accounts default to `viewer`. Signup cannot select a role. -## User management +## User-management API -Only admins can access: -- `GET /api/users` — list users, effective roles and permissions. -- `PATCH /api/users/:id/role` with `{"role":"operator"}` — set `admin`, `operator` or `viewer`. +Only admins are allowed: +- `GET /api/users` — list users, roles, and effective permissions. +- `PATCH /api/users/:id/role` with `{"role":"operator"}` — assign `admin`, `operator`, or `viewer`. -The last admin cannot demote themselves. Roles are read from the database on each authenticated API request so changes apply immediately. +The last administrator cannot demote themselves. Roles are read live from the database on each request, so changes take effect immediately. -## Authorization behavior +## Enforcement -API methods GET/HEAD/OPTIONS require read permissions; POST/PUT/PATCH/DELETE require write permissions. Unknown API paths fail closed with 403 until a policy is explicitly added. The public exception list is limited to health, signup capability, authentication capability and auth routes. Proxy status is no longer public. - -Domain and server data continues to use existing owner-scoped service/repository queries. Roles grant feature access, not access to another user's private portfolio records. +GET/HEAD/OPTIONS operations map to read permissions; POST/PUT/PATCH/DELETE map to write permissions. Unclassified API paths fail closed with 403. The previously public proxy status endpoint now requires a session and proxy-read access. Existing owner-scoped domain and server data queries are retained; roles do not grant access to another user's private inventory. diff --git a/drizzle/0004_add_user_roles.sql b/drizzle/0004_add_user_roles.sql index f74bb81..9694db0 100644 --- a/drizzle/0004_add_user_roles.sql +++ b/drizzle/0004_add_user_roles.sql @@ -1,12 +1,5 @@ -ALTER TABLE "user" - ADD COLUMN "role" text NOT NULL DEFAULT 'viewer'; - --- Preserve the first existing account as the instance administrator. -WITH first_user AS ( - SELECT "id" FROM "user" ORDER BY "createdAt" ASC, "id" ASC LIMIT 1 -) -UPDATE "user" SET "role" = 'admin' -WHERE "id" IN (SELECT "id" FROM first_user); - -ALTER TABLE "user" - ADD CONSTRAINT "user_role_check" CHECK ("role" IN ('admin','operator','viewer')); +ALTER TABLE "user" ADD COLUMN "role" text NOT NULL DEFAULT 'viewer'; +-- Promote the first pre-existing account so current installations retain admin access. +WITH first_user AS (SELECT "id" FROM "user" ORDER BY "createdAt" ASC,"id" ASC LIMIT 1) +UPDATE "user" SET "role"='admin' WHERE "id" IN (SELECT "id" FROM first_user); +ALTER TABLE "user" ADD CONSTRAINT "user_role_check" CHECK ("role" IN ('admin','operator','viewer')); diff --git a/src/__tests__/roles.test.ts b/src/__tests__/roles.test.ts index 687cfbc..8927e34 100644 --- a/src/__tests__/roles.test.ts +++ b/src/__tests__/roles.test.ts @@ -1,36 +1,10 @@ -import { describe, expect, test } from "bun:test"; -import { hasPermission, permissionForApiRequest } from "../auth/roles"; - -describe("RBAC", () => { - test("admin can do everything", () => { - for (const p of ["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"] as const) - expect(hasPermission("admin",p)).toBe(true); - }); - test("operator manages domain/server/proxy but not global settings or users", () => { - expect(hasPermission("operator","domains:write")).toBe(true); - expect(hasPermission("operator","servers:write")).toBe(true); - expect(hasPermission("operator","proxy:write")).toBe(true); - expect(hasPermission("operator","settings:write")).toBe(false); - expect(hasPermission("operator","users:write")).toBe(false); - }); - test("viewer is read-only", () => { - expect(hasPermission("viewer","domains:read")).toBe(true); - expect(hasPermission("viewer","domains:write")).toBe(false); - expect(hasPermission("viewer","proxy:write")).toBe(false); - }); - test("resource API methods map to read/write permissions", () => { - expect(permissionForApiRequest("GET","/api/domains")).toBe("domains:read"); - expect(permissionForApiRequest("POST","/api/domains")).toBe("domains:write"); - expect(permissionForApiRequest("DELETE","/api/servers/id")).toBe("servers:write"); - expect(permissionForApiRequest("POST","/api/config/apply")).toBe("proxy:write"); - expect(permissionForApiRequest("PUT","/api/settings/app")).toBe("settings:write"); - expect(permissionForApiRequest("GET","/api/logs")).toBe("logs:read"); - }); - test("unknown API routes are denied by default", () => { - expect(permissionForApiRequest("GET","/api/not-classified")).toBe("admin:only"); - }); - test("public health/auth capability paths do not require a role", () => { - expect(permissionForApiRequest("GET","/api/health")).toBeNull(); - expect(permissionForApiRequest("POST","/api/auth/sign-out")).toBeNull(); - }); +import {describe,expect,test} from "bun:test"; +import {hasPermission,permissionForApiRequest} from "../auth/roles"; +describe("role based access control",()=>{ + test("admin has every permission",()=>{for(const p of ["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"] as const)expect(hasPermission("admin",p)).toBe(true)}); + test("operator can operate services but cannot manage users or instance settings",()=>{expect(hasPermission("operator","domains:write")).toBe(true);expect(hasPermission("operator","servers:write")).toBe(true);expect(hasPermission("operator","proxy:write")).toBe(true);expect(hasPermission("operator","settings:write")).toBe(false);expect(hasPermission("operator","users:write")).toBe(false)}); + test("viewer is read only",()=>{expect(hasPermission("viewer","domains:read")).toBe(true);expect(hasPermission("viewer","domains:write")).toBe(false);expect(hasPermission("viewer","proxy:write")).toBe(false)}); + test("API method mapping covers resource read and write operations",()=>{expect(permissionForApiRequest("GET","/api/domains")).toBe("domains:read");expect(permissionForApiRequest("POST","/api/domains")).toBe("domains:write");expect(permissionForApiRequest("DELETE","/api/servers/1")).toBe("servers:write");expect(permissionForApiRequest("POST","/api/config/apply")).toBe("proxy:write");expect(permissionForApiRequest("PUT","/api/settings/app")).toBe("settings:write");expect(permissionForApiRequest("GET","/api/logs")).toBe("logs:read")}); + test("unknown routes fail closed",()=>expect(permissionForApiRequest("GET","/api/new-feature")).toBe("admin:only")); + test("health and auth capability endpoints have no role requirement",()=>{expect(permissionForApiRequest("GET","/api/health")).toBeNull();expect(permissionForApiRequest("POST","/api/auth/sign-out")).toBeNull()}); }); diff --git a/src/auth/roles.ts b/src/auth/roles.ts index fe97796..a51bfcb 100644 --- a/src/auth/roles.ts +++ b/src/auth/roles.ts @@ -1,37 +1,24 @@ export const USER_ROLES = ["admin", "operator", "viewer"] as const; export type UserRole = (typeof USER_ROLES)[number]; -export type Permission = - | "domains:read" | "domains:write" | "servers:read" | "servers:write" - | "proxy:read" | "proxy:write" | "logs:read" - | "settings:read" | "settings:write" | "users:read" | "users:write"; - +export type Permission = "domains:read" | "domains:write" | "servers:read" | "servers:write" | "proxy:read" | "proxy:write" | "logs:read" | "settings:read" | "settings:write" | "users:read" | "users:write"; const ROLE_PERMISSIONS: Record = { - admin: ["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"], - operator: ["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read"], - viewer: ["domains:read","servers:read","proxy:read","logs:read","settings:read"], + admin:["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"], + operator:["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read"], + viewer:["domains:read","servers:read","proxy:read","logs:read","settings:read"], }; -export function isUserRole(v: unknown): v is UserRole { - return typeof v === "string" && (USER_ROLES as readonly string[]).includes(v); -} -export function hasPermission(role: UserRole, permission: Permission): boolean { - return ROLE_PERMISSIONS[role].includes(permission); -} -export function permissionsForRole(role: UserRole): readonly Permission[] { - return ROLE_PERMISSIONS[role]; -} -/** Fail closed: any new API route must be classified before it is accessible. */ -export function permissionForApiRequest(method: string, pathname: string): Permission | null { - const verb = method.toUpperCase(); - const write = ["POST","PUT","PATCH","DELETE"].includes(verb); - if (pathname === "/api/health" || pathname === "/api/allow-signup" || - pathname === "/api/auth-capabilities" || pathname.startsWith("/api/auth/") || - pathname === "/api/docs" || pathname.startsWith("/api/docs/")) return null; - if (pathname.startsWith("/api/users")) return "users:read"; - if (pathname === "/api/settings/app") return write ? "settings:write" : "settings:read"; - if (pathname.startsWith("/api/domains")) return write ? "domains:write" : "domains:read"; - if (pathname.startsWith("/api/servers")) return write ? "servers:write" : "servers:read"; - if (pathname.startsWith("/api/config")) return write ? "proxy:write" : "proxy:read"; - if (pathname === "/api/proxy/status" || pathname === "/api/certificates") return "proxy:read"; - if (pathname === "/api/logs") return "logs:read"; - return "admin:only" as Permission; +export function isUserRole(v:unknown):v is UserRole{return typeof v==="string"&&(USER_ROLES as readonly string[]).includes(v)} +export function hasPermission(role:UserRole,permission:Permission):boolean{return ROLE_PERMISSIONS[role].includes(permission)} +export function permissionsForRole(role:UserRole):readonly Permission[]{return ROLE_PERMISSIONS[role]} +/** Fail closed: every newly added API family needs an explicit policy. */ +export function permissionForApiRequest(method:string,pathname:string):Permission|null{ + const write=["POST","PUT","PATCH","DELETE"].includes(method.toUpperCase()); + if(pathname==="/api/health"||pathname==="/api/allow-signup"||pathname==="/api/auth-capabilities"||pathname.startsWith("/api/auth/")||pathname==="/api/docs"||pathname.startsWith("/api/docs/"))return null; + if(pathname.startsWith("/api/users"))return "users:read"; + if(pathname==="/api/settings/app")return write?"settings:write":"settings:read"; + if(pathname.startsWith("/api/domains"))return write?"domains:write":"domains:read"; + if(pathname.startsWith("/api/servers"))return write?"servers:write":"servers:read"; + if(pathname.startsWith("/api/config"))return write?"proxy:write":"proxy:read"; + if(pathname==="/api/proxy/status"||pathname==="/api/certificates")return "proxy:read"; + if(pathname==="/api/logs")return "logs:read"; + return "admin:only" as Permission; } diff --git a/src/controllers/appSettings.controller.ts b/src/controllers/appSettings.controller.ts index 6fd97fb..fc9461c 100644 --- a/src/controllers/appSettings.controller.ts +++ b/src/controllers/appSettings.controller.ts @@ -57,8 +57,7 @@ export const appSettingsRoutes = new Elysia().group("/api/settings", (app) => const userId = await getUserIdFromRequest(request); if (!userId) return jsonResponse({ error: "Unauthorized" }, 401); const session = await getSession(request); - const role = (session?.user as { role?: string } | undefined)?.role; - if (role !== "admin") return jsonResponse({ error: "Only administrators can update application settings" }, 403); + if ((session?.user as { role?: string } | undefined)?.role !== "admin") return jsonResponse({ error: "Only administrators can update application settings" }, 403); const raw = await readJsonBody(request); if (raw === null) return jsonResponse({ error: "Expected application/json body" }, 400); try { diff --git a/src/controllers/system.controller.ts b/src/controllers/system.controller.ts index a486a20..04abc93 100644 --- a/src/controllers/system.controller.ts +++ b/src/controllers/system.controller.ts @@ -5,11 +5,11 @@ import { allowSignup } from "../auth/allow-signup"; import { getEmailCapabilities } from "../services/appSettings.service"; import { getProxyStatusSafe } from "../services/proxyStatus.service"; import { readProxyLogTail } from "../services/logs.service"; -const systemOpenapi = { tags: ["system"] }; -export const systemRoutes = new Elysia() - .get("/api/health", () => jsonResponse({ ok:true, version:appVersion }), { detail:{...systemOpenapi,summary:"Health check"} }) - .get("/api/allow-signup", async () => jsonResponse({allowSignup:await allowSignup()}), { detail:{...systemOpenapi,summary:"Signup allowed?"} }) - .get("/api/auth-capabilities", async () => jsonResponse({email:await getEmailCapabilities()}), { detail:{...systemOpenapi,summary:"Auth-related feature flags"} }) - .get("/api/proxy/status", async () => jsonResponse(await getProxyStatusSafe()), { detail:{...systemOpenapi,summary:"Proxy detection status"} }) - .get("/api/certificates", () => jsonResponse([]), { detail:{...systemOpenapi,summary:"Certificates (stub)"} }) - .get("/api/logs", () => jsonResponse({lines:readProxyLogTail()}), { detail:{...systemOpenapi,summary:"Recent proxy log lines"} }); +const systemOpenapi={tags:["system"]}; +export const systemRoutes=new Elysia() + .get("/api/health",()=>jsonResponse({ok:true,version:appVersion}),{detail:{...systemOpenapi,summary:"Health check"}}) + .get("/api/allow-signup",async()=>jsonResponse({allowSignup:await allowSignup()}),{detail:{...systemOpenapi,summary:"Signup allowed?"}}) + .get("/api/auth-capabilities",async()=>jsonResponse({email:await getEmailCapabilities()}),{detail:{...systemOpenapi,summary:"Auth-related feature flags"}}) + .get("/api/proxy/status",async()=>jsonResponse(await getProxyStatusSafe()),{detail:{...systemOpenapi,summary:"Proxy detection status"}}) + .get("/api/certificates",()=>jsonResponse([]),{detail:{...systemOpenapi,summary:"Certificates (stub)"}}) + .get("/api/logs",()=>jsonResponse({lines:readProxyLogTail()}),{detail:{...systemOpenapi,summary:"Recent proxy log lines"}}); diff --git a/src/http/constants.ts b/src/http/constants.ts index 94ef7e4..807612e 100644 --- a/src/http/constants.ts +++ b/src/http/constants.ts @@ -1,4 +1,2 @@ /** Public API endpoints; keep the unauthenticated surface deliberately small. */ -export const PUBLIC_API_PATHS = [ - "/api/auth", "/api/docs", "/api/health", "/api/allow-signup", "/api/auth-capabilities", -] as const; +export const PUBLIC_API_PATHS=["/api/auth","/api/docs","/api/health","/api/allow-signup","/api/auth-capabilities"] as const; diff --git a/src/middleware/apiAuthGuard.ts b/src/middleware/apiAuthGuard.ts index 22844ef..21098f4 100644 --- a/src/middleware/apiAuthGuard.ts +++ b/src/middleware/apiAuthGuard.ts @@ -21,11 +21,7 @@ export async function apiAuthGuard({ request }: { request: Request }): Promise getAuth().handler(request); - return new Elysia().use(requestLogPlugin()).onBeforeHandle(apiAuthGuard) - .use(domainRoutes).use(infrastructureServerRoutes).use(configApiRoutes) - .use(appSettingsRoutes).use(userRoutes).use(systemRoutes).use(openapiDocsPlugin()) - .get("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) - .post("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) - .options("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) - .get("/*",({request})=>{ - const pathname=new URL(request.url).pathname; - if(pathname.startsWith("/api")) return; - const res=serveStatic(pathname); if(res) return res; - const indexRes=serveStatic("/index.html"); - return indexRes ?? new Response("Not Found",{status:404}); - }); +export function createApp(frontendDistDir:string){ + const serveStatic=createSpaStaticHandler(frontendDistDir); + const forwardAuth=(request:Request)=>getAuth().handler(request); + return new Elysia().use(requestLogPlugin()).onBeforeHandle(apiAuthGuard) + .use(domainRoutes).use(infrastructureServerRoutes).use(configApiRoutes).use(appSettingsRoutes).use(userRoutes).use(systemRoutes).use(openapiDocsPlugin()) + .get("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) + .post("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) + .options("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) + .get("/*",({request})=>{const pathname=new URL(request.url).pathname;if(pathname.startsWith("/api"))return;const res=serveStatic(pathname);if(res)return res;const indexRes=serveStatic("/index.html");return indexRes??new Response("Not Found",{status:404})}); } From 8a746dd3855d6d032c0cc0d4b6151832a4b4fc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Tue, 3 Nov 2026 15:00:02 +0530 Subject: [PATCH 3/4] fix(security): tighten role route policies --- src/auth/roles.ts | 20 ++--- src/controllers/appSettings.controller.ts | 98 ++++++----------------- src/controllers/user.controller.ts | 47 +++++------ src/middleware/apiAuthGuard.ts | 41 +++++----- 4 files changed, 71 insertions(+), 135 deletions(-) diff --git a/src/auth/roles.ts b/src/auth/roles.ts index a51bfcb..b7b501e 100644 --- a/src/auth/roles.ts +++ b/src/auth/roles.ts @@ -1,19 +1,15 @@ -export const USER_ROLES = ["admin", "operator", "viewer"] as const; -export type UserRole = (typeof USER_ROLES)[number]; -export type Permission = "domains:read" | "domains:write" | "servers:read" | "servers:write" | "proxy:read" | "proxy:write" | "logs:read" | "settings:read" | "settings:write" | "users:read" | "users:write"; -const ROLE_PERMISSIONS: Record = { - admin:["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"], - operator:["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read"], - viewer:["domains:read","servers:read","proxy:read","logs:read","settings:read"], -}; +export const USER_ROLES=["admin","operator","viewer"] as const; +export type UserRole=(typeof USER_ROLES)[number]; +export type Permission="domains:read"|"domains:write"|"servers:read"|"servers:write"|"proxy:read"|"proxy:write"|"logs:read"|"settings:read"|"settings:write"|"users:read"|"users:write"; +const ROLE_PERMISSIONS:Record={admin:["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"],operator:["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read"],viewer:["domains:read","servers:read","proxy:read","logs:read","settings:read"]}; export function isUserRole(v:unknown):v is UserRole{return typeof v==="string"&&(USER_ROLES as readonly string[]).includes(v)} export function hasPermission(role:UserRole,permission:Permission):boolean{return ROLE_PERMISSIONS[role].includes(permission)} export function permissionsForRole(role:UserRole):readonly Permission[]{return ROLE_PERMISSIONS[role]} -/** Fail closed: every newly added API family needs an explicit policy. */ export function permissionForApiRequest(method:string,pathname:string):Permission|null{ - const write=["POST","PUT","PATCH","DELETE"].includes(method.toUpperCase()); - if(pathname==="/api/health"||pathname==="/api/allow-signup"||pathname==="/api/auth-capabilities"||pathname.startsWith("/api/auth/")||pathname==="/api/docs"||pathname.startsWith("/api/docs/"))return null; - if(pathname.startsWith("/api/users"))return "users:read"; + const verb=method.toUpperCase(),write=["POST","PUT","PATCH","DELETE"].includes(verb); + if(pathname==="/api/health"||pathname==="/api/allow-signup"||pathname==="/api/auth-capabilities"||pathname==="/api/auth"||pathname.startsWith("/api/auth/")||pathname==="/api/docs"||pathname.startsWith("/api/docs/"))return null; + if(pathname==="/api/users"||pathname==="/api/users/")return "users:read"; + if(pathname.startsWith("/api/users/"))return write?"users:write":"users:read"; if(pathname==="/api/settings/app")return write?"settings:write":"settings:read"; if(pathname.startsWith("/api/domains"))return write?"domains:write":"domains:read"; if(pathname.startsWith("/api/servers"))return write?"servers:write":"servers:read"; diff --git a/src/controllers/appSettings.controller.ts b/src/controllers/appSettings.controller.ts index fc9461c..cfd2a0c 100644 --- a/src/controllers/appSettings.controller.ts +++ b/src/controllers/appSettings.controller.ts @@ -2,79 +2,29 @@ import { Elysia } from "elysia"; import { jsonResponse, readJsonBody, toResponse } from "../http/json"; import { getUserIdFromRequest } from "../http/sessionUser"; import { getSession } from "../auth/middleware"; -import { - type AppSettingsUpdateBody, - getAppSettingsPublic, - updateAppSettingsFromUser, -} from "../services/appSettings.service"; - -const openapi = { tags: ["settings"] as string[] }; - -function parseSettingsUpdate(raw: unknown): AppSettingsUpdateBody { - if (raw === null || typeof raw !== "object") return {}; - const o = raw as Record; - const out: AppSettingsUpdateBody = {}; - - if ("forgotPasswordEnabled" in o) out.forgotPasswordEnabled = Boolean(o.forgotPasswordEnabled); - if ("emailVerificationEnabled" in o) out.emailVerificationEnabled = Boolean(o.emailVerificationEnabled); - if ("requireVerifiedSignIn" in o) out.requireVerifiedSignIn = Boolean(o.requireVerifiedSignIn); - if ("smtpHost" in o && typeof o.smtpHost === "string") out.smtpHost = o.smtpHost; - if ("smtpFrom" in o && typeof o.smtpFrom === "string") out.smtpFrom = o.smtpFrom; - if ("smtpUser" in o && typeof o.smtpUser === "string") out.smtpUser = o.smtpUser; - if ("smtpSecure" in o) out.smtpSecure = Boolean(o.smtpSecure); - if ("smtpPort" in o && o.smtpPort !== undefined) { - const n = Number(o.smtpPort); - if (Number.isFinite(n)) out.smtpPort = n; - } - if ("smtpPassword" in o) { - if (o.smtpPassword === null) out.smtpPassword = null; - else if (typeof o.smtpPassword === "string") out.smtpPassword = o.smtpPassword; - } - - return out; +import { type AppSettingsUpdateBody, getAppSettingsPublic, updateAppSettingsFromUser } from "../services/appSettings.service"; +const openapi={tags:["settings"] as string[]}; +function parseSettingsUpdate(raw:unknown):AppSettingsUpdateBody{ + if(raw===null||typeof raw!=="object")return{}; + const o=raw as Record,out:AppSettingsUpdateBody={}; + if("forgotPasswordEnabled"in o)out.forgotPasswordEnabled=Boolean(o.forgotPasswordEnabled); + if("emailVerificationEnabled"in o)out.emailVerificationEnabled=Boolean(o.emailVerificationEnabled); + if("requireVerifiedSignIn"in o)out.requireVerifiedSignIn=Boolean(o.requireVerifiedSignIn); + if("smtpHost"in o&&typeof o.smtpHost==="string")out.smtpHost=o.smtpHost; + if("smtpFrom"in o&&typeof o.smtpFrom==="string")out.smtpFrom=o.smtpFrom; + if("smtpUser"in o&&typeof o.smtpUser==="string")out.smtpUser=o.smtpUser; + if("smtpSecure"in o)out.smtpSecure=Boolean(o.smtpSecure); + if("smtpPort"in o&&o.smtpPort!==undefined){const n=Number(o.smtpPort);if(Number.isFinite(n))out.smtpPort=n} + if("smtpPassword"in o){if(o.smtpPassword===null)out.smtpPassword=null;else if(typeof o.smtpPassword==="string")out.smtpPassword=o.smtpPassword} + return out; } - -export const appSettingsRoutes = new Elysia().group("/api/settings", (app) => - app - .get( - "/app", - async ({ request }) => { - const userId = await getUserIdFromRequest(request); - if (!userId) return jsonResponse({ error: "Unauthorized" }, 401); - return toResponse({ status: 200, body: await getAppSettingsPublic() }); - }, - { - detail: { - ...openapi, - summary: "Get application settings", - description: "Outbound email / auth toggles and SMTP fields (password not returned).", - }, - } - ) - .put( - "/app", - async ({ request }) => { - const userId = await getUserIdFromRequest(request); - if (!userId) return jsonResponse({ error: "Unauthorized" }, 401); - const session = await getSession(request); - if ((session?.user as { role?: string } | undefined)?.role !== "admin") return jsonResponse({ error: "Only administrators can update application settings" }, 403); - const raw = await readJsonBody(request); - if (raw === null) return jsonResponse({ error: "Expected application/json body" }, 400); - try { - const result = await updateAppSettingsFromUser(parseSettingsUpdate(raw)); - return toResponse({ status: 200, body: result }); - } catch (e) { - const msg = e instanceof Error ? e.message : "Invalid request"; - return toResponse({ status: 400, body: { error: msg } }); - } - }, - { - parse: "none", - detail: { - ...openapi, - summary: "Update application settings", - description: "Partial update. Omit smtpPassword to keep current; null clears stored password.", - }, - } - ) +export const appSettingsRoutes=new Elysia().group("/api/settings",app=>app + .get("/app",async({request})=>{const id=await getUserIdFromRequest(request);if(!id)return jsonResponse({error:"Unauthorized"},401);return toResponse({status:200,body:await getAppSettingsPublic()})},{detail:{...openapi,summary:"Get application settings"}}) + .put("/app",async({request})=>{ + const id=await getUserIdFromRequest(request);if(!id)return jsonResponse({error:"Unauthorized"},401); + const session=await getSession(request);if((session?.user as {role?:string}|undefined)?.role!=="admin")return jsonResponse({error:"Only administrators can update application settings"},403); + const raw=await readJsonBody(request);if(raw===null)return jsonResponse({error:"Expected application/json body"},400); + try{return toResponse({status:200,body:await updateAppSettingsFromUser(parseSettingsUpdate(raw))})} + catch(e){return toResponse({status:400,body:{error:e instanceof Error?e.message:"Invalid request"}})} + },{parse:"none",detail:{...openapi,summary:"Update application settings"}}) ); diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 81ecccc..2fe9156 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -1,35 +1,28 @@ import { Elysia } from "elysia"; -import { eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { db } from "../db/client"; import { user } from "../db/schema"; import { getUserIdFromRequest } from "../http/sessionUser"; import { isUserRole, permissionsForRole, USER_ROLES, type UserRole } from "../auth/roles"; import { jsonResponse } from "../http/json"; -export const userRoutes = new Elysia().group("/api/users", (app) => - app.get("/", async ({ request }) => { - const actorId = await getUserIdFromRequest(request); - if (!actorId) return jsonResponse({ error: "Unauthorized" }, 401); - const rows = await db.select({ - id:user.id,name:user.name,email:user.email,username:user.username,role:user.role,createdAt:user.createdAt, - }).from(user).orderBy(user.createdAt); - return jsonResponse({ roles:USER_ROLES, users:rows.map((entry) => ({ - ...entry, permissions:permissionsForRole(entry.role as UserRole), - })) }); - }).patch("/:id/role", async ({ request, params }) => { - const actorId = await getUserIdFromRequest(request); - if (!actorId) return jsonResponse({ error:"Unauthorized" },401); - let body: unknown; - try { body = await request.json(); } catch { return jsonResponse({ error:"Invalid JSON body" },400); } - const role = body && typeof body === "object" ? (body as { role?: unknown }).role : null; - if (!isUserRole(role)) return jsonResponse({ error:"role must be admin, operator, or viewer" },400); - if (params.id === actorId && role !== "admin") { - const admins = await db.select({ id:user.id }).from(user).where(eq(user.role,"admin")); - if (admins.length <= 1) return jsonResponse({ error:"The last administrator cannot demote themselves" },409); - } - const result = await db.update(user).set({role,updatedAt:new Date()}) - .where(eq(user.id,params.id)).returning({id:user.id,role:user.role}); - if (!result.length) return jsonResponse({error:"User not found"},404); - return jsonResponse({user:result[0],permissions:permissionsForRole(role)}); - }) +export const userRoutes=new Elysia().group("/api/users",(app)=> + app.get("/",async({request})=>{ + const actorId=await getUserIdFromRequest(request);if(!actorId)return jsonResponse({error:"Unauthorized"},401); + const rows=await db.select({id:user.id,name:user.name,email:user.email,username:user.username,role:user.role,createdAt:user.createdAt}).from(user).orderBy(user.createdAt); + return jsonResponse({roles:USER_ROLES,users:rows.map(entry=>({...entry,permissions:permissionsForRole(entry.role as UserRole)}))}); + }).patch("/:id/role",async({request,params})=>{ + const actorId=await getUserIdFromRequest(request);if(!actorId)return jsonResponse({error:"Unauthorized"},401); + let body:unknown;try{body=await request.json()}catch{return jsonResponse({error:"Invalid JSON body"},400)} + const role=body&&typeof body==="object"?(body as {role?:unknown}).role:null; + if(!isUserRole(role))return jsonResponse({error:"role must be admin, operator, or viewer"},400); + const target=await db.select({id:user.id,role:user.role}).from(user).where(eq(user.id,params.id)).limit(1); + if(!target.length)return jsonResponse({error:"User not found"},404); + if(target[0].role==="admin"&&role!=="admin"){ + const admins=await db.select({id:user.id}).from(user).where(eq(user.role,"admin")); + if(admins.length<=1)return jsonResponse({error:"The last administrator cannot be demoted"},409); + } + const result=await db.update(user).set({role,updatedAt:new Date()}).where(eq(user.id,params.id)).returning({id:user.id,role:user.role}); + return jsonResponse({user:result[0],permissions:permissionsForRole(role)}); + }) ); diff --git a/src/middleware/apiAuthGuard.ts b/src/middleware/apiAuthGuard.ts index 21098f4..a4e91b3 100644 --- a/src/middleware/apiAuthGuard.ts +++ b/src/middleware/apiAuthGuard.ts @@ -2,26 +2,23 @@ import { getSession } from "../auth/middleware"; import { hasPermission, permissionForApiRequest, type Permission, type UserRole } from "../auth/roles"; import { getAppSettingsRowCached, rowRequiresVerifiedSignIn } from "../services/appSettings.service"; -const API_OK_BEFORE_EMAIL_VERIFIED = ["/api/auth","/api/health","/api/allow-signup","/api/auth-capabilities","/api/docs"] as const; -function jsonError(status: number, error: string, code: string): Response { - return new Response(JSON.stringify({ error, code }), { status, headers: { "Content-Type": "application/json" } }); -} -export async function apiAuthGuard({ request }: { request: Request }): Promise { - const pathname = new URL(request.url).pathname; - if (!pathname.startsWith("/api")) return; - const session = await getSession(request); - if (!session) return jsonError(401, "Unauthorized", "UNAUTHORIZED"); - const currentUser = session.user as { emailVerified?: boolean; role?: unknown }; - const settings = await getAppSettingsRowCached(); - if (rowRequiresVerifiedSignIn(settings) && currentUser.emailVerified === false && - !API_OK_BEFORE_EMAIL_VERIFIED.some((p) => pathname === p || pathname.startsWith(p + "/"))) { - return jsonError(403, "Verify your email to use this API.", "EMAIL_NOT_VERIFIED"); - } - const permission = permissionForApiRequest(request.method, pathname); - if (permission === null) return; - if (permission === "admin:only") return jsonError(403, "No authorization policy is defined for this API route.", "ROLE_POLICY_MISSING"); - const role = currentUser.role; - if (role !== "admin" && role !== "operator" && role !== "viewer") return jsonError(403, "User role is missing or invalid.", "INVALID_ROLE"); - if (!hasPermission(role as UserRole, permission as Permission)) return jsonError(403, "Your role does not allow this action.", "FORBIDDEN"); - return; +const API_OK_BEFORE_EMAIL_VERIFIED=["/api/auth","/api/health","/api/allow-signup","/api/auth-capabilities","/api/docs"] as const; +function jsonError(status:number,error:string,code:string):Response{return new Response(JSON.stringify({error,code}),{status,headers:{"Content-Type":"application/json"}})} +export async function apiAuthGuard({request}:{request:Request}):Promise{ + const pathname=new URL(request.url).pathname; + if(!pathname.startsWith("/api"))return; + const isPublic=["/api/auth","/api/docs","/api/health","/api/allow-signup","/api/auth-capabilities"].some(p=>pathname===p||pathname.startsWith(p+"/")); + if(isPublic)return; + const currentSession=await getSession(request); + if(!currentSession)return jsonError(401,"Unauthorized","UNAUTHORIZED"); + const currentUser=currentSession.user as {emailVerified?:boolean;role?:unknown}; + const settings=await getAppSettingsRowCached(); + if(rowRequiresVerifiedSignIn(settings)&¤tUser.emailVerified===false&&!API_OK_BEFORE_EMAIL_VERIFIED.some(p=>pathname===p||pathname.startsWith(p+"/")))return jsonError(403,"Verify your email to use this API.","EMAIL_NOT_VERIFIED"); + const permission=permissionForApiRequest(request.method,pathname); + if(permission===null)return; + if(permission==="admin:only")return jsonError(403,"No authorization policy is defined for this API route.","ROLE_POLICY_MISSING"); + const role=currentUser.role; + if(role!=="admin"&&role!=="operator"&&role!=="viewer")return jsonError(403,"User role is missing or invalid.","INVALID_ROLE"); + if(!hasPermission(role as UserRole,permission as Permission))return jsonError(403,"Your role does not allow this action.","FORBIDDEN"); + return; } From 318b39fa6e362c8d244d64d921ed48c5090bfa0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Tue, 3 Nov 2026 15:01:30 +0530 Subject: [PATCH 4/4] fix(security): type deny-by-default RBAC policy --- src/auth/roles.ts | 2 +- src/controllers/appSettings.controller.ts | 98 +++++++++++++++++------ src/controllers/user.controller.ts | 47 ++++++----- 3 files changed, 102 insertions(+), 45 deletions(-) diff --git a/src/auth/roles.ts b/src/auth/roles.ts index b7b501e..abbe1b4 100644 --- a/src/auth/roles.ts +++ b/src/auth/roles.ts @@ -1,6 +1,6 @@ export const USER_ROLES=["admin","operator","viewer"] as const; export type UserRole=(typeof USER_ROLES)[number]; -export type Permission="domains:read"|"domains:write"|"servers:read"|"servers:write"|"proxy:read"|"proxy:write"|"logs:read"|"settings:read"|"settings:write"|"users:read"|"users:write"; +export type Permission="admin:only"|"domains:read"|"domains:write"|"servers:read"|"servers:write"|"proxy:read"|"proxy:write"|"logs:read"|"settings:read"|"settings:write"|"users:read"|"users:write"; const ROLE_PERMISSIONS:Record={admin:["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"],operator:["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read"],viewer:["domains:read","servers:read","proxy:read","logs:read","settings:read"]}; export function isUserRole(v:unknown):v is UserRole{return typeof v==="string"&&(USER_ROLES as readonly string[]).includes(v)} export function hasPermission(role:UserRole,permission:Permission):boolean{return ROLE_PERMISSIONS[role].includes(permission)} diff --git a/src/controllers/appSettings.controller.ts b/src/controllers/appSettings.controller.ts index cfd2a0c..fc9461c 100644 --- a/src/controllers/appSettings.controller.ts +++ b/src/controllers/appSettings.controller.ts @@ -2,29 +2,79 @@ import { Elysia } from "elysia"; import { jsonResponse, readJsonBody, toResponse } from "../http/json"; import { getUserIdFromRequest } from "../http/sessionUser"; import { getSession } from "../auth/middleware"; -import { type AppSettingsUpdateBody, getAppSettingsPublic, updateAppSettingsFromUser } from "../services/appSettings.service"; -const openapi={tags:["settings"] as string[]}; -function parseSettingsUpdate(raw:unknown):AppSettingsUpdateBody{ - if(raw===null||typeof raw!=="object")return{}; - const o=raw as Record,out:AppSettingsUpdateBody={}; - if("forgotPasswordEnabled"in o)out.forgotPasswordEnabled=Boolean(o.forgotPasswordEnabled); - if("emailVerificationEnabled"in o)out.emailVerificationEnabled=Boolean(o.emailVerificationEnabled); - if("requireVerifiedSignIn"in o)out.requireVerifiedSignIn=Boolean(o.requireVerifiedSignIn); - if("smtpHost"in o&&typeof o.smtpHost==="string")out.smtpHost=o.smtpHost; - if("smtpFrom"in o&&typeof o.smtpFrom==="string")out.smtpFrom=o.smtpFrom; - if("smtpUser"in o&&typeof o.smtpUser==="string")out.smtpUser=o.smtpUser; - if("smtpSecure"in o)out.smtpSecure=Boolean(o.smtpSecure); - if("smtpPort"in o&&o.smtpPort!==undefined){const n=Number(o.smtpPort);if(Number.isFinite(n))out.smtpPort=n} - if("smtpPassword"in o){if(o.smtpPassword===null)out.smtpPassword=null;else if(typeof o.smtpPassword==="string")out.smtpPassword=o.smtpPassword} - return out; +import { + type AppSettingsUpdateBody, + getAppSettingsPublic, + updateAppSettingsFromUser, +} from "../services/appSettings.service"; + +const openapi = { tags: ["settings"] as string[] }; + +function parseSettingsUpdate(raw: unknown): AppSettingsUpdateBody { + if (raw === null || typeof raw !== "object") return {}; + const o = raw as Record; + const out: AppSettingsUpdateBody = {}; + + if ("forgotPasswordEnabled" in o) out.forgotPasswordEnabled = Boolean(o.forgotPasswordEnabled); + if ("emailVerificationEnabled" in o) out.emailVerificationEnabled = Boolean(o.emailVerificationEnabled); + if ("requireVerifiedSignIn" in o) out.requireVerifiedSignIn = Boolean(o.requireVerifiedSignIn); + if ("smtpHost" in o && typeof o.smtpHost === "string") out.smtpHost = o.smtpHost; + if ("smtpFrom" in o && typeof o.smtpFrom === "string") out.smtpFrom = o.smtpFrom; + if ("smtpUser" in o && typeof o.smtpUser === "string") out.smtpUser = o.smtpUser; + if ("smtpSecure" in o) out.smtpSecure = Boolean(o.smtpSecure); + if ("smtpPort" in o && o.smtpPort !== undefined) { + const n = Number(o.smtpPort); + if (Number.isFinite(n)) out.smtpPort = n; + } + if ("smtpPassword" in o) { + if (o.smtpPassword === null) out.smtpPassword = null; + else if (typeof o.smtpPassword === "string") out.smtpPassword = o.smtpPassword; + } + + return out; } -export const appSettingsRoutes=new Elysia().group("/api/settings",app=>app - .get("/app",async({request})=>{const id=await getUserIdFromRequest(request);if(!id)return jsonResponse({error:"Unauthorized"},401);return toResponse({status:200,body:await getAppSettingsPublic()})},{detail:{...openapi,summary:"Get application settings"}}) - .put("/app",async({request})=>{ - const id=await getUserIdFromRequest(request);if(!id)return jsonResponse({error:"Unauthorized"},401); - const session=await getSession(request);if((session?.user as {role?:string}|undefined)?.role!=="admin")return jsonResponse({error:"Only administrators can update application settings"},403); - const raw=await readJsonBody(request);if(raw===null)return jsonResponse({error:"Expected application/json body"},400); - try{return toResponse({status:200,body:await updateAppSettingsFromUser(parseSettingsUpdate(raw))})} - catch(e){return toResponse({status:400,body:{error:e instanceof Error?e.message:"Invalid request"}})} - },{parse:"none",detail:{...openapi,summary:"Update application settings"}}) + +export const appSettingsRoutes = new Elysia().group("/api/settings", (app) => + app + .get( + "/app", + async ({ request }) => { + const userId = await getUserIdFromRequest(request); + if (!userId) return jsonResponse({ error: "Unauthorized" }, 401); + return toResponse({ status: 200, body: await getAppSettingsPublic() }); + }, + { + detail: { + ...openapi, + summary: "Get application settings", + description: "Outbound email / auth toggles and SMTP fields (password not returned).", + }, + } + ) + .put( + "/app", + async ({ request }) => { + const userId = await getUserIdFromRequest(request); + if (!userId) return jsonResponse({ error: "Unauthorized" }, 401); + const session = await getSession(request); + if ((session?.user as { role?: string } | undefined)?.role !== "admin") return jsonResponse({ error: "Only administrators can update application settings" }, 403); + const raw = await readJsonBody(request); + if (raw === null) return jsonResponse({ error: "Expected application/json body" }, 400); + try { + const result = await updateAppSettingsFromUser(parseSettingsUpdate(raw)); + return toResponse({ status: 200, body: result }); + } catch (e) { + const msg = e instanceof Error ? e.message : "Invalid request"; + return toResponse({ status: 400, body: { error: msg } }); + } + }, + { + parse: "none", + detail: { + ...openapi, + summary: "Update application settings", + description: "Partial update. Omit smtpPassword to keep current; null clears stored password.", + }, + } + ) ); diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 2fe9156..81ecccc 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -1,28 +1,35 @@ import { Elysia } from "elysia"; -import { and, eq, ne } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { db } from "../db/client"; import { user } from "../db/schema"; import { getUserIdFromRequest } from "../http/sessionUser"; import { isUserRole, permissionsForRole, USER_ROLES, type UserRole } from "../auth/roles"; import { jsonResponse } from "../http/json"; -export const userRoutes=new Elysia().group("/api/users",(app)=> - app.get("/",async({request})=>{ - const actorId=await getUserIdFromRequest(request);if(!actorId)return jsonResponse({error:"Unauthorized"},401); - const rows=await db.select({id:user.id,name:user.name,email:user.email,username:user.username,role:user.role,createdAt:user.createdAt}).from(user).orderBy(user.createdAt); - return jsonResponse({roles:USER_ROLES,users:rows.map(entry=>({...entry,permissions:permissionsForRole(entry.role as UserRole)}))}); - }).patch("/:id/role",async({request,params})=>{ - const actorId=await getUserIdFromRequest(request);if(!actorId)return jsonResponse({error:"Unauthorized"},401); - let body:unknown;try{body=await request.json()}catch{return jsonResponse({error:"Invalid JSON body"},400)} - const role=body&&typeof body==="object"?(body as {role?:unknown}).role:null; - if(!isUserRole(role))return jsonResponse({error:"role must be admin, operator, or viewer"},400); - const target=await db.select({id:user.id,role:user.role}).from(user).where(eq(user.id,params.id)).limit(1); - if(!target.length)return jsonResponse({error:"User not found"},404); - if(target[0].role==="admin"&&role!=="admin"){ - const admins=await db.select({id:user.id}).from(user).where(eq(user.role,"admin")); - if(admins.length<=1)return jsonResponse({error:"The last administrator cannot be demoted"},409); - } - const result=await db.update(user).set({role,updatedAt:new Date()}).where(eq(user.id,params.id)).returning({id:user.id,role:user.role}); - return jsonResponse({user:result[0],permissions:permissionsForRole(role)}); - }) +export const userRoutes = new Elysia().group("/api/users", (app) => + app.get("/", async ({ request }) => { + const actorId = await getUserIdFromRequest(request); + if (!actorId) return jsonResponse({ error: "Unauthorized" }, 401); + const rows = await db.select({ + id:user.id,name:user.name,email:user.email,username:user.username,role:user.role,createdAt:user.createdAt, + }).from(user).orderBy(user.createdAt); + return jsonResponse({ roles:USER_ROLES, users:rows.map((entry) => ({ + ...entry, permissions:permissionsForRole(entry.role as UserRole), + })) }); + }).patch("/:id/role", async ({ request, params }) => { + const actorId = await getUserIdFromRequest(request); + if (!actorId) return jsonResponse({ error:"Unauthorized" },401); + let body: unknown; + try { body = await request.json(); } catch { return jsonResponse({ error:"Invalid JSON body" },400); } + const role = body && typeof body === "object" ? (body as { role?: unknown }).role : null; + if (!isUserRole(role)) return jsonResponse({ error:"role must be admin, operator, or viewer" },400); + if (params.id === actorId && role !== "admin") { + const admins = await db.select({ id:user.id }).from(user).where(eq(user.role,"admin")); + if (admins.length <= 1) return jsonResponse({ error:"The last administrator cannot demote themselves" },409); + } + const result = await db.update(user).set({role,updatedAt:new Date()}) + .where(eq(user.id,params.id)).returning({id:user.id,role:user.role}); + if (!result.length) return jsonResponse({error:"User not found"},404); + return jsonResponse({user:result[0],permissions:permissionsForRole(role)}); + }) );