diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index 226ff59..621da0c 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -2,27 +2,49 @@ namespace App\Domains\Auth\Models; +use App\Domains\Authorization\Models\Role; +use App\Domains\Tenant\Models\Tenant; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; +use LogicException; -#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id'])] +#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable { /** @use HasFactory */ use HasApiTokens, HasFactory, Notifiable; + protected $attributes = [ + 'rol_codigo' => 'user', + ]; + protected static function newFactory(): UserFactory { return UserFactory::new(); } + protected static function booted(): void + { + static::saving(function (User $user): void { + $hasTenant = $user->tenant_codigo !== null; + $isTenantAdmin = $user->rol_codigo === 'tenant_admin'; + + if ($hasTenant !== $isTenantAdmin) { + throw new LogicException( + 'tenant_codigo debe estar informado únicamente para usuarios con rol tenant_admin.' + ); + } + }); + } + /** @return HasMany */ public function resetPasswordAttempts(): HasMany { @@ -35,6 +57,22 @@ class User extends Authenticatable return $this->hasMany(LoginAttempt::class); } + /** + * @return BelongsTo + */ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class, 'rol_codigo', 'codigo'); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); + } + /** * @return array */ diff --git a/app/Domains/Auth/Resources/UserResource.php b/app/Domains/Auth/Resources/UserResource.php index d666514..89cee51 100644 --- a/app/Domains/Auth/Resources/UserResource.php +++ b/app/Domains/Auth/Resources/UserResource.php @@ -2,11 +2,12 @@ namespace App\Domains\Auth\Resources; +use App\Domains\Auth\Models\User; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Domains\Auth\Models\User + * @mixin User */ class UserResource extends JsonResource { @@ -21,6 +22,8 @@ class UserResource extends JsonResource 'email' => $this->email, 'dni' => $this->dni, 'telefono' => $this->telefono, + 'rol_codigo' => $this->rol_codigo, + 'tenant_codigo' => $this->tenant_codigo, ]; } } diff --git a/app/Domains/Authorization/Models/Role.php b/app/Domains/Authorization/Models/Role.php index bc9abc0..1ac419a 100644 --- a/app/Domains/Authorization/Models/Role.php +++ b/app/Domains/Authorization/Models/Role.php @@ -2,8 +2,10 @@ namespace App\Domains\Authorization\Models; +use App\Domains\Auth\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Role extends Model { @@ -29,4 +31,12 @@ class Role extends Model 'codigo' )->withTimestamps(); } + + /** + * @return HasMany + */ + public function users(): HasMany + { + return $this->hasMany(User::class, 'rol_codigo', 'codigo'); + } } diff --git a/database/migrations/2026_07_28_000100_add_role_and_tenant_codes_to_users_table.php b/database/migrations/2026_07_28_000100_add_role_and_tenant_codes_to_users_table.php new file mode 100644 index 0000000..afd24de --- /dev/null +++ b/database/migrations/2026_07_28_000100_add_role_and_tenant_codes_to_users_table.php @@ -0,0 +1,63 @@ +insertOrIgnore([ + [ + 'codigo' => 'admin', + 'nombre' => 'Administrador general', + 'descripcion' => 'Administra la plataforma y todos sus tenants.', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'codigo' => 'tenant_admin', + 'nombre' => 'Administrador del tenant', + 'descripcion' => 'Administra la operación de su propio tenant.', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'codigo' => 'user', + 'nombre' => 'Usuario', + 'descripcion' => 'Cliente final limitado a sus propios datos y operaciones.', + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + + Schema::table('users', function (Blueprint $table): void { + $table->string('rol_codigo')->default('user')->after('id'); + $table->string('tenant_codigo')->nullable()->after('rol_codigo'); + + $table->foreign('rol_codigo') + ->references('codigo') + ->on('roles') + ->cascadeOnUpdate() + ->restrictOnDelete(); + $table->foreign('tenant_codigo') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropForeign(['tenant_codigo']); + $table->dropForeign(['rol_codigo']); + $table->dropColumn(['tenant_codigo', 'rol_codigo']); + }); + } +}; diff --git a/docs/authorization.md b/docs/authorization.md index 19e5784..f811c85 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -7,6 +7,12 @@ afectar las reglas de autorización. Los permisos administrativos deben evaluarse siempre dentro del tenant correspondiente. El rol `admin` es la única excepción porque su alcance es toda la plataforma. +Cada registro de `users` contiene: + +- `rol_codigo`: código obligatorio del rol. Su valor predeterminado es `user`. +- `tenant_codigo`: código nullable del tenant. Solamente se utiliza para usuarios con + rol `tenant_admin`; para `admin` y `user` debe permanecer en `null`. + ## Roles | Código | Nombre | Alcance | diff --git a/tests/Feature/Auth/UserAuthorizationRelationsTest.php b/tests/Feature/Auth/UserAuthorizationRelationsTest.php new file mode 100644 index 0000000..73b6924 --- /dev/null +++ b/tests/Feature/Auth/UserAuthorizationRelationsTest.php @@ -0,0 +1,106 @@ +assertTrue(Schema::hasColumns('users', [ + 'rol_codigo', + 'tenant_codigo', + ])); + } + + public function test_a_new_user_has_the_user_role_and_no_tenant_by_default(): void + { + $user = User::factory()->create(); + + $this->assertSame('user', $user->rol_codigo); + $this->assertNull($user->tenant_codigo); + $this->assertSame('user', $user->role->codigo); + $this->assertNull($user->tenant); + } + + public function test_a_tenant_admin_belongs_to_its_role_and_tenant(): void + { + $tenant = $this->createTenant(); + $user = User::factory()->create([ + 'rol_codigo' => 'tenant_admin', + 'tenant_codigo' => $tenant->codigo, + ]); + + $this->assertSame('tenant_admin', $user->role->codigo); + $this->assertTrue($user->tenant->is($tenant)); + $this->assertTrue( + Role::query() + ->where('codigo', 'tenant_admin') + ->firstOrFail() + ->users + ->contains($user) + ); + } + + public function test_only_a_tenant_admin_can_have_a_tenant_code(): void + { + $tenant = $this->createTenant(); + + $this->expectException(LogicException::class); + + User::factory()->create([ + 'rol_codigo' => 'user', + 'tenant_codigo' => $tenant->codigo, + ]); + } + + public function test_a_tenant_admin_must_have_a_tenant_code(): void + { + $this->expectException(LogicException::class); + + User::factory()->create([ + 'rol_codigo' => 'tenant_admin', + ]); + } + + private function createTenant(): Tenant + { + $headerLogo = $this->createAttachment('header.png'); + $footerLogo = $this->createAttachment('footer.png'); + + return Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#444444', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } +}