From 44bf67bd120573219ae3757bb1a187bf89f4fc26 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 30 Sep 2026 15:02:10 -0300 Subject: [PATCH] feat(admin): add admin scope management for users and events - Introduced AdminScope enum for tenant and event scopes. - Updated User model to include admin_scope and event_id attributes. - Enhanced AdminAppAccessService to validate user scopes. - Modified AdminAppMeResource and UserResource to include event data. - Implemented middleware to ensure valid tenant access. - Created migration to add admin_scope and event_id to users table. - Added tests for event admin functionality and scope validation. --- app/Domains/Core/Auth/Enums/AdminScope.php | 9 + app/Domains/Core/Auth/Models/User.php | 23 +- .../Auth/Resources/AdminAppMeResource.php | 5 + .../Core/Auth/Resources/UserResource.php | 3 + .../Auth/Services/AdminAppAccessService.php | 27 +++ .../Auth/Services/AdminAppContextService.php | 5 + .../Auth/Services/PasswordLoginService.php | 10 + app/Domains/Core/Auth/routes/adminapp.php | 2 +- app/Http/Middleware/EnsureAdminAppTenant.php | 8 +- ..._09_30_000000_add_admin_scope_to_users.php | 25 +++ tests/Feature/Auth/AdminAppEventScopeTest.php | 203 ++++++++++++++++++ 11 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 app/Domains/Core/Auth/Enums/AdminScope.php create mode 100644 app/Domains/Core/Auth/Services/AdminAppAccessService.php create mode 100644 database/migrations/2026_09_30_000000_add_admin_scope_to_users.php create mode 100644 tests/Feature/Auth/AdminAppEventScopeTest.php diff --git a/app/Domains/Core/Auth/Enums/AdminScope.php b/app/Domains/Core/Auth/Enums/AdminScope.php new file mode 100644 index 00000000..cd7e6399 --- /dev/null +++ b/app/Domains/Core/Auth/Enums/AdminScope.php @@ -0,0 +1,9 @@ + RoleCode::User->value, + 'admin_scope' => AdminScope::Tenant->value, ]; protected static function newFactory(): UserFactory @@ -86,6 +89,19 @@ class User extends Authenticatable return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); } + /** @return BelongsTo */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function isTenantAdministrator(): bool + { + return $this->rol_codigo === RoleCode::AdminApp->value + && $this->admin_scope === AdminScope::Tenant->value + && $this->event_id === null; + } + /** @return BelongsToMany */ public function scanCategories(): BelongsToMany { @@ -103,6 +119,7 @@ class User extends Authenticatable protected function casts(): array { return [ + 'event_id' => 'integer', 'email_verified_at' => 'datetime', 'password' => 'hashed', 'failed_login_attempts' => 'integer', diff --git a/app/Domains/Core/Auth/Resources/AdminAppMeResource.php b/app/Domains/Core/Auth/Resources/AdminAppMeResource.php index 2c642172..4150f13a 100644 --- a/app/Domains/Core/Auth/Resources/AdminAppMeResource.php +++ b/app/Domains/Core/Auth/Resources/AdminAppMeResource.php @@ -20,6 +20,11 @@ class AdminAppMeResource extends JsonResource return [ 'user' => UserResource::make($this->resource), 'tenant' => TenantResource::make($this->tenant), + 'event' => $this->whenLoaded('event', fn () => $this->event === null ? null : [ + 'id' => $this->event->id, + 'title' => $this->event->title, + 'tenant_code' => $this->event->tenant_code, + ]), ]; } } diff --git a/app/Domains/Core/Auth/Resources/UserResource.php b/app/Domains/Core/Auth/Resources/UserResource.php index 8a8ddbac..f6c32c93 100644 --- a/app/Domains/Core/Auth/Resources/UserResource.php +++ b/app/Domains/Core/Auth/Resources/UserResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Core\Auth\Resources; use App\Domains\Core\Auth\Models\User; +use App\Domains\Core\Authorization\Enums\RoleCode; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -24,6 +25,8 @@ class UserResource extends JsonResource 'telefono' => $this->telefono, 'rol_codigo' => $this->rol_codigo, 'tenant_codigo' => $this->tenant_codigo, + 'admin_scope' => $this->when($this->rol_codigo === RoleCode::AdminApp->value, $this->admin_scope), + 'event_id' => $this->when($this->rol_codigo === RoleCode::AdminApp->value, $this->event_id), 'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories ->map(fn ($category) => [ 'id' => $category->id, diff --git a/app/Domains/Core/Auth/Services/AdminAppAccessService.php b/app/Domains/Core/Auth/Services/AdminAppAccessService.php new file mode 100644 index 00000000..d83d9942 --- /dev/null +++ b/app/Domains/Core/Auth/Services/AdminAppAccessService.php @@ -0,0 +1,27 @@ +rol_codigo !== RoleCode::AdminApp->value + || ! $user->tenant_codigo + || ! $user->tenant()->exists()) { + return false; + } + + if ($user->isTenantAdministrator()) { + return true; + } + + return $user->admin_scope === AdminScope::Event->value + && $user->event_id !== null + && $user->event()->where('tenant_code', $user->tenant_codigo)->exists(); + } +} diff --git a/app/Domains/Core/Auth/Services/AdminAppContextService.php b/app/Domains/Core/Auth/Services/AdminAppContextService.php index b62a55c3..1e2b5e66 100644 --- a/app/Domains/Core/Auth/Services/AdminAppContextService.php +++ b/app/Domains/Core/Auth/Services/AdminAppContextService.php @@ -20,6 +20,11 @@ class AdminAppContextService ->firstOrFail(); $user->setRelation('tenant', $tenant); + $user->load('event'); + + if (! $user->isTenantAdministrator()) { + $tenant->setRelation('menues', $tenant->menues->where('code', 'main.adminapp')->values()); + } return $user; } diff --git a/app/Domains/Core/Auth/Services/PasswordLoginService.php b/app/Domains/Core/Auth/Services/PasswordLoginService.php index d1be20ca..35a88982 100644 --- a/app/Domains/Core/Auth/Services/PasswordLoginService.php +++ b/app/Domains/Core/Auth/Services/PasswordLoginService.php @@ -19,6 +19,7 @@ class PasswordLoginService { public function __construct( private readonly ResetPasswordAttemptService $resetPasswordAttemptService, + private readonly AdminAppAccessService $adminAppAccessService, ) {} /** @@ -196,6 +197,15 @@ class PasswordLoginService ]; } + if ($requiredRole === RoleCode::AdminApp && ! $this->adminAppAccessService->hasValidScope($user)) { + $this->recordAttempt( + $user, $normalizedEmail, $attemptTenantCode, + LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $ipAddress, $userAgent, + ); + + return ['outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, 'user' => $user, 'locked_until' => null]; + } + $user->forceFill([ 'failed_login_attempts' => 0, 'last_failed_login_at' => null, diff --git a/app/Domains/Core/Auth/routes/adminapp.php b/app/Domains/Core/Auth/routes/adminapp.php index faebadbb..fb8170f1 100644 --- a/app/Domains/Core/Auth/routes/adminapp.php +++ b/app/Domains/Core/Auth/routes/adminapp.php @@ -17,6 +17,6 @@ Route::prefix('v1/adminapp')->group(function (): void { Route::post('password/reset', ResetPasswordController::class) ->defaults('reset_role', 'adminapp') ->middleware('throttle:5,1'); - Route::middleware(['auth:sanctum', 'adminapp.tenant']) + Route::middleware(['auth:sanctum', 'adminapp.tenant:context']) ->get('me', AdminAppMeController::class); }); diff --git a/app/Http/Middleware/EnsureAdminAppTenant.php b/app/Http/Middleware/EnsureAdminAppTenant.php index 0ecab480..c43e9316 100644 --- a/app/Http/Middleware/EnsureAdminAppTenant.php +++ b/app/Http/Middleware/EnsureAdminAppTenant.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Domains\Core\Auth\Services\AdminAppAccessService; use App\Domains\Core\Authorization\Enums\RoleCode; use Closure; use Illuminate\Auth\Access\AuthorizationException; @@ -10,10 +11,12 @@ use Symfony\Component\HttpFoundation\Response; class EnsureAdminAppTenant { + public function __construct(private readonly AdminAppAccessService $accessService) {} + /** * Ensure the authenticated user is an AdminApp user bound to a tenant. */ - public function handle(Request $request, Closure $next): Response + public function handle(Request $request, Closure $next, string $access = 'tenant'): Response { $user = $request->user(); @@ -21,6 +24,9 @@ class EnsureAdminAppTenant ! $user || $user->rol_codigo !== RoleCode::AdminApp->value || ! $user->tenant_codigo + || ! $this->accessService->hasValidScope($user) + // Tenant operations remain unavailable until they implement event authorization. + || ($access !== 'context' && ! $user->isTenantAdministrator()) ) { throw new AuthorizationException; } diff --git a/database/migrations/2026_09_30_000000_add_admin_scope_to_users.php b/database/migrations/2026_09_30_000000_add_admin_scope_to_users.php new file mode 100644 index 00000000..85a05734 --- /dev/null +++ b/database/migrations/2026_09_30_000000_add_admin_scope_to_users.php @@ -0,0 +1,25 @@ +string('admin_scope', 20)->default('tenant'); + $table->foreignId('event_id')->nullable()->constrained('events')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropConstrainedForeignId('event_id'); + $table->dropColumn('admin_scope'); + }); + } +}; diff --git a/tests/Feature/Auth/AdminAppEventScopeTest.php b/tests/Feature/Auth/AdminAppEventScopeTest.php new file mode 100644 index 00000000..dfb97bbd --- /dev/null +++ b/tests/Feature/Auth/AdminAppEventScopeTest.php @@ -0,0 +1,203 @@ +id(); + $table->string('codigo')->unique(); + $table->string('nombre'); + $table->string('dominio'); + $table->string('search_product_layout')->default('column_with_image'); + $table->string('search_group_layout')->default('paginated'); + }); + Schema::create('events', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('title'); + $table->timestamps(); + }); + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->string('nombre_apellido'); + $table->string('email'); + $table->string('active_email')->virtualAs('CASE WHEN deleted_at IS NULL THEN lower(email) ELSE NULL END'); + $table->string('password'); + $table->string('rol_codigo')->default('user'); + $table->string('tenant_codigo')->nullable(); + $table->softDeletes(); + $table->timestamps(); + $table->unique(['active_email', 'rol_codigo']); + }); + Schema::create('menues', function (Blueprint $table): void { + $table->id(); + $table->string('code')->unique(); + $table->string('label'); + $table->string('route'); + $table->string('parent_menu_code')->nullable(); + $table->string('content_type')->default('dynamic'); + }); + Schema::create('roles_menues', function (Blueprint $table): void { + $table->string('rol_codigo'); + $table->string('menu_codigo'); + }); + Schema::create('tenants_menues', function (Blueprint $table): void { + $table->string('tenant_code'); + $table->string('menu_code'); + $table->json('static_content')->nullable(); + $table->timestamps(); + }); + foreach ([ + '2026_06_18_130006_create_personal_access_tokens_table.php', + '2026_07_28_000000_create_roles_and_permissions_tables.php', + '2026_07_29_000000_add_login_security_fields_to_users_table.php', + '2026_07_29_000100_create_login_attempts_table.php', + ] as $file) { + (require database_path('migrations/'.$file))->up(); + } + + DB::table('roles')->insert(['codigo' => 'adminapp', 'nombre' => 'AdminApp']); + DB::table('tenants')->insert([ + ['codigo' => 'onticket', 'nombre' => 'OnTicket', 'dominio' => 'onticket.test'], + ['codigo' => 'other', 'nombre' => 'Other', 'dominio' => 'other.test'], + ]); + DB::table('events')->insert([ + ['id' => 1, 'tenant_code' => 'onticket', 'title' => 'Evento A'], + ['id' => 2, 'tenant_code' => 'onticket', 'title' => 'Evento B'], + ['id' => 3, 'tenant_code' => 'other', 'title' => 'Evento ajeno'], + ]); + // An existing administrator must remain general after applying the new migration. + DB::table('users')->insert([ + 'id' => 1, 'nombre_apellido' => 'Admin', 'email' => 'admin@example.test', + 'password' => Hash::make('secret123'), 'rol_codigo' => 'adminapp', 'tenant_codigo' => 'onticket', + ]); + $this->scopeMigration()->up(); + $this->user = User::query()->findOrFail(1); + DB::table('menues')->insert([ + ['code' => 'main.adminapp', 'label' => 'Administración', 'route' => '/', 'parent_menu_code' => null], + ['code' => 'onticket.adminapp.ventas', 'label' => 'Ventas', 'route' => '/admin/ventas', 'parent_menu_code' => 'main.adminapp'], + ]); + foreach (['main.adminapp', 'onticket.adminapp.ventas'] as $code) { + DB::table('roles_menues')->insert(['rol_codigo' => 'adminapp', 'menu_codigo' => $code]); + DB::table('tenants_menues')->insert(['tenant_code' => 'onticket', 'menu_code' => $code]); + } + } + + private function scopeMigration(): Migration + { + return require database_path('migrations/2026_09_30_000000_add_admin_scope_to_users.php'); + } + + private function login(array $extra = []): TestResponse + { + return $this->postJson('/api/v1/adminapp/login', [ + 'email' => ' ADMIN@EXAMPLE.TEST ', 'password' => 'secret123', ...$extra, + ]); + } + + public function test_existing_admin_keeps_general_access_after_migration(): void + { + $this->assertTrue($this->user->isTenantAdministrator()); + $token = $this->login()->assertOk()->assertJsonPath('user.admin_scope', 'tenant') + ->assertJsonPath('user.event_id', null)->json('token'); + $this->withToken($token)->getJson('/api/v1/adminapp/me')->assertOk() + ->assertJsonPath('data.event', null)->assertJsonCount(1, 'data.tenant.menues.0.submenues'); + } + + public function test_event_admin_logs_in_and_restores_only_its_assigned_context(): void + { + $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); + $this->assertSame(1, $this->user->event->id); + // Scope cannot be chosen by the caller during login. + $token = $this->login(['admin_scope' => 'tenant', 'event_id' => 2])->assertOk() + ->assertJsonPath('user.admin_scope', 'event')->assertJsonPath('user.event_id', 1)->json('token'); + $this->withToken($token)->getJson('/api/v1/adminapp/me')->assertOk() + ->assertJsonPath('data.event.id', 1)->assertJsonPath('data.event.title', 'Evento A') + ->assertJsonPath('data.tenant.codigo', 'onticket') + ->assertJsonCount(0, 'data.tenant.menues.0.submenues'); + $this->assertSame(['adminapp'], $this->user->tokens()->sole()->abilities); + } + + public function test_invalid_scopes_never_issue_tokens_or_increment_password_failure_counts(): void + { + foreach ([ + ['admin_scope' => 'event', 'event_id' => null], + ['admin_scope' => 'event', 'event_id' => 3], + ['admin_scope' => 'tenant', 'event_id' => 1], + ['admin_scope' => 'unknown', 'event_id' => null], + ['admin_scope' => 'event', 'event_id' => 1, 'tenant_codigo' => null], + ] as $attributes) { + $this->user->update($attributes); + $this->login()->assertUnprocessable()->assertJsonValidationErrors('email'); + } + $this->assertDatabaseCount('personal_access_tokens', 0); + $this->assertSame(0, $this->user->refresh()->failed_login_attempts); + } + + public function test_event_admin_cannot_access_tenant_operations_but_can_logout(): void + { + $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); + $token = $this->login()->assertOk()->json('token'); + foreach (['tenant/sales', 'tenant/tickets', 'tenant/event', 'tenant/administrators', 'forms/event'] as $path) { + $this->withToken($token)->getJson('/api/v1/adminapp/'.$path)->assertForbidden(); + } + $this->withToken($token)->postJson('/api/v1/adminapp/tenant/administrators', [])->assertForbidden(); + $this->withToken($token)->postJson('/api/logout')->assertOk(); + $this->assertDatabaseCount('personal_access_tokens', 0); + } + + public function test_deleting_the_event_invalidates_an_existing_token_without_promoting_the_user(): void + { + $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); + $token = $this->login()->assertOk()->json('token'); + DB::table('events')->where('id', 1)->delete(); + $this->assertNull($this->user->refresh()->event_id); + $this->assertSame('event', $this->user->admin_scope); + $this->withToken($token)->getJson('/api/v1/adminapp/me')->assertForbidden(); + $this->withToken($token)->getJson('/api/v1/adminapp/tenant/administrators')->assertForbidden(); + } + + public function test_event_reassignment_and_tenant_changes_apply_to_existing_tokens(): void + { + $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); + $token = $this->login()->assertOk()->json('token'); + $this->user->update(['event_id' => 2]); + $this->withToken($token)->getJson('/api/v1/adminapp/me')->assertOk()->assertJsonPath('data.event.id', 2); + DB::table('events')->where('id', 2)->update(['tenant_code' => 'other']); + $this->withToken($token)->getJson('/api/v1/adminapp/me')->assertForbidden(); + } + + public function test_wrong_password_still_counts_as_a_failed_attempt(): void + { + $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); + $this->login(['password' => 'wrong'])->assertUnprocessable()->assertJsonValidationErrors('email'); + $this->assertSame(1, $this->user->refresh()->failed_login_attempts); + $this->assertDatabaseCount('personal_access_tokens', 0); + } + + public function test_scope_migration_can_be_rolled_back_without_removing_users(): void + { + $this->scopeMigration()->down(); + $this->assertFalse(Schema::hasColumn('users', 'admin_scope')); + $this->assertFalse(Schema::hasColumn('users', 'event_id')); + $this->assertDatabaseHas('users', ['id' => 1, 'email' => 'admin@example.test']); + } +}