feat(manager-core): admin auth gate (Phase 3a)
Closes the regression risk of the admin API and dashboard being open
to anyone reaching the bound port. Required foundation before v1.1
data-plane services land.
Per-user accounts (admin_users), Argon2id passwords, env-var bootstrap
of the first admin that becomes inert once any admin exists, opaque
32-byte session token doubling as bearer credential, 24h sliding TTL
configurable via PICLOUD_SESSION_TTL_HOURS. is_active column lets
admins be deactivated without losing audit history; last-active-admin
guard on DELETE and on PATCH that flips is_active to false (sessions
also wiped on deactivation).
require_admin middleware fronts every /api/v1/admin/* route. The data
plane (/api/v1/execute/{id}), /healthz, /version, and user routes
stay open. picloud admin reset-password <username> subcommand handles
recovery without going through HTTP.
Dashboard gains /admin/login and /admin/admins surfaces, a top-bar
user menu, and a token store with a localStorage echo so refreshes
don't sign you out. Cookie-based auth works in parallel for non-SPA
clients.
Forward compatibility: future RBAC tables (admin_roles,
admin_user_roles) join on admin_users.id; the auth middleware is the
seam where role checks slot in. Email, 2FA, passkeys, and personal
API tokens are all additive without touching admin_users.
Blueprint §11.4 updated to reflect what actually shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,11 @@
|
||||
// the same Caddy upstream so the "Test invoke" panel can hit it
|
||||
// without any cross-origin gymnastics.
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { browser } from '$app/environment';
|
||||
import { clearSession, getToken, setSession, type AdminUser } from './auth';
|
||||
|
||||
export interface ScriptSandbox {
|
||||
max_operations?: number;
|
||||
max_string_size?: number;
|
||||
@@ -134,12 +139,26 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
...((init?.headers as Record<string, string>) ?? {})
|
||||
};
|
||||
const tok = getToken();
|
||||
if (tok && !headers['authorization']) {
|
||||
headers['authorization'] = `Bearer ${tok}`;
|
||||
}
|
||||
const res = await fetch(path, { ...init, headers });
|
||||
const text = await res.text();
|
||||
const parsed: unknown = text ? safeJson(text) : null;
|
||||
if (res.status === 401) {
|
||||
// Token gone stale or never present. Drop any cached session
|
||||
// and bounce to login — unless we're already on it, in which
|
||||
// case throw and let the login form render the error.
|
||||
clearSession();
|
||||
if (browser && !window.location.pathname.endsWith('/login')) {
|
||||
void goto(`${base}/login`);
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(parsed && typeof parsed === 'object' && 'error' in parsed
|
||||
@@ -158,11 +177,76 @@ function safeJson(text: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminUserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAdminInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PatchAdminInput {
|
||||
username?: string;
|
||||
password?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
user: AdminUser;
|
||||
token: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => fetch('/healthz').then((r) => r.text()),
|
||||
|
||||
version: () => adminRequest<VersionInfo>('/version'),
|
||||
|
||||
auth: {
|
||||
login: async (username: string, password: string): Promise<AdminUser> => {
|
||||
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
setSession(r.user, r.token);
|
||||
return r.user;
|
||||
},
|
||||
logout: async (): Promise<void> => {
|
||||
try {
|
||||
await adminRequest<null>('/api/v1/admin/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
// Always clear locally — logout is idempotent server-side
|
||||
// and we don't want a network blip to strand the SPA in
|
||||
// a "logged out on server, still logged in client-side"
|
||||
// state.
|
||||
clearSession();
|
||||
}
|
||||
},
|
||||
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me')
|
||||
},
|
||||
|
||||
admins: {
|
||||
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'),
|
||||
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`),
|
||||
create: (input: CreateAdminInput) =>
|
||||
adminRequest<AdminUserRecord>('/api/v1/admin/admins', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
update: (id: string, input: PatchAdminInput) =>
|
||||
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
remove: (id: string) =>
|
||||
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
routes: {
|
||||
listForScript: (scriptId: string) =>
|
||||
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
||||
|
||||
60
dashboard/src/lib/auth.ts
Normal file
60
dashboard/src/lib/auth.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Session state for the dashboard. Backed by a pair of Svelte stores
|
||||
// plus a tiny localStorage echo so a page reload doesn't sign you out.
|
||||
//
|
||||
// The bearer token doubles as the cookie value on the server side, so
|
||||
// in browsers that honor the Set-Cookie response the cookie path "just
|
||||
// works"; the token-in-localStorage path covers the rest (HTTP dev, API
|
||||
// clients impersonating the dashboard) by being injected into the
|
||||
// Authorization header in api.ts.
|
||||
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'picloud.admin.token';
|
||||
|
||||
function readStoredToken(): string | null {
|
||||
if (!browser) return null;
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredToken(value: string | null) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(TOKEN_KEY);
|
||||
else localStorage.setItem(TOKEN_KEY, value);
|
||||
} catch {
|
||||
// Non-fatal: localStorage can be disabled. The session will
|
||||
// just not survive page reloads, but the in-memory store still
|
||||
// works for the current SPA lifetime.
|
||||
}
|
||||
}
|
||||
|
||||
export const token = writable<string | null>(readStoredToken());
|
||||
export const currentUser = writable<AdminUser | null>(null);
|
||||
|
||||
token.subscribe((value) => writeStoredToken(value));
|
||||
|
||||
/** Snapshot of the current token without subscribing — used by the
|
||||
* fetch wrapper. Returns null when no admin is logged in. */
|
||||
export function getToken(): string | null {
|
||||
return get(token);
|
||||
}
|
||||
|
||||
export function setSession(user: AdminUser, raw_token: string) {
|
||||
currentUser.set(user);
|
||||
token.set(raw_token);
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
currentUser.set(null);
|
||||
token.set(null);
|
||||
}
|
||||
Reference in New Issue
Block a user