Examples
Admin console
Every privileged action is checked, scoped and recorded — including the ones an admin performs as somebody else.
Guard the admin surface
A flag on the session is not enough. Guard the routes, re-check the permission on every action, and write an audit row for anything that changes access.
import { ForbiddenError, UnauthorizedError } from "yatta/auth";import type { Middleware } from "yatta/api";import type { PublicUser } from "yatta/auth";import { auth } from "./auth"; export interface AdminRequest { userId: string; roles: string[]; ip: string;} /** * Require an admin role. * * Requires the permission rather than matching a role string, so adding a * "support" role that can read users is a config change, not a code change. */export const requireAdmin: Middleware = async (ctx, next) => { const user = (await auth.getSession(ctx.req)) as PublicUser | null; if (!user) throw new UnauthorizedError(); if (!auth.permissions.check(user.roles, "user:manage", "user")) { throw new ForbiddenError(); } // Stash the identity so handlers do not re-resolve it, and so the audit row // records who acted even if the session is later revoked. ctx.state.admin = { userId: user.id, roles: user.roles, ip: ctx.req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown", } satisfies AdminRequest; return next();}; export function actor(ctx: { state: Record<string, unknown> }): AdminRequest { return ctx.state.admin as AdminRequest;}User management
import { createAPI, HttpError } from "yatta/api";import { and, col } from "yatta/db";import { db } from "../../func/db";import { auth } from "../../func/auth";import { requireAdmin, actor } from "../../func/admin-guard";import { record } from "../../func/audit"; const route = createAPI("/admin/users"); route.use(requireAdmin); route.get("/", async (ctx) => { const q = ctx.query().q; const limit = Math.min(Number(ctx.query().limit ?? 50), 200); const users = q ? await db.users .where((f) => f.email.contains(String(q))) .orderBy({ createdAt: "desc" }) .limit(limit) .all() : await db.users.orderBy({ createdAt: "desc" }).limit(limit).all(); // Never return password material, even to an admin. return Response.json(users.map(({ passwordHash, ...safe }) => safe));}); route.post("/:id/ban", async (ctx) => { const admin = actor(ctx); const target = await db.users.findById(ctx.params.id); if (!target) throw new HttpError(404, "Unknown user"); await db.users.where((f) => f.id.isEqualTo(ctx.params.id)) .update({ bannedAt: new Date() }); // Kill every live session, or the ban is decorative. // Session revocation lives on the store; Auth exposes no shortcut for it. await auth.store.deleteSessionsByUserId(ctx.params.id); await record({ tenantId: null, actorId: admin.userId, action: "user.ban", target: `user:${ctx.params.id}`, before: { bannedAt: null }, after: { bannedAt: new Date().toISOString() }, }); return new Response(null, { status: 204 });});Audited impersonation
Impersonation is the most dangerous thing an admin panel offers. It needs a short lifetime, a visible banner, and an audit row written before the session is created.
import { createAPI, HttpError } from "yatta/api";import { auth } from "../../func/auth";import { record } from "../../func/audit";import { requireAdmin, actor } from "../../func/admin-guard";import { observer } from "../../func/observe"; const route = createAPI("/admin"); route.use(requireAdmin); const MAX_MINUTES = 15; route.post("/impersonate/:id", async (ctx) => { const admin = actor(ctx); if (ctx.params.id === admin.userId) { throw new HttpError(400, "Already you"); } const target = await db.users.findById(ctx.params.id); if (!target) throw new HttpError(404, "Unknown user"); if (target.bannedAt) throw new HttpError(409, "User is banned"); // Audit first. If this write fails we must not create the session. await record({ tenantId: null, actorId: admin.userId, action: "user.impersonate", target: `user:${ctx.params.id}`, before: null, after: { minutes: MAX_MINUTES, from: admin.ip }, }); // There is no Auth.createSession(). Minting a session for another user means // building the AuthSession yourself and handing it to the store — note that // this bypasses the normal sign-in path, so the audit record above is the only // thing tying the resulting session back to the admin who made it. const sessionId = crypto.randomUUID(); const rawSessionToken = auth.crypto.randomToken(); const refreshJwt = auth.jwt.sign({ sub: target.id, sid: sessionId, type: "refresh", ver: 0, expInSec: MAX_MINUTES * 60, }); const session = await auth.store.createSession({ id: sessionId, userId: target.id, sessionTokenHash: auth.crypto.hash(rawSessionToken), refreshTokenHash: auth.crypto.hash(refreshJwt), expiresAt: new Date(Date.now() + MAX_MINUTES * 60_000), refreshVersion: 0, userAgent: ctx.req.headers.get("user-agent") ?? undefined, ip: admin.ip, lastSeenAt: new Date(), lastAuthenticatedAt: new Date(), createdAt: new Date(), }); observer.log.warn("Admin impersonation started", { admin: admin.userId, target: target.id, minutes: MAX_MINUTES, }); return Response.json({ ...session, // The client shows this until the session expires. banner: `You are viewing as ${target.email}. This ends in ${MAX_MINUTES} minutes.`, });}); /** Refuse any write that is not a read while impersonating. */route.post("/impersonate/stop", async (ctx) => { await auth.signOut(ctx.req); return Response.json({ ok: true });});Warning
Write the audit row before creating the session. If the process dies between the two, you want an audit entry with no session; you do not want an unaudited live session.
List and revoke sessions
route.get("/users/:id/sessions", async (ctx) => { const sessions = await db.authSessions .where((f) => f.userId.isEqualTo(ctx.params.id)) .orderBy({ lastSeenAt: "desc" }) .all(); return Response.json(sessions.map((s) => ({ id: s.id, createdAt: s.createdAt, lastSeenAt: s.lastSeenAt, ip: s.ip, // Enough to recognise the device, not enough to be a fingerprint. userAgent: s.userAgent?.slice(0, 120), current: s.id === ctx.cookies().yatta_session, })));}); route.delete("/sessions/:id", async (ctx) => { await auth.store.deleteSession(ctx.params.id); await record({ tenantId: null, actorId: actor(ctx).userId, action: "session.revoke", target: `session:${ctx.params.id}`, before: null, after: null, }); return new Response(null, { status: 204 });});The read-only guard
During an investigation an admin usually needs to look, not touch. A read-only flag on the impersonated session turns every write route into a 403 without touching each one.
/** * Block writes while an admin is impersonating. * * Applied to the impersonation session only, so normal admin writes are * unaffected. */export const denyWritesWhileImpersonating: Middleware = async (ctx, next) => { const user = await auth.getUser(ctx.req); const impersonating = user?.impersonatedBy; if (impersonating && !["GET", "HEAD", "OPTIONS"].includes(ctx.req.method)) { observer.log.warn("Blocked write during impersonation", { admin: impersonating, path: new URL(ctx.req.url).pathname, }); throw new HttpError(403, "Read-only session"); } return next();};