mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: grant admin role to anonymous user and add DEFAULT_TOOL_VIEW env var
When AUTH_ENABLED=false, the anonymous user was assigned the "user" role which lacks settings:write permission, making all settings saves return 403. Since no admin exists when auth is disabled, settings were permanently read-only. Promote the anonymous user to "admin" so the single user has full control of the instance. Also adds DEFAULT_TOOL_VIEW env var (sidebar|fullscreen) following the existing DEFAULT_THEME pattern, seeded via ensureDefaultSettings() on first boot. Closes #135
This commit is contained in:
@@ -63,6 +63,7 @@ function ensureDefaultSettings() {
|
|||||||
const defaults: Record<string, string> = {
|
const defaults: Record<string, string> = {
|
||||||
defaultTheme: env.DEFAULT_THEME,
|
defaultTheme: env.DEFAULT_THEME,
|
||||||
defaultLocale: env.DEFAULT_LOCALE,
|
defaultLocale: env.DEFAULT_LOCALE,
|
||||||
|
defaultToolView: env.DEFAULT_TOOL_VIEW,
|
||||||
};
|
};
|
||||||
for (const [key, value] of Object.entries(defaults)) {
|
for (const [key, value] of Object.entries(defaults)) {
|
||||||
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
|
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const envSchema = z
|
|||||||
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
|
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
|
||||||
DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"),
|
DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"),
|
||||||
DEFAULT_LOCALE: z.string().default("en"),
|
DEFAULT_LOCALE: z.string().default("en"),
|
||||||
|
DEFAULT_TOOL_VIEW: z.enum(["sidebar", "fullscreen"]).default("sidebar"),
|
||||||
CORS_ORIGIN: z.string().default(""),
|
CORS_ORIGIN: z.string().default(""),
|
||||||
MAX_USERS: z.coerce.number().default(0),
|
MAX_USERS: z.coerce.number().default(0),
|
||||||
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
|
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
|
||||||
|
|||||||
@@ -847,13 +847,11 @@ function isPublicRoute(url: string): boolean {
|
|||||||
|
|
||||||
export async function authMiddleware(app: FastifyInstance): Promise<void> {
|
export async function authMiddleware(app: FastifyInstance): Promise<void> {
|
||||||
app.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
// When auth is disabled, attach a synthetic non-admin user so tools work
|
|
||||||
// but admin-only routes (user management, settings write, etc.) stay locked
|
|
||||||
if (!env.AUTH_ENABLED) {
|
if (!env.AUTH_ENABLED) {
|
||||||
(request as FastifyRequest & { user?: AuthUser }).user = {
|
(request as FastifyRequest & { user?: AuthUser }).user = {
|
||||||
id: "anonymous",
|
id: "anonymous",
|
||||||
username: "anonymous",
|
username: "anonymous",
|
||||||
role: "user",
|
role: "admin",
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ All configuration is done through environment variables. Every variable has a se
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DEFAULT_THEME` | `light` | Default theme for new sessions. `light` or `dark`. |
|
| `DEFAULT_THEME` | `light` | Default theme for new sessions. `light` or `dark`. |
|
||||||
| `DEFAULT_LOCALE` | `en` | Default interface language. |
|
| `DEFAULT_LOCALE` | `en` | Default interface language. |
|
||||||
|
| `DEFAULT_TOOL_VIEW` | `sidebar` | Default tool layout. `sidebar` or `fullscreen`. |
|
||||||
|
|
||||||
### Docker permissions
|
### Docker permissions
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,21 @@ interface AuthState {
|
|||||||
hasLocalPassword: boolean;
|
hasLocalPassword: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const USER_PERMISSIONS = [
|
const ANON_ADMIN_PERMISSIONS = [
|
||||||
"tools:use",
|
"tools:use",
|
||||||
"files:own",
|
"files:own",
|
||||||
|
"files:all",
|
||||||
"apikeys:own",
|
"apikeys:own",
|
||||||
|
"apikeys:all",
|
||||||
"pipelines:own",
|
"pipelines:own",
|
||||||
|
"pipelines:all",
|
||||||
"settings:read",
|
"settings:read",
|
||||||
|
"settings:write",
|
||||||
|
"users:manage",
|
||||||
|
"teams:manage",
|
||||||
|
"features:manage",
|
||||||
|
"system:health",
|
||||||
|
"audit:read",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
@@ -58,8 +67,8 @@ export function useAuth() {
|
|||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
role: "user",
|
role: "admin",
|
||||||
permissions: USER_PERMISSIONS,
|
permissions: ANON_ADMIN_PERMISSIONS,
|
||||||
analyticsEnabled: null,
|
analyticsEnabled: null,
|
||||||
analyticsConsentShownAt: null,
|
analyticsConsentShownAt: null,
|
||||||
analyticsConsentRemindAt: null,
|
analyticsConsentRemindAt: null,
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ ENV PORT=1349 \
|
|||||||
U2NET_HOME=/data/ai/models/rembg \
|
U2NET_HOME=/data/ai/models/rembg \
|
||||||
DEFAULT_THEME=light \
|
DEFAULT_THEME=light \
|
||||||
DEFAULT_LOCALE=en \
|
DEFAULT_LOCALE=en \
|
||||||
|
DEFAULT_TOOL_VIEW=sidebar \
|
||||||
FILE_MAX_AGE_HOURS=72 \
|
FILE_MAX_AGE_HOURS=72 \
|
||||||
CLEANUP_INTERVAL_MINUTES=60 \
|
CLEANUP_INTERVAL_MINUTES=60 \
|
||||||
MAX_UPLOAD_SIZE_MB=0 \
|
MAX_UPLOAD_SIZE_MB=0 \
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../../apps/api/src/db/index.js", () => ({
|
||||||
|
db: {
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({ get: () => null, all: () => [] }),
|
||||||
|
all: () => [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: () => ({
|
||||||
|
values: () => ({ onConflictDoNothing: () => ({ run: vi.fn() }), run: vi.fn() }),
|
||||||
|
}),
|
||||||
|
delete: () => ({ where: () => ({ run: vi.fn() }) }),
|
||||||
|
update: () => ({ set: () => ({ where: () => ({ run: vi.fn() }) }) }),
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
users: { id: {}, username: {}, role: {} },
|
||||||
|
sessions: { id: {}, userId: {} },
|
||||||
|
settings: { key: {} },
|
||||||
|
apiKeys: { id: {}, userId: {}, keyPrefix: {} },
|
||||||
|
teams: { id: {}, name: {} },
|
||||||
|
roles: { name: {} },
|
||||||
|
auditLog: {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../apps/api/src/config.js", () => ({
|
||||||
|
env: {
|
||||||
|
AUTH_ENABLED: false,
|
||||||
|
DEFAULT_USERNAME: "admin",
|
||||||
|
DEFAULT_PASSWORD: "Adminpass1",
|
||||||
|
SKIP_MUST_CHANGE_PASSWORD: false,
|
||||||
|
SESSION_DURATION_HOURS: 168,
|
||||||
|
RATE_LIMIT_PER_MIN: 10000,
|
||||||
|
LOGIN_ATTEMPT_LIMIT: 500,
|
||||||
|
MAX_USERS: 50,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../apps/api/src/lib/audit.js", () => ({
|
||||||
|
auditLog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import { hasPermission } from "../../../apps/api/src/permissions.js";
|
||||||
|
import { authMiddleware, getAuthUser } from "../../../apps/api/src/plugins/auth.js";
|
||||||
|
|
||||||
|
describe("anonymous user when AUTH_ENABLED=false", () => {
|
||||||
|
it("assigns admin role to anonymous user", async () => {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
|
||||||
|
await authMiddleware(app);
|
||||||
|
|
||||||
|
let capturedUser: ReturnType<typeof getAuthUser> = null;
|
||||||
|
app.get("/test", (request, reply) => {
|
||||||
|
capturedUser = getAuthUser(request);
|
||||||
|
reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.ready();
|
||||||
|
await app.inject({ method: "GET", url: "/test" });
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
expect(capturedUser).not.toBeNull();
|
||||||
|
expect(capturedUser!.role).toBe("admin");
|
||||||
|
expect(capturedUser!.username).toBe("anonymous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anonymous admin has settings:write permission", () => {
|
||||||
|
expect(hasPermission("admin", "settings:write")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anonymous admin has all admin permissions", () => {
|
||||||
|
const adminPerms = [
|
||||||
|
"tools:use",
|
||||||
|
"files:own",
|
||||||
|
"files:all",
|
||||||
|
"apikeys:own",
|
||||||
|
"apikeys:all",
|
||||||
|
"pipelines:own",
|
||||||
|
"pipelines:all",
|
||||||
|
"settings:read",
|
||||||
|
"settings:write",
|
||||||
|
"users:manage",
|
||||||
|
"teams:manage",
|
||||||
|
"features:manage",
|
||||||
|
"system:health",
|
||||||
|
"audit:read",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const perm of adminPerms) {
|
||||||
|
expect(hasPermission("admin", perm)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anonymous admin can pass requirePermission('settings:write')", async () => {
|
||||||
|
const { requirePermission } = await import("../../../apps/api/src/permissions.js");
|
||||||
|
|
||||||
|
const user = { id: "anonymous", username: "anonymous", role: "admin" as const };
|
||||||
|
const req = { user } as never;
|
||||||
|
const reply = { status: vi.fn().mockReturnThis(), send: vi.fn() } as never;
|
||||||
|
|
||||||
|
const result = requirePermission("settings:write")(req, reply);
|
||||||
|
expect(result).toEqual(user);
|
||||||
|
expect((reply as { status: ReturnType<typeof vi.fn> }).status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -874,6 +874,7 @@ describe("loadEnv", () => {
|
|||||||
"WORKSPACE_PATH",
|
"WORKSPACE_PATH",
|
||||||
"DEFAULT_THEME",
|
"DEFAULT_THEME",
|
||||||
"DEFAULT_LOCALE",
|
"DEFAULT_LOCALE",
|
||||||
|
"DEFAULT_TOOL_VIEW",
|
||||||
];
|
];
|
||||||
for (const key of keysToClean) {
|
for (const key of keysToClean) {
|
||||||
delete process.env[key];
|
delete process.env[key];
|
||||||
@@ -915,6 +916,7 @@ describe("loadEnv", () => {
|
|||||||
expect(typeof env.WORKSPACE_PATH).toBe("string");
|
expect(typeof env.WORKSPACE_PATH).toBe("string");
|
||||||
expect(["light", "dark"]).toContain(env.DEFAULT_THEME);
|
expect(["light", "dark"]).toContain(env.DEFAULT_THEME);
|
||||||
expect(typeof env.DEFAULT_LOCALE).toBe("string");
|
expect(typeof env.DEFAULT_LOCALE).toBe("string");
|
||||||
|
expect(["sidebar", "fullscreen"]).toContain(env.DEFAULT_TOOL_VIEW);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses custom PORT as a number via coercion", async () => {
|
it("parses custom PORT as a number via coercion", async () => {
|
||||||
@@ -953,6 +955,30 @@ describe("loadEnv", () => {
|
|||||||
expect(() => loadEnv()).toThrow();
|
expect(() => loadEnv()).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts DEFAULT_TOOL_VIEW=fullscreen", async () => {
|
||||||
|
process.env.DEFAULT_TOOL_VIEW = "fullscreen";
|
||||||
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||||
|
expect(loadEnv().DEFAULT_TOOL_VIEW).toBe("fullscreen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts DEFAULT_TOOL_VIEW=sidebar", async () => {
|
||||||
|
process.env.DEFAULT_TOOL_VIEW = "sidebar";
|
||||||
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||||
|
expect(loadEnv().DEFAULT_TOOL_VIEW).toBe("sidebar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults DEFAULT_TOOL_VIEW to sidebar when not set", async () => {
|
||||||
|
delete process.env.DEFAULT_TOOL_VIEW;
|
||||||
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||||
|
expect(loadEnv().DEFAULT_TOOL_VIEW).toBe("sidebar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects DEFAULT_TOOL_VIEW with an invalid value", async () => {
|
||||||
|
process.env.DEFAULT_TOOL_VIEW = "grid";
|
||||||
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||||
|
expect(() => loadEnv()).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("coerces numeric strings for MAX_MEGAPIXELS", async () => {
|
it("coerces numeric strings for MAX_MEGAPIXELS", async () => {
|
||||||
process.env.MAX_MEGAPIXELS = "50";
|
process.env.MAX_MEGAPIXELS = "50";
|
||||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||||
|
|||||||
Reference in New Issue
Block a user