Files
novaconium/novaconium/pages/admin/docs/access-control/index.twig
T
code b882c304b1 Replace Basic Auth with multi-user login, roles, groups, and Lib\Access
Admin login & user management (novaconium/ISSUES.md): session-based
login against a SQLite users table replaces the single-user HTTP Basic
Auth stopgap (admin_username/admin_password_hash and /admin/password-hash
are gone; one admin_auth_enabled flag, off by default with zero DB
footprint). New /admin/login, /admin/logout (POST-only, real page), and
/admin/users pages plus bin/create-admin-user.php.

First user created is the admin; everyone after is registered with a
unique normalized email and an optional group. /admin/* and drafts are
admin-only; Lib\Access gates page content from sidecars
(Access::require('group:members')) with login-redirect/404 responses —
public by default, static pages always public by construction. User
management covers disable/enable, delete, promote/demote, group, email,
and password, with last-active-admin lockout guards.

Also: Session::regenerate() against fixation, friendly missing-PDO-driver
errors in Lib\Db, docs at /admin/docs/access-control and updates across
admin-auth/drafts/sidecars/config/libraries and README/AGENTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:17:54 +00:00

71 lines
5.2 KiB
Twig

{% 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 %}
<h1>Access control</h1>
<p><code>Lib\Access</code> (<code>novaconium/lib/Access.php</code>) assigns a page to a user, a group, or just "anyone logged in" — from the page's own sidecar, using the accounts <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> manages at <a href="/admin/users">/admin/users</a>. One call at the top of <code>index.php</code>:</p>
<pre><code>&lt;?php
use Lib\Access;
if ($denied = Access::require('group:members')) {
return $denied;
}
return [
// ...normal sidecar context...
];</code></pre>
<p><code>Access::require()</code> returns <code>null</code> when the request may proceed, or a <code>Response</code> the sidecar returns as-is: nobody logged in → a <code>303</code> to <a href="/admin/login">/admin/login</a> carrying a <code>?return=</code> path so a successful login lands right back on the page they wanted; logged in but not allowed → a plain <code>404</code>, the same hide-don't-tease posture as <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft pages</a>.</p>
<h2>Rules</h2>
<ul>
<li><code>Access::require()</code> — no rules: any logged-in user (admin or registered).</li>
<li><code>Access::require('group:members')</code> — users whose group (assigned at <a href="/admin/users">/admin/users</a>) is <code>members</code>. Each user has at most one group; a group is just a text label, matched exactly — there's no groups table to manage.</li>
<li><code>Access::require('user:bob')</code> — exactly that account.</li>
<li><code>Access::require('group:members', 'user:bob')</code> — several rules mean <em>any</em> of them grants access.</li>
</ul>
<p><strong>Admins always pass every rule</strong> — the admin role exists to run the site, so there's no way to write a rule that locks an admin out of content.</p>
<h2>Public is the default — and static pages are always public</h2>
<p>A sidecar that never calls <code>Access::require()</code> is completely untouched by all of this, and a page with no sidecar at all <em>can't</em> 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 <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}static HTML cache</a>, 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 <code>/admin/*</code> need explicit cache exclusions for is satisfied here by construction.</p>
<h2>Gating a section</h2>
<p>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 <code>_access.php</code> file in the section directory (any file that isn't <code>index.php</code>/<code>index.twig</code> is invisible to the router) and <code>require</code> it from each sidecar — a PHP file can return a value, so it composes exactly like a direct call:</p>
<pre><code>&lt;?php
// App/pages/members/_access.php — the section's one shared rule
use Lib\Access;
return Access::require('group:members');</code></pre>
<pre><code>&lt;?php
// App/pages/members/anything/index.php — each page in the section
if ($denied = require dirname(__DIR__) . '/_access.php') {
return $denied;
}
return [];</code></pre>
<h2>Interactions worth knowing</h2>
<ul>
<li><strong>Off means open.</strong> With <code>admin_auth_enabled</code> off (or while no users exist yet), <code>Access::require()</code> 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 <code>Lib\Db</code> in that state.</li>
<li><strong>Gated pages stay out of <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}search and the sitemap</a> automatically.</strong> The content-index crawl runs every sidecar as an anonymous GET, so a gated sidecar short-circuits to the login redirect and the crawler skips the page — nothing to configure, verified for real. <code>require()</code> 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.</li>
<li><strong>Who's logged in?</strong> A sidecar that wants to greet the user (or vary content by account) can read <code>App\AdminAuth::currentUser()</code> — <code>id</code>/<code>username</code>/<code>email</code>/<code>role</code>/<code>user_group</code>, or <code>null</code> — and pass what it needs into its template context.</li>
</ul>
{% endblock %}