Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ follows [Semantic Versioning](https://semver.org).
Run `dune migrate:config` (core) to rewrite `config/system.yaml`. A config that
still carries an old key is refused by core at load time, so this cannot be
silently ignored. Requires `@dune/core` 0.36 or newer.
- **Security policy, sessions, users and passwords come from core's `services`**
(`MountApi.services`, `@dune/core/plugin-api`) instead of imports from
`@dune/core`: HTML sanitising, SSRF-guarded fetch, body limits, client IP, the
YAML parser, password hashing/verification, the user store, rate limiting and
the logger. A core patch release now fixes them for the admin without an admin
release, and session storage (local, KV, Redis) follows `system.session_store`
including its per-site `prefix`. Errors are matched by `isDuneError(err, code)`,
not `instanceof`. Removed: the `hashPassword` / `verifyPassword` / `DUMMY_HASH`
re-exports from `@dune/plugin-admin/admin` (import them from
`@dune/core/auth/passwords`), and the old `createSessionManager` signature
(now async, takes `services`). Requires `@dune/core` with `plugin-api` (0.36).
- **The data directory is `system.data_dir` only.** `admin.data_dir` no longer
exists (core removed the deprecated form aliases); the plugin resolves the
directory with `resolveDataDir()` from `@dune/core/forms`.
Expand Down
32 changes: 11 additions & 21 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ import type { StorageAdapter } from "@dune/core/storage";
import { createUserManager } from "./src/admin/auth/users.ts";
import { highestValidRole } from "./src/admin/auth/role-utils.ts";
import { createSessionManager } from "./src/admin/auth/sessions.ts";
import { createSessionStore } from "@dune/core/session";
import { createAuthMiddleware } from "./src/admin/auth/middleware.ts";
import { LocalRateLimitStore } from "@dune/core/security";
import { LocalAuthProvider } from "./src/admin/auth/local-provider.ts";
import type { AuthProvider } from "./src/admin/auth/provider.ts";
import { createWorkflowEngine } from "@dune/core/workflow";
Expand All @@ -45,7 +43,6 @@ import { initAdminContext } from "./src/admin/context.ts";
import type { AdminContext } from "./src/admin/context.ts";
import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx";
import { getDuneAdminIslands, mountDuneAdmin, mountDuneAdminEarly } from "./src/admin/mount.ts";
import { logger } from "@dune/core/logger";
import { sectionRegistry } from "@dune/core/sections";
import type { SectionRegistry } from "@dune/core/sections";
import * as corePlugins from "@dune/core/plugins";
Expand Down Expand Up @@ -164,7 +161,8 @@ export function createAdminPlugin(
setupState = { config, storage, opts, auditLogger, mt };
},

async mount({ app, bootstrap, adminServices }: MountApi) {
async mount({ app, bootstrap, adminServices, services }: MountApi) {
const { logger } = services;
if (!setupState) return; // admin disabled

const { opts: o } = setupState;
Expand All @@ -183,6 +181,7 @@ export function createAdminPlugin(
const users = createUserManager({
storage,
usersDir: `${dataDir}/users`,
services,
});

// Migration warning: detect users left in the old .dune/admin/users location
Expand All @@ -200,25 +199,13 @@ export function createAdminPlugin(
} catch { /* ignore */ }
}

const sessionStoreCfg = config.system?.session_store;
const resolvedSessionStore = await createSessionStore({
type: sessionStoreCfg?.type ?? "local",
redisUrl: sessionStoreCfg?.url
? (sessionStoreCfg.url.startsWith("$")
? Deno.env.get(sessionStoreCfg.url.slice(1))
: sessionStoreCfg.url)
: undefined,
storage,
const sessions = await createSessionManager({
services,
sessionsDir: `${runtimeDir}/sessions`,
lifetimeMs: adminCfg.session_lifetime * 1000,
});

const sessions = createSessionManager({
store: resolvedSessionStore,
lifetime: adminCfg.session_lifetime,
});

const rateLimitStore = new LocalRateLimitStore();
const rateLimitStore = services.rateLimit.createLocalStore();

// Auth provider: injection from bootstrap options > config > local default.
let authProvider: AuthProvider;
Expand All @@ -227,7 +214,7 @@ export function createAdminPlugin(
} else {
const provCfg = config.admin?.auth_provider;
if (!provCfg || provCfg.type === "local") {
authProvider = new LocalAuthProvider(users);
authProvider = new LocalAuthProvider(users, services.passwords);
} else if (provCfg.type === "ldap" || provCfg.type === "saml") {
throw new Error(
`[dune] auth_provider.type "${provCfg.type}" is not implemented. ` +
Expand All @@ -246,6 +233,7 @@ export function createAdminPlugin(
users,
secure: secureCookies,
trustForwardedFor: config.system?.trusted_proxies === true,
security: services.security,
});

const workflow = createWorkflowEngine(
Expand All @@ -270,7 +258,7 @@ export function createAdminPlugin(
return;
}
try {
await applyWorkflowTransition({ engine: bootstrap.engine, storage, config, hooks: bootstrap.hooks, workflow }, action.sourcePath, newStatus);
await applyWorkflowTransition({ engine: bootstrap.engine, storage, config, hooks: bootstrap.hooks, workflow, services }, action.sourcePath, newStatus);
} catch (err) {
logger.error("scheduler.action-failed", {
actionId: action.id,
Expand All @@ -290,6 +278,7 @@ export function createAdminPlugin(
engine: bootstrap.engine,
history: bootstrap.history,
contentDir: config.system.content.dir,
security: services.security,
});

// Core accepts and stores public form submissions; the admin only
Expand Down Expand Up @@ -329,6 +318,7 @@ export function createAdminPlugin(
.flatMap((p) => p.adminPages ?? []);

const adminContext: AdminContext = {
services,
engine: bootstrap.engine,
search: bootstrap.search,
storage,
Expand Down
8 changes: 4 additions & 4 deletions src/admin/auth/local-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@

import type { AuthProvider, AuthCredentials, AuthProviderUser } from "./provider.ts";
import type { UserManager } from "./users.ts";
import { verifyPassword, DUMMY_HASH } from "@dune/core/auth/passwords";
import type { PasswordService } from "@dune/core/plugin-api";

export class LocalAuthProvider implements AuthProvider {
readonly type = "local" as const;

constructor(private users: UserManager) {}
constructor(private users: UserManager, private passwords: Pick<PasswordService, "verify" | "dummyHash">) {}

async authenticate(creds: AuthCredentials): Promise<AuthProviderUser | null> {
const user = await this.users.getByUsername(creds.username);
// Always run verifyPassword even when the user is not found to prevent
// username enumeration via response-time differences (timing oracle).
const hash = user?.passwordHash ?? DUMMY_HASH;
const valid = await verifyPassword(creds.password, hash);
const hash = user?.passwordHash ?? this.passwords.dummyHash;
const valid = await this.passwords.verify(creds.password, hash);
if (!valid || !user || !user.enabled) return null;
return {
externalId: user.id,
Expand Down
6 changes: 4 additions & 2 deletions src/admin/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import type { SessionManager } from "./sessions.ts";
import type { UserManager } from "./users.ts";
import type { AuthResult } from "../types.ts";
import { clientIp } from "@dune/core/security";
import type { SecurityService } from "@dune/core/plugin-api";

/** Options for {@link createAuthMiddleware}. */
export interface AuthMiddlewareConfig {
Expand All @@ -29,6 +29,8 @@ export interface AuthMiddlewareConfig {
* a cookie thief can spoof the original IP via a forged header.
*/
trustForwardedFor?: boolean;
/** Core's security service (client IP resolution). */
security: Pick<SecurityService, "clientIp">;
}

/** Validates session cookies. Obtain via {@link createAuthMiddleware}. */
Expand Down Expand Up @@ -74,7 +76,7 @@ export function createAuthMiddleware(
// also send a forged forwarded header that matches the original session
// IP, and the binding check is meaningless.
if (session.ip) {
const requestIp = clientIp(req, { trustForwardedFor });
const requestIp = config.security.clientIp(req, { trustForwardedFor });
// "unknown" is a missing peer (no socket stamp, no trusted XFF).
// A stolen cookie that omits forwarded headers used to skip this
// check; treat a missing IP as a mismatch when the session is bound.
Expand Down
52 changes: 12 additions & 40 deletions src/admin/auth/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,18 @@
* over whichever SessionStore backend (local/KV/Redis) is configured. This
* file exists to keep the original SessionManager interface stable for
* existing call sites (create() has no embeddedUser param — admin sessions
* never need one) and to preserve the storage/sessionsDir convenience
* construction path.
* never need one).
*/

import type { StorageAdapter } from "@dune/core/storage";
import type { Session } from "../types.ts";
import type { SessionStore } from "@dune/core/session";
import { createLocalSessionStore, createSessionManager as createCoreSessionManager } from "@dune/core/session";

export type { SessionStore };
import type { PluginServices } from "@dune/core/plugin-api";

/** Options for {@link createSessionManager}. */
export interface SessionManagerConfig {
/** Core's services; the session backend is chosen by core from `system.session_store`. */
services: Pick<PluginServices, "sessions">;
/**
* Pre-constructed session store. When provided, `storage` and `sessionsDir`
* are ignored.
*/
store?: SessionStore;
/**
* StorageAdapter for the local (file-backed) store.
* Required when `store` is not supplied.
*/
storage?: StorageAdapter;
/**
* Directory for session files when using the local backend.
* Directory for session files when the local backend is configured.
* E.g. ".dune/admin/sessions"
*/
sessionsDir?: string;
Expand All @@ -53,29 +40,14 @@ export interface SessionManager {
}

/**
* Create a session manager backed by the given store.
*
* When `config.store` is not supplied, falls back to constructing a
* LocalSessionStore from `config.storage` + `config.sessionsDir`, preserving
* the original file-backed behaviour for existing callers.
* Create the admin session manager. Core builds the underlying manager over
* the configured backend (local files, Deno KV or Redis, scoped per site).
*/
export function createSessionManager(config: SessionManagerConfig): SessionManager {
const { lifetime } = config;

const store: SessionStore = config.store ?? (() => {
if (!config.storage) {
throw new Error(
"[dune] createSessionManager: either 'store' or 'storage' must be provided.",
);
}
return createLocalSessionStore({
storage: config.storage,
sessionsDir: config.sessionsDir ?? ".dune/admin/sessions",
lifetimeMs: lifetime * 1000,
});
})();

const core = createCoreSessionManager(store, lifetime * 1000);
export async function createSessionManager(config: SessionManagerConfig): Promise<SessionManager> {
const core = await config.services.sessions.createManager({
sessionsDir: config.sessionsDir ?? ".dune/admin/sessions",
lifetimeMs: config.lifetime * 1000,
});

return {
// Admin sessions never carry an embeddedUser — the underlying factory's
Expand Down
10 changes: 6 additions & 4 deletions src/admin/auth/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@
*/

import type { StorageAdapter } from "@dune/core/storage";
import { createLocalUserStore } from "@dune/core/auth/user-store";
import type { UserStore } from "@dune/core/auth/user-store";
import type { User, Role } from "../types.ts";
import { hashPassword } from "@dune/core/auth/passwords";
import type { PluginServices } from "@dune/core/plugin-api";
import { withRole } from "./role-utils.ts";

/** Options for {@link createUserManager}. */
export interface UserManagerConfig {
storage: StorageAdapter;
/** Directory for user files (e.g. "data/users") */
usersDir: string;
/** Core's services: the user store and password hashing. */
services: Pick<PluginServices, "users" | "passwords">;
}

/** Input for {@link UserManager.create}. */
Expand Down Expand Up @@ -63,8 +64,9 @@ export interface UserManager {
* Create a user manager backed by @dune/core's UserStore.
*/
export function createUserManager(config: UserManagerConfig): UserManager {
const { storage, usersDir } = config;
const store: UserStore = createLocalUserStore({ storage, usersDir });
const { usersDir, services } = config;
const store: UserStore = services.users.createLocalStore({ usersDir });
const hashPassword = services.passwords.hash;

async function create(input: CreateUserInput): Promise<User> {
return store.create({
Expand Down
3 changes: 3 additions & 0 deletions src/admin/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { AuditLogger } from "@dune/core/audit";
import type { MetricsCollector } from "@dune/core/metrics";
import type { MachineTranslator } from "@dune/core/mt";
import type { RateLimitStore } from "@dune/core/security";
import type { PluginServices } from "@dune/core/plugin-api";
import type { DuneAuthSystem } from "@dune/core/auth/authz";
import type { SearchManager } from "@dune/core/search";
import type { SectionRegistry } from "@dune/core/sections";
Expand All @@ -36,6 +37,8 @@ export type { AdminPageRegistration };

/** All services available to admin route handlers via `FreshContext.state.adminContext`. */
export interface AdminContext {
/** Services core hands plugins (security policy, sessions, passwords, ...). */
services: PluginServices;
engine: DuneEngine;
search: SearchManager;
storage: StorageAdapter;
Expand Down
2 changes: 0 additions & 2 deletions src/admin/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ export type { UserManager, UserManagerConfig, CreateUserInput } from "./auth/use
export { createAuthMiddleware } from "./auth/middleware.ts";
export type { AuthMiddleware, AuthMiddlewareConfig } from "./auth/middleware.ts";

export { hashPassword, verifyPassword, DUMMY_HASH } from "@dune/core/auth/passwords";

export type {
AuthProvider,
AuthProviderUser,
Expand Down
7 changes: 3 additions & 4 deletions src/admin/routes/api/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type { FreshContext } from "fresh";
import type { AdminPermission, AdminState } from "../../types.ts";
import { highestValidRole } from "../../auth/role-utils.ts";
import { csrfTokenMatches, resolveCsrfSecret } from "../../auth/csrf.ts";
import { clientIp } from "@dune/core/security";

// json()/serverError()/typed error classes live in ../../http.ts so the
// public API (public-api.ts) and admin routes share one implementation.
Expand All @@ -29,7 +28,7 @@ function logAuthzDenial(
void auditLogger.log({
event,
actor: actorFromAuth(ctx.state.auth ?? {}),
ip: getClientIp(ctx.req),
ip: getClientIp(ctx),
userAgent: ctx.req.headers.get("user-agent") ?? null,
target: { type: "route", id: ctx.url.pathname },
detail,
Expand Down Expand Up @@ -296,8 +295,8 @@ export function validatePagePath(p: string): boolean {
* attribution. Callers that need the raw claimed IP should read the headers
* themselves and record them as untrusted metadata.
*/
export function getClientIp(req: Request): string | null {
const ip = clientIp(req, { trustForwardedFor: false });
export function getClientIp(ctx: FreshContext<AdminState>): string | null {
const ip = ctx.state.adminContext.services.security.clientIp(ctx.req, { trustForwardedFor: false });
return ip === "unknown" ? null : ip;
}

Expand Down
5 changes: 2 additions & 3 deletions src/admin/routes/api/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import type { AdminState } from "../../../types.ts";
import { requirePermission, json, serverError, csrfCheck } from "../_utils.ts";
import { stringify as stringifyYaml } from "@std/yaml";
import { parseUserYaml as parseYaml } from "@dune/core/security";
import type { FreshContext } from "fresh";

export const handler = {
Expand All @@ -21,7 +20,7 @@ export const handler = {
const denied = await requirePermission(ctx, "config.update");
if (denied) return denied;

const { engine, storage } = ctx.state.adminContext;
const { engine, storage, services } = ctx.state.adminContext;
try {
const body = await ctx.req.json() as {
title?: string; description?: string; url?: string;
Expand All @@ -32,7 +31,7 @@ export const handler = {

// Read-modify-write config/site.yaml (same approach as switchTheme)
const existingRaw = await storage.readText("config/site.yaml").catch(() => "");
const existing = ((parseYaml(existingRaw || "") ?? {}) as Record<string, unknown>);
const existing = ((services.security.parseUserYaml(existingRaw || "") ?? {}) as Record<string, unknown>);

if (body.title !== undefined) existing.title = body.title;
if (body.description !== undefined) existing.description = body.description;
Expand Down
2 changes: 1 addition & 1 deletion src/admin/routes/api/media/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const handler = {
void auditLogger?.log({
event: "media.delete",
actor: actorFromAuth(ctx.state.auth),
ip: getClientIp(ctx.req),
ip: getClientIp(ctx),
userAgent: ctx.req.headers.get("user-agent") ?? null,
target: { type: "media", id: name },
detail: {},
Expand Down
Loading
Loading