Restrict admin access by role
A site with more than one kind of editor usually needs more than “signed in or not”: a publisher
edits posts but not pages, a club-admin reaches the money screens but a webmaster doesn’t. The
access map is cairn’s answer, one declaration that gates both the route and the sidebar so they
can never say two different things. This guide assumes you’ve already declared a role vocabulary
with defineRoles; if you haven’t, start with Give a role its own
admin area.
Declare the map
defineAccess takes your role vocabulary and a map of targets to the role names admitted to each
one. A target is either one of cairn’s own screens, by concept id or one of the fixed utility
screens (media, vocabulary, nav, settings), or one of your own /admin-prefixed routes:
// src/lib/cairn.access.ts
import { defineAccess } from '@glw907/cairn-cms';
import { roles } from './cairn.config.js';
export const access = defineAccess(roles, {
pages: ['webmaster'],
media: ['webmaster', 'publisher'],
vocabulary: ['webmaster'],
'/admin/money': ['club-admin'],
});
A screen or route absent from the map keeps today’s behavior: any editor-capability session
reaches it, mapped or not, so adding the map to one screen doesn’t quietly lock out every screen
you haven’t gotten to yet. Owner capability always passes, regardless of what the map says; a
site can’t lock its own owners out. posts is absent above, so every editor, publisher and
webmaster alike, still reaches it.
Wire the same map onto the adapter’s access member and onto the guard, the same two-places
pattern roles already follows:
// src/lib/cairn.config.ts
import { defineAdapter } from '@glw907/cairn-cms';
import { roles } from './cairn.config.js';
import { access } from './cairn.access.js';
export const cairn = defineAdapter({
// ...content, backend, email, rendering...
roles,
access,
});
// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import { createAuthGuard } from '@glw907/cairn-cms/sveltekit';
import { roles } from './lib/cairn.config.js';
import { access } from './lib/cairn.access.js';
import { theme } from './theme-handle.js';
export const handle = sequence(theme, createAuthGuard({ roles, access }));
Wire the map in both places, or it does nothing. createAuthGuard({ roles, access }) and the
adapter’s access member aren’t two views onto one setting. Each is its own wiring, and a site
that passes the map to only one of them ends up with a silent misconfiguration, not a startup
error. canReach treats an undefined access map as “no restrictions,” the same zero-config
default a site with no map at all gets, so every engine screen quietly falls back to any
editor-capability session while a custom route’s requireAccess call keeps enforcing correctly,
because it reads the map from the adapter, not the guard. The failure mode looks like the map
“mostly working”: your own /admin/money route still refuses the right roles, but pages and
media stay open to everyone. Declare access once, in its own module the way this guide’s
snippets do, and import that one value into both createAuthGuard and defineAdapter, the same
two-places pattern roles already follows.
defineAccess validates at construction: an empty map, a role name outside your vocabulary, an
empty role list (write owner-only explicitly as ['owner']), or a key that’s neither a plausible
screen id nor a well-formed /admin-prefixed path all throw an actionable error naming the bad
key. A screen-id key’s existence against your real concepts, and an href key’s collision with a
built-in admin route, validate a moment later, when the runtime composes the whole adapter, so a
typo’d concept id or a route that shadows a built-in view fails the same way a bad navLayout
entry does.
Gate your own routes
requireAccess is the one-line authorization story for a custom route: the session the guard
already resolved, checked against the map for the request path, or a 403.
// src/routes/admin/club/money/+page.server.ts
import { requireAccess } from '@glw907/cairn-cms/sveltekit';
export const load = (event) => {
const editor = requireAccess(event); // denies every role the map doesn't name for this path
return { displayName: editor.displayName };
};
The zero-argument call reads event.route.id, never event.url.pathname: on a catch-all route
the request path is attacker-chosen while the route id isn’t, so a map stays keyed by the route’s
compile-time shape rather than whatever a request happens to carry. A static route’s id and path
are the same string, so this reads no differently for the preceding worked example. A parameterized
or catch-all route’s map key needs the bracket-form route id (/admin/posts/[id]), never a
concrete path. Pass an explicit target when a route’s action needs to check a different path
than the one it’s mounted at. There’s one sharp edge worth knowing before you reach for this
helper: an unmatched target, the map has no key that covers it at all, refuses every session,
owner included, not just the roles the map doesn’t name. The helper’s contract is “this route
opted into the map, and the map has no opinion on it,” a misconfiguration made loud, not an access
decision, so canReach’s owner bypass doesn’t apply here. If a route wants the zero-config
any-editor behavior instead, call requireSession or requireEditor and don’t map that path at
all.
Deny at the route, never merely hide
Nav placement is never authorization. Hiding a screen from the sidebar, whether through a
navLayout node’s hidden: true or simply because a role can’t see it, doesn’t stop a signed-in
editor from reaching it by typing the URL directly. The access map is what actually closes the
door: declare it, and the sidebar and the route agree by construction, because both read the same
canReach function. A site that only removes a
menu item and never maps the route has built a UI convenience, not a permission system; the
0.85.0-era roles field on a navLayout node still exists for exactly that lighter case (see
Organize your admin nav), but it’s a visibility
hint, never enforcement.
The media-picker landmine
Restricting the media screen restricts the media routes underneath it, including the ones the
concept editor’s own image picker calls. If a role edits an image-bearing concept, it needs
media reachable too, or its editor sees a broken picker on every entry with an image field. The
worked map at the top of this guide grants publisher access to media for exactly this reason,
alongside pages, which publisher cannot edit. cairn doesn’t special-case this: splitting
“can browse the library” from “can manage it” is a real distinction the engine doesn’t grow until
a site actually needs it, so today the grant is all-or-nothing per role.
Verify it
Sign in as each mapped role and confirm it reaches exactly the screens and routes its rows name,
that the sidebar shows exactly the same set (a mapped-out door disappears from the nav too, since
resolveNavLayout reads the same map), and that
typing a restricted screen’s URL directly answers 403 rather than rendering it. Sign in as an
owner and confirm every mapped screen still opens.
One transport caveat when you verify with curl rather than a browser: the denial renders the
403 error page and emits auth.access.denied, but the response’s numeric status can read 200.
The admin shell’s layout load streams its pending-drafts count, and SvelteKit commits the status
before a streamed sibling load rejects (sveltejs/kit#12533).
The enforcement holds: the response carries no restricted data. Judge a scripted check by
the rendered error page or the log event, not the status line, until the upstream fix lands. If a mapped role edits an image-bearing
concept, confirm its picker still resolves images rather than erroring on a refused media call.
Related reference
Access map documents defineAccess, canReach, and
hasAccessRule in full. requireAccess documents the
helper’s unmatched-path contract in depth. Organize your admin
nav covers the declarative roles visibility hint the map
supersedes for enforcement, plus collapse defaults, icon overrides, and attention badges. Give a
role its own admin area covers defineRoles and the none
capability this guide’s vocabulary builds on.