{% extends 'admin/docs/_layout/layout.twig' %} {% import '_layout/icons.twig' as icons %} {% block title %}Access control{% endblock %} {% block description %}Assign a page or section to a user or group from its sidecar with Lib\Access — public by default, static pages always public.{% endblock %} {% block robots %}noindex, nofollow{% endblock %} {% block docs_content %}
Lib\Access (novaconium/lib/Access.php) assigns a page to a user, a group, or just "anyone logged in" — from the page's own sidecar, using the accounts {{ icons.lock() }}Admin authentication manages at /admin/users. One call at the top of index.php:
<?php
use Lib\Access;
if ($denied = Access::require('group:members')) {
return $denied;
}
return [
// ...normal sidecar context...
];
Access::require() returns null when the request may proceed, or a Response the sidecar returns as-is: nobody logged in → a 303 to /admin/login carrying a ?return= path so a successful login lands right back on the page they wanted; logged in but not allowed → a plain 404, the same hide-don't-tease posture as {{ icons.lock() }}draft pages.
Access::require() — no rules: any logged-in user (admin or registered).Access::require('group:members') — users whose group (assigned at /admin/users) is members. Each user has at most one group; a group is just a text label, matched exactly — there's no groups table to manage.Access::require('user:bob') — exactly that account.Access::require('group:members', 'user:bob') — several rules mean any of them grants access.Admins always pass every rule — the admin role exists to run the site, so there's no way to write a rule that locks an admin out of content.
A sidecar that never calls Access::require() is completely untouched by all of this, and a page with no sidecar at all can't call it — so sidecar-less pages are always public. That's load-bearing rather than incidental: only sidecar-less pages are ever written to the {{ icons.book() }}static HTML cache, which Apache serves before PHP (and therefore any access check) runs. Because a gated page necessarily has a sidecar, it's never statically cached, so there's no way to leak a gated page through the cache — the caching/auth rule that drafts and /admin/* need explicit cache exclusions for is satisfied here by construction.
There's no per-directory config for this — a "section" is gated by giving each page in it a sidecar with the same check, which stays visible and greppable at the page level. To keep the rule itself in one place, put a _access.php file in the section directory (any file that isn't index.php/index.twig is invisible to the router) and require it from each sidecar — a PHP file can return a value, so it composes exactly like a direct call:
<?php
// App/pages/members/_access.php — the section's one shared rule
use Lib\Access;
return Access::require('group:members');
<?php
// App/pages/members/anything/index.php — each page in the section
if ($denied = require dirname(__DIR__) . '/_access.php') {
return $denied;
}
return [];
admin_auth_enabled off (or while no users exist yet), Access::require() allows everything — there'd be nothing to log in as. Same open-until-configured posture as the rest of admin auth, and the same zero-footprint guarantee: it never touches Lib\Db in that state.require() also has no side effects on deny (the return path travels in the redirect URL, not the session), so a crawl can't scribble on the visiting user's session.App\AdminAuth::currentUser() — id/username/email/role/user_group, or null — and pass what it needs into its template context.