Skip to main content

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: Bearer header. The legacy ?token= query-string fallback was removed (CWE-598 — token leakage via referrer/logs).
  • The middleware calls IdentityService::getUserFromToken, which is cached by hash('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: true or 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.ts deduplicates concurrent 401-triggered refresh attempts via a single Promise queue.
  • A failed refresh clears all three localStorage entries and redirects to /login.

Authorization

Two layers

  1. Coarse: auth.oauth middleware on every /api/admin/* route. Rejects unauthenticated callers and non-admins.

  2. Fine (gradually expanding): can: middleware on selected routes. Each ability is registered in AppServiceProvider::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:

ColumnPurpose
actionDotted slug, e.g. tenant.suspend
target_typeResource family, e.g. tenant, subscription
target_idID of the affected resource
metadataJSON context (changed fields, request IP, trace ID)
resultsuccess, failure, partial_failure
user_emailAuthenticated user's email
created_atTimestamp

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 generic messages.server_error translation when APP_DEBUG=false. Stack traces and SQL fragments never reach the client.
  • Every response includes X-Trace-Id and trace_id body 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_oauth via 'encrypted:array' cast.
  • Replace ip_whitelist_entries truncate-and-reinsert with a diff-and-upsert pattern wrapped in a transaction.
  • Tag impersonation tokens with reduced scope and a max session length.

Tests

TestVerifies
tests/Feature/Middleware/VerifyOAuthTokenTest.phpHeader parsing, identity rejection, query-token rejection (audit #03)
tests/Feature/Authorization/AdminGatesTest.phpAll registered abilities, wildcard matching, denial without permission (audit S1)
tests/Unit/Models/TenantNamespaceTest.phpname → namespace mutator (audit #29)
tests/Feature/NotificationControllerTest.phpIDOR scope (audit #07)
tests/Feature/Tenant/ScalingConfigTest.phpScaling-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.