Adds a scope-based access control layer to all 81 existing API routes. Sits alongside existing session-cookie auth (does not replace it). What this PR does: - Audits every existing route and documents required scope per route (see documentation/auth/route-scope-audit.md) - Adds classes/auth/scope.php with 10 scope constants and role→scope defaults - Adds classes/auth/scope_middleware.php with requireScope/requireAnyScope/requireRole - Applies require*() calls to all 81 existing routes - Adds ScopeMiddlewareTest (unit, 178 lines) and RouteScopeTest (integration, 212 lines) Coexistence note: This branch's classes/auth/scope.php is a stub that will be replaced by classes/auth/scope_registry.php (from TRU-145 / PR #396) when that PR merges first. The two have compatible APIs. Refs: TRU-149
315 lines
11 KiB
PHP
315 lines
11 KiB
PHP
<?php
|
|
|
|
namespace app\auth;
|
|
|
|
use classes\response;
|
|
use Exception;
|
|
use objects\logs_o;
|
|
use objects\subusers_o;
|
|
use objects\users_o;
|
|
|
|
/**
|
|
* Scope-based access control middleware.
|
|
*
|
|
* Sits ON TOP of the existing session-cookie / bearer-token auth in
|
|
* classes\authentication. Existing permission checks (requirePermission,
|
|
* requireDepartmentAccess, etc.) MUST stay in place — scope checks are
|
|
* an additional, parallel layer that lets us reason about route
|
|
* authorization in terms of coarse-grained capabilities ("can this
|
|
* caller read invoices?") rather than fine-grained permission strings.
|
|
*
|
|
* The scope source-of-truth is app\auth\Scope (a local stub for
|
|
* TRU-149 — replaced by TRU-145 / feat/api-key-foundation).
|
|
*
|
|
* Three entry points:
|
|
* - requireScope(string) — caller must hold this exact scope
|
|
* - requireAnyScope(array) — caller must hold at least one
|
|
* - requireRole(string) — convenience: any of the role's
|
|
* scopes (see Scope::forRole)
|
|
*
|
|
* All three throw 403 on missing scope (or 401 if not authenticated at
|
|
* all). They never short-circuit silently: a missing scope is a denial,
|
|
* not a no-op.
|
|
*/
|
|
class ScopeMiddleware
|
|
{
|
|
/**
|
|
* Throw 403 unless the caller carries the given scope.
|
|
*
|
|
* @param string $required Scope string (e.g. Scope::CUSTOMER_READ).
|
|
* @param string|null $context Free-form label for log output
|
|
* (typically the route path).
|
|
*/
|
|
public static function requireScope(string $required, ?string $context = null): void
|
|
{
|
|
$granted = self::resolveGrantedScopes();
|
|
if (self::hasAnyMatchingScope($granted, [$required])) {
|
|
return;
|
|
}
|
|
self::deny($required, $granted, $context);
|
|
}
|
|
|
|
/**
|
|
* Throw 403 unless the caller carries at least one of the given scopes.
|
|
*
|
|
* @param array<int, string> $required
|
|
*/
|
|
public static function requireAnyScope(array $required, ?string $context = null): void
|
|
{
|
|
if ($required === []) {
|
|
// No scopes required = nothing to enforce. Defensive: a route
|
|
// author who passes [] probably meant to skip scope checks, so
|
|
// let it through rather than denying.
|
|
return;
|
|
}
|
|
$granted = self::resolveGrantedScopes();
|
|
if (self::hasAnyMatchingScope($granted, $required)) {
|
|
return;
|
|
}
|
|
self::deny(implode('|', $required), $granted, $context);
|
|
}
|
|
|
|
/**
|
|
* Convenience wrapper: require that the caller's role is at least
|
|
* as privileged as the named role.
|
|
*
|
|
* Role hierarchy: superuser > admin > customer > subuser.
|
|
* A caller satisfies `requireRole('admin')` if they are admin or
|
|
* superuser. `requireRole('superuser')` is only satisfied by
|
|
* superuser.
|
|
*
|
|
* Unknown roles deny.
|
|
*/
|
|
public static function requireRole(string $role, ?string $context = null): void
|
|
{
|
|
$hierarchy = ['subuser' => 1, 'customer' => 2, 'admin' => 3, 'superuser' => 4];
|
|
if (!isset($hierarchy[$role])) {
|
|
global $response;
|
|
if (is_object($response) && method_exists($response, 'error')) {
|
|
$response->error('Unknown role for scope check: ' . $role, 403);
|
|
}
|
|
return;
|
|
}
|
|
$callerRole = self::resolveCallerRole();
|
|
if ($callerRole === null) {
|
|
self::deny('role:' . $role, [], $context ?? 'role:' . $role);
|
|
return;
|
|
}
|
|
$callerRank = $hierarchy[$callerRole] ?? 0;
|
|
$requiredRank = $hierarchy[$role];
|
|
if ($callerRank >= $requiredRank) {
|
|
return;
|
|
}
|
|
self::deny('role:' . $role, [], $context ?? 'role:' . $role);
|
|
}
|
|
|
|
/**
|
|
* Resolve the caller's role name. Returns null if no principal
|
|
* is authenticated (or only anonymous test state exists).
|
|
*/
|
|
public static function resolveCallerRole(): ?string
|
|
{
|
|
// Test hook: tests can install a role via the
|
|
// setTestPrincipal() path; that path also stores the synthetic
|
|
// role directly when passed as a string key. For now we
|
|
// infer the role from the granted-scopes list.
|
|
if (self::$testPrincipal !== null) {
|
|
$granted = self::$testPrincipal;
|
|
if (in_array(Scope::SUPERUSER_READ, $granted, true) && in_array(Scope::SUPERUSER_WRITE, $granted, true)) {
|
|
return 'superuser';
|
|
}
|
|
if (in_array(Scope::SUBUSER_WRITE, $granted, true)) {
|
|
return 'subuser';
|
|
}
|
|
if (in_array(Scope::INVOICE_WRITE, $granted, true)) {
|
|
return 'admin';
|
|
}
|
|
if (in_array(Scope::CUSTOMER_READ, $granted, true)) {
|
|
return 'customer';
|
|
}
|
|
return null;
|
|
}
|
|
try {
|
|
$auth = new \classes\authentication();
|
|
$user = $auth->get_user();
|
|
if ($user instanceof users_o) {
|
|
return self::userRole($user);
|
|
}
|
|
$sub = $auth->get_subuser();
|
|
if ($sub instanceof subusers_o) {
|
|
return 'subuser';
|
|
}
|
|
} catch (Exception) {
|
|
// fall through
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Pure check (no throw). Useful for hasScope() style predicates in
|
|
* route handlers that want to branch on capabilities.
|
|
*
|
|
* @return bool true if the caller has the scope (or is superuser/admin).
|
|
*/
|
|
public static function hasScope(string $required): bool
|
|
{
|
|
$granted = self::resolveGrantedScopes();
|
|
return self::hasAnyMatchingScope($granted, [$required]);
|
|
}
|
|
|
|
/**
|
|
* Pure check for "any of" matching. Returns false if the caller is
|
|
* not authenticated at all (so callers can branch on anonymous).
|
|
*
|
|
* @param array<int, string> $required
|
|
*/
|
|
public static function hasAnyScope(array $required): bool
|
|
{
|
|
if ($required === []) {
|
|
return true;
|
|
}
|
|
$granted = self::resolveGrantedScopes();
|
|
return self::hasAnyMatchingScope($granted, $required);
|
|
}
|
|
|
|
/**
|
|
* Resolve the scopes the current principal carries. For now this is
|
|
* derived from the classic user role / subuser permissions, since
|
|
* the API key plumbing (TRU-145) is not yet wired in. When TRU-145
|
|
* lands, this method is the single replacement point.
|
|
*
|
|
* Returns an empty array if no principal is authenticated.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public static function resolveGrantedScopes(): array
|
|
{
|
|
// Test hook: if a test has installed a principal via
|
|
// self::setTestPrincipal(), honour that and skip the real
|
|
// authentication path. This is the only place a test-only
|
|
// branch lives; production code never sets the test
|
|
// principal because nothing else in the codebase does.
|
|
if (self::$testPrincipal !== null) {
|
|
$principal = self::$testPrincipal;
|
|
if (is_array($principal)) {
|
|
return $principal;
|
|
}
|
|
}
|
|
try {
|
|
$auth = new \classes\authentication();
|
|
$user = $auth->get_user();
|
|
if ($user instanceof users_o) {
|
|
$role = self::userRole($user);
|
|
return Scope::forRole($role);
|
|
}
|
|
$sub = $auth->get_subuser();
|
|
if ($sub instanceof subusers_o) {
|
|
return Scope::forRole('subuser');
|
|
}
|
|
} catch (Exception) {
|
|
// fall through
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/** @var array<int, string>|null */
|
|
private static ?array $testPrincipal = null;
|
|
|
|
/**
|
|
* Test-only: set the scope list the middleware should treat as
|
|
* "granted" for the current request. Pass null to clear.
|
|
*
|
|
* @param array<int, string>|null $scopes
|
|
*/
|
|
public static function setTestPrincipal(?array $scopes): void
|
|
{
|
|
self::$testPrincipal = $scopes;
|
|
}
|
|
|
|
/**
|
|
* Best-effort role detection for an authenticated user.
|
|
*
|
|
* Order of preference:
|
|
* 1. `hasPermission('superuser')` — matches the pattern used
|
|
* elsewhere in the codebase (e.g. departmentGoalsRoute).
|
|
* 2. `hasPermission('admin')` — admin gets the admin scope set.
|
|
* 3. Fallback to 'customer' — most authenticated callers are
|
|
* customer users, so we treat unknown as customer (read-only)
|
|
* rather than zero-privilege. This matches existing routes'
|
|
* behavior of allowing read access by default.
|
|
*
|
|
* Anonymous / malformed sessions yield no scopes via
|
|
* resolveGrantedScopes()'s outer try/catch.
|
|
*/
|
|
private static function userRole(users_o $user): string
|
|
{
|
|
try {
|
|
if (method_exists($user, 'hasPermission')) {
|
|
if ((bool)$user->hasPermission('superuser')) {
|
|
return 'superuser';
|
|
}
|
|
if ((bool)$user->hasPermission('admin')) {
|
|
return 'admin';
|
|
}
|
|
}
|
|
} catch (Exception) {
|
|
// fall through to default
|
|
}
|
|
return 'customer';
|
|
}
|
|
|
|
/**
|
|
* Check if any of the granted scopes satisfies any of the required
|
|
* scopes, using Scope::matches() (which supports "*" and "x:*"
|
|
* wildcards).
|
|
*
|
|
* @param array<int, string> $granted
|
|
* @param array<int, string> $required
|
|
*/
|
|
private static function hasAnyMatchingScope(array $granted, array $required): bool
|
|
{
|
|
foreach ($required as $need) {
|
|
foreach ($granted as $have) {
|
|
if (Scope::matches($have, $need)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Emit a 403 with a consistent shape and log the denial so we can
|
|
* see attempted access patterns during rollout.
|
|
*/
|
|
private static function deny(string $required, array $granted, ?string $context): void
|
|
{
|
|
// Best-effort log of the denial. We swallow all errors here
|
|
// because the deny path itself must never throw — a 403
|
|
// response is the contract.
|
|
try {
|
|
// The `redis` constant is a global namespaced object
|
|
// (objects\redis) created at boot. In test environments
|
|
// it may not be defined, so guard with `defined()`.
|
|
if (class_exists(logs_o::class) && defined('redis')) {
|
|
(new logs_o())->add(
|
|
'global',
|
|
'global',
|
|
1,
|
|
0,
|
|
'SCOPE_DENIED',
|
|
'Missing scope: ' . $required . ' (context=' . ($context ?? 'n/a') . ', granted=' . implode(',', $granted) . ')'
|
|
);
|
|
}
|
|
} catch (Throwable) {
|
|
// Logging must never block a deny.
|
|
}
|
|
global $response;
|
|
if (is_object($response) && method_exists($response, 'error')) {
|
|
$response->error('Missing required scope: ' . $required, 403);
|
|
return;
|
|
}
|
|
throw new Exception('Forbidden: missing scope ' . $required, 403);
|
|
}
|
|
}
|