diff --git a/app-modules/booking/src/Policies/BookingPolicy.php b/app-modules/booking/src/Policies/BookingPolicy.php new file mode 100644 index 0000000..08a928d --- /dev/null +++ b/app-modules/booking/src/Policies/BookingPolicy.php @@ -0,0 +1,38 @@ +can('view_bookings'); + } + + public function view(User $user, mixed $booking): bool + { + return $user->can('view_bookings'); + } + + public function create(User $user): bool + { + return true; + } + + public function cancel(User $user, mixed $booking): bool + { + return $user->can('manage_bookings'); + } + + public function refund(User $user, mixed $booking): bool + { + return $user->can('process_refunds'); + } +} diff --git a/app-modules/booking/src/Providers/BookingServiceProvider.php b/app-modules/booking/src/Providers/BookingServiceProvider.php index dd601ae..bc96f84 100644 --- a/app-modules/booking/src/Providers/BookingServiceProvider.php +++ b/app-modules/booking/src/Providers/BookingServiceProvider.php @@ -2,11 +2,16 @@ namespace Modules\Booking\Providers; +use Illuminate\Contracts\Auth\Access\Gate; use Illuminate\Support\ServiceProvider; +use Modules\Booking\Policies\BookingPolicy; class BookingServiceProvider extends ServiceProvider { public function register(): void {} - public function boot(): void {} + public function boot(Gate $gate): void + { + $gate->policy('Modules\Booking\Models\Booking', BookingPolicy::class); + } } diff --git a/app-modules/booking/tests/Feature/BookingPolicyTest.php b/app-modules/booking/tests/Feature/BookingPolicyTest.php new file mode 100644 index 0000000..d2bdcff --- /dev/null +++ b/app-modules/booking/tests/Feature/BookingPolicyTest.php @@ -0,0 +1,49 @@ +create()->givePermissionTo('view_bookings'); + $withoutPermission = User::factory()->create(); + + expect($policy->viewAny($withPermission))->toBeTrue() + ->and($policy->view($withPermission, null))->toBeTrue() + ->and($policy->viewAny($withoutPermission))->toBeFalse() + ->and($policy->view($withoutPermission, null))->toBeFalse(); +}); + +test('create is open to any authenticated user', function () { + $policy = new BookingPolicy; + + expect($policy->create(User::factory()->create()))->toBeTrue(); +}); + +test('cancel requires the manage_bookings permission', function () { + $policy = new BookingPolicy; + + $withPermission = User::factory()->create()->givePermissionTo('manage_bookings'); + $withoutPermission = User::factory()->create(); + + expect($policy->cancel($withPermission, null))->toBeTrue() + ->and($policy->cancel($withoutPermission, null))->toBeFalse(); +}); + +test('refund requires the process_refunds permission', function () { + $policy = new BookingPolicy; + + $withPermission = User::factory()->create()->givePermissionTo('process_refunds'); + $withoutPermission = User::factory()->create(); + + expect($policy->refund($withPermission, null))->toBeTrue() + ->and($policy->refund($withoutPermission, null))->toBeFalse(); +}); diff --git a/app-modules/identity/database/seeders/RolePermissionSeeder.php b/app-modules/identity/database/seeders/RolePermissionSeeder.php new file mode 100644 index 0000000..15a7464 --- /dev/null +++ b/app-modules/identity/database/seeders/RolePermissionSeeder.php @@ -0,0 +1,72 @@ + + */ + protected array $permissions = [ + 'manage_catalog', + 'manage_routes', + 'manage_pricing', + 'view_bookings', + 'manage_bookings', + 'view_payments', + 'process_refunds', + 'view_audit_log', + ]; + + /** + * @var array> + */ + protected array $roles = [ + 'super_admin' => [ + 'manage_catalog', + 'manage_routes', + 'manage_pricing', + 'view_bookings', + 'manage_bookings', + 'view_payments', + 'process_refunds', + 'view_audit_log', + ], + 'admin' => [ + 'manage_catalog', + 'manage_routes', + 'manage_pricing', + 'view_bookings', + 'manage_bookings', + 'view_payments', + 'process_refunds', + 'view_audit_log', + ], + 'support' => [ + 'view_bookings', + 'view_payments', + 'view_audit_log', + ], + ]; + + public function run(): void + { + foreach ($this->permissions as $permission) { + Permission::findOrCreate($permission, 'web'); + } + + // WithoutModelEvents (used by DatabaseSeeder) suppresses the model + // events Spatie's permission cache relies on to invalidate itself, + // so the freshly created permissions above must be flushed manually. + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + foreach ($this->roles as $role => $permissions) { + Role::findOrCreate($role, 'web')->syncPermissions($permissions); + } + } +} diff --git a/app-modules/identity/routes/identity-routes.php b/app-modules/identity/routes/identity-routes.php index b3d9bbc..f57b773 100644 --- a/app-modules/identity/routes/identity-routes.php +++ b/app-modules/identity/routes/identity-routes.php @@ -1 +1,8 @@ middleware('api')->group(function () { + Route::post('/auth/token', [TokenController::class, 'store'])->name('identity.auth.token'); +}); diff --git a/app-modules/identity/src/Console/Commands/IssueFastApiAgentTokenCommand.php b/app-modules/identity/src/Console/Commands/IssueFastApiAgentTokenCommand.php new file mode 100644 index 0000000..9702719 --- /dev/null +++ b/app-modules/identity/src/Console/Commands/IssueFastApiAgentTokenCommand.php @@ -0,0 +1,40 @@ +option('email'); + $tokenName = $this->option('name'); + + $agent = User::firstOrCreate( + ['email' => $email], + [ + 'name' => 'FastAPI Agent', + 'password' => Str::password(40), + ], + ); + + $agent->tokens()->where('name', $tokenName)->delete(); + + $token = $agent->createToken($tokenName, TokenAbility::fastApiAgentAbilities()); + + $this->components->info('FastAPI agent token issued.'); + $this->line($token->plainTextToken); + + return self::SUCCESS; + } +} diff --git a/app-modules/identity/src/Enums/TokenAbility.php b/app-modules/identity/src/Enums/TokenAbility.php new file mode 100644 index 0000000..bb07dd8 --- /dev/null +++ b/app-modules/identity/src/Enums/TokenAbility.php @@ -0,0 +1,44 @@ + + */ + public static function customerAbilities(): array + { + return [ + self::RouteRead->value, + self::BookingCreate->value, + self::BookingRead->value, + self::BookingCancel->value, + self::PaymentInitiate->value, + ]; + } + + /** + * Restricted ability set granted to the FastAPI AI agent token — it must + * never be able to refund, cancel someone else's booking, or write + * catalog/pricing data. + * + * @return list + */ + public static function fastApiAgentAbilities(): array + { + return [ + self::RouteRead->value, + self::BookingCreate->value, + self::BookingRead->value, + ]; + } +} diff --git a/app-modules/identity/src/Http/Controllers/TokenController.php b/app-modules/identity/src/Http/Controllers/TokenController.php new file mode 100644 index 0000000..4d6ce1c --- /dev/null +++ b/app-modules/identity/src/Http/Controllers/TokenController.php @@ -0,0 +1,37 @@ +string('email'))->first(); + + if (! $user || ! Hash::check($request->string('password'), $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['The provided credentials are incorrect.'], + ]); + } + + $token = $user->createToken( + $request->string('device_name')->toString(), + TokenAbility::customerAbilities(), + ); + + return [ + 'token' => $token->plainTextToken, + ]; + } +} diff --git a/app-modules/identity/src/Http/Middleware/EnsureFastApiAgent.php b/app-modules/identity/src/Http/Middleware/EnsureFastApiAgent.php new file mode 100644 index 0000000..b38844e --- /dev/null +++ b/app-modules/identity/src/Http/Middleware/EnsureFastApiAgent.php @@ -0,0 +1,38 @@ +user()?->currentAccessToken(); + + if (! $token) { + abort(403, 'This action requires a FastAPI agent token.'); + } + + $actual = collect($token->abilities)->sort()->values()->all(); + $expected = collect(TokenAbility::fastApiAgentAbilities())->sort()->values()->all(); + + if ($actual !== $expected) { + abort(403, 'This action requires a FastAPI agent token.'); + } + + return $next($request); + } +} diff --git a/app-modules/identity/src/Http/Requests/IssueTokenRequest.php b/app-modules/identity/src/Http/Requests/IssueTokenRequest.php new file mode 100644 index 0000000..a16f06e --- /dev/null +++ b/app-modules/identity/src/Http/Requests/IssueTokenRequest.php @@ -0,0 +1,25 @@ +> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + 'device_name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app-modules/identity/tests/Feature/EnsureFastApiAgentTest.php b/app-modules/identity/tests/Feature/EnsureFastApiAgentTest.php new file mode 100644 index 0000000..fc200cb --- /dev/null +++ b/app-modules/identity/tests/Feature/EnsureFastApiAgentTest.php @@ -0,0 +1,41 @@ +get('/__test/fastapi-agent-only', fn () => response()->json(['ok' => true])); +}); + +test('a token scoped to exactly the agent abilities is allowed through', function () { + $agent = User::factory()->create(); + $token = $agent->createToken('fastapi-agent', TokenAbility::fastApiAgentAbilities())->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/__test/fastapi-agent-only') + ->assertSuccessful(); +}); + +test('a customer token carrying broader abilities is rejected', function () { + $customer = User::factory()->create(); + $token = $customer->createToken('iphone', TokenAbility::customerAbilities())->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/__test/fastapi-agent-only') + ->assertForbidden(); +}); + +test('a token missing one of the agent abilities is rejected', function () { + $user = User::factory()->create(); + $token = $user->createToken('partial', [TokenAbility::RouteRead->value, TokenAbility::BookingCreate->value])->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/__test/fastapi-agent-only') + ->assertForbidden(); +}); + +test('an unauthenticated request is rejected', function () { + $this->getJson('/__test/fastapi-agent-only')->assertUnauthorized(); +}); diff --git a/app-modules/identity/tests/Feature/TokenIssuanceTest.php b/app-modules/identity/tests/Feature/TokenIssuanceTest.php new file mode 100644 index 0000000..31d3beb --- /dev/null +++ b/app-modules/identity/tests/Feature/TokenIssuanceTest.php @@ -0,0 +1,46 @@ +create([ + 'password' => Hash::make('secret-password'), + ]); + + $response = $this->postJson('/api/v1/auth/token', [ + 'email' => $user->email, + 'password' => 'secret-password', + 'device_name' => 'iphone', + ]); + + $response->assertSuccessful()->assertJsonStructure(['token']); + + $accessToken = $user->tokens()->sole(); + expect($accessToken->abilities)->toEqualCanonicalizing(TokenAbility::customerAbilities()); +}); + +test('customer token endpoint rejects invalid credentials', function () { + $user = User::factory()->create([ + 'password' => Hash::make('secret-password'), + ]); + + $response = $this->postJson('/api/v1/auth/token', [ + 'email' => $user->email, + 'password' => 'wrong-password', + 'device_name' => 'iphone', + ]); + + $response->assertUnprocessable(); + expect($user->tokens()->count())->toBe(0); +}); + +test('identity:issue-agent-token command provisions a token scoped to the agent ability set', function () { + $this->artisan('identity:issue-agent-token')->assertSuccessful(); + + $agent = User::where('email', 'fastapi-agent@system.internal')->sole(); + $token = $agent->tokens()->sole(); + + expect($token->abilities)->toEqualCanonicalizing(TokenAbility::fastApiAgentAbilities()); +}); diff --git a/app-modules/routing/src/Policies/RoutePolicy.php b/app-modules/routing/src/Policies/RoutePolicy.php new file mode 100644 index 0000000..6333251 --- /dev/null +++ b/app-modules/routing/src/Policies/RoutePolicy.php @@ -0,0 +1,32 @@ +can('manage_routes'); + } + + public function create(User $user): bool + { + return $user->can('manage_routes'); + } + + public function update(User $user, mixed $route): bool + { + return $user->can('manage_routes'); + } + + public function delete(User $user, mixed $route): bool + { + return $user->can('manage_routes'); + } +} diff --git a/app-modules/routing/src/Providers/RoutingServiceProvider.php b/app-modules/routing/src/Providers/RoutingServiceProvider.php index ffd9071..5ce781b 100644 --- a/app-modules/routing/src/Providers/RoutingServiceProvider.php +++ b/app-modules/routing/src/Providers/RoutingServiceProvider.php @@ -2,11 +2,16 @@ namespace Modules\Routing\Providers; +use Illuminate\Contracts\Auth\Access\Gate; use Illuminate\Support\ServiceProvider; +use Modules\Routing\Policies\RoutePolicy; class RoutingServiceProvider extends ServiceProvider { public function register(): void {} - public function boot(): void {} + public function boot(Gate $gate): void + { + $gate->policy('Modules\Routing\Models\EvRoute', RoutePolicy::class); + } } diff --git a/app-modules/routing/tests/Feature/RoutePolicyTest.php b/app-modules/routing/tests/Feature/RoutePolicyTest.php new file mode 100644 index 0000000..479185f --- /dev/null +++ b/app-modules/routing/tests/Feature/RoutePolicyTest.php @@ -0,0 +1,28 @@ +create()->givePermissionTo('manage_routes'); + $withoutPermission = User::factory()->create(); + + $args = $method === 'viewAny' || $method === 'create' + ? [$withPermission] + : [$withPermission, null]; + + expect($policy->{$method}(...$args))->toBeTrue(); + + $args = $method === 'viewAny' || $method === 'create' + ? [$withoutPermission] + : [$withoutPermission, null]; + + expect($policy->{$method}(...$args))->toBeFalse(); +})->with(['viewAny', 'create', 'update', 'delete']); diff --git a/app/Models/User.php b/app/Models/User.php index fd514cf..ca8ffaa 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,17 +4,31 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; +use Filament\Models\Contracts\FilamentUser; +use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; -class User extends Authenticatable +class User extends Authenticatable implements FilamentUser { + /** + * Roles permitted to sign in to the Filament admin panel. + * + * @var list + */ + public const ADMIN_TIER_ROLES = ['super_admin', 'admin', 'support']; + /** @use HasFactory */ use HasApiTokens, HasFactory, HasRoles, Notifiable; + public function canAccessPanel(Panel $panel): bool + { + return $this->hasAnyRole(self::ADMIN_TIER_ROLES); + } + /** * The attributes that are mass assignable. * diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 7c74b3e..24491f4 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -6,6 +6,7 @@ use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Navigation\NavigationGroup; use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; @@ -31,6 +32,13 @@ class AdminPanelProvider extends PanelProvider ->colors([ 'primary' => Color::Amber, ]) + ->navigationGroups([ + NavigationGroup::make()->label('Catalog'), + NavigationGroup::make()->label('Routing'), + NavigationGroup::make()->label('Operations'), + NavigationGroup::make()->label('Access'), + ]) + ->plugins([]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->pages([ diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c5..f588264 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Modules\Identity\Http\Middleware\EnsureFastApiAgent; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -12,7 +13,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'fastapi.agent' => EnsureFastApiAgent::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..ee8c225 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Modules\Identity\Database\Seeders\RolePermissionSeeder; class DatabaseSeeder extends Seeder { @@ -15,6 +16,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { + $this->call(RolePermissionSeeder::class); + // User::factory(10)->create(); User::factory()->create([ diff --git a/phpunit.xml b/phpunit.xml index a85936a..26ffbab 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,9 +1,5 @@ - + tests/Unit @@ -11,7 +7,7 @@ tests/Feature - + ./app-modules/*/tests app diff --git a/tests/Feature/AdminPanelAccessTest.php b/tests/Feature/AdminPanelAccessTest.php new file mode 100644 index 0000000..1050197 --- /dev/null +++ b/tests/Feature/AdminPanelAccessTest.php @@ -0,0 +1,19 @@ +create(); + $user->assignRole($role); + + $this->actingAs($user)->get('/admin')->assertSuccessful(); +})->with(['super_admin', 'admin', 'support']); + +test('a user without an admin-tier role cannot access the admin panel', function () { + $user = User::factory()->create(); + + $this->actingAs($user)->get('/admin')->assertForbidden(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a4..9a2c021 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,8 @@ extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) +pest()->extend(TestCase::class) + ->use(RefreshDatabase::class) ->in('Feature'); +pest()->extend(TestCase::class) + ->use(RefreshDatabase::class) + ->in('../app-modules/*/tests/Feature'); + /* |-------------------------------------------------------------------------- | Expectations