Files
novaconium/novaconium/lib/Access.php
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

106 lines
4.0 KiB
PHP

<?php
namespace Lib;
use App\AdminAuth;
use App\Response;
/**
* Sidecar-level access control for page content — the way a page (or a
* whole section, one line per page) is assigned to a user, a group, or
* just "anyone logged in". See /admin/docs/access-control. Usage, at the
* top of a sidecar:
*
* if ($denied = Access::require('group:members')) {
* return $denied;
* }
*
* Rules: 'group:<name>' (the user's users.user_group matches), or
* 'user:<username>' (that exact account); several rules mean "any of
* these". No rules at all means any logged-in user. Admins always pass
* every rule. Returns null when the request may proceed, or a Response
* for the sidecar to return: a 303 to /admin/login (with a ?return= path
* back here) when nobody is logged in, or a plain 404 when someone *is*
* logged in but isn't allowed — same hide-don't-tease posture as draft
* pages, and the same plain-text 404 /search returns when disabled.
*
* Public is the default, twice over: a sidecar that never calls this is
* untouched, and a page with no sidecar at all *can't* call it — static
* (cached) pages are always public. That's load-bearing, not incidental:
* only sidecar-less pages are ever written to the static HTML cache
* (which .htaccess serves before PHP runs — see /admin/docs/caching), so
* a gated page, necessarily having a sidecar, is never cached. This
* satisfies the caching/auth standing rule in AGENTS.md by construction
* rather than by a bootstrap.php exclusion like drafts/admin need.
*
* Same open-until-configured posture as the rest of admin auth: with
* admin_auth_enabled off, or while no users exist yet, require() allows
* everything (there'd be nothing to log in as) — and never touches
* Lib\Db, so a site that never opted in never gets a database file.
*
* The content-index crawl runs every sidecar as an anonymous GET, so a
* gated page's sidecar short-circuits to the login redirect during a
* crawl — Renderer::renderForIndex() discards Responses, meaning gated
* pages are automatically absent from /search and /sitemap.xml, with no
* extra wiring. require() has no side effects on deny (the return path
* travels in the redirect URL, not the session) for the same reason: a
* crawl must not scribble on the visitor's session.
*/
final class Access
{
private static ?bool $enabled = null;
public static function require(string ...$rules): ?Response
{
if (!self::enabled() || !AdminAuth::hasUsers()) {
return null;
}
$user = AdminAuth::currentUser();
if ($user === null) {
$path = (string) (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
return Response::redirect('/admin/login?return=' . rawurlencode($path), 303);
}
if ($user['role'] === 'admin' || $rules === []) {
return null;
}
foreach ($rules as $rule) {
if (str_starts_with($rule, 'user:') && substr($rule, 5) === $user['username']) {
return null;
}
if (str_starts_with($rule, 'group:') && $user['user_group'] !== '' && substr($rule, 6) === $user['user_group']) {
return null;
}
}
return Response::html('404 Not Found', 404);
}
/**
* Same two-step config load bootstrap.php/bin scripts and the
* /search sidecar use — Lib\ classes aren't handed $config, so this
* loads its own copy to read admin_auth_enabled (memoized per
* request; static state never survives across requests).
*/
private static function enabled(): bool
{
if (self::$enabled === null) {
$config = require __DIR__ . '/../config.php';
$appConfigFile = __DIR__ . '/../../App/config.php';
if (is_file($appConfigFile)) {
$config = array_merge($config, require $appConfigFile);
}
self::$enabled = (bool) $config['admin_auth_enabled'];
}
return self::$enabled;
}
}