Skip to main content

Security

Identity is the most security-sensitive Vecton service. This page captures the post-F0/F6 state across authentication, authorisation, audit logging, and exception rendering.

Authentication chain

HTTP request

AddTraceId (OTel span ids on every request)

IpBlacklistMiddleware (config-driven CIDR deny list)

SetUserLocale

CreateFreshApiToken (Passport: SPA cookie → Bearer header)

auth:api (Passport guard, validates the bearer)

permission:identity.X (CheckPermission middleware → resolver glob match)

controller

Everything in the request pipeline is registered in bootstrap/app.php. The two notable removals from F0 are EnsureSessionFromToken and SkipTrustedClientAuthorization, both of which were trying to bridge Bearer-token auth to cookie sessions in unsafe ways. The replacement: AuthController::completeLogin calls Auth::guard('web')->login($user, true) so a successful login establishes both a Bearer token and a web session in one shot.

Authorisation: Gate::define + Policies

F6 introduced a per-action Gate::define registry living in AppServiceProvider::registerGates. Two layers stack:

  1. Gate::before — system admins bypass every check (with ?User $user = null signature so guest-allowed checks still work).
  2. Gate::define($ability, fn(User) => $resolver->hasPermission(...)) — every other principal goes through PermissionResolver which walks the user's direct policies, global roles, and tenant-pivot role permissions, with dotted glob support (identity.users.*, identity.*, *).

Resource-aware policies registered:

Eloquent classPolicy
App\Models\UserApp\Policies\UserPolicy
App\Models\TenantApp\Policies\TenantPolicy

Standalone (non-resource) Gates:

AbilityClass
identity.membership.manageMembershipPolicy::manage
identity.membership.assign_roleMembershipPolicy::assignRole
identity.oauth_client.rotateOAuthClientPolicy::rotate
identity.service_account.api_keys.listServiceAccountPolicy::listApiKeys

The full list of Gate::define'd permission strings is the union of the identity.* prefix (users, tenants, roles, policies, audit, devices, profile, authorizations, service_accounts, api_keys) and the admin.* prefix (tenants, users, audit). See AppServiceProvider::registerGates for the canonical list.

Adding a new permission

  1. Add the permission string to the array in AppServiceProvider::registerGates.
  2. Add it to PolicySeeder under the appropriate role (Owner / Admin / Manager / Member).
  3. Reference it from a route via the existing permission:X middleware OR from a controller via Gate::authorize('X').
  4. Cover with a test in tests/Feature/Authorization/IdentityGatesTest.php.

Mass-assignment hardening

User::$fillable no longer contains is_system_admin. The only path to set the flag is the explicit User::setSystemAdmin(bool) setter which uses forceFill and is called from:

  • database/seeders/TestDatabaseSeeder.php (test admin)
  • Admin\UserController::store and update (audit-logged grant / revoke)

The audit's update($validated) privilege-escalation path (#38, #39) no longer exists.

Token storage and cache

