Admin Security Model
This page collects everything related to authentication, authorization, audit logging, and the changes we made to harden the platform admin against the issues found in the May 2026 audit.
Authentication
OAuth flow
User → admin.vecton.hu/login
↓
Frontend redirects to Identity (/oauth/authorize?client_id=…&redirect_uri=…&state=…)
↓
User authenticates against Identity
↓
Identity → admin.vecton.hu/auth/callback?code=…&state=…
↓
Frontend POSTs code to /api/admin/auth/callback
↓
Backend (AuthController::callback) exchanges code → access_token + refresh_token
↓
Frontend stores tokens in localStorage; subsequent API calls send `Authorization: Bearer …`
Per-request validation (VerifyOAuthToken)
- Tokens are accepted only in the
Authorization: Bearerheader. The legacy?token=query-string fallback was removed (CWE-598 — token leakage via referrer/logs). - The middleware calls
IdentityService::getUserFromToken, which is cached byhash('sha256', $token)for 60 seconds. This eliminates the per-request 50–200 ms round-trip. - The cached payload is rejected unless it has
is_admin: trueor one of the configured admin roles. - The user payload is exposed to the controller via
$request->input('authenticated_user').
Refresh tokens (AuthController::refresh)
- The frontend interceptor in
lib/axios.tsdeduplicates concurrent 401-triggered refresh attempts via a singlePromisequeue. - A failed refresh clears all three localStorage entries and redirects to
/login.
Authorization
Two layers
-
Coarse:
auth.oauthmiddleware on every/api/admin/*route. Rejects unauthenticated callers and non-admins. -
Fine (gradually expanding):
can:middleware on selected routes. Each ability is registered inAppServiceProvider::registerAbilities():private const ADMIN_ABILITIES = [
'admin.tenants.email.view',
'admin.tenants.email.messages.read_body',
'admin.tenants.email.messages.resend',
'admin.tenants.email.quarantine.manage',
'admin.tenants.email.gdpr.erase',
'admin.tenants.email.bayes.train',
];The resolver inspects
request()->input('authenticated_user.permissions')and supports glob wildcards (admin.tenants.email.*matches every email-namespaced ability). Platform admins (is_admin: true) implicitly have every ability.
Without the Gate::define() calls, Laravel's can: middleware would either silently allow or throw AuthorizationException — both incorrect. The AdminGatesTest feature test pins down the expected behaviour.
IDOR scoping
NotificationController::markRead and destroy previously operated on any id without scoping. They now go through scopedQuery($userEmail), which returns either globally targeted notifications or notifications addressed to the authenticated admin's email — preventing one admin from reading or deleting another's notification.
Auditing
AuditService::log() records mutations to the audit_logs table with structure:
| Column | Purpose |
|---|---|
action | Dotted slug, e.g. tenant.suspend |
target_type | Resource family, e.g. tenant, subscription |
target_id | ID of the affected resource |
metadata | JSON context (changed fields, request IP, trace ID) |
result | success, failure, partial_failure |
user_email | Authenticated user's email |
created_at | Timestamp |
A migration adds compound indexes on (target_type, target_id), user_email, action, and created_at so queries on the admin Audit Logs page stay fast as the table grows.
Production-safe error responses
bootstrap/app.php renders JSON exceptions with these rules:
- HTTP exceptions (validation, 404, 403) always return
getMessage()— they're already user-safe. - Server errors (500, anything not implementing
HttpExceptionInterface) return a genericmessages.server_errortranslation whenAPP_DEBUG=false. Stack traces and SQL fragments never reach the client. - Every response includes
X-Trace-Idandtrace_idbody so support can correlate with logs.
Defense-in-depth follow-ups
These tasks remain (see main/AUDIT_FINDINGS.md for the complete list):
- Scope alert webhook URLs — block private CIDRs to prevent SSRF (
AlertDispatchService). - Apply per-action policies (
TenantPolicy,BackupPolicy, …) so granular permissions like "manager can suspend but not delete" become possible. - Encrypt
tenants.settings._provisioning_oauthvia'encrypted:array'cast. - Replace
ip_whitelist_entriestruncate-and-reinsert with a diff-and-upsert pattern wrapped in a transaction. - Tag impersonation tokens with reduced scope and a max session length.
Tests
| Test | Verifies |
|---|---|
tests/Feature/Middleware/VerifyOAuthTokenTest.php | Header parsing, identity rejection, query-token rejection (audit #03) |
tests/Feature/Authorization/AdminGatesTest.php | All registered abilities, wildcard matching, denial without permission (audit S1) |
tests/Unit/Models/TenantNamespaceTest.php | name → namespace mutator (audit #29) |
tests/Feature/NotificationControllerTest.php | IDOR scope (audit #07) |
tests/Feature/Tenant/ScalingConfigTest.php | Scaling-config response shape after the controller split |
Run them via php artisan test. The base TestCase bypasses VerifyOAuthToken for application-level tests; the middleware itself is exercised by VerifyOAuthTokenTest which re-enables it explicitly.