062-tenant-rbac-v1 #74
@ -910,9 +910,8 @@ ### Replaced Utilities
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
## Recent Changes
|
||||
- 054-unify-runs-suitewide: Added PHP 8.4 + Filament v4, Laravel v12, Livewire v3
|
||||
- 054-unify-runs-suitewide: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
- 054-unify-runs-suitewide: Added PHP 8.4 + Filament v4, Laravel v12, Livewire v3
|
||||
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
|
||||
## Active Technologies
|
||||
- PostgreSQL (`operation_runs` table + JSONB) (054-unify-runs-suitewide)
|
||||
|
||||
97
app/Filament/Pages/BreakGlassRecovery.php
Normal file
97
app/Filament/Pages/BreakGlassRecovery.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class BreakGlassRecovery extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Break-glass recovery';
|
||||
|
||||
protected static ?int $navigationSort = 999;
|
||||
|
||||
protected string $view = 'filament.pages.break-glass-recovery';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User && $user->isPlatformSuperadmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('bootstrap_recover')
|
||||
->label('Assign owner (recovery)')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Break-glass: assign owner')
|
||||
->modalDescription('This grants Owner access to a tenant. Use for recovery only. This action is audited.')
|
||||
->form([
|
||||
Select::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->required()
|
||||
->searchable()
|
||||
->options(fn (): array => Tenant::query()
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
->all()),
|
||||
Select::make('user_id')
|
||||
->label('User')
|
||||
->required()
|
||||
->searchable()
|
||||
->options(fn (): array => User::query()
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
->all()),
|
||||
])
|
||||
->action(function (array $data, TenantMembershipManager $manager): void {
|
||||
$actor = auth()->user();
|
||||
|
||||
if (! $actor instanceof User || ! $actor->isPlatformSuperadmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->whereKey((int) $data['tenant_id'])
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()->title('Tenant not found')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$member = User::query()->whereKey((int) $data['user_id'])->first();
|
||||
|
||||
if (! $member instanceof User) {
|
||||
Notification::make()->title('User not found')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$manager->bootstrapRecover($tenant, $actor, $member);
|
||||
|
||||
Notification::make()->title('Owner assigned')->success()->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\TenantRole;
|
||||
use Filament\Forms;
|
||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||
@ -74,8 +75,30 @@ protected function handleRegistration(array $data): Model
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => TenantRole::Owner->value],
|
||||
$tenant->getKey() => [
|
||||
'role' => TenantRole::Owner->value,
|
||||
'source' => 'manual',
|
||||
'created_by_user_id' => $user->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant_membership.bootstrap_assign',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => TenantRole::Owner->value,
|
||||
'source' => 'manual',
|
||||
],
|
||||
],
|
||||
actorId: (int) $user->getKey(),
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
status: 'success',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Jobs\BulkTenantSyncJob;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
@ -576,6 +577,13 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\TenantMembershipsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function rbacAction(): Actions\Action
|
||||
{
|
||||
// ... [RBAC Action Omitted - No Change] ...
|
||||
|
||||
@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\TenantRole;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class TenantMembershipsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'memberships';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label('User')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
->label('Email')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('role')
|
||||
->badge()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('source')
|
||||
->badge()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
])
|
||||
->headerActions([
|
||||
Actions\Action::make('add_member')
|
||||
->label('Add member')
|
||||
->icon('heroicon-o-plus')
|
||||
->visible(function (): bool {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::allows('tenant_membership.manage', $tenant);
|
||||
})
|
||||
->form([
|
||||
Forms\Components\Select::make('user_id')
|
||||
->label('User')
|
||||
->required()
|
||||
->searchable()
|
||||
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
||||
Forms\Components\Select::make('role')
|
||||
->label('Role')
|
||||
->required()
|
||||
->options([
|
||||
TenantRole::Owner->value => 'Owner',
|
||||
TenantRole::Manager->value => 'Manager',
|
||||
TenantRole::Operator->value => 'Operator',
|
||||
TenantRole::Readonly->value => 'Readonly',
|
||||
]),
|
||||
])
|
||||
->action(function (array $data, TenantMembershipManager $manager): void {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
if (! $actor instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::allows('tenant_membership.manage', $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$member = User::query()->find((int) $data['user_id']);
|
||||
if (! $member) {
|
||||
Notification::make()->title('User not found')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$manager->addMember(
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
member: $member,
|
||||
role: TenantRole::from((string) $data['role']),
|
||||
source: 'manual',
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title('Failed to add member')
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->title('Member added')->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('change_role')
|
||||
->label('Change role')
|
||||
->icon('heroicon-o-pencil')
|
||||
->visible(function (): bool {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::allows('tenant_membership.manage', $tenant);
|
||||
})
|
||||
->form([
|
||||
Forms\Components\Select::make('role')
|
||||
->label('Role')
|
||||
->required()
|
||||
->options([
|
||||
TenantRole::Owner->value => 'Owner',
|
||||
TenantRole::Manager->value => 'Manager',
|
||||
TenantRole::Operator->value => 'Operator',
|
||||
TenantRole::Readonly->value => 'Readonly',
|
||||
]),
|
||||
])
|
||||
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
if (! $actor instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::allows('tenant_membership.manage', $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$manager->changeRole(
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
membership: $record,
|
||||
newRole: TenantRole::from((string) $data['role']),
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title('Failed to change role')
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->title('Role updated')->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
Actions\Action::make('remove')
|
||||
->label('Remove')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(function (): bool {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::allows('tenant_membership.manage', $tenant);
|
||||
})
|
||||
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
if (! $actor instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::allows('tenant_membership.manage', $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$manager->removeMember($tenant, $actor, $record);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title('Failed to remove member')
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->title('Member removed')->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
@ -152,6 +152,16 @@ public static function current(): self
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantMembership::class);
|
||||
}
|
||||
|
||||
public function roleMappings(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantRoleMapping::class);
|
||||
}
|
||||
|
||||
public function getFilamentName(): string
|
||||
{
|
||||
$environment = strtoupper((string) ($this->environment ?? 'other'));
|
||||
|
||||
40
app/Models/TenantMembership.php
Normal file
40
app/Models/TenantMembership.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class TenantMembership extends Pivot
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $table = 'tenant_memberships';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function createdByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
}
|
||||
27
app/Models/TenantRoleMapping.php
Normal file
27
app/Models/TenantRoleMapping.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TenantRoleMapping extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'is_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,8 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'entra_tenant_id',
|
||||
'entra_object_id',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -52,9 +54,15 @@ protected function casts(): array
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_platform_superadmin' => 'bool',
|
||||
];
|
||||
}
|
||||
|
||||
public function isPlatformSuperadmin(): bool
|
||||
{
|
||||
return (bool) $this->is_platform_superadmin;
|
||||
}
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return true;
|
||||
@ -62,11 +70,17 @@ public function canAccessPanel(Panel $panel): bool
|
||||
|
||||
public function tenants(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Tenant::class)
|
||||
->withPivot('role')
|
||||
return $this->belongsToMany(Tenant::class, 'tenant_memberships')
|
||||
->using(TenantMembership::class)
|
||||
->withPivot(['id', 'role', 'source', 'source_ref', 'created_by_user_id'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function tenantMemberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantMembership::class);
|
||||
}
|
||||
|
||||
public function tenantPreferences(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserTenantPreference::class);
|
||||
@ -76,7 +90,7 @@ private function tenantPivotTableExists(): bool
|
||||
{
|
||||
static $exists;
|
||||
|
||||
return $exists ??= Schema::hasTable('tenant_user');
|
||||
return $exists ??= Schema::hasTable('tenant_memberships');
|
||||
}
|
||||
|
||||
private function tenantPreferencesTableExists(): bool
|
||||
@ -116,6 +130,10 @@ public function canAccessTenant(Model $tenant): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->isPlatformSuperadmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->tenantPivotTableExists()) {
|
||||
return false;
|
||||
}
|
||||
@ -127,6 +145,13 @@ public function canAccessTenant(Model $tenant): bool
|
||||
|
||||
public function getTenants(Panel $panel): array|Collection
|
||||
{
|
||||
if ($this->isPlatformSuperadmin()) {
|
||||
return Tenant::query()
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
if (! $this->tenantPivotTableExists()) {
|
||||
return collect();
|
||||
}
|
||||
@ -139,6 +164,13 @@ public function getTenants(Panel $panel): array|Collection
|
||||
|
||||
public function getDefaultTenant(Panel $panel): ?Model
|
||||
{
|
||||
if ($this->isPlatformSuperadmin()) {
|
||||
return Tenant::query()
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $this->tenantPivotTableExists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
@ -19,28 +21,29 @@ public function boot(): void
|
||||
{
|
||||
$this->registerPolicies();
|
||||
|
||||
Gate::define('provider.view', function (User $user, Tenant $tenant): bool {
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $user->tenantRole($tenant)?->canViewProviders() ?? false;
|
||||
$defineTenantCapability = function (string $capability) use ($resolver): void {
|
||||
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool {
|
||||
return $resolver->can($user, $tenant, $capability);
|
||||
});
|
||||
};
|
||||
|
||||
Gate::define('provider.manage', function (User $user, Tenant $tenant): bool {
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
foreach ([
|
||||
Capabilities::PROVIDER_VIEW,
|
||||
Capabilities::PROVIDER_MANAGE,
|
||||
Capabilities::PROVIDER_RUN,
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
|
||||
Capabilities::AUDIT_VIEW,
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_DELETE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
] as $capability) {
|
||||
$defineTenantCapability($capability);
|
||||
}
|
||||
|
||||
return $user->tenantRole($tenant)?->canManageProviders() ?? false;
|
||||
});
|
||||
|
||||
Gate::define('provider.run', function (User $user, Tenant $tenant): bool {
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->tenantRole($tenant)?->canRunProviderOperations() ?? false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -38,6 +39,10 @@ public function panel(Panel $panel): Panel
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_START,
|
||||
fn () => view('filament.partials.break-glass-banner')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||
@ -68,6 +73,7 @@ public function panel(Panel $panel): Panel
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
|
||||
94
app/Services/Auth/CapabilityResolver.php
Normal file
94
app/Services/Auth/CapabilityResolver.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantRole;
|
||||
|
||||
/**
|
||||
* Capability Resolver
|
||||
*
|
||||
* Resolves user memberships and capabilities for a given tenant.
|
||||
* Caches results per request to avoid N+1 queries.
|
||||
*/
|
||||
class CapabilityResolver
|
||||
{
|
||||
private array $resolvedMemberships = [];
|
||||
|
||||
/**
|
||||
* Get the user's role for a tenant
|
||||
*/
|
||||
public function getRole(User $user, Tenant $tenant): ?TenantRole
|
||||
{
|
||||
if ($user->isPlatformSuperadmin()) {
|
||||
return TenantRole::Owner;
|
||||
}
|
||||
|
||||
$membership = $this->getMembership($user, $tenant);
|
||||
|
||||
if ($membership === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TenantRole::tryFrom($membership['role']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can perform a capability on a tenant
|
||||
*/
|
||||
public function can(User $user, Tenant $tenant, string $capability): bool
|
||||
{
|
||||
if ($user->isPlatformSuperadmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$role = $this->getRole($user, $tenant);
|
||||
|
||||
if ($role === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return RoleCapabilityMap::hasCapability($role, $capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has any membership for a tenant
|
||||
*/
|
||||
public function isMember(User $user, Tenant $tenant): bool
|
||||
{
|
||||
if ($user->isPlatformSuperadmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->getMembership($user, $tenant) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get membership details (cached per request)
|
||||
*/
|
||||
private function getMembership(User $user, Tenant $tenant): ?array
|
||||
{
|
||||
$cacheKey = "membership_{$user->id}_{$tenant->id}";
|
||||
|
||||
if (! isset($this->resolvedMemberships[$cacheKey])) {
|
||||
$membership = TenantMembership::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->first(['role', 'source', 'source_ref']);
|
||||
|
||||
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
||||
}
|
||||
|
||||
return $this->resolvedMemberships[$cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached memberships (useful for testing or after membership changes)
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->resolvedMemberships = [];
|
||||
}
|
||||
}
|
||||
98
app/Services/Auth/RoleCapabilityMap.php
Normal file
98
app/Services/Auth/RoleCapabilityMap.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\TenantRole;
|
||||
|
||||
/**
|
||||
* Role to Capability Mapping (Single Source of Truth)
|
||||
*
|
||||
* This class defines which capabilities each role has.
|
||||
* All capability strings MUST be references from the Capabilities registry.
|
||||
*/
|
||||
class RoleCapabilityMap
|
||||
{
|
||||
private static array $roleCapabilities = [
|
||||
TenantRole::Owner->value => [
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_DELETE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
||||
|
||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
|
||||
|
||||
Capabilities::PROVIDER_VIEW,
|
||||
Capabilities::PROVIDER_MANAGE,
|
||||
Capabilities::PROVIDER_RUN,
|
||||
|
||||
Capabilities::AUDIT_VIEW,
|
||||
],
|
||||
|
||||
TenantRole::Manager->value => [
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
||||
|
||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
|
||||
|
||||
Capabilities::PROVIDER_VIEW,
|
||||
Capabilities::PROVIDER_MANAGE,
|
||||
Capabilities::PROVIDER_RUN,
|
||||
|
||||
Capabilities::AUDIT_VIEW,
|
||||
],
|
||||
|
||||
TenantRole::Operator->value => [
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_SYNC,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||
|
||||
Capabilities::PROVIDER_VIEW,
|
||||
Capabilities::PROVIDER_RUN,
|
||||
|
||||
Capabilities::AUDIT_VIEW,
|
||||
],
|
||||
|
||||
TenantRole::Readonly->value => [
|
||||
Capabilities::TENANT_VIEW,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||
|
||||
Capabilities::PROVIDER_VIEW,
|
||||
|
||||
Capabilities::AUDIT_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all capabilities for a given role
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function getCapabilities(TenantRole|string $role): array
|
||||
{
|
||||
$roleValue = $role instanceof TenantRole ? $role->value : $role;
|
||||
|
||||
return self::$roleCapabilities[$roleValue] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role has a specific capability
|
||||
*/
|
||||
public static function hasCapability(TenantRole|string $role, string $capability): bool
|
||||
{
|
||||
return in_array($capability, self::getCapabilities($role), true);
|
||||
}
|
||||
}
|
||||
236
app/Services/Auth/TenantMembershipManager.php
Normal file
236
app/Services/Auth/TenantMembershipManager.php
Normal file
@ -0,0 +1,236 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/Support/Auth/Capabilities.php
Normal file
53
app/Support/Auth/Capabilities.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Auth;
|
||||
|
||||
/**
|
||||
* Canonical Capability Registry
|
||||
*
|
||||
* This is the single source of truth for all capability strings in the system.
|
||||
* All role-to-capability mappings must reference only these constants.
|
||||
*/
|
||||
class Capabilities
|
||||
{
|
||||
// Tenants
|
||||
public const TENANT_VIEW = 'tenant.view';
|
||||
|
||||
public const TENANT_MANAGE = 'tenant.manage';
|
||||
|
||||
public const TENANT_DELETE = 'tenant.delete';
|
||||
|
||||
public const TENANT_SYNC = 'tenant.sync';
|
||||
|
||||
// Tenant memberships
|
||||
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view';
|
||||
|
||||
public const TENANT_MEMBERSHIP_MANAGE = 'tenant_membership.manage';
|
||||
|
||||
// Optional mappings (no Graph resolution in v1)
|
||||
public const TENANT_ROLE_MAPPING_VIEW = 'tenant_role_mapping.view';
|
||||
|
||||
public const TENANT_ROLE_MAPPING_MANAGE = 'tenant_role_mapping.manage';
|
||||
|
||||
// Providers (existing gate names used throughout the app)
|
||||
public const PROVIDER_VIEW = 'provider.view';
|
||||
|
||||
public const PROVIDER_MANAGE = 'provider.manage';
|
||||
|
||||
public const PROVIDER_RUN = 'provider.run';
|
||||
|
||||
// Audit
|
||||
public const AUDIT_VIEW = 'audit.view';
|
||||
|
||||
/**
|
||||
* Get all capability constants
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$reflection = new \ReflectionClass(self::class);
|
||||
|
||||
return array_values($reflection->getConstants());
|
||||
}
|
||||
}
|
||||
37
app/Support/Middleware/DenyNonMemberTenantAccess.php
Normal file
37
app/Support/Middleware/DenyNonMemberTenantAccess.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DenyNonMemberTenantAccess
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$tenant = $request->route()?->parameter('tenant');
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_memberships', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('role', ['owner', 'manager', 'operator', 'readonly']);
|
||||
$table->enum('source', ['manual', 'entra_group', 'entra_app_role', 'break_glass'])->default('manual');
|
||||
$table->string('source_ref')->nullable();
|
||||
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['tenant_id', 'user_id']);
|
||||
$table->index(['tenant_id', 'role']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_memberships');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_role_mappings', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('mapping_type', ['entra_group', 'entra_app_role']);
|
||||
$table->string('external_id');
|
||||
$table->enum('role', ['owner', 'manager', 'operator', 'readonly']);
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['tenant_id', 'mapping_type', 'external_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_role_mappings');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('entra_tenant_id')->nullable()->after('email');
|
||||
$table->string('entra_object_id')->nullable()->after('entra_tenant_id');
|
||||
|
||||
$table->unique(['entra_tenant_id', 'entra_object_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropUnique(['entra_tenant_id', 'entra_object_id']);
|
||||
$table->dropColumn(['entra_tenant_id', 'entra_object_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('tenant_user')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('tenant_memberships')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DB::table('tenant_memberships')->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$rows = [];
|
||||
|
||||
foreach (DB::table('tenant_user')->select(['tenant_id', 'user_id', 'role'])->cursor() as $pivot) {
|
||||
$rows[] = [
|
||||
'id' => (string) Str::uuid(),
|
||||
'tenant_id' => (int) $pivot->tenant_id,
|
||||
'user_id' => (int) $pivot->user_id,
|
||||
'role' => is_string($pivot->role) && $pivot->role !== '' ? $pivot->role : 'owner',
|
||||
'source' => 'manual',
|
||||
'source_ref' => null,
|
||||
'created_by_user_id' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if (count($rows) >= 500) {
|
||||
DB::table('tenant_memberships')->insertOrIgnore($rows);
|
||||
$rows = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows !== []) {
|
||||
DB::table('tenant_memberships')->insertOrIgnore($rows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void {}
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_platform_superadmin')->default(false)->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_platform_superadmin');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-200">
|
||||
Use this page to recover tenant access by assigning an <span class="font-semibold">Owner</span> membership.
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
All recovery actions are audited.
|
||||
</p>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,18 @@
|
||||
@php
|
||||
/** @var \App\Models\User|null $user */
|
||||
$user = auth()->user();
|
||||
@endphp
|
||||
|
||||
@if ($user instanceof \App\Models\User && $user->isPlatformSuperadmin())
|
||||
<div class="fi-topbar sticky top-0 z-50 border-b border-red-500/30 bg-red-600 text-white">
|
||||
<div class="mx-auto flex max-w-screen-2xl items-center justify-between gap-4 px-4 py-2">
|
||||
<div class="text-sm font-semibold">
|
||||
Break-glass mode: platform superadmin access
|
||||
</div>
|
||||
|
||||
<div class="text-xs opacity-90">
|
||||
Use for recovery only. All actions are audited.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
34
specs/062-tenant-rbac-v1/checklists/requirements.md
Normal file
34
specs/062-tenant-rbac-v1/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Tenant RBAC v1
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-01-25
|
||||
**Feature**: [specs/062-tenant-rbac-v1/spec.md](specs/062-tenant-rbac-v1/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [X] No implementation details (languages, frameworks, APIs)
|
||||
- [X] Focused on user value and business needs
|
||||
- [X] Written for non-technical stakeholders
|
||||
- [X] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||
- [X] Requirements are testable and unambiguous
|
||||
- [X] Success criteria are measurable
|
||||
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||
- [X] All acceptance scenarios are defined
|
||||
- [X] Edge cases are identified
|
||||
- [X] Scope is clearly bounded
|
||||
- [X] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [X] All functional requirements have clear acceptance criteria
|
||||
- [X] User scenarios cover primary flows
|
||||
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [X] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All checks passed. The specification is ready for the next phase.
|
||||
49
specs/062-tenant-rbac-v1/data-model.md
Normal file
49
specs/062-tenant-rbac-v1/data-model.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Data Model for Tenant RBAC v1
|
||||
|
||||
This document outlines the data models for the Tenant RBAC feature.
|
||||
|
||||
## `users`
|
||||
|
||||
Represents a user identity, linked to an Entra ID.
|
||||
|
||||
- `id` (PK)
|
||||
- `entra_tenant_id` (string) - The Entra ID tenant ID (tid).
|
||||
- `entra_object_id` (string) - The Entra ID object ID (oid).
|
||||
- `name` (string)
|
||||
- `email` (string, nullable)
|
||||
- `timestamps`
|
||||
|
||||
**Indexes**:
|
||||
- Unique index on `(entra_tenant_id, entra_object_id)`.
|
||||
|
||||
## `tenant_memberships`
|
||||
|
||||
Links a User to a Suite Tenant with a specific role. This is the source of truth for authorization.
|
||||
|
||||
- `id` (PK, uuid)
|
||||
- `tenant_id` (FK to `tenants.id`)
|
||||
- `user_id` (FK to `users.id`)
|
||||
- `role` (enum: `owner`, `manager`, `operator`, `readonly`)
|
||||
- `source` (enum: `manual`, `entra_group`, `entra_app_role`, `break_glass`)
|
||||
- `source_ref` (string, nullable) - e.g., Entra group ID or app role ID.
|
||||
- `created_by_user_id` (FK to `users.id`, nullable)
|
||||
- `timestamps`
|
||||
|
||||
**Indexes**:
|
||||
- Unique index on `(tenant_id, user_id)`.
|
||||
- Index on `(tenant_id, role)`.
|
||||
|
||||
## `tenant_role_mappings`
|
||||
|
||||
Defines the mapping between an Entra group/app-role and a TenantAtlas role for a Suite Tenant.
|
||||
|
||||
- `id` (PK, uuid)
|
||||
- `tenant_id` (FK to `tenants.id`)
|
||||
- `mapping_type` (enum: `entra_group`, `entra_app_role`)
|
||||
- `external_id` (string) - The Entra group GUID or appRole string.
|
||||
- `role` (enum: `owner`, `manager`, `operator`, `readonly`)
|
||||
- `is_enabled` (boolean)
|
||||
- `timestamps`
|
||||
|
||||
**Indexes**:
|
||||
- Unique index on `(tenant_id, mapping_type, external_id)`.
|
||||
103
specs/062-tenant-rbac-v1/plan.md
Normal file
103
specs/062-tenant-rbac-v1/plan.md
Normal file
@ -0,0 +1,103 @@
|
||||
# Implementation Plan: Tenant RBAC v1
|
||||
|
||||
**Branch**: `062-tenant-rbac-v1` | **Date**: 2026-01-25 | **Spec**: [specs/062-tenant-rbac-v1/spec.md](specs/062-tenant-rbac-v1/spec.md)
|
||||
**Input**: Feature specification from `specs/062-tenant-rbac-v1/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
This feature introduces a comprehensive Role-Based Access Control (RBAC) system for TenantAtlas. It leverages Microsoft Entra ID for authentication and manages authorization on a per-Suite-Tenant basis. The core of this feature is the `tenant_memberships` table, which will be the source of truth for authorization. The implementation will follow a capabilities-first approach, where permissions are checked using Gates and Policies rather than direct role comparisons.
|
||||
|
||||
### Clarifications
|
||||
- No Entra user credentials are stored; only the dedicated break-glass platform superadmin may have local credentials.
|
||||
- All access control decisions must be auditable.
|
||||
- Non-member access to tenant-scoped routes (including direct `/t/{tenant}` URLs) MUST be deny-as-not-found (404).
|
||||
- A canonical capability registry (e.g., `app/Support/Auth/Capabilities.php` or an enum) will be the source of truth. Role → capability mapping MUST reference only registry entries; tests must fail if unknown capabilities are used.
|
||||
- Audit action_ids will be standardized:
|
||||
- `tenant_membership.add`
|
||||
- `tenant_membership.role_change`
|
||||
- `tenant_membership.remove`
|
||||
- `tenant_membership.bootstrap_assign`
|
||||
- `tenant_membership.bootstrap_recover`
|
||||
- `tenant_role_mapping.create`
|
||||
- `tenant_role_mapping.update`
|
||||
- `tenant_role_mapping.delete`
|
||||
- The system MUST prevent removing or demoting the last remaining `owner` membership for a Suite Tenant.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL
|
||||
**Testing**: Pest
|
||||
**Target Platform**: Web
|
||||
**Project Type**: Web Application
|
||||
**Performance Goals**:
|
||||
- User login and tenant selection should be completed in under 3 seconds.
|
||||
- Membership changes should be reflected in under 2 seconds.
|
||||
- Audit log entries should be created in under 1 second.
|
||||
**Constraints**:
|
||||
- No Entra user credentials are stored; only the dedicated break-glass platform superadmin may have local credentials.
|
||||
- All access control decisions must be auditable.
|
||||
**Scale/Scope**:
|
||||
- The system should be designed to handle up to 1,000 tenants and 10,000 users.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- **Inventory-first**: Not directly applicable.
|
||||
- **Read/write separation**: **PASS**.
|
||||
- **Graph contract path**: **PASS**.
|
||||
- **Deterministic capabilities**: **PASS**.
|
||||
- **Tenant isolation**: **PASS**.
|
||||
- **Run observability**: **PASS**.
|
||||
- **Automation**: **PASS**.
|
||||
- **Data minimization**: **PASS**.
|
||||
- **Badge semantics (BADGE-001)**: Not applicable.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/062-tenant-rbac-v1/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
└── tasks.md # Phase 2 output
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Models/
|
||||
│ ├── User.php
|
||||
│ ├── Tenant.php
|
||||
│ ├── TenantMembership.php
|
||||
│ └── TenantRoleMapping.php
|
||||
├── Policies/
|
||||
│ └── TenantMembershipPolicy.php
|
||||
├── Providers/
|
||||
│ └── AuthServiceProvider.php
|
||||
└── Support/
|
||||
└── Auth/
|
||||
└── Capabilities.php
|
||||
database/
|
||||
└── migrations/
|
||||
├── XXXX_XX_XX_XXXXXX_create_tenant_memberships_table.php
|
||||
└── XXXX_XX_XX_XXXXXX_create_tenant_role_mappings_table.php
|
||||
routes/
|
||||
└── web.php
|
||||
tests/
|
||||
└── Feature/
|
||||
└── TenantRBAC.php
|
||||
```
|
||||
|
||||
**Structure Decision**: The project is a standard Laravel application. New files will be created in the appropriate directories.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No violations to the constitution.
|
||||
16
specs/062-tenant-rbac-v1/quickstart.md
Normal file
16
specs/062-tenant-rbac-v1/quickstart.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Quickstart for Tenant RBAC v1
|
||||
|
||||
This document provides a brief overview of how to get started with the new RBAC feature.
|
||||
|
||||
## 1. Login
|
||||
- Users can now log in to TenantAtlas using their Microsoft Entra ID credentials.
|
||||
|
||||
## 2. Managing Tenant Members
|
||||
- Users with the `owner` or `manager` role can manage tenant members from the "Settings" -> "Tenants" -> "Members" page.
|
||||
- From here, you can add, edit, or remove members from the tenant.
|
||||
|
||||
## 3. Role Mappings
|
||||
- Optional role mappings can be configured from the tenant detail page to automatically provision memberships based on Entra groups or app roles.
|
||||
|
||||
## 4. Break-glass
|
||||
- A local superadmin account exists for emergency access. When logged in as the break-glass admin, a persistent banner will be displayed.
|
||||
3
specs/062-tenant-rbac-v1/research.md
Normal file
3
specs/062-tenant-rbac-v1/research.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Research & Decisions for Tenant RBAC v1
|
||||
|
||||
No major research was required for this feature as the technical approach is straightforward and relies on existing patterns within the TenantPilot application. The provided clarifications have been incorporated into the implementation plan.
|
||||
83
specs/062-tenant-rbac-v1/spec.md
Normal file
83
specs/062-tenant-rbac-v1/spec.md
Normal file
File diff suppressed because one or more lines are too long
123
specs/062-tenant-rbac-v1/tasks.md
Normal file
123
specs/062-tenant-rbac-v1/tasks.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Actionable Tasks for Tenant RBAC v1
|
||||
|
||||
**Feature**: Tenant RBAC v1
|
||||
**Branch**: `062-tenant-rbac-v1`
|
||||
**Plan**: `specs/062-tenant-rbac-v1/plan.md`
|
||||
|
||||
This task list is dependency-ordered and test-driven. It implements:
|
||||
- Entra (OIDC) identity (no Entra credentials stored)
|
||||
- Suite-tenant authorization via `tenant_memberships` (SoT)
|
||||
- Capabilities-first gates/policies (no role checks in feature code)
|
||||
- Tenant switcher + direct route enforcement (non-members = 404)
|
||||
- Audit logging with canonical action_ids
|
||||
- Break-glass platform superadmin recovery
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Discovery / Fit Check
|
||||
- [x] T001 [P] Confirm existing auth entrypoints (where OIDC callback/upsert happens) and Filament tenancy resolver (where current tenant is set).
|
||||
- [x] T002 [P] Confirm existing `User` / `Tenant` models and current schema (do NOT create duplicates). Identify required columns for Entra identity: `entra_tenant_id (tid)`, `entra_object_id (oid)`.
|
||||
- [x] T003 [P] Identify existing AuditLog service/model and how to write audit entries (target format + redaction).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Schema (RBAC source of truth)
|
||||
- [x] T004 Create migration `create_tenant_memberships_table` with:
|
||||
- `tenant_id`, `user_id`, `role` (`owner|manager|operator|readonly`)
|
||||
- `source` (`manual|entra_group|entra_app_role|break_glass`)
|
||||
- `source_ref` (nullable)
|
||||
- `created_by_user_id` (nullable)
|
||||
- unique `(tenant_id, user_id)` and index `(tenant_id, role)`
|
||||
- [ ] T005 (Optional, but supported) Create migration `create_tenant_role_mappings_table` with:
|
||||
- `tenant_id`, `mapping_type` (`entra_group|entra_app_role`), `external_id`, `role`, `is_enabled`
|
||||
- unique `(tenant_id, mapping_type, external_id)`
|
||||
- [x] T006 Add/adjust `users` columns if missing: `entra_tenant_id` (tid), `entra_object_id` (oid) + unique index `(entra_tenant_id, entra_object_id)`.
|
||||
- [x] T007 Run migrations.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Models + Capability Registry (capabilities-first)
|
||||
- [x] T008 Create `app/Support/Auth/Capabilities.php` as the canonical allowlist (constants/enum) of capability strings.
|
||||
- [x] T009 Create `app/Services/Auth/RoleCapabilityMap.php` (single source of truth) mapping roles → capabilities.
|
||||
- [x] T010 Create `app/Services/Auth/CapabilityResolver.php`:
|
||||
- resolves membership for (user, tenant) once per request (no N+1)
|
||||
- answers `can($capability)` using the registry + map
|
||||
- [x] T011 Register Gates in `app/Providers/AuthServiceProvider.php` using `CapabilityResolver` (no direct role checks).
|
||||
- [x] T012 Add model `TenantMembership` and (if used) `TenantRoleMapping` with relationships:
|
||||
- `Tenant::memberships()`, `User::tenantMemberships()`
|
||||
- [x] T013 Unit tests:
|
||||
- `CapabilitiesRegistryTest`: role map only references registry entries
|
||||
- `CapabilityResolverTest`: Owner/Manager/Operator/Readonly mapping works and is deterministic
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Tenant Isolation (switcher + deny-as-not-found)
|
||||
- [x] T014 Enforce tenant switcher scoping: only tenants with a membership are listable/selectable for a user.
|
||||
- [x] T015 Enforce route-level deny-as-not-found:
|
||||
- direct access to `/t/{tenant}` and tenant-scoped resources returns 404 when user is not a member.
|
||||
- member without capability returns 403.
|
||||
- [x] T016 Feature tests:
|
||||
- `TenantSwitcherScopeTest`: only membership tenants appear
|
||||
- `TenantRouteDenyAsNotFoundTest`: non-member gets 404 for direct URL
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Suite Tenant Membership Management UI (Tenant → Members)
|
||||
- [x] T017 Add a Filament Relation Manager (or equivalent) under `Settings → Tenants` to manage memberships:
|
||||
- list members + role
|
||||
- add member (select existing user) + role
|
||||
- edit member role
|
||||
- remove member
|
||||
- [x] T018 Implement **Last Owner Guard**:
|
||||
- prevent removing/demoting last `owner` membership (clear UI message)
|
||||
- [x] T019 Implement **Bootstrap assign**:
|
||||
- on tenant creation, creator becomes Owner (action_id `tenant_membership.bootstrap_assign`)
|
||||
- [x] T020 Implement **Bootstrap recover** (platform superadmin path):
|
||||
- add/assign Owner when needed (action_id `tenant_membership.bootstrap_recover`)
|
||||
- [x] T021 Feature tests:
|
||||
- `TenantMembershipCrudTest`
|
||||
- `LastOwnerGuardTest`
|
||||
- `TenantBootstrapAssignTest`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Audit Logging (canonical action_ids)
|
||||
- [x] T022 Add audit logging for membership and mapping changes with canonical action_ids:
|
||||
- `tenant_membership.add`
|
||||
- `tenant_membership.role_change`
|
||||
- `tenant_membership.remove`
|
||||
- `tenant_membership.bootstrap_assign`
|
||||
- `tenant_membership.bootstrap_recover`
|
||||
- `tenant_role_mapping.create|update|delete` (if mappings are enabled)
|
||||
|
||||
Audit entries must be redacted (no secrets; minimal identity data).
|
||||
- [x] T023 Feature test `MembershipAuditLogTest` ensures audit entries are written on add/change/remove and contain no sensitive fields.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Break-glass Platform Superadmin (recovery)
|
||||
- [x] T024 Implement (or confirm existing) local platform superadmin authentication separate from Entra users.
|
||||
- [x] T025 Add a persistent UI banner when authenticated as break-glass.
|
||||
- [x] T026 Ensure platform superadmin can manage memberships across all tenants for recovery (at least to add an Owner).
|
||||
- [x] T027 Feature test `BreakGlassRecoveryTest`:
|
||||
- can assign owner to tenant
|
||||
- actions are audited with bootstrap_recover
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Optional: Entra Mapping (deferred execution in v1)
|
||||
- [ ] T028 (Optional) Add UI to manage `tenant_role_mappings` (no Graph calls for resolution in v1).
|
||||
- [ ] T029 (Optional) Test that mapping records are tenant-scoped and audited on create/update/delete.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Quality Gates
|
||||
- [x] T030 Run formatting: `./vendor/bin/sail php ./vendor/bin/pint --dirty`
|
||||
- [x] T031 Run focused tests: `./vendor/bin/sail artisan test tests/Feature/TenantRBAC --stop-on-failure`
|
||||
|
||||
---
|
||||
|
||||
## Notes / Guardrails
|
||||
- Non-member access = **404** (deny-as-not-found). Member without capability = **403**.
|
||||
- No feature code may use `role == ...` checks. Always gates/capabilities.
|
||||
- Do not add any render-time Graph calls (group/app-role resolution is deferred unless explicitly scheduled as a job in a later feature).
|
||||
39
tests/Feature/TenantRBAC/BreakGlassRecoveryTest.php
Normal file
39
tests/Feature/TenantRBAC/BreakGlassRecoveryTest.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\BreakGlassRecovery;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('allows platform superadmin to assign an owner via break-glass recovery and audits it', function () {
|
||||
$superadmin = User::factory()->create(['is_platform_superadmin' => true]);
|
||||
$this->actingAs($superadmin);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$targetUser = User::factory()->create();
|
||||
|
||||
Livewire::test(BreakGlassRecovery::class)
|
||||
->callAction('bootstrap_recover', data: [
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $targetUser->getKey(),
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('tenant_memberships', [
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $targetUser->getKey(),
|
||||
'role' => 'owner',
|
||||
'source' => 'break_glass',
|
||||
]);
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('action', 'tenant_membership.bootstrap_recover')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
});
|
||||
38
tests/Feature/TenantRBAC/LastOwnerGuardTest.php
Normal file
38
tests/Feature/TenantRBAC/LastOwnerGuardTest.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use App\Models\TenantMembership;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\TenantRole;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('prevents demoting the last remaining owner', function () {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$membership = TenantMembership::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('user_id', $actor->getKey())
|
||||
->firstOrFail();
|
||||
|
||||
$manager = app(TenantMembershipManager::class);
|
||||
|
||||
$callback = fn () => $manager->changeRole($tenant, $actor, $membership, TenantRole::Readonly);
|
||||
|
||||
expect($callback)->toThrow(DomainException::class, 'You cannot demote the last remaining owner.');
|
||||
});
|
||||
|
||||
it('prevents removing the last remaining owner', function () {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$membership = TenantMembership::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('user_id', $actor->getKey())
|
||||
->firstOrFail();
|
||||
|
||||
$manager = app(TenantMembershipManager::class);
|
||||
|
||||
$callback = fn () => $manager->removeMember($tenant, $actor, $membership);
|
||||
|
||||
expect($callback)->toThrow(DomainException::class, 'You cannot remove the last remaining owner.');
|
||||
});
|
||||
53
tests/Feature/TenantRBAC/MembershipAuditLogTest.php
Normal file
53
tests/Feature/TenantRBAC/MembershipAuditLogTest.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\TenantRole;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('writes audit logs for membership add, role change, and remove without sensitive fields', function () {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$member = User::factory()->create();
|
||||
|
||||
$manager = app(TenantMembershipManager::class);
|
||||
|
||||
$membership = $manager->addMember($tenant, $actor, $member, TenantRole::Readonly);
|
||||
$manager->changeRole($tenant, $actor, $membership, TenantRole::Operator);
|
||||
$manager->removeMember($tenant, $actor, $membership);
|
||||
|
||||
$actions = AuditLog::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereIn('action', [
|
||||
'tenant_membership.add',
|
||||
'tenant_membership.role_change',
|
||||
'tenant_membership.remove',
|
||||
])
|
||||
->pluck('action')
|
||||
->all();
|
||||
|
||||
expect($actions)->toContain('tenant_membership.add');
|
||||
expect($actions)->toContain('tenant_membership.role_change');
|
||||
expect($actions)->toContain('tenant_membership.remove');
|
||||
|
||||
$metadata = AuditLog::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereIn('action', [
|
||||
'tenant_membership.add',
|
||||
'tenant_membership.role_change',
|
||||
'tenant_membership.remove',
|
||||
])
|
||||
->get()
|
||||
->pluck('metadata')
|
||||
->all();
|
||||
|
||||
foreach ($metadata as $entry) {
|
||||
expect($entry)->toBeArray();
|
||||
expect(array_key_exists('app_client_secret', $entry))->toBeFalse();
|
||||
expect(array_key_exists('client_secret', $entry))->toBeFalse();
|
||||
expect(array_key_exists('refresh_token', $entry))->toBeFalse();
|
||||
expect(array_key_exists('access_token', $entry))->toBeFalse();
|
||||
}
|
||||
});
|
||||
43
tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php
Normal file
43
tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('bootstraps tenant creator as owner and audits the assignment', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenantGuid = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
Livewire::test(RegisterTenant::class)
|
||||
->set('data.name', 'Acme')
|
||||
->set('data.environment', 'other')
|
||||
->set('data.tenant_id', $tenantGuid)
|
||||
->set('data.domain', 'acme.example')
|
||||
->call('register');
|
||||
|
||||
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
|
||||
|
||||
$membership = TenantMembership::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('user_id', $user->getKey())
|
||||
->firstOrFail();
|
||||
|
||||
expect($membership->role)->toBe('owner');
|
||||
expect($membership->source)->toBe('manual');
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('action', 'tenant_membership.bootstrap_assign')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
});
|
||||
36
tests/Feature/TenantRBAC/TenantMembershipCrudTest.php
Normal file
36
tests/Feature/TenantRBAC/TenantMembershipCrudTest.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\TenantRole;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('can add, change role, and remove tenant members', function () {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$member = User::factory()->create();
|
||||
|
||||
$manager = app(TenantMembershipManager::class);
|
||||
|
||||
$membership = $manager->addMember($tenant, $actor, $member, TenantRole::Readonly);
|
||||
|
||||
$this->assertDatabaseHas('tenant_memberships', [
|
||||
'id' => $membership->getKey(),
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
$updated = $manager->changeRole($tenant, $actor, $membership, TenantRole::Operator);
|
||||
|
||||
expect($updated->role)->toBe('operator');
|
||||
|
||||
$manager->removeMember($tenant, $actor, $updated);
|
||||
|
||||
$this->assertDatabaseMissing('tenant_memberships', [
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $member->getKey(),
|
||||
]);
|
||||
});
|
||||
24
tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php
Normal file
24
tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns 404 for non-members on tenant dashboard route', function () {
|
||||
$tenant = Tenant::factory()->create(['external_id' => 'tenant-a']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('allows members to access the tenant dashboard route', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertSuccessful();
|
||||
});
|
||||
46
tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
Normal file
46
tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\PanelRegistry;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('only returns membership tenants for normal users', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$allowed = Tenant::factory()->create(['name' => 'Allowed']);
|
||||
$blocked = Tenant::factory()->create(['name' => 'Blocked']);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$allowed->getKey() => ['role' => 'readonly'],
|
||||
]);
|
||||
|
||||
/** @var \Filament\Panel $panel */
|
||||
$panel = app(PanelRegistry::class)->get('admin');
|
||||
|
||||
$tenants = $user->getTenants($panel);
|
||||
|
||||
expect($tenants)->toHaveCount(1);
|
||||
expect($tenants->first()?->getKey())->toBe($allowed->getKey());
|
||||
expect($tenants->first()?->name)->toBe('Allowed');
|
||||
|
||||
expect($tenants->contains(fn (Tenant $tenant) => $tenant->getKey() === $blocked->getKey()))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns all active tenants for platform superadmins', function () {
|
||||
$user = User::factory()->create(['is_platform_superadmin' => true]);
|
||||
|
||||
$a = Tenant::factory()->create(['name' => 'A']);
|
||||
$b = Tenant::factory()->create(['name' => 'B']);
|
||||
|
||||
/** @var \Filament\Panel $panel */
|
||||
$panel = app(PanelRegistry::class)->get('admin');
|
||||
|
||||
$tenants = $user->getTenants($panel);
|
||||
|
||||
expect($tenants->pluck('id')->all())
|
||||
->toContain($a->getKey())
|
||||
->toContain($b->getKey());
|
||||
});
|
||||
15
tests/Unit/Auth/CapabilitiesRegistryTest.php
Normal file
15
tests/Unit/Auth/CapabilitiesRegistryTest.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\TenantRole;
|
||||
|
||||
it('role map only references registry entries', function () {
|
||||
$registry = Capabilities::all();
|
||||
|
||||
foreach (TenantRole::cases() as $role) {
|
||||
foreach (RoleCapabilityMap::getCapabilities($role) as $capability) {
|
||||
expect($registry)->toContain($capability);
|
||||
}
|
||||
}
|
||||
});
|
||||
36
tests/Unit/Auth/CapabilityResolverTest.php
Normal file
36
tests/Unit/Auth/CapabilityResolverTest.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\TenantRole;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('resolves membership and capabilities deterministically', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$owner->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Owner->value, 'source' => 'manual']);
|
||||
|
||||
$readonly = User::factory()->create();
|
||||
$readonly->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Readonly->value, 'source' => 'manual']);
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
expect($resolver->isMember($owner, $tenant))->toBeTrue();
|
||||
expect($resolver->can($owner, $tenant, Capabilities::PROVIDER_MANAGE))->toBeTrue();
|
||||
expect($resolver->can($owner, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeTrue();
|
||||
|
||||
expect($resolver->isMember($readonly, $tenant))->toBeTrue();
|
||||
expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_VIEW))->toBeTrue();
|
||||
expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_MANAGE))->toBeFalse();
|
||||
expect($resolver->can($readonly, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeFalse();
|
||||
|
||||
$outsider = User::factory()->create();
|
||||
|
||||
expect($resolver->isMember($outsider, $tenant))->toBeFalse();
|
||||
expect($resolver->can($outsider, $tenant, Capabilities::PROVIDER_VIEW))->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user