AssetStorageNotes
oauth_clients.secretbcryptPassport-managed
applications.client_secretbcryptF0/F6 — replaced reversible encryption (audit #6)
system_token:* cache keysha256(token)SystemAuthController::tokenCacheKey (audit #8)
user_security.email_2fa_codebcryptF6 (audit #18)
user_security.two_factor_recovery_codesbcrypt[] inside encrypted castF6 (audit #20–#21)
Personal-access tokensPassport (DB)TTL 1 day default; auth.impersonation_ttl_minutes for impersonation

Race-condition guards

WhereGuard
UserSecurity::incrementFailedAttemptsSingle SQL UPDATE … CASE WHEN failed_login_attempts + 1 >= max THEN locked_until = … ELSE locked_until END (audit #14, #57, #75)
TwoFactorService::verifyEmailCodeAtomic UPDATE WHERE attempts < max slot-claim before Hash::check
AuthController::refreshTokenReuse marker stored under token_rotation:replaced:{old_id}
  • Redirect targets in pages/login.vue and pages/logout.vue go through resolveSafeRedirect (F0). Allowlist via VITE_REDIRECT_ALLOWED_ORIGINS.
  • Session cookies use secure=true + same_site=lax in production.
  • nginx rewrite (in main-identity-frontend/nginx.conf) sets Strict-Transport-Security, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, a default-deny CSP, and Cross-Origin-Opener-Policy: same-origin (F0).

Audit log retention

audit_logs rows are pruned daily via the audit:prune artisan command (PruneAuditLogs), default 365 days. Override with AUDIT_RETENTION_DAYS env. Sessions table is pruned hourly via sessions:prune (PruneStaleSessions) at session.lifetime + 60min.

Health endpoint

GET /api/health (F6) actually probes the dependencies — DB SELECT 1, cache write/read, Redis PING. Returns 503 with a per-check breakdown if any of them is down. K8s liveness probes can fail tenants out of the load balancer instead of always-200.

Step-up authentication

Tenant Owner does NOT carry the destructive Danger Zone permissions by default. The audit (#15) flagged this as a silent denial. The flow is now wired end-to-end:

  • POST /api/auth/step-up — re-auth with password + reason. Stores a short-lived flag in step_up:user:{id} cache for DEFAULT_TTL (300 s) seconds.
  • GET /api/auth/step-up/status — query.
  • DELETE /api/auth/step-up — drop the elevation early.

The canonical danger-action list lives in StepUpAuthService::DANGER_ACTIONS (~36 patterns across identity, crm, warehouse, webshop, webhook, analytics, email, ws_proxy). The PolicySeeder::tenantOwnerDangerZonePolicy() reads from the same constant, so seeder and runtime cannot drift. Patterns are matched segment-by-segment with * as a per-segment wildcard (tenant.*.crm.tasks.delete matches tenant.acme.crm.tasks.delete but not tenant.x.y.crm.tasks.delete).

CheckPermission middleware enforcement order:

  1. Authentication (401 if missing).
  2. System-admin bypass.
  3. Permission check (hasPermission / DB fallback) → 403 forbidden with required_permission if absent.
  4. Step-up gate: if StepUpAuthService::isDangerAction($permission) and the user is not currently elevated, return 403 with error: 'step_up_required', step_up.{request_endpoint, status_endpoint, ttl_seconds} payload so the SPA can prompt the user to re-authenticate.
  5. Resource scope check (if resourceParam is supplied).

The frontend handles step-up via:

  • useStepUp() composable (src/composables/useStepUp.ts) — exposes requireElevation(reason, fn) which checks /api/auth/step-up/status, opens the singleton dialog if not elevated, POSTs to /api/auth/step-up on submit, and only then runs fn(). Returns null if the user cancels.
  • <StepUpDialog> (src/components/dialogs/StepUpDialog.vue) — password-only modal mounted once at the App root. i18n strings live under the stepUp.* key in hu.json / en.json / de.json.

Tests: tests/Feature/Auth/StepUpDangerZoneTest.php (5 cases: pattern matching, unelevated → step_up_required, elevated → passes, system admin bypass, no permission → plain forbidden) and src/composables/__tests__/useStepUp.spec.ts (5 cases: elevated short-circuit, dialog → submit → callback, cancel returns null, status-fetch failure falls through to dialog, submit error surfacing).

Where to look in code

  • app/Providers/AppServiceProvider.php — Gate registry
  • app/Policies/ — IdentityPolicy + 5 children
  • app/Services/PermissionResolver.php — single source of truth for permission resolution
  • app/Http/Middleware/CheckPermission.php — middleware-level guard (F-future will fold this into Gate::authorize)
  • app/Models/UserSecurity.php — atomic lockout
  • app/Services/TwoFactorService.php — hashed codes + atomic attempt-claim
  • app/Models/Application.php — bcrypt secret + exact redirect_uri