feat(authorization): add role and tenant codes to users, implement validation logic, and create tests
This commit is contained in:
@@ -2,27 +2,49 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Models;
|
namespace App\Domains\Auth\Models;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Models\Role;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
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'])]
|
#[Hidden(['password', 'remember_token'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'rol_codigo' => 'user',
|
||||||
|
];
|
||||||
|
|
||||||
protected static function newFactory(): UserFactory
|
protected static function newFactory(): UserFactory
|
||||||
{
|
{
|
||||||
return UserFactory::new();
|
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> */
|
/** @return HasMany<ResetPasswordAttempt, $this> */
|
||||||
public function resetPasswordAttempts(): HasMany
|
public function resetPasswordAttempts(): HasMany
|
||||||
{
|
{
|
||||||
@@ -35,6 +57,22 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(LoginAttempt::class);
|
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>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Resources;
|
namespace App\Domains\Auth\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @mixin \App\Domains\Auth\Models\User
|
* @mixin User
|
||||||
*/
|
*/
|
||||||
class UserResource extends JsonResource
|
class UserResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -21,6 +22,8 @@ class UserResource extends JsonResource
|
|||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'tenant_codigo' => $this->tenant_codigo,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Domains\Authorization\Models;
|
namespace App\Domains\Authorization\Models;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Role extends Model
|
class Role extends Model
|
||||||
{
|
{
|
||||||
@@ -29,4 +31,12 @@ class Role extends Model
|
|||||||
'codigo'
|
'codigo'
|
||||||
)->withTimestamps();
|
)->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<User, $this>
|
||||||
|
*/
|
||||||
|
public function users(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class, 'rol_codigo', 'codigo');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,6 +7,12 @@ afectar las reglas de autorización.
|
|||||||
Los permisos administrativos deben evaluarse siempre dentro del tenant correspondiente.
|
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.
|
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
|
## Roles
|
||||||
|
|
||||||
| Código | Nombre | Alcance |
|
| Código | Nombre | Alcance |
|
||||||
|
|||||||
106
tests/Feature/Auth/UserAuthorizationRelationsTest.php
Normal file
106
tests/Feature/Auth/UserAuthorizationRelationsTest.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user