feat(authorization): add role and tenant codes to users, implement validation logic, and create tests

This commit is contained in:
2026-07-28 15:01:40 -03:00
parent cba32e90e7
commit d56d387933
6 changed files with 228 additions and 2 deletions

View File

@@ -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<UserFactory> */
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<ResetPasswordAttempt, $this> */
public function resetPasswordAttempts(): HasMany
{
@@ -35,6 +57,22 @@ class User extends Authenticatable
return $this->hasMany(LoginAttempt::class);
}
/**
* @return BelongsTo<Role, $this>
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return array<string, string>
*/

View File

@@ -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,
];
}
}

View File

@@ -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<User, $this>
*/
public function users(): HasMany
{
return $this->hasMany(User::class, 'rol_codigo', 'codigo');
}
}

View File

@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$now = now();
DB::table('roles')->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']);
});
}
};

View File

@@ -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 |

View File

@@ -0,0 +1,106 @@
<?php
namespace Tests\Feature\Auth;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Models\Role;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use LogicException;
use Tests\TestCase;
class UserAuthorizationRelationsTest extends TestCase
{
use RefreshDatabase;
public function test_users_table_has_role_and_tenant_codes(): void
{
$this->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',
]);
}
}