Crewli Admin
+
+
diff --git a/apps/app/index.html b/apps/app/index.html
index ee97e40e..4defa60c 100644
--- a/apps/app/index.html
+++ b/apps/app/index.html
@@ -7,6 +7,9 @@
Crewli — Organizer
+
+
diff --git a/apps/portal/index.html b/apps/portal/index.html
index 46a24c6b..21d1926d 100644
--- a/apps/portal/index.html
+++ b/apps/portal/index.html
@@ -7,6 +7,9 @@
Crewli — Portal
+
+
diff --git a/deploy/README.md b/deploy/README.md
new file mode 100644
index 00000000..b92c47fb
--- /dev/null
+++ b/deploy/README.md
@@ -0,0 +1,69 @@
+# Crewli Deployment — Security Configuration
+
+## Nginx Security Headers
+
+Copy the configuration snippets to your Nginx server:
+
+### API (api.crewli.app)
+```nginx
+server {
+ server_name api.crewli.app;
+
+ include /path/to/deploy/nginx/security-headers.conf;
+ include /path/to/deploy/nginx/csp-api.conf;
+
+ # ... rest of config
+}
+```
+
+### Organizer App (app.crewli.app)
+```nginx
+server {
+ server_name app.crewli.app;
+
+ include /path/to/deploy/nginx/security-headers.conf;
+ include /path/to/deploy/nginx/csp-spa.conf;
+
+ # ... rest of config
+}
+```
+
+### Admin (admin.crewli.app)
+```nginx
+server {
+ server_name admin.crewli.app;
+
+ include /path/to/deploy/nginx/security-headers.conf;
+ include /path/to/deploy/nginx/csp-spa.conf;
+
+ # ... rest of config
+}
+```
+
+### Portal (portal.crewli.app)
+```nginx
+server {
+ server_name portal.crewli.app;
+
+ include /path/to/deploy/nginx/security-headers.conf;
+ include /path/to/deploy/nginx/csp-portal.conf;
+
+ # ... rest of config
+}
+```
+
+## CSP Rollout Process
+
+1. Start with `Content-Security-Policy-Report-Only` (uncomment in `csp-spa.conf`)
+2. Monitor browser console for CSP violations for 1-2 weeks
+3. Add any missing sources to the policy
+4. Switch to enforcing `Content-Security-Policy`
+5. Monitor for false positives after enforcement
+
+## DirectAdmin Integration
+
+If using DirectAdmin with Nginx:
+1. Place the `.conf` files in `/usr/local/directadmin/data/users/USERNAME/nginx.conf`
+ or use DirectAdmin's custom Nginx configuration feature
+2. Reload Nginx: `service nginx reload`
+3. Verify headers: `curl -I https://app.crewli.app | grep -i security`
diff --git a/deploy/nginx/csp-api.conf b/deploy/nginx/csp-api.conf
new file mode 100644
index 00000000..c7c99436
--- /dev/null
+++ b/deploy/nginx/csp-api.conf
@@ -0,0 +1,3 @@
+# CSP for api.crewli.app
+# The API serves JSON only — no scripts, styles, or images needed.
+add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always;
diff --git a/deploy/nginx/csp-portal.conf b/deploy/nginx/csp-portal.conf
new file mode 100644
index 00000000..241c04dc
--- /dev/null
+++ b/deploy/nginx/csp-portal.conf
@@ -0,0 +1,4 @@
+# CSP for portal.crewli.app
+# Same policy as SPA but with stricter connect-src since portal
+# should only talk to the API.
+add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.crewli.app; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always;
diff --git a/deploy/nginx/csp-spa.conf b/deploy/nginx/csp-spa.conf
new file mode 100644
index 00000000..22470873
--- /dev/null
+++ b/deploy/nginx/csp-spa.conf
@@ -0,0 +1,15 @@
+# CSP for app.crewli.app and admin.crewli.app
+# Vite bundles all JS/CSS into same-origin files.
+# 'unsafe-inline' for style-src is required by Vuetify (inline styles for theming).
+# img-src https: allows organisation logos loaded from external URLs.
+# connect-src must include the API domain for XHR/fetch calls.
+#
+# IMPORTANT: Start with Content-Security-Policy-Report-Only to catch
+# false positives. Switch to Content-Security-Policy after 1-2 weeks
+# of clean logs.
+
+# Report-only mode (start with this):
+# add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.crewli.app; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always;
+
+# Enforce mode (switch to this after testing):
+add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.crewli.app; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always;
diff --git a/deploy/nginx/security-headers.conf b/deploy/nginx/security-headers.conf
new file mode 100644
index 00000000..6832efa0
--- /dev/null
+++ b/deploy/nginx/security-headers.conf
@@ -0,0 +1,19 @@
+# ============================================================
+# Crewli Security Headers — Nginx Configuration
+# ============================================================
+# Include this file in each server block:
+# include /path/to/deploy/nginx/security-headers.conf;
+#
+# Three separate CSP policies for API vs SPA vs Portal.
+# Adjust connect-src if the API domain changes.
+# ============================================================
+
+# --- Shared headers (all server blocks) ---
+
+add_header X-Content-Type-Options "nosniff" always;
+add_header X-Frame-Options "DENY" always;
+add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+
+# HSTS: enable after confirming HTTPS works correctly
+# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
diff --git a/dev-docs/SECURITY_AUDIT.md b/dev-docs/SECURITY_AUDIT.md
new file mode 100644
index 00000000..b4e21166
--- /dev/null
+++ b/dev-docs/SECURITY_AUDIT.md
@@ -0,0 +1,689 @@
+# Crewli Security Audit — 2026-04-14
+
+## Summary
+
+- **Total findings: 57**
+- **Critical: 10 | High: 19 | Medium: 18 | Low: 10**
+
+Audit scope: all files under `api/` and `apps/` (admin, app, portal).
+
+---
+
+## Findings
+
+---
+
+### A01 — Broken Access Control
+
+#### [CRITICAL] A01-1: `PortalTokenMiddleware` is a complete no-op
+
+- **File:** `api/app/Http/Middleware/PortalTokenMiddleware.php:17-19`
+- **Description:** The `portal.token` middleware calls `$next($request)` unconditionally — it performs zero token validation.
+- **Risk:** Any route protected by `portal.token` is completely unprotected. Future routes added under this middleware will silently allow unauthenticated access.
+- **Fix:** Implement the middleware to extract and validate a portal token from the request, establish the portal context (actor identity + permitted event), and return 401 if invalid.
+
+#### [CRITICAL] A01-2: `PortalShiftController::claim()` does not verify the shift belongs to the event
+
+- **File:** `api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php:173-202`
+- **Description:** Route `POST /portal/events/{event}/shifts/{shift}/claim` resolves `$person` by `user_id + event_id` but does NOT verify that `$shift` belongs to `$event`. A volunteer can supply any shift ID from a different event or organisation.
+- **Risk:** Cross-tenant shift claiming — volunteers can claim shifts in events/organisations they have no association with.
+- **Fix:** Verify `$shift->festivalSection->event_id` is within the resolved event IDs before proceeding.
+
+#### [CRITICAL] A01-3: `ShiftAssignmentPolicy` does not verify the assignment belongs to the event
+
+- **File:** `api/app/Policies/ShiftAssignmentPolicy.php:19-43`
+- **Description:** The `approve`, `reject`, and `cancel` policy methods check whether the user can manage `$event`, but never verify `$assignment` belongs to that event. A malicious org_admin for event A can approve/reject/cancel assignments from event B.
+- **Risk:** Cross-event data manipulation across organisations.
+- **Fix:** Add `if ($assignment->shift->festivalSection->event_id !== $event->id) return false;` in each method.
+
+#### [HIGH] A01-4: `OrganisationScope` is defined but never registered on any model
+
+- **File:** `api/app/Models/Scopes/OrganisationScope.php`
+- **Description:** The scope class exists but no model registers it via `booted()` / `addGlobalScope()`. The CLAUDE.md mandates using it on all event-related models.
+- **Risk:** No automatic multi-tenancy safety net. Isolation relies entirely on controller-level routing, which is fragile.
+- **Fix:** Register the scope on `Event`, `Person`, `Shift`, `FestivalSection`, `TimeSlot`, `Location`, `CrowdList`, `CrowdType`, `Company`, `PersonTag`, `RegistrationFormField`, `RegistrationFieldTemplate`.
+
+#### [HIGH] A01-5: `ShiftController::assign()` and `claim()` use unscoped `Person::findOrFail()`
+
+- **File:** `api/app/Http/Controllers/Api/V1/ShiftController.php:74,84`
+- **File:** `api/app/Http/Requests/Api/V1/AssignShiftRequest.php:20`
+- **Description:** `Person::findOrFail($request->validated('person_id'))` fetches any person by ID regardless of event or organisation. The `exists:persons,id` rule has no scoping.
+- **Risk:** Cross-organisation person assignment — an org admin can assign shifts to persons from another tenant.
+- **Fix:** Scope to event: `Person::where('id', $personId)->where('event_id', $event->id)->firstOrFail()`.
+
+#### [HIGH] A01-6: `CrowdListController::addPerson()` uses unscoped `Person::findOrFail()`
+
+- **File:** `api/app/Http/Controllers/Api/V1/CrowdListController.php:81`
+- **File:** `api/app/Http/Requests/Api/V1/AddPersonToCrowdListRequest.php:20`
+- **Description:** Same issue as A01-5. A person from another organisation can be added to a crowd list.
+- **Risk:** Cross-tenant data leakage and reference pollution.
+- **Fix:** Scope the `exists` rule: `Rule::exists('persons', 'id')->where('event_id', $request->route('event')->id)`.
+
+#### [HIGH] A01-7: `BulkApproveShiftAssignmentRequest` uses unscoped `exists:shift_assignments,id`
+
+- **File:** `api/app/Http/Requests/Api/V1/BulkApproveShiftAssignmentRequest.php:21`
+- **File:** `api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php:98-103`
+- **Description:** `assignment_ids.*` validated with `exists:shift_assignments,id` without event or organisation scope. The controller loads assignments without filtering.
+- **Risk:** Cross-tenant bulk approval of shift assignments.
+- **Fix:** Add `.whereHas('shift.festivalSection', fn ($q) => $q->where('event_id', $event->id))` to the query.
+
+#### [HIGH] A01-8: `BulkConfirmIdentityMatchesRequest` uses unscoped `exists:person_identity_matches,id`
+
+- **File:** `api/app/Http/Requests/Api/V1/BulkConfirmIdentityMatchesRequest.php:21`
+- **Description:** Match IDs are not scoped to the route's organisation. An org admin could include IDs from another organisation.
+- **Risk:** Cross-tenant identity match manipulation.
+- **Fix:** Scope the query to organisation events in the controller.
+
+#### [HIGH] A01-9: `ReorderRegistrationFormFieldsRequest` uses unscoped `exists:registration_form_fields,id`
+
+- **File:** `api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php:21`
+- **Description:** Field IDs are not scoped to the event. Fields from other events could be reordered.
+- **Risk:** Cross-event field manipulation.
+- **Fix:** Add `Rule::exists('registration_form_fields', 'id')->where('event_id', $this->route('event')->id)`.
+
+#### [HIGH] A01-10: `PortalMeController::index()` accepts any `event_id` without access verification
+
+- **File:** `api/app/Http/Controllers/Api/V1/PortalMeController.php:23-83`
+- **Description:** Takes `event_id` as a query parameter and calls `Event::findOrFail()` which returns any event from any organisation.
+- **Risk:** A user registered for events at multiple organisations can access their profile data cross-org by probing event IDs.
+- **Fix:** Validate that the authenticated user has a Person record for the requested event.
+
+#### [HIGH] A01-11: `InvitationController::revoke()` does not verify the invitation belongs to the organisation
+
+- **File:** `api/app/Http/Controllers/Api/V1/InvitationController.php:76`
+- **Description:** Route `DELETE organisations/{organisation}/invitations/{invitation}` resolves the invitation globally. Never checks `$invitation->organisation_id === $organisation->id`.
+- **Risk:** An org admin of Org A can revoke invitations belonging to Org B.
+- **Fix:** Add `if ($invitation->organisation_id !== $organisation->id) abort(404);`.
+
+#### [HIGH] A01-12: `PersonIdentityMatchController` does not verify matches belong to the route organisation
+
+- **File:** `api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php:51,66,85`
+- **Description:** `confirm`, `dismiss`, and `bulkConfirm` resolve matches globally. The route's `{organisation}` parameter is unused in policy checks.
+- **Risk:** Cross-org identity match confirmation/dismissal.
+- **Fix:** Scope match resolution: `->whereHas('person', fn ($q) => $q->whereIn('event_id', $organisation->events()->select('id')))`.
+
+#### [HIGH] A01-13: Event routes without organisation prefix bypass scope
+
+- **File:** `api/routes/api.php:152-207`
+- **Description:** Routes like `events/{event}/stats`, `events/{event}/locations`, etc. are not nested under `organisations/{organisation}`. The Event model has no OrganisationScope. Route model binding finds events globally.
+- **Risk:** Authenticated users can access event data from other organisations. The `stats` endpoint specifically calls `Gate::authorize('view', $event)` with no organisation parameter, skipping the org ownership check in the policy.
+- **Fix:** Move routes under `organisations/{organisation}/events/{event}/...` or register OrganisationScope on the Event model.
+
+#### [MEDIUM] A01-14: Multiple form requests use unscoped `exists:crowd_types,id` and `exists:companies,id`
+
+- **File:** `api/app/Http/Requests/Api/V1/StorePersonRequest.php:20,26`
+- **File:** `api/app/Http/Requests/Api/V1/UpdatePersonRequest.php:20,26`
+- **File:** `api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php:22,25`
+- **File:** `api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php:22,25`
+- **Description:** These allow `crowd_type_id` and `company_id` values from any organisation.
+- **Risk:** Cross-tenant reference pollution.
+- **Fix:** Use `Rule::exists()->where('organisation_id', ...)`.
+
+#### [MEDIUM] A01-15: `UpdateEventRequest` uses unscoped `exists:events,id` for `parent_event_id`
+
+- **File:** `api/app/Http/Requests/Api/V1/UpdateEventRequest.php:26`
+- **Description:** `parent_event_id` validated with `exists:events,id` without organisation scoping. The update controller does not re-verify.
+- **Risk:** An event can be re-parented to an event from a different organisation.
+- **Fix:** Scope: `Rule::exists('events', 'id')->where('organisation_id', $this->route('organisation')->id)`.
+
+#### [MEDIUM] A01-16: `PortalShiftController::cancel()` does not verify `shiftAssignment` belongs to the event
+
+- **File:** `api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php:204-237`
+- **Description:** Checks `person_id` ownership but not that the assignment belongs to the URL's event context.
+- **Risk:** Cross-event assignment cancellation for users registered in multiple events.
+- **Fix:** Verify `$shiftAssignment->shift->timeSlot->event_id` is in the resolved event IDs.
+
+#### [MEDIUM] A01-17: `MemberController::update()` and `destroy()` don't verify the user is a member
+
+- **File:** `api/app/Http/Controllers/Api/V1/MemberController.php:27-82`
+- **Description:** Neither verifies the target `$user` is actually a member of the organisation. The "last org_admin" safeguard can be bypassed.
+- **Risk:** Misleading success responses; bypasses admin count safeguard.
+- **Fix:** Add `if (!$organisation->users()->where('user_id', $user->id)->exists()) abort(404);`.
+
+#### [MEDIUM] A01-18: `RegistrationFormFieldController::importFromEvent()` fragile org check
+
+- **File:** `api/app/Http/Controllers/Api/V1/RegistrationFormFieldController.php:98-107`
+- **File:** `api/app/Http/Requests/Api/V1/ImportFromEventRequest.php:20-40`
+- **Description:** The `exists:events,id` rule accepts any event. Cross-org check is in `withValidator` callback, not in the rule itself.
+- **Risk:** Fragile defence — removing the `withValidator` callback silently enables cross-org import.
+- **Fix:** Use `Rule::exists('events', 'id')->where('organisation_id', ...)` directly in the rules array.
+
+#### [LOW] A01-19: `OrganisationPolicy::viewAny()` returns `true` unconditionally
+
+- **File:** `api/app/Policies/OrganisationPolicy.php:13-16`
+- **Description:** Returns `true` for all authenticated users. Controller correctly scopes results, but the policy provides no guard.
+- **Risk:** No current exposure; maintenance risk if controller query changes.
+- **Fix:** Document the design decision; optionally remove the policy gate call.
+
+#### [LOW] A01-20: `ShiftAssignmentController::assignablePersons()` uses raw `DB::table()` query
+
+- **File:** `api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php:156-170`
+- **Description:** Raw query correctly scoped but bypasses Eloquent models and future global scopes.
+- **Risk:** Maintenance risk.
+- **Fix:** Replace with Eloquent query.
+
+---
+
+### A02 — Cryptographic Failures
+
+#### [CRITICAL] A02-1: Portal and invitation tokens use ULID (predictable time-prefix)
+
+- **File:** `api/database/migrations/2026_04_08_170000_create_artists_table.php:31`
+- **File:** `api/app/Services/InvitationService.php:45`
+- **Description:** Portal tokens are `char(26)` (ULID size). ULID has 48 predictable timestamp bits + only 80 random bits. Invitation tokens also use `Str::ulid()`.
+- **Risk:** An attacker who knows the approximate creation time can reduce the search space significantly. Combined with no rate limiting (A07-5), token brute-force becomes practical.
+- **Fix:** Use `bin2hex(random_bytes(32))` for all security tokens — provides 256 bits of cryptographic randomness.
+
+#### [HIGH] A02-2: Sanctum tokens never expire
+
+- **File:** `api/config/sanctum.php:52`
+- **Description:** `'expiration' => null` — all issued tokens are permanent.
+- **Risk:** A stolen token grants indefinite access with no automatic remediation.
+- **Fix:** Set `'expiration' => 60 * 24 * 7` (7 days). Implement token refresh for long-lived sessions.
+
+#### [HIGH] A02-3: Portal tokens stored plaintext with no expiry or revocation
+
+- **File:** `api/app/Http/Controllers/Api/V1/PortalTokenController.php`
+- **File:** `api/database/migrations/2026_04_08_170000_create_artists_table.php:31`
+- **Description:** Portal tokens are compared with `where('portal_token', $token)` — plaintext storage. No `expires_at` column, no revocation mechanism.
+- **Risk:** A database dump exposes all active tokens. A compromised token is valid indefinitely.
+- **Fix:** Store `hash('sha256', $token)`, add `portal_token_expires_at` column, expire tokens after the event ends.
+
+#### [MEDIUM] A02-4: Session `secure` cookie not enforced
+
+- **File:** `api/config/session.php:172`
+- **Description:** `'secure' => env('SESSION_SECURE_COOKIE')` resolves to `null`/`false` when env var is absent. Session cookie sent over plain HTTP.
+- **Risk:** Session hijacking via HTTP interception.
+- **Fix:** Add `SESSION_SECURE_COOKIE=true` to `.env.example`. Default: `env('SESSION_SECURE_COOKIE', app()->isProduction())`.
+
+#### [LOW] A02-5: Session encryption disabled by default
+
+- **File:** `api/.env:28`
+- **Description:** `SESSION_ENCRYPT=false` — session data in database is unencrypted.
+- **Risk:** Database access exposes session contents.
+- **Fix:** Set `SESSION_ENCRYPT=true` in production.
+
+#### [LOW] A02-6: No HTTPS enforcement middleware
+
+- **File:** `api/config/app.php:55`
+- **Description:** `APP_URL` defaults to `http://` and no `ForceHttps` middleware exists.
+- **Risk:** API accessible over HTTP in misconfigured deployments.
+- **Fix:** Add HTTPS enforcement middleware for production, or use `URL::forceScheme('https')` in `AppServiceProvider`.
+
+---
+
+### A03 — Injection
+
+#### [MEDIUM] A03-1: `EventController::index()` uses unvalidated query parameters in `where()` clauses
+
+- **File:** `api/app/Http/Controllers/Api/V1/EventController.php:31-33`
+- **Description:** Unvalidated query param `type` used in `where()` — no Form Request for index action. While Eloquent uses PDO bindings (no SQL injection), this is an input validation gap.
+- **Risk:** Unexpected behaviour with malformed input.
+- **Fix:** Add a Form Request or validate `type` as `in:event,festival,series`.
+
+#### [LOW] A03-2: `PersonController::reject()` — unvalidated `reason` passed to mail
+
+- **File:** `api/app/Http/Controllers/Api/V1/PersonController.php:130`
+- **Description:** `$request->input('reason')` used without length validation. The Blade template escapes it (`{{ }}`), so no XSS, but no length cap exists.
+- **Risk:** Oversized email payload.
+- **Fix:** Add a Form Request with `'reason' => ['nullable', 'string', 'max:1000']`.
+
+#### Positive findings (A03)
+
+- All `whereRaw()` / `selectRaw()` calls use parameterised bindings — no SQL injection vectors found.
+- No `exec()`, `shell_exec()`, `system()`, `proc_open()`, `passthru()`, or backtick operators found.
+- No `{!! !!}` (unescaped Blade output) found — all output uses `{{ }}`.
+- No LDAP or XPath injection vectors found.
+
+---
+
+### A04 — Insecure Design
+
+#### [CRITICAL] A04-1: `billing_status` writable by org_admin via API
+
+- **File:** `api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php:26`
+- **File:** `api/app/Models/Organisation.php:23`
+- **Description:** `billing_status` is in `Organisation::$fillable` AND in `UpdateOrganisationRequest` rules. Any org_admin can self-upgrade from `suspended`/`trial` to `active`.
+- **Risk:** Billing bypass — organisations can override their subscription status.
+- **Fix:** Remove `billing_status` from `UpdateOrganisationRequest::rules()`. Create a separate `super_admin`-only endpoint.
+
+#### [CRITICAL] A04-2: No rate limiting on `auth/login` endpoint
+
+- **File:** `api/routes/api.php:58`
+- **Description:** `Route::post('auth/login', LoginController::class)` has no `throttle` middleware. Unlimited credential-guessing is possible.
+- **Risk:** Full credential-stuffing and password-spray attacks.
+- **Fix:** `->middleware('throttle:5,1')`. For tighter control, configure a named limiter keyed on `email|ip`.
+
+#### [CRITICAL] A04-3: No rate limiting on `portal/token-auth` endpoint
+
+- **File:** `api/routes/api.php:73`
+- **Description:** No throttle middleware on `POST portal/token-auth`. Combined with ULID predictability (A02-1), token enumeration becomes practical.
+- **Risk:** Automated token brute-force/enumeration.
+- **Fix:** `->middleware('throttle:10,1')`.
+
+#### [CRITICAL] A04-4: `portal/token-auth` returns raw model data including all columns
+
+- **File:** `api/app/Http/Controllers/Api/V1/PortalTokenController.php:29-33,42-47`
+- **Description:** Returns `(array) $artist` and `$event?->toArray()` — every database column exposed including internal fields, milestone booleans, project_leader_id, etc.
+- **Risk:** Internal operational data exposed to portal users. Violates "Return all responses through API resources."
+- **Fix:** Create `ArtistPortalResource` and `PortalEventResource` to whitelist only necessary fields.
+
+#### [HIGH] A04-5: `Person::$fillable` includes `user_id` and `status`
+
+- **File:** `api/app/Models/Person.php:27,36`
+- **Description:** `user_id` (identity link) and `status` (approval state) are in `$fillable`. Controller layer is safe today, but model-level permission enables future mass-assignment.
+- **Risk:** Latent mass-assignment risk for identity hijacking.
+- **Fix:** Remove `user_id` from `$fillable`. Set explicitly: `$person->user_id = $user->id`.
+
+#### [HIGH] A04-6: `ShiftAssignment::$fillable` includes audit trail fields
+
+- **File:** `api/app/Models/ShiftAssignment.php:23-40`
+- **Description:** `auto_approved`, `assigned_by`, `approved_by`, `approved_at`, `cancelled_by`, `cancelled_at`, `checked_in_at`, `checked_out_at` are fillable.
+- **Risk:** Future `fill($request->all())` call could forge audit trails.
+- **Fix:** Remove all audit-trail fields from `$fillable`. Set explicitly in the service layer.
+
+#### [HIGH] A04-7: `UserInvitation::$fillable` includes role, token, status
+
+- **File:** `api/app/Models/UserInvitation.php:18-27`
+- **Description:** `role`, `token`, `status`, `organisation_id`, `invited_by_user_id`, `expires_at` are all fillable — entirely system-set fields.
+- **Risk:** Latent mass-assignment vector.
+- **Fix:** Remove from `$fillable`, set explicitly in `InvitationService`.
+
+#### [MEDIUM] A04-8: No self-approval guard in `ShiftAssignmentPolicy`
+
+- **File:** `api/app/Policies/ShiftAssignmentPolicy.php:19-23`
+- **Description:** An org_admin who is also a registered volunteer can approve their own shift assignment.
+- **Risk:** Self-approval of shift assignments bypasses the approval workflow.
+- **Fix:** Add `if ($assignment->person?->user_id === $user->id) return false;` in `approve()`.
+
+#### [MEDIUM] A04-9: No rate limiting on volunteer registration endpoint
+
+- **File:** `api/routes/api.php:72`
+- **Description:** `events/{event}/volunteer-register` has no throttle middleware.
+- **Risk:** Automated mass registration / spam.
+- **Fix:** `->middleware('throttle:5,1')`.
+
+#### [LOW] A04-10: No rate limiting on invitation accept endpoint
+
+- **File:** `api/routes/api.php:62`
+- **Description:** `invitations/{token}/accept` has no throttle middleware.
+- **Risk:** Token brute-force (mitigated by ULID entropy).
+- **Fix:** `->middleware('throttle:10,1')`.
+
+#### [LOW] A04-11: Inline `validate()` in `PasswordResetController`
+
+- **File:** `api/app/Http/Controllers/Api/V1/PasswordResetController.php:28-32`
+- **Description:** Both methods use `$request->validate()` inline instead of Form Requests. Violates project rules.
+- **Risk:** Inconsistent validation patterns.
+- **Fix:** Extract to dedicated Form Requests.
+
+---
+
+### A05 — Security Misconfiguration
+
+#### [HIGH] A05-1: No security headers middleware
+
+- **Description:** No `X-Content-Type-Options`, `X-Frame-Options`, `Strict-Transport-Security`, or `Content-Security-Policy` headers are added anywhere in the middleware stack.
+- **Risk:** Clickjacking, MIME sniffing attacks, missing HSTS.
+- **Fix:** Create and register a `SecurityHeaders` middleware with: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Strict-Transport-Security: max-age=31536000; includeSubDomains`, `X-XSS-Protection: 1; mode=block`.
+
+#### [MEDIUM] A05-2: `APP_DEBUG=true` in `.env.example`
+
+- **File:** `api/.env.example:4`
+- **Description:** `.env.example` defaults `APP_DEBUG=true`, training developers to leave it enabled. The exception handler (correctly) gates debug output on `config('app.debug')`.
+- **Risk:** Accidental production deployment with debug enabled exposes stack traces and query details.
+- **Fix:** Change `.env.example` to `APP_DEBUG=false` with comment `# Set to true only in local development`.
+
+#### [MEDIUM] A05-3: Session `SameSite=Lax` may break cross-subdomain Sanctum cookie auth
+
+- **File:** `api/config/session.php:202`
+- **Description:** With three SPAs on different subdomains calling `api.crewli.app`, `SameSite=Lax` blocks session cookies on cross-origin requests.
+- **Risk:** Sanctum SPA authentication may fail silently in production.
+- **Fix:** Set `SESSION_DOMAIN=.crewli.app` and `SESSION_SAME_SITE=none` (with `SESSION_SECURE_COOKIE=true`) in production.
+
+#### [MEDIUM] A05-4: `/mail-preview/{type}` web route exposes real database records without auth
+
+- **File:** `api/routes/web.php:12-52`
+- **Description:** Mail preview gated by `app()->environment('local', 'staging')` but fetches real Person, Event, UserInvitation records with no authentication.
+- **Risk:** Staging environment exposes real data including invitation tokens.
+- **Fix:** Add `->middleware('auth:sanctum')` or generate previews using factory-only data.
+
+#### [MEDIUM] A05-5: Local `.env` uses wrong mail sender domain
+
+- **File:** `api/.env:50`
+- **Description:** `MAIL_FROM_ADDRESS="noreply@eventcrew.nl"` — should be `noreply@crewli.app` per CLAUDE.md.
+- **Risk:** Failed SPF/DKIM, confused recipients.
+- **Fix:** Update to `noreply@crewli.app`.
+
+#### [LOW] A05-6: `allowed_headers: ['*']` in CORS config
+
+- **File:** `api/config/cors.php:22`
+- **Description:** Accepts any header from allowed origins. Only `Content-Type`, `Authorization`, `X-Requested-With`, `Accept` are needed.
+- **Risk:** Overly broad attack surface.
+- **Fix:** Constrain to needed headers.
+
+#### Positive findings (A05)
+
+- CORS `allowed_origins` correctly restricted to three specific SPAs (not `*`).
+- `supports_credentials: true` correctly set for Sanctum.
+- `APP_DEBUG` defaults to `false` in `config/app.php`.
+- No Telescope/Horizon routes found (not installed).
+- No phpinfo endpoints exposed.
+- Password hashing uses bcrypt with `BCRYPT_ROUNDS=12`.
+- `$password` is in `$hidden` on User model.
+
+---
+
+### A06 — Vulnerable Components
+
+#### [CRITICAL] A06-1: `@casl/ability` 6.7.3 — Prototype Pollution
+
+- **File:** `apps/*/package.json`
+- **Description:** CASL is used for frontend permission enforcement across all three apps. Prototype pollution could corrupt ability checks.
+- **Risk:** Permission bypass in the frontend. Admin tokens at elevated risk.
+- **Fix:** Upgrade to `>=6.7.5` in all three apps.
+
+#### [CRITICAL] A06-2: `axios` ~1.13.x — SSRF and metadata exfiltration
+
+- **File:** `apps/*/package.json`
+- **Description:** Two critical CVEs: `NO_PROXY` hostname normalization bypass (SSRF) and unrestricted cloud metadata exfiltration via header injection.
+- **Risk:** Cloud metadata theft, SSRF in SSR contexts.
+- **Fix:** Upgrade to `>=1.15.0` in all three apps.
+
+#### [CRITICAL] A06-3: `swiper` 11.2.10 — Prototype Pollution
+
+- **File:** `apps/*/package.json`
+- **Description:** Prototype pollution vulnerability.
+- **Risk:** Client-side code execution.
+- **Fix:** Upgrade to `>=12.1.2` (major version bump — review migration guide).
+
+#### [MEDIUM] A06-4: `league/commonmark` 2.8.0 — HTML injection bypass
+
+- **File:** `api/composer.json`
+- **Description:** Two CVEs: `allowed_domains` bypass in embed extension (CVE-2026-33347) and `DisallowedRawHtml` bypass via whitespace (CVE-2026-30838).
+- **Risk:** If user-supplied Markdown is rendered, raw HTML injection is possible.
+- **Fix:** `composer update league/commonmark` to `>=2.8.2`.
+
+#### [LOW] A06-5: `phpunit/phpunit` 11.5.46 — Unsafe deserialization (dev only)
+
+- **File:** `api/composer.json`
+- **Description:** CVE-2026-24765 — dev dependency only, not deployed.
+- **Risk:** CI/CD pipeline risk only.
+- **Fix:** `composer update phpunit/phpunit` to `>=11.5.50`.
+
+#### [LOW] A06-6: `psy/psysh` 0.12.18 — Local privilege escalation
+
+- **File:** `api/composer.json` (via `laravel/tinker`)
+- **Description:** CVE-2026-25129 — local privilege escalation via CWD `.psysh.php` auto-load.
+- **Risk:** Low — requires shell access. Confirm Tinker is not exposed via web route.
+- **Fix:** `composer update laravel/tinker`.
+
+#### [LOW] A06-7: Frontend dev dependencies — 45 vulnerabilities in build tools
+
+- **File:** `apps/*/pnpm-lock.yaml`
+- **Description:** `vite`, `rollup`, `tar`, `undici`, `minimatch`, `svgo`, `flatted`, `immutable`, `lodash`, `picomatch` — various high/moderate vulnerabilities in dev/build-time dependencies.
+- **Risk:** Not shipped to production. CI/CD pipeline risk.
+- **Fix:** Update dev dependencies: `pnpm update` for each app.
+
+---
+
+### A07 — Identification and Authentication Failures
+
+#### [MEDIUM] A07-1: Account enumeration via `CheckEmailController`
+
+- **File:** `api/app/Http/Controllers/Api/V1/CheckEmailController.php:16-18`
+- **Description:** Returns `{"exists": true/false}` for any email. Rate limited to 10/min but still enables targeted enumeration.
+- **Risk:** Confirms which email addresses are registered users.
+- **Fix:** Consider removing this endpoint or requiring CAPTCHA. If needed for UX, apply stricter rate limiting.
+
+#### [MEDIUM] A07-2: Existing Sanctum tokens not revoked on password reset
+
+- **File:** `api/app/Http/Controllers/Api/V1/PasswordResetController.php:35-43`
+- **Description:** `Password::reset()` updates the password but does not revoke existing tokens. An attacker with a stolen token retains access after reset.
+- **Risk:** Password reset does not evict active attackers.
+- **Fix:** Add `$user->tokens()->delete();` in the closure after saving the new password.
+
+#### [MEDIUM] A07-3: Weak password validation (min:8 only, no complexity)
+
+- **File:** `api/app/Http/Requests/Api/V1/LoginRequest.php:22`
+- **File:** `api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php:27`
+- **File:** `api/app/Http/Controllers/Api/V1/PasswordResetController.php:32`
+- **Description:** All password fields only enforce `min:8`. No mixed case, digits, symbols, or breach-check.
+- **Risk:** Common/weak passwords accepted.
+- **Fix:** Use `Password::min(8)->mixedCase()->numbers()` consistently. Consider `->uncompromised()`.
+
+#### [MEDIUM] A07-4: No rate limiting on invitation routes
+
+- **File:** `api/routes/api.php:61-62`
+- **Description:** `GET/POST invitations/{token}` and `invitations/{token}/accept` have no throttle.
+- **Risk:** Token brute-force and enumeration. `show` endpoint confirms token validity (404 vs. 200).
+- **Fix:** `->middleware('throttle:10,1')` on both routes.
+
+#### [LOW] A07-5: `Schema::hasTable()` runtime checks in `PortalTokenController`
+
+- **File:** `api/app/Http/Controllers/Api/V1/PortalTokenController.php:22,37`
+- **Description:** Schema introspection on every request. The 501 response reveals database schema info.
+- **Risk:** Minor info disclosure + performance cost.
+- **Fix:** Remove `Schema::hasTable()` guards; handle via migration, not runtime.
+
+#### [LOW] A07-6: Email in password reset URL query string
+
+- **File:** `api/app/Providers/AppServiceProvider.php:19-21`
+- **Description:** Email address included as plaintext query parameter in the reset URL. Appears in browser history, server logs, referrer headers.
+- **Risk:** Email address leakage.
+- **Fix:** Omit email from URL; require user to enter it on the reset form.
+
+---
+
+### A08 — Software and Data Integrity
+
+#### Positive findings (A08)
+
+- CSRF intentionally disabled for stateless token-based API — appropriate architecture.
+- File uploads validate `image`, `mimes:jpg,jpeg,png,webp`, `max:5120`. Uses server-generated filenames.
+- All dependency lock files committed (`composer.lock`, `pnpm-lock.yaml` x3).
+- Sanctum token prefix is empty (`''`) — consider setting `SANCTUM_TOKEN_PREFIX=crewli_` to enable GitHub secret scanning.
+
+---
+
+### A09 — Security Logging and Monitoring
+
+#### [HIGH] A09-1: Failed login attempts not logged
+
+- **File:** `api/app/Http/Controllers/Api/V1/LoginController.php:17`
+- **Description:** On failed `Auth::attempt()`, returns 401 with no log entry, no event firing.
+- **Risk:** Brute-force attacks leave no audit trail.
+- **Fix:** Add `Log::warning('Failed login attempt', ['email' => ..., 'ip' => ..., 'user_agent' => ...])`.
+
+#### [MEDIUM] A09-2: Authorization failures (403) not logged
+
+- **File:** `api/bootstrap/app.php:65-90`
+- **Description:** `AuthorizationException` is handled but not logged. Cross-tenant access probes leave no trace.
+- **Risk:** No visibility into privilege escalation attempts.
+- **Fix:** Add a `Log::warning()` in the exception handler for `AuthorizationException` with user, IP, path, method context.
+
+#### [MEDIUM] A09-3: Missing activity log on 10+ critical actions
+
+- **Description:** `spatie/laravel-activitylog` is installed but many critical actions are not logged:
+ - Login success/failure
+ - Logout
+ - Person approved/rejected
+ - Shift assignment approved/rejected/cancelled
+ - Portal profile updated / password changed
+ - Password reset completed
+ - Organisation settings changed
+ - Event status transitions
+ - Identity match confirmed/dismissed
+- **Risk:** No audit trail for administrative decisions.
+- **Fix:** Add `activity()` or `Log::info()` calls for these actions.
+
+#### [MEDIUM] A09-4: No suspicious pattern detection
+
+- **Description:** No mechanism to detect rapid failed logins, cross-tenant access attempts, or token enumeration.
+- **Risk:** Attacks proceed indefinitely without alerts.
+- **Fix:** Configure `RateLimiter` in `AppServiceProvider::boot()` with `Lockout` notifications after N failures.
+
+#### [LOW] A09-5: `VolunteerRegistrationService` catch block swallows exception context
+
+- **File:** `api/app/Services/VolunteerRegistrationService.php:143-154`
+- **Description:** `UniqueConstraintViolationException` is caught and re-thrown as `ValidationException` without logging the original.
+- **Risk:** Race condition failures are invisible.
+- **Fix:** Log the original exception before rethrowing.
+
+---
+
+### A10 — Server-Side Request Forgery
+
+#### [LOW] A10-1: `email_logo_url` accepts any HTTP URL rendered in email templates
+
+- **File:** `api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php:28`
+- **File:** `api/resources/views/mail/layouts/crewli.blade.php:22`
+- **Description:** User-supplied URL used as `` in outbound emails. Email clients request this URL, leaking recipient IP and metadata.
+- **Risk:** Tracking pixel injection, recipient metadata exfiltration.
+- **Fix:** Restrict to HTTPS only (`regex:/^https:\/\//`). Consider server-side image proxy.
+
+#### Positive findings (A10)
+
+- No `Http::get/post`, `file_get_contents()`, or `curl_*` calls in application code.
+- No webhook or callback URL configurations found.
+- No ZenderService or external HTTP client services exist.
+
+---
+
+### Frontend Security (A13)
+
+#### [CRITICAL] A13-1: Bearer tokens stored in `localStorage` (apps/app and apps/portal)
+
+- **File:** `apps/app/src/stores/useAuthStore.ts:7,10,30,66`
+- **File:** `apps/portal/src/stores/useAuthStore.ts:6,9,18,20`
+- **Description:** Sanctum bearer tokens stored in `localStorage` under `crewli_token` and `crewli_portal_token`. Accessible to any JavaScript on the page.
+- **Risk:** Any XSS vulnerability (or supply-chain attack) can steal tokens and impersonate users indefinitely.
+- **Fix:** Migrate to `httpOnly; Secure; SameSite=Strict` cookies set by the Laravel backend. Remove `setItem`/`getItem` usage.
+
+#### [HIGH] A13-2: Admin app cookies lack `httpOnly`, `Secure`, and `SameSite` flags
+
+- **File:** `apps/admin/src/@core/composable/useCookie.ts:38-43`
+- **Description:** Token, user data, and ability rules stored in cookies written via `document.cookie` with no `httpOnly`, `secure`, or `sameSite`.
+- **Risk:** XSS token theft and CSRF.
+- **Fix:** Refactor to have the API set httpOnly cookies via `Set-Cookie` response header. Add `sameSite: 'strict'` as interim measure.
+
+#### [HIGH] A13-3: Open redirect vulnerability on post-login redirect (all apps)
+
+- **File:** `apps/portal/src/pages/login.vue:61,74-76`
+- **File:** `apps/app/src/pages/login.vue:55`
+- **File:** `apps/admin/src/pages/login.vue:97`
+- **Description:** All login pages accept `?to=` query parameter and redirect to it after login without validating it's a relative path. Portal falls back to `window.location.href` with the raw value.
+- **Risk:** Phishing: `https://portal.crewli.app/login?to=https://evil.com/steal`.
+- **Fix:** Validate that redirect target starts with `/` before using it.
+
+#### [HIGH] A13-4: `v-html` with API-sourced data in admin app (template pages)
+
+- **File:** `apps/admin/src/pages/front-pages/help-center/article/[title].vue:59-62`
+- **File:** `apps/admin/src/pages/apps/academy/course-details.vue:154`
+- **Description:** Template/demo pages from Vuexy render API data with `v-html` — stored XSS vector.
+- **Risk:** XSS within the admin app where users have super_admin privileges.
+- **Fix:** Remove these template demo pages entirely, or sanitize with DOMPurify.
+
+#### [HIGH] A13-5: Admin router guard relies on JS-readable cookie without server validation
+
+- **File:** `apps/admin/src/plugins/1.router/guards.ts:40-42`
+- **Description:** Guard reads `userData`/`accessToken` from `document.cookie`. Does not call API to validate — just checks truthy value.
+- **Risk:** An XSS attacker who steals cookies bypasses the guard. Stale or tampered cookies pass validation.
+- **Fix:** Use the `authStore.initialize()` pattern from the app/portal guards (server-validates token on init).
+
+#### [MEDIUM] A13-6: Server error messages forwarded to UI
+
+- **File:** `apps/admin/src/pages/events/create.vue:74`
+- **File:** `apps/app/src/pages/login.vue:59-69`
+- **File:** `apps/portal/src/pages/login.vue:51`
+- **Description:** Raw `data.message` from API errors rendered in UI. Could expose internal details if backend misconfigured.
+- **Risk:** Information disclosure (table names, file paths).
+- **Fix:** Use safelist-based error mapping. The portal's `mapLoginErrorMessage` is a good pattern — extend it.
+
+#### [MEDIUM] A13-7: Admin `main.ts` mount-error handler uses `innerHTML` interpolation
+
+- **File:** `apps/admin/src/main.ts:41`
+- **Description:** Error fallback injects `error` object via template string into `innerHTML`.
+- **Risk:** XSS if error message is attacker-influenced.
+- **Fix:** Use `document.createTextNode(String(error))` instead.
+
+#### [MEDIUM] A13-8: Portal localStorage event state persists across session expiry
+
+- **File:** `apps/portal/src/stores/usePortalStore.ts:9-34,86-94`
+- **Description:** Event data and registration state stored in localStorage survives logout (partially cleared).
+- **Risk:** Stale data from previous sessions could contaminate new sessions.
+- **Fix:** Verify `reset()` is called on all logout paths including token expiry via 401 interceptor.
+
+#### [LOW] A13-9: No Content Security Policy on any SPA — RESOLVED
+
+- **File:** `apps/*/index.html`, `api/app/Http/Middleware/SecurityHeaders.php`, `deploy/nginx/`
+- **Description:** ~~None of the three apps set a CSP meta tag or header.~~
+- **Risk:** Injected scripts have unrestricted access.
+- **Resolution:** API CSP enforced via `SecurityHeaders` middleware (`default-src 'none'; frame-ancestors 'none'`). SPA CSP configured via Nginx snippets (`deploy/nginx/csp-spa.conf`, `csp-portal.conf`). Dev CSP meta tags added to all `index.html` files for local testing. See `deploy/README.md` for rollout instructions.
+
+#### [LOW] A13-10: No hardcoded secrets found in frontend code (positive)
+
+- **Description:** No API keys, secrets, or credentials found in `.env.local` files or source code. `.env.local` files correctly gitignored.
+
+---
+
+## Positive Findings
+
+The following security measures ARE correctly implemented:
+
+1. **Password hashing:** bcrypt with `BCRYPT_ROUNDS=12`. `$password` in User model's `$hidden` array.
+2. **CORS origins:** Correctly restricted to three specific SPA origins (not `*`). `supports_credentials: true` set.
+3. **SQL injection prevention:** All Eloquent queries use PDO parameter binding. All `whereRaw()`/`selectRaw()` calls use `?` placeholders.
+4. **No shell execution:** No `exec()`, `shell_exec()`, `system()`, `proc_open()`, or `passthru()` in application code.
+5. **Blade escaping:** All Blade output uses `{{ }}` (escaped). Zero instances of `{!! !!}`.
+6. **CSRF:** Intentionally disabled for stateless token-based API — correct architecture.
+7. **File uploads:** Validated for type, mime, and size. Server-generated filenames prevent path traversal.
+8. **Lock files:** `composer.lock` and all `pnpm-lock.yaml` files committed.
+9. **Password reset throttling:** `auth/forgot-password` has `throttle:5,1`.
+10. **Check-email throttling:** `public/check-email` has `throttle:10,1`.
+11. **Debug output gated:** Exception handler correctly gates debug info behind `config('app.debug')`.
+12. **No Telescope/Horizon exposed:** Not installed in the application.
+13. **Invitation expiry:** Invitations expire after 7 days and are checked for `pending` status + `expires_at`.
+14. **Activity logging:** `spatie/laravel-activitylog` in use for invitation and volunteer registration flows.
+15. **Policy coverage:** 16 policies exist covering all major models. Controllers consistently use `Gate::authorize()`.
+16. **No external HTTP calls:** No outbound HTTP client usage in application code — no SSRF surface.
+
+---
+
+## Priority Remediation Plan
+
+### Immediate (before any production deployment)
+
+| Priority | Finding | Fix effort |
+|----------|---------|-----------|
+| 1 | A04-2: Rate limit login | One line |
+| 2 | A04-3: Rate limit portal/token-auth | One line |
+| 3 | A04-4: API resources for portal/token-auth response | Small |
+| 4 | A04-1: Remove billing_status from UpdateOrganisationRequest | One line |
+| 5 | A02-2: Set Sanctum token expiration | One line |
+| 6 | A02-1: Replace ULID tokens with cryptographic random | Small |
+| 7 | A01-1: Implement PortalTokenMiddleware | Medium |
+| 8 | A13-3: Fix open redirect on login pages | Small |
+
+### Short-term (within 1 sprint)
+
+| Priority | Finding | Fix effort |
+|----------|---------|-----------|
+| 9 | A01-2/3: Add event boundary checks in portal shift + policy | Small |
+| 10 | A01-5/6/7/8/9: Scope all `exists:` rules to org/event | Medium |
+| 11 | A01-4: Register OrganisationScope on models | Medium |
+| 12 | A04-5/6/7: Clean up $fillable on Person, ShiftAssignment, UserInvitation | Small |
+| 13 | A07-2: Revoke tokens on password reset | One line |
+| 14 | A05-1: Add security headers middleware | Small |
+| 15 | A06-1/2: Upgrade @casl/ability and axios | Small |
+| 16 | A13-1: Migrate token storage to httpOnly cookies | Medium |
+
+### Medium-term (within 2-3 sprints)
+
+| Priority | Finding | Fix effort |
+|----------|---------|-----------|
+| 17 | A01-13: Move event routes under org prefix | Large |
+| 18 | A09-1/2/3: Add security logging | Medium |
+| 19 | A07-3: Strengthen password rules | Small |
+| 20 | A13-2/5: Fix admin cookie security | Medium |
+| 21 | A06-3: Upgrade swiper (major version) | Medium |