Files
crewli/api/app/Providers/AppServiceProvider.php
bert.hausmans adab3be781 fix: register AuthScopeContextListener for Sanctum bearer-token flow
Live HTTP smoke test on the post-architectural-fixes branch surfaced
that captured Sentry events carried only route-scope tags (app,
route_name, http.method) — auth-scope tags (user_id, actor_type,
actor_scope) were absent on every request.

Root cause: Sanctum's Guard fires Laravel\Sanctum\Events\TokenAuthenticated
(vendor/laravel/sanctum/src/Guard.php:77) on bearer-token resolution,
NOT Illuminate\Auth\Events\Authenticated. The Authenticated event only
fires from SessionGuard
(vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php:833),
which Crewli does not use — CookieBearerToken middleware injects the
httpOnly cookie as Authorization: Bearer, then auth:sanctum invokes
Sanctum's Guard. So the listener never ran on Crewli's HTTP path.

Offline tests in AuthScopeContextListenerTest passed because they
dispatch event(new Authenticated(...)) directly, bypassing the Guard
layer. Sanctum::actingAs() in tests has the same blind spot — it
short-circuits the Guard via guard('sanctum')->setUser() and fires
neither event.

Fix:
- New handleTokenAuthenticated(TokenAuthenticated $event) method on
  AuthScopeContextListener extracts the user via $event->token->tokenable
  and delegates to a private bindForUser() shared with handle().
