This commit introduces a comprehensive Role-Based Access Control (RBAC) system for TenantAtlas. - Implements authentication via Microsoft Entra ID (OIDC). - Manages authorization on a per-Suite-Tenant basis using a table. - Follows a capabilities-first approach, using Gates and Policies. - Includes a break-glass mechanism for platform superadmins. - Adds policies for bootstrapping tenants and managing admin responsibilities.
237 lines
7.9 KiB
PHP
237 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Auth;
|
|
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantMembership;
|
|
use App\Models\User;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Support\TenantRole;
|
|
use DomainException;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class TenantMembershipManager
|
|
{
|
|
public function __construct(public AuditLogger $auditLogger) {}
|
|
|
|
public function addMember(
|
|
Tenant $tenant,
|
|
User $actor,
|
|
User $member,
|
|
TenantRole $role,
|
|
string $source = 'manual',
|
|
?string $sourceRef = null,
|
|
): TenantMembership {
|
|
return DB::transaction(function () use ($tenant, $actor, $member, $role, $source, $sourceRef): TenantMembership {
|
|
$existing = TenantMembership::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('user_id', $member->getKey())
|
|
->first();
|
|
|
|
if ($existing) {
|
|
if ($existing->role !== $role->value) {
|
|
$existing->forceFill([
|
|
'role' => $role->value,
|
|
'source' => $source,
|
|
'source_ref' => $sourceRef,
|
|
'created_by_user_id' => (int) $actor->getKey(),
|
|
])->save();
|
|
|
|
$this->auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'tenant_membership.role_change',
|
|
context: [
|
|
'metadata' => [
|
|
'member_user_id' => (int) $member->getKey(),
|
|
'from_role' => $existing->getOriginal('role'),
|
|
'to_role' => $role->value,
|
|
'source' => $source,
|
|
],
|
|
],
|
|
actorId: (int) $actor->getKey(),
|
|
actorEmail: $actor->email,
|
|
actorName: $actor->name,
|
|
status: 'success',
|
|
resourceType: 'tenant',
|
|
resourceId: (string) $tenant->getKey(),
|
|
);
|
|
}
|
|
|
|
return $existing->refresh();
|
|
}
|
|
|
|
$membership = TenantMembership::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'user_id' => (int) $member->getKey(),
|
|
'role' => $role->value,
|
|
'source' => $source,
|
|
'source_ref' => $sourceRef,
|
|
'created_by_user_id' => (int) $actor->getKey(),
|
|
]);
|
|
|
|
$this->auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'tenant_membership.add',
|
|
context: [
|
|
'metadata' => [
|
|
'member_user_id' => (int) $member->getKey(),
|
|
'role' => $role->value,
|
|
'source' => $source,
|
|
],
|
|
],
|
|
actorId: (int) $actor->getKey(),
|
|
actorEmail: $actor->email,
|
|
actorName: $actor->name,
|
|
status: 'success',
|
|
resourceType: 'tenant',
|
|
resourceId: (string) $tenant->getKey(),
|
|
);
|
|
|
|
return $membership;
|
|
});
|
|
}
|
|
|
|
public function changeRole(Tenant $tenant, User $actor, TenantMembership $membership, TenantRole $newRole): TenantMembership
|
|
{
|
|
return DB::transaction(function () use ($tenant, $actor, $membership, $newRole): TenantMembership {
|
|
$membership->refresh();
|
|
|
|
if ($membership->tenant_id !== (int) $tenant->getKey()) {
|
|
throw new DomainException('Membership belongs to a different tenant.');
|
|
}
|
|
|
|
$oldRole = $membership->role;
|
|
|
|
if ($oldRole === $newRole->value) {
|
|
return $membership;
|
|
}
|
|
|
|
$this->guardLastOwnerDemotion($tenant, $membership, $newRole);
|
|
|
|
$membership->forceFill([
|
|
'role' => $newRole->value,
|
|
])->save();
|
|
|
|
$this->auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'tenant_membership.role_change',
|
|
context: [
|
|
'metadata' => [
|
|
'member_user_id' => (int) $membership->user_id,
|
|
'from_role' => $oldRole,
|
|
'to_role' => $newRole->value,
|
|
],
|
|
],
|
|
actorId: (int) $actor->getKey(),
|
|
actorEmail: $actor->email,
|
|
actorName: $actor->name,
|
|
status: 'success',
|
|
resourceType: 'tenant',
|
|
resourceId: (string) $tenant->getKey(),
|
|
);
|
|
|
|
return $membership->refresh();
|
|
});
|
|
}
|
|
|
|
public function removeMember(Tenant $tenant, User $actor, TenantMembership $membership): void
|
|
{
|
|
DB::transaction(function () use ($tenant, $actor, $membership): void {
|
|
$membership->refresh();
|
|
|
|
if ($membership->tenant_id !== (int) $tenant->getKey()) {
|
|
throw new DomainException('Membership belongs to a different tenant.');
|
|
}
|
|
|
|
$this->guardLastOwnerRemoval($tenant, $membership);
|
|
|
|
$memberUserId = (int) $membership->user_id;
|
|
$oldRole = (string) $membership->role;
|
|
|
|
$membership->delete();
|
|
|
|
$this->auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'tenant_membership.remove',
|
|
context: [
|
|
'metadata' => [
|
|
'member_user_id' => $memberUserId,
|
|
'role' => $oldRole,
|
|
],
|
|
],
|
|
actorId: (int) $actor->getKey(),
|
|
actorEmail: $actor->email,
|
|
actorName: $actor->name,
|
|
status: 'success',
|
|
resourceType: 'tenant',
|
|
resourceId: (string) $tenant->getKey(),
|
|
);
|
|
});
|
|
}
|
|
|
|
public function bootstrapRecover(Tenant $tenant, User $actor, User $member): TenantMembership
|
|
{
|
|
$membership = $this->addMember(
|
|
tenant: $tenant,
|
|
actor: $actor,
|
|
member: $member,
|
|
role: TenantRole::Owner,
|
|
source: 'break_glass',
|
|
);
|
|
|
|
$this->auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'tenant_membership.bootstrap_recover',
|
|
context: [
|
|
'metadata' => [
|
|
'member_user_id' => (int) $member->getKey(),
|
|
],
|
|
],
|
|
actorId: (int) $actor->getKey(),
|
|
actorEmail: $actor->email,
|
|
actorName: $actor->name,
|
|
status: 'success',
|
|
resourceType: 'tenant',
|
|
resourceId: (string) $tenant->getKey(),
|
|
);
|
|
|
|
return $membership;
|
|
}
|
|
|
|
private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $membership): void
|
|
{
|
|
if ($membership->role !== TenantRole::Owner->value) {
|
|
return;
|
|
}
|
|
|
|
$owners = TenantMembership::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('role', TenantRole::Owner->value)
|
|
->count();
|
|
|
|
if ($owners <= 1) {
|
|
throw new DomainException('You cannot remove the last remaining owner.');
|
|
}
|
|
}
|
|
|
|
private function guardLastOwnerDemotion(Tenant $tenant, TenantMembership $membership, TenantRole $newRole): void
|
|
{
|
|
if ($membership->role !== TenantRole::Owner->value) {
|
|
return;
|
|
}
|
|
|
|
if ($newRole === TenantRole::Owner) {
|
|
return;
|
|
}
|
|
|
|
$owners = TenantMembership::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('role', TenantRole::Owner->value)
|
|
->count();
|
|
|
|
if ($owners <= 1) {
|
|
throw new DomainException('You cannot demote the last remaining owner.');
|
|
}
|
|
}
|
|
}
|