Files
api/services/nginx/app/classes/auth/scope_middleware.php
T
OpenClaw 51a87655d6 feat(auth): add scope-based access control to all existing routes (TRU-149)
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
2026-08-17 11:43:13 +00:00

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);
}
}