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:
Gate::before— system admins bypass every check (with?User $user = nullsignature so guest-allowed checks still work).Gate::define($ability, fn(User) => $resolver->hasPermission(...))— every other principal goes throughPermissionResolverwhich 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 class | Policy |
|---|---|
App\Models\User | App\Policies\UserPolicy |
App\Models\Tenant | App\Policies\TenantPolicy |
Standalone (non-resource) Gates:
| Ability | Class |
|---|---|
identity.membership.manage | MembershipPolicy::manage |
identity.membership.assign_role | MembershipPolicy::assignRole |
identity.oauth_client.rotate | OAuthClientPolicy::rotate |
identity.service_account.api_keys.list | ServiceAccountPolicy::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
- Add the permission string to the array in
AppServiceProvider::registerGates. - Add it to
PolicySeederunder the appropriate role (Owner / Admin / Manager / Member). - Reference it from a route via the existing
permission:Xmiddleware OR from a controller viaGate::authorize('X'). - 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::storeandupdate(audit-logged grant / revoke)
The audit's update($validated) privilege-escalation path (#38, #39)
no longer exists.
Token storage and cache
| Asset | Storage | Notes |
|---|---|---|
oauth_clients.secret | bcrypt | Passport-managed |
applications.client_secret | bcrypt | F0/F6 — replaced reversible encryption (audit #6) |
system_token:* cache key | sha256(token) | SystemAuthController::tokenCacheKey (audit #8) |
user_security.email_2fa_code | bcrypt | F6 (audit #18) |
user_security.two_factor_recovery_codes | bcrypt[] inside encrypted cast | F6 (audit #20–#21) |
| Personal-access tokens | Passport (DB) | TTL 1 day default; auth.impersonation_ttl_minutes for impersonation |
Race-condition guards
| Where | Guard |
|---|---|
UserSecurity::incrementFailedAttempts | Single SQL UPDATE … CASE WHEN failed_login_attempts + 1 >= max THEN locked_until = … ELSE locked_until END (audit #14, #57, #75) |
TwoFactorService::verifyEmailCode | Atomic UPDATE WHERE attempts < max slot-claim before Hash::check |
AuthController::refreshToken | Reuse marker stored under token_rotation:replaced:{old_id} |
Open-redirect / cookie hygiene
- Redirect targets in
pages/login.vueandpages/logout.vuego throughresolveSafeRedirect(F0). Allowlist viaVITE_REDIRECT_ALLOWED_ORIGINS. - Session cookies use
secure=true+same_site=laxin production. - nginx rewrite (in
main-identity-frontend/nginx.conf) setsStrict-Transport-Security,X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, a default-deny CSP, andCross-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 instep_up:user:{id}cache forDEFAULT_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:
- Authentication (401 if missing).
- System-admin bypass.
- Permission check (
hasPermission/ DB fallback) → 403forbiddenwithrequired_permissionif absent. - Step-up gate: if
StepUpAuthService::isDangerAction($permission)and the user is not currently elevated, return 403 witherror: 'step_up_required',step_up.{request_endpoint, status_endpoint, ttl_seconds}payload so the SPA can prompt the user to re-authenticate. - Resource scope check (if
resourceParamis supplied).
The frontend handles step-up via:
useStepUp()composable (src/composables/useStepUp.ts) — exposesrequireElevation(reason, fn)which checks/api/auth/step-up/status, opens the singleton dialog if not elevated, POSTs to/api/auth/step-upon submit, and only then runsfn(). Returnsnullif the user cancels.<StepUpDialog>(src/components/dialogs/StepUpDialog.vue) — password-only modal mounted once at the App root. i18n strings live under thestepUp.*key inhu.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 registryapp/Policies/— IdentityPolicy + 5 childrenapp/Services/PermissionResolver.php— single source of truth for permission resolutionapp/Http/Middleware/CheckPermission.php— middleware-level guard (F-future will fold this intoGate::authorize)app/Models/UserSecurity.php— atomic lockoutapp/Services/TwoFactorService.php— hashed codes + atomic attempt-claimapp/Models/Application.php— bcrypt secret + exact redirect_uri