- AppServiceProvider registers the listener for both Authenticated
  (covers SessionGuard / login flow / future authenticators) and
  TokenAuthenticated (covers Crewli's bearer-token Sanctum flow).

Regression coverage: AuthScopeBindingHttpFlowTest exercises the real
Sanctum Guard via $user->createToken() + Authorization: Bearer header.
Three cases:
  - super_admin on a user-scope route: actor_scope=user, all auth tags
    present.
  - super_admin on an admin.* route: actor_scope=platform, no
    organisation_id (correct platform-mode behaviour).
  - org_admin on a route with {organisation} param: actor_scope=
    organisation, organisation_id valid ULID.

Test count 1541 to 1544. Larastan clean. Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:58:42 +02:00

344 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Providers;
use App\Events\FormBuilder\FormSubmissionSectionSubmitted;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\FormBuilder\Bindings\BindingActivityLogger;
use App\FormBuilder\Bindings\BindingConflictResolver;
use App\FormBuilder\Bindings\BindingTypeRegistry;
use App\FormBuilder\Bindings\FormBindingApplicator;
use App\FormBuilder\Bindings\PersonProvisioner;
use App\FormBuilder\Purposes\PurposeRegistry;
use App\Listeners\FormBuilder\ApplyBindingsOnFormSectionSubmitted;
use App\Listeners\FormBuilder\ApplyBindingsOnFormSubmit;
use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit;
use App\Listeners\FormBuilder\TriggerPersonIdentityMatchOnFormSubmit;
use App\Models\Company;
use App\Models\CrowdList;
use App\Models\CrowdType;
use App\Models\EmailChangeRequest;
use App\Models\EmailLog;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\FormBuilder\FormSchemaWebhook;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionDelegation;
use App\Models\FormBuilder\FormSubmissionSectionStatus;
use App\Models\FormBuilder\FormTemplate;
use App\Models\FormBuilder\FormValue;
use App\Models\FormBuilder\FormValueOption;
use App\Models\FormBuilder\FormWebhookDelivery;
use App\Models\ImpersonationSession;
use App\Models\Location;
use App\Models\MfaBackupCode;
use App\Models\MfaEmailCode;
use App\Models\Organisation;
use App\Models\OrganisationEmailSettings;
use App\Models\OrganisationEmailTemplate;
use App\Models\Person;
use App\Models\PersonIdentityMatch;
use App\Models\PersonSectionPreference;
use App\Models\PersonTag;
use App\Models\Shift;
use App\Models\ShiftAssignment;
use App\Models\ShiftWaitlist;
use App\Models\TimeSlot;
use App\Models\TrustedDevice;
use App\Models\User;
use App\Models\UserInvitation;
use App\Models\UserOrganisationTag;
use App\Models\UserProfile;
use App\Models\VolunteerAvailability;
use App\Observers\FormBuilder\FormFieldChildTablesCascadeObserver;
use App\Observers\FormBuilder\FormSubmissionObserver;
use App\Observers\FormBuilder\FormValueObserver;
use App\Observers\PersonObserver;
use App\Observers\UserObserver;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Spatie\Activitylog\Models\Activity;
class AppServiceProvider extends ServiceProvider
{
/**
* FQCN lookup for every `subject_type` alias that PurposeRegistry may
* return. Single source of truth for domain-subject → model mapping,
* consumed by the morph-map build in boot(). The `artist` class is
* declared as a string because the Artist model is not yet landed;
* it is safe to register the morph alias (lazily resolved).
*
* @var array<string, class-string|string>
*/
private const PURPOSE_SUBJECT_FQCN = [
'person' => Person::class,
'user' => User::class,
'company' => Company::class,
'artist' => 'App\\Models\\Artist',
];
public function register(): void
{
$this->mergeConfigFrom(
base_path('config/form_builder/purposes.php'),
'form_builder.purposes',
);
$this->mergeConfigFrom(
base_path('config/form_builder/binding_targets.php'),
'form_builder.binding_targets',
);
$this->app->singleton(PurposeRegistry::class);
$this->app->singleton(BindingTypeRegistry::class);
$this->app->singleton(PersonProvisioner::class);
$this->app->singleton(BindingConflictResolver::class);
$this->app->singleton(BindingActivityLogger::class);
$this->app->singleton(FormBindingApplicator::class);
// Telescope is a dev-only debugging dashboard. Three-layer
// defense keeps it out of production: composer `dont-discover`
// suppresses auto-registration, this block gates manual
// registration to local/testing, and TELESCOPE_ENABLED in .env
// is the runtime toggle (see /dev-docs/TELESCOPE.md). The vendor
// service provider registers routes/migrations/publishing;
// App\Providers\TelescopeServiceProvider attaches the gate +
// filter. Both must register for the dashboard to work.
if ($this->app->environment('local', 'testing')) {
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
$this->app->register(\App\Providers\TelescopeServiceProvider::class);
}
}
public function boot(): void
{
$this->registerMorphMap();
// RFC-WS-6 V3 — explicit policy registration. Laravel's auto-discovery
// doesn't reliably resolve nested-namespace models like
// App\Models\FormBuilder\FormSubmissionActionFailure to
// App\Policies\FormBuilder\FormSubmissionActionFailurePolicy.
Gate::policy(
\App\Models\FormBuilder\FormSubmissionActionFailure::class,
\App\Policies\FormBuilder\FormSubmissionActionFailurePolicy::class,
);
// RFC-WS-6 v1.1 §3 Q5 — explicit route model binding for the
// form-failures admin endpoints. Implicit binding on nested route
// groups triggers Laravel's scoped-binding logic that tries to
// resolve the failure as a relation of the route's parent
// ({organisation}) — Organisation has no formSubmissionActionFailures()
// relation, so the implicit lookup silently fails and the
// controller receives a string. Route::bind sidesteps the
// scoped-binding path entirely. Bypass global scopes so cross-
// tenant access reaches the policy (which translates denied → 404
// per RFC V3 in the controller).
\Illuminate\Support\Facades\Route::bind('formSubmissionActionFailure', static function (string $value): \App\Models\FormBuilder\FormSubmissionActionFailure {
$model = \App\Models\FormBuilder\FormSubmissionActionFailure::query()
->withoutGlobalScopes()
->find($value);
if ($model === null) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
return $model;
});
Person::observe(PersonObserver::class);
User::observe(UserObserver::class);
FormValue::observe(FormValueObserver::class);
\App\Models\FormBuilder\FormSubmission::observe(FormSubmissionObserver::class);
// Cascade binding / validation-rule / config rows on owner delete.
// Children are physical state; deleted on soft-delete as well as
// hard-delete of the owner (WS-5a bindings, WS-5b validation rules
// + configs).
FormField::observe(FormFieldChildTablesCascadeObserver::class);
FormFieldLibrary::observe(FormFieldChildTablesCascadeObserver::class);
// RFC-WS-6 §3 (Q1) — sync chain on FormSubmissionSubmitted, in
// this exact order:
// 1. ApplyBindingsOnFormSubmit (sync)
// 2. TriggerPersonIdentityMatchOnFormSubmit (sync)
// Queued listeners on the same event (SyncTagPickerSelectionsOnSubmit,
// future webhook dispatcher, mailables) run in parallel after the
// sync chain via the queue. Their relative registration position
// is irrelevant.
// RFC Q1 — applies bindings sync before identity match runs.
\Illuminate\Support\Facades\Event::listen(
FormSubmissionSubmitted::class,
ApplyBindingsOnFormSubmit::class,
);
// ARCH §31.10 — FORM-02 TAG_PICKER sync listener (queued).
\Illuminate\Support\Facades\Event::listen(
FormSubmissionSubmitted::class,
SyncTagPickerSelectionsOnSubmit::class,
);
// ARCH §31.1 — identity-match trigger on event_registration (sync).
\Illuminate\Support\Facades\Event::listen(
FormSubmissionSubmitted::class,
TriggerPersonIdentityMatchOnFormSubmit::class,
);
// RFC Q10 — section-level apply stub. Runtime gated by feature flag.
\Illuminate\Support\Facades\Event::listen(
FormSubmissionSectionSubmitted::class,
ApplyBindingsOnFormSectionSubmitted::class,
);
// RFC-WS-7 §3.6 / §3.11 — tag captured Sentry events with the queue
// attempt count. Default stack-trace grouping is preserved (no
// per-attempt fingerprinting).
\Illuminate\Support\Facades\Event::listen(
\Illuminate\Queue\Events\JobProcessing::class,
\App\Listeners\Observability\TagJobAttemptOnSentry::class,
);
// RFC-WS-7 §3.6 — auth-scope Sentry tags + Log::withContext on
// every successful authentication. Listens to two events:
// - Authenticated covers SessionGuard flows (login etc.).
// - TokenAuthenticated covers Sanctum bearer-token flows; this
// is Crewli's actual SPA auth path because CookieBearerToken
// middleware injects the cookie as an Authorization header.
// Without this, live HTTP events would carry no auth-scope
// tags even though the offline (event-dispatch) tests pass.
\Illuminate\Support\Facades\Event::listen(
\Illuminate\Auth\Events\Authenticated::class,
[\App\Listeners\Observability\AuthScopeContextListener::class, 'handle'],
);
\Illuminate\Support\Facades\Event::listen(
\Laravel\Sanctum\Events\TokenAuthenticated::class,
[\App\Listeners\Observability\AuthScopeContextListener::class, 'handleTokenAuthenticated'],
);
ResetPassword::createUrlUsing(function ($user, string $token) {
return config('crewli.portal_url').'/wachtwoord-resetten?token='.$token.'&email='.urlencode($user->email);
});
// Tag activity log entries with impersonation context
Activity::saving(function (Activity $activity) {
$request = request();
$impersonator = $request->attributes->get('impersonator');
$session = $request->attributes->get('impersonation_session');
if ($impersonator && $session) {
$properties = $activity->properties?->toArray() ?? [];
$properties['impersonated_by'] = [
'user_id' => $impersonator->id,
'name' => $impersonator->full_name,
'email' => $impersonator->email,
];
$properties['impersonation_session_id'] = $session->id;
$activity->properties = collect($properties);
}
});
}
/**
* Morph-map is built from three labelled blocks:
*
* 1. Domain subject types — derived from PurposeRegistry. These are
* the aliases allowed on `form_submissions.subject_type`.
* 2. Non-purpose domain types — form_schemas owner_types that are
* not subjects (`organisation`, `event`, `user_profile`) plus
* domain models referenced as activity-log subjects/causers.
* 3. Framework types — spatie/activitylog subjects.
*
* Sanctum's `personal_access_tokens.tokenable_type` is intentionally
* absent (addendum Q4: framework polymorphie uses framework defaults,
* not the domain morph-map). `MorphMapAlignmentTest` guards blocks
* 1 and 2 against drift.
*/
private function registerMorphMap(): void
{
/** @var PurposeRegistry $registry */
$registry = $this->app->make(PurposeRegistry::class);
// Block 1 — domain subject types, derived from PurposeRegistry.
$domainSubjectTypes = [];
foreach ($registry->allSubjectTypes() as $alias) {
$fqcn = self::PURPOSE_SUBJECT_FQCN[$alias] ?? null;
if ($fqcn === null) {
throw new \RuntimeException(
"No FQCN mapping for purpose subject_type '{$alias}'. "
.'Add it to AppServiceProvider::PURPOSE_SUBJECT_FQCN.'
);
}
$domainSubjectTypes[$alias] = $fqcn;
}
// Block 2 — non-purpose domain types.
$nonPurposeDomainTypes = [
// form_schemas owner_type candidates that are not subject_types.
'organisation' => Organisation::class,
'event' => Event::class,
'user_profile' => UserProfile::class,
// Domain models referenced as activity-log subjects/causers.
'crowd_list' => CrowdList::class,
'crowd_type' => CrowdType::class,
'email_change_request' => EmailChangeRequest::class,
'email_log' => EmailLog::class,
'festival_section' => FestivalSection::class,
'impersonation_session' => ImpersonationSession::class,
'location' => Location::class,
'mfa_backup_code' => MfaBackupCode::class,
'mfa_email_code' => MfaEmailCode::class,
'organisation_email_settings' => OrganisationEmailSettings::class,
'organisation_email_template' => OrganisationEmailTemplate::class,
'person_identity_match' => PersonIdentityMatch::class,
'person_section_preference' => PersonSectionPreference::class,
'person_tag' => PersonTag::class,
'shift' => Shift::class,
'shift_assignment' => ShiftAssignment::class,
'shift_waitlist' => ShiftWaitlist::class,
'time_slot' => TimeSlot::class,
'trusted_device' => TrustedDevice::class,
'user_invitation' => UserInvitation::class,
'user_organisation_tag' => UserOrganisationTag::class,
'volunteer_availability' => VolunteerAvailability::class,
// Form-builder models used as activity-log subjects and (S2+)
// polymorphic webhook payload subjects.
'form_schema' => FormSchema::class,
'form_schema_section' => FormSchemaSection::class,
'form_field' => FormField::class,
'form_field_library' => FormFieldLibrary::class,
'form_submission' => FormSubmission::class,
'form_submission_section_status' => FormSubmissionSectionStatus::class,
'form_submission_delegation' => FormSubmissionDelegation::class,
'form_submission_action_failure' => \App\Models\FormBuilder\FormSubmissionActionFailure::class,
'form_value' => FormValue::class,
'form_value_option' => FormValueOption::class,
'form_template' => FormTemplate::class,
'form_schema_webhook' => FormSchemaWebhook::class,
'form_webhook_delivery' => FormWebhookDelivery::class,
];
// Block 3 — framework types (spatie/activitylog). Sanctum is absent
// on purpose; see class-level docblock.
$frameworkTypes = [
// activitylog causer/subject fall-back entries are not needed
// here because every framework morph value lands on a registered
// domain alias above. Reserved for future framework extensions.
];
Relation::enforceMorphMap(array_merge(
$domainSubjectTypes,
$nonPurposeDomainTypes,
$frameworkTypes,
));
}
}