From 3b1dd98f52151d80614d56471924bc157964d3ef Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 25 Jan 2026 16:01:50 +0100 Subject: [PATCH] feat(rbac): Implement Tenant RBAC v1 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. --- GEMINI.md | 7 +- app/Filament/Pages/BreakGlassRecovery.php | 97 +++++++ app/Filament/Pages/Tenancy/RegisterTenant.php | 25 +- app/Filament/Resources/TenantResource.php | 8 + .../TenantMembershipsRelationManager.php | 224 +++++++++++++++++ app/Models/Tenant.php | 10 + app/Models/TenantMembership.php | 40 +++ app/Models/TenantRoleMapping.php | 27 ++ app/Models/User.php | 38 ++- app/Providers/AuthServiceProvider.php | 45 ++-- app/Providers/Filament/AdminPanelProvider.php | 6 + app/Services/Auth/CapabilityResolver.php | 94 +++++++ app/Services/Auth/RoleCapabilityMap.php | 98 ++++++++ app/Services/Auth/TenantMembershipManager.php | 236 ++++++++++++++++++ app/Support/Auth/Capabilities.php | 53 ++++ .../Middleware/DenyNonMemberTenantAccess.php | 37 +++ ...022729_create_tenant_memberships_table.php | 36 +++ ...2733_create_tenant_role_mappings_table.php | 34 +++ ...22740_add_entra_columns_to_users_table.php | 32 +++ ...ll_tenant_memberships_from_tenant_user.php | 58 +++++ ..._is_platform_superadmin_to_users_table.php | 25 ++ .../pages/break-glass-recovery.blade.php | 11 + .../partials/break-glass-banner.blade.php | 18 ++ .../checklists/requirements.md | 34 +++ specs/062-tenant-rbac-v1/data-model.md | 49 ++++ specs/062-tenant-rbac-v1/plan.md | 103 ++++++++ specs/062-tenant-rbac-v1/quickstart.md | 16 ++ specs/062-tenant-rbac-v1/research.md | 3 + specs/062-tenant-rbac-v1/spec.md | 83 ++++++ specs/062-tenant-rbac-v1/tasks.md | 123 +++++++++ .../TenantRBAC/BreakGlassRecoveryTest.php | 39 +++ .../Feature/TenantRBAC/LastOwnerGuardTest.php | 38 +++ .../TenantRBAC/MembershipAuditLogTest.php | 53 ++++ .../TenantRBAC/TenantBootstrapAssignTest.php | 43 ++++ .../TenantRBAC/TenantMembershipCrudTest.php | 36 +++ .../TenantRouteDenyAsNotFoundTest.php | 24 ++ .../TenantRBAC/TenantSwitcherScopeTest.php | 46 ++++ tests/Unit/Auth/CapabilitiesRegistryTest.php | 15 ++ tests/Unit/Auth/CapabilityResolverTest.php | 36 +++ 39 files changed, 1971 insertions(+), 29 deletions(-) create mode 100644 app/Filament/Pages/BreakGlassRecovery.php create mode 100644 app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php create mode 100644 app/Models/TenantMembership.php create mode 100644 app/Models/TenantRoleMapping.php create mode 100644 app/Services/Auth/CapabilityResolver.php create mode 100644 app/Services/Auth/RoleCapabilityMap.php create mode 100644 app/Services/Auth/TenantMembershipManager.php create mode 100644 app/Support/Auth/Capabilities.php create mode 100644 app/Support/Middleware/DenyNonMemberTenantAccess.php create mode 100644 database/migrations/2026_01_25_022729_create_tenant_memberships_table.php create mode 100644 database/migrations/2026_01_25_022733_create_tenant_role_mappings_table.php create mode 100644 database/migrations/2026_01_25_022740_add_entra_columns_to_users_table.php create mode 100644 database/migrations/2026_01_25_023708_backfill_tenant_memberships_from_tenant_user.php create mode 100644 database/migrations/2026_01_25_093947_add_is_platform_superadmin_to_users_table.php create mode 100644 resources/views/filament/pages/break-glass-recovery.blade.php create mode 100644 resources/views/filament/partials/break-glass-banner.blade.php create mode 100644 specs/062-tenant-rbac-v1/checklists/requirements.md create mode 100644 specs/062-tenant-rbac-v1/data-model.md create mode 100644 specs/062-tenant-rbac-v1/plan.md create mode 100644 specs/062-tenant-rbac-v1/quickstart.md create mode 100644 specs/062-tenant-rbac-v1/research.md create mode 100644 specs/062-tenant-rbac-v1/spec.md create mode 100644 specs/062-tenant-rbac-v1/tasks.md create mode 100644 tests/Feature/TenantRBAC/BreakGlassRecoveryTest.php create mode 100644 tests/Feature/TenantRBAC/LastOwnerGuardTest.php create mode 100644 tests/Feature/TenantRBAC/MembershipAuditLogTest.php create mode 100644 tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php create mode 100644 tests/Feature/TenantRBAC/TenantMembershipCrudTest.php create mode 100644 tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php create mode 100644 tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php create mode 100644 tests/Unit/Auth/CapabilitiesRegistryTest.php create mode 100644 tests/Unit/Auth/CapabilityResolverTest.php diff --git a/GEMINI.md b/GEMINI.md index e1aafa1..76d8c15 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -910,9 +910,8 @@ ### Replaced Utilities ## 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) diff --git a/app/Filament/Pages/BreakGlassRecovery.php b/app/Filament/Pages/BreakGlassRecovery.php new file mode 100644 index 0000000..364ce1b --- /dev/null +++ b/app/Filament/Pages/BreakGlassRecovery.php @@ -0,0 +1,97 @@ +user(); + + return $user instanceof User && $user->isPlatformSuperadmin(); + } + + /** + * @return array + */ + 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(); + }), + ]; + } +} diff --git a/app/Filament/Pages/Tenancy/RegisterTenant.php b/app/Filament/Pages/Tenancy/RegisterTenant.php index b39b512..c8b7855 100644 --- a/app/Filament/Pages/Tenancy/RegisterTenant.php +++ b/app/Filament/Pages/Tenancy/RegisterTenant.php @@ -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; diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 9caeaef..605c1c9 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -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] ... diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php new file mode 100644 index 0000000..ee56836 --- /dev/null +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php @@ -0,0 +1,224 @@ +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([]); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index baf6186..72f57ef 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -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')); diff --git a/app/Models/TenantMembership.php b/app/Models/TenantMembership.php new file mode 100644 index 0000000..e3c063e --- /dev/null +++ b/app/Models/TenantMembership.php @@ -0,0 +1,40 @@ + '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'); + } +} diff --git a/app/Models/TenantRoleMapping.php b/app/Models/TenantRoleMapping.php new file mode 100644 index 0000000..ff4b0e0 --- /dev/null +++ b/app/Models/TenantRoleMapping.php @@ -0,0 +1,27 @@ + 'boolean', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 8c08d23..312e932 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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; } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 9396bf7..ba90042 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -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; - } - - 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; - }); + 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); + } } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index bf4ea95..3df82e0 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -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, ]) diff --git a/app/Services/Auth/CapabilityResolver.php b/app/Services/Auth/CapabilityResolver.php new file mode 100644 index 0000000..25171c6 --- /dev/null +++ b/app/Services/Auth/CapabilityResolver.php @@ -0,0 +1,94 @@ +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 = []; + } +} diff --git a/app/Services/Auth/RoleCapabilityMap.php b/app/Services/Auth/RoleCapabilityMap.php new file mode 100644 index 0000000..ca3c746 --- /dev/null +++ b/app/Services/Auth/RoleCapabilityMap.php @@ -0,0 +1,98 @@ +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 + */ + 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); + } +} diff --git a/app/Services/Auth/TenantMembershipManager.php b/app/Services/Auth/TenantMembershipManager.php new file mode 100644 index 0000000..12ce073 --- /dev/null +++ b/app/Services/Auth/TenantMembershipManager.php @@ -0,0 +1,236 @@ +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.'); + } + } +} diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php new file mode 100644 index 0000000..e496928 --- /dev/null +++ b/app/Support/Auth/Capabilities.php @@ -0,0 +1,53 @@ + + */ + public static function all(): array + { + $reflection = new \ReflectionClass(self::class); + + return array_values($reflection->getConstants()); + } +} diff --git a/app/Support/Middleware/DenyNonMemberTenantAccess.php b/app/Support/Middleware/DenyNonMemberTenantAccess.php new file mode 100644 index 0000000..d3b021a --- /dev/null +++ b/app/Support/Middleware/DenyNonMemberTenantAccess.php @@ -0,0 +1,37 @@ +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); + } +} diff --git a/database/migrations/2026_01_25_022729_create_tenant_memberships_table.php b/database/migrations/2026_01_25_022729_create_tenant_memberships_table.php new file mode 100644 index 0000000..4d93d9b --- /dev/null +++ b/database/migrations/2026_01_25_022729_create_tenant_memberships_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/database/migrations/2026_01_25_022733_create_tenant_role_mappings_table.php b/database/migrations/2026_01_25_022733_create_tenant_role_mappings_table.php new file mode 100644 index 0000000..867db2b --- /dev/null +++ b/database/migrations/2026_01_25_022733_create_tenant_role_mappings_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/database/migrations/2026_01_25_022740_add_entra_columns_to_users_table.php b/database/migrations/2026_01_25_022740_add_entra_columns_to_users_table.php new file mode 100644 index 0000000..b3153d8 --- /dev/null +++ b/database/migrations/2026_01_25_022740_add_entra_columns_to_users_table.php @@ -0,0 +1,32 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_01_25_023708_backfill_tenant_memberships_from_tenant_user.php b/database/migrations/2026_01_25_023708_backfill_tenant_memberships_from_tenant_user.php new file mode 100644 index 0000000..359aad8 --- /dev/null +++ b/database/migrations/2026_01_25_023708_backfill_tenant_memberships_from_tenant_user.php @@ -0,0 +1,58 @@ +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 {} +}; diff --git a/database/migrations/2026_01_25_093947_add_is_platform_superadmin_to_users_table.php b/database/migrations/2026_01_25_093947_add_is_platform_superadmin_to_users_table.php new file mode 100644 index 0000000..fe93689 --- /dev/null +++ b/database/migrations/2026_01_25_093947_add_is_platform_superadmin_to_users_table.php @@ -0,0 +1,25 @@ +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'); + }); + } +}; diff --git a/resources/views/filament/pages/break-glass-recovery.blade.php b/resources/views/filament/pages/break-glass-recovery.blade.php new file mode 100644 index 0000000..5c7fd72 --- /dev/null +++ b/resources/views/filament/pages/break-glass-recovery.blade.php @@ -0,0 +1,11 @@ + +
+

+ Use this page to recover tenant access by assigning an Owner membership. +

+ +

+ All recovery actions are audited. +

+
+
diff --git a/resources/views/filament/partials/break-glass-banner.blade.php b/resources/views/filament/partials/break-glass-banner.blade.php new file mode 100644 index 0000000..4a0aac9 --- /dev/null +++ b/resources/views/filament/partials/break-glass-banner.blade.php @@ -0,0 +1,18 @@ +@php + /** @var \App\Models\User|null $user */ + $user = auth()->user(); +@endphp + +@if ($user instanceof \App\Models\User && $user->isPlatformSuperadmin()) +
+
+
+ Break-glass mode: platform superadmin access +
+ +
+ Use for recovery only. All actions are audited. +
+
+
+@endif diff --git a/specs/062-tenant-rbac-v1/checklists/requirements.md b/specs/062-tenant-rbac-v1/checklists/requirements.md new file mode 100644 index 0000000..a39a26f --- /dev/null +++ b/specs/062-tenant-rbac-v1/checklists/requirements.md @@ -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. diff --git a/specs/062-tenant-rbac-v1/data-model.md b/specs/062-tenant-rbac-v1/data-model.md new file mode 100644 index 0000000..8fd6428 --- /dev/null +++ b/specs/062-tenant-rbac-v1/data-model.md @@ -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)`. \ No newline at end of file diff --git a/specs/062-tenant-rbac-v1/plan.md b/specs/062-tenant-rbac-v1/plan.md new file mode 100644 index 0000000..bed9e6c --- /dev/null +++ b/specs/062-tenant-rbac-v1/plan.md @@ -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. diff --git a/specs/062-tenant-rbac-v1/quickstart.md b/specs/062-tenant-rbac-v1/quickstart.md new file mode 100644 index 0000000..ec29ad9 --- /dev/null +++ b/specs/062-tenant-rbac-v1/quickstart.md @@ -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. \ No newline at end of file diff --git a/specs/062-tenant-rbac-v1/research.md b/specs/062-tenant-rbac-v1/research.md new file mode 100644 index 0000000..e474254 --- /dev/null +++ b/specs/062-tenant-rbac-v1/research.md @@ -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. diff --git a/specs/062-tenant-rbac-v1/spec.md b/specs/062-tenant-rbac-v1/spec.md new file mode 100644 index 0000000..15dda99 --- /dev/null +++ b/specs/062-tenant-rbac-v1/spec.md @@ -0,0 +1,83 @@ +# Feature Specification: Tenant RBAC v1 (Entra Login + Suite-Tenant Memberships) + +**Feature Branch**: `062-tenant-rbac-v1` +**Created**: 2026-01-25 +**Status**: Draft +**Input**: User description: "# Feature 062 — Tenant RBAC v1 (Entra Login + Suite-Tenant Memberships + Optional Group/AppRole Mapping + Break-glass) **Stack:** Laravel 12 · PHP 8.4 · Filament v5 · Livewire v4 · Postgres **Scope:** Best-practice, scalable authorization for a multi-tenant SaaS/MSP suite: - Authentication via Microsoft Entra ID (OIDC) - Authorization via **suite-tenant** memberships in TenantAtlas (SoT) - Optional automation: Entra Groups / App Roles → memberships - Platform break-glass local superadmin (operator-safe, audited) **Goal:** Enable MSPs and customers to manage who can do what per Suite Tenant (customer + env), without mixing Microsoft tenant admin concepts into TenantAtlas authorization. --- ## 0. Key Concepts (avoid confusion) ### K-001 — Two different “tenants” 1) **Suite Tenant (TenantAtlas Tenant)**: a customer/environment container inside your app (e.g., “Customer A – PROD”, “Customer A – DEV”). 2) **Microsoft Tenant (Entra/Intune tenant)**: the target platform you connect to (Entra tenant ID GUID). TenantAtlas RBAC controls who can use TenantAtlas features per **Suite Tenant**. Microsoft (Entra/Intune) RBAC controls what the app is technically able to read/write. --- ## 1. Principles (Constitution-aligned) ### RBAC-001 — Capabilities-first, not role checks Feature code MUST check **capabilities/permissions** (Gates/Policies), never `if role == X`. Roles are a mapping layer only. ### RBAC-002 — Suite Tenant is the authorization scope All permissions are evaluated within the current Suite Tenant context. ### RBAC-003 — Audited changes All membership/role/permission changes must write an AuditLog entry (no secrets, no PII dumps). ### RBAC-004 — Safe defaults If a user is not a member of a Suite Tenant: deny access (404/403), no “implicit member”. ### RBAC-005 — Break-glass exists A local platform superadmin account exists to recover access when Entra integration fails. --- ## 2. Goals 1) Allow adding/removing members per Suite Tenant and setting their role via UI. 2) Support MSP reality: same person can have different roles across different Suite Tenants. 3) Keep authorization stable even if Entra groups change (source recorded). 4) Allow optional Entra group/app-role mapping to auto-provision memberships. 5) Support future fine-grained permissions without refactoring all features. --- ## 3. Non-Goals (v1) - Full local user management for all users (passwords etc.) — only break-glass. - Cross-tenant global RBAC (platform-wide “auditor across all tenants”) beyond superadmin. - Impersonation (future). - Per-resource row-level permissions beyond tenant scope (future). - Full SCIM provisioning (future). --- # 4. Role Model & Capabilities ## 4.1 Canonical roles (v1) - `owner` - `manager` - `operator` - `readonly` ## 4.2 Canonical capabilities (v1 baseline) Define these high-level permissions (more can be added later): **General** - `tenant.view` - `tenant.manage` (edit tenant metadata) **Providers (from Feature 061)** - `provider.view` - `provider.manage` (connections + credentials) - `provider.run` (check/snapshot/sync) **Operations** - `ops.view` (Monitoring → Operations) - `ops.run` (start ops) **Inventory** - `inventory.view` - `inventory.run` (run sync) **Policies** - `policy.view` - `policy.run` (sync, bulk ops) - `policy.restore` (if applicable) **Backups/Restore** - `backup.view` - `backup.run` - `restore.view` - `restore.execute` (high-risk) **Drift/Findings** - `drift.view` - `drift.run` ## 4.3 Role → capability mapping (v1 defaults) **Owner** - all capabilities for the Suite Tenant **Manager** - all except the highest-risk destructive actions (if you want a stricter split) - recommended v1: same as owner except `restore.execute` (optional) **Operator** - view + run operational tasks - cannot manage providers/credentials or tenant config - cannot execute destructive ops unless explicitly granted later **Readonly** - view-only across all tenant features ### Minimum enforced mapping for Provider v1 (must match Feature 061) - Owner/Manager: `provider.view`, `provider.manage`, `provider.run` - Operator: `provider.view`, `provider.run` - Readonly: `provider.view` --- # 5. Data Model ## 5.1 users Store Entra identity as stable keys: - `entra_tenant_id` (tid) - `entra_object_id` (oid) - `email` (optional/nullable; do not rely on it as identity) - `name` - timestamps Unique index: - `(entra_tenant_id, entra_object_id)`. ## 5.2 tenant_memberships (Suite Tenant authorization SoT) - `id` (uuid) - `tenant_id` (fk to suite tenant) - `user_id` (fk) - `role` (enum string) - `source` (`manual | entra_group | entra_app_role | break_glass`) - `source_ref` (nullable string; e.g., group id/app role id) - `created_by_user_id` (nullable; for manual changes) - `timestamps` Constraints: - unique `(tenant_id, user_id)` - index `(tenant_id, role)` ## 5.3 tenant_role_mappings (optional automation) Allows automatic provisioning based on Entra groups/app roles. - `id` (uuid) - `tenant_id` (fk) - `mapping_type` (`entra_group | entra_app_role`) - `external_id` (group GUID or appRole string) - `role` (enum string) - `is_enabled` (bool) - timestamps Constraints: - unique `(tenant_id, mapping_type, external_id)` ## 5.4 audit_logs (existing) Used for membership changes; must support: - actor - tenant - action_id - target - before/after (redacted) - timestamp --- # 6. Authentication & Provisioning ## 6.1 Entra login (OIDC) On successful login: 1) Upsert `users` by `(entra_tenant_id, entra_object_id)` 2) Determine accessible Suite Tenants by memberships 3) Set current tenant context via Filament tenant selection ## 6.2 Membership sources and precedence (v1) Membership evaluation per Suite Tenant: 1) Manual membership (`source=manual`) takes precedence over automated sources. 2) If no manual membership exists: - apply Entra mapping rules (groups/app roles) if configured. 3) If still no membership: deny. Record the source: - If created by mapping: `source=entra_group/app_role`, `source_ref=` ## 6.3 Entra group overage handling (v2-ready) v1 may rely on claims if available; if group claims are not present: - v1: treat mapping as optional; show “group overage not supported yet” - v2: add Graph call to resolve group membership (OperationRun-backed admin job, not render-time) --- # 7. Authorization (Policies/Gates) ## 7.1 Capability engine Implement a central `CapabilityResolver`: - input: user, tenant, membership role, optional overrides - output: `can($capability)` boolean All feature policies call `can()`. Forbidden: - direct role comparisons in feature code ## 7.2 Tenant scoping enforcement Every Filament resource query MUST scope to the current Suite Tenant and MUST enforce membership: - Non-members see 404 (deny-as-not-found) on tenant resources. --- # 8. UI Requirements (Filament) ## 8.1 Tenant “Members” management Add to **Settings → Tenants**: - Tab/Relation Manager: **Members** - Search existing users (by email/name) - Add member (select user) + role - Edit role - Remove member Permissions: - only Owner/Manager with `tenant.manage` can manage members - Operator/Readonly can view member list only if `tenant.view` (optional) Every change writes AuditLog. ## 8.2 Role mapping configuration (optional) Tenant detail page: - “Role Mappings” section - Add mapping: group/app-role → role - enable/disable mapping - show last sync attempt (if you add later) v1 can ship with UI but without group-membership resolution (if you choose). ## 8.3 Break-glass admin UX When logged in as break-glass: - Show a persistent banner “Break-glass account” - All actions are audited with source `break_glass` --- # 9. Break-glass Local Superadmin (Platform) ## 9.1 Purpose Allow platform operator to recover access if Entra login/mapping breaks. ## 9.2 Minimal implementation (v1) - local user table entry flagged `is_platform_superadmin = true` - password-based auth (local) OR separate guard - platform superadmin can: - view/manage all Suite Tenants - manage memberships - manage provider connections - must be audited and clearly indicated Constraints: - only one/few accounts - not used for day-to-day operations --- # 10. Tests & Guardrails ## 10.1 RBAC tests (required) - Membership required: non-member cannot access tenant resources (deny-as-not-found) - Role mapping: - Owner/Manager can manage members - Operator cannot manage credentials (ties into Feature 061) - Readonly cannot start operations ## 10.2 Capability resolver tests - role → capability mapping is stable - adding a new capability won’t break existing roles (safe defaults) ## 10.3 Audit tests - membership changes create audit log records - no secrets/PII in audit before/after ## 10.4 UI tests (Filament) - Members relation manager visible only to authorized roles - Role dropdown reflects canonical roles --- # 11. Migration & Backward Compatibility - Introduce membership enforcement gradually: - v1: create a default owner membership for the creator of a tenant (migration/seed) - existing dev tenants: backfill membership for current admin user - Ensure no tenant becomes inaccessible after deployment: - platform superadmin can always recover --- # 12. Task Plan (high level) ## Phase 0 — Discovery - Identify existing “tenant scoping” and any implicit access assumptions. - Locate current auth flow and where to hook Entra identity upsert. ## Phase 1 — Schema - `tenant_memberships` - `tenant_role_mappings` (optional) - user identity fields (`entra_tenant_id`, `entra_object_id`) if not present ## Phase 2 — Capability resolver + gates - Implement `CapabilityResolver` - Register Gates: `provider.manage`, `provider.run`, etc. ## Phase 3 — UI - Tenant Members manager (CRUD) - Optional role mapping UI ## Phase 4 — Break-glass - platform_superadmin account + banner + audit ## Phase 5 — Tests - RBAC + audit + UI tests --- # 13. Acceptance Criteria (DoD) 1) Users authenticate via Entra; user identities are stored by stable `(tid, oid)`. 2) A user’s access is determined by tenant_memberships for the current Suite Tenant. 3) Owner/Manager can add/remove members and set roles via UI; changes are audited. 4) Operator/Readonly restrictions are enforced across provider management and operations. 5) Optional: Entra group/app-role mappings can auto-provision memberships (at least config + data model). 6) Break-glass platform superadmin exists and can recover access; actions are audited and bannered. 7) No feature code uses direct role checks; only capabilities/gates/policies. + +--- + +## Addendum — Bootstrap & Admin Responsibilities + +### Context +TenantAtlas operates across two distinct administrative planes: +- **Microsoft tenant administration (Entra/Intune)** governs what the platform is technically allowed to do in the target Microsoft tenant (consent, permissions, RBAC assignments). +- **TenantAtlas administration (Suite Tenant RBAC)** governs who is allowed to use TenantAtlas features for a given Suite Tenant (memberships, roles, operational actions). + +These roles may be held by the same person in small orgs, but must not be assumed to be the same in enterprise/MSP environments. + +### FR-013 Admin Planes Are Distinct (No Assumptions) +The system MUST NOT assume that: +- a Microsoft tenant admin is automatically a TenantAtlas admin, or +- a TenantAtlas admin is automatically a Microsoft tenant admin. + +TenantAtlas permissions are determined solely by `tenant_memberships` (and optional mappings), independent of Microsoft directory roles. + +**Acceptance:** +- A user can be a TenantAtlas Owner/Manager without holding any Microsoft admin role. +- A Microsoft admin can grant consent without being a TenantAtlas member (unless explicitly added). + +### FR-014 Bootstrap Owner Assignment (First-Admin Rule) +Each Suite Tenant MUST always have at least one Owner-capable administrator to prevent lockout. + +#### FR-014a Default Bootstrap Rule (v1) +On Suite Tenant creation, the creator is assigned as `owner` membership (source: `manual`, created_by = creator). + +#### FR-014b Platform Recovery Rule (Break-glass) +A platform superadmin MUST be able to assign/change the initial owner membership in case: +- the creator no longer exists, +- Entra login/mapping breaks, +- the tenant was imported. + +#### FR-014c Non-Removable Last Owner Guard +The system MUST prevent removing or demoting the last remaining `owner` membership for a Suite Tenant. + +**Acceptance:** +- Attempting to remove the last Owner fails with a clear message. +- Platform superadmin can recover by adding a new Owner first. + +### FR-015 TenantAtlas Admin Responsibilities (v1) +TenantAtlas admins (Owner/Manager) are responsible for: +- managing Suite Tenant members and roles (tenant_memberships), +- approving who can run operations and manage providers inside TenantAtlas, +- reviewing audit logs for membership/role changes. + +Microsoft tenant admins are responsible for: +- granting admin consent for the app, +- ensuring required Graph permissions are granted, +- maintaining any Microsoft-side RBAC/group prerequisites. + +**Acceptance:** +- The UI communicates this split (e.g., small help text near membership management and provider setup links). + +### FR-016 Audit Requirements for Bootstrap & Membership Management +All bootstrap and membership management actions MUST be audited with canonical action_ids: +- `tenant_membership.add` +- `tenant_membership.role_change` +- `tenant_membership.remove` +- `tenant_membership.bootstrap_assign` (initial owner assignment) +- `tenant_membership.bootstrap_recover` (platform superadmin recovery) + +Audit entries MUST include: +- actor id +- suite tenant id +- target user id/email (minimal) +- before/after role (redacted) +- timestamp + +No secrets, no tokens, no Microsoft credentials. + +### UI Requirements (Bootstrap-related) +- Tenant creation flow MUST result in an Owner membership being present (visible in Tenant → Members). +- Tenant → Members UI MUST show an “Owner” role and enforce “last owner cannot be removed/demoted”. +- Platform superadmin UI MUST support adding an Owner membership if none exists or recovery is required. diff --git a/specs/062-tenant-rbac-v1/tasks.md b/specs/062-tenant-rbac-v1/tasks.md new file mode 100644 index 0000000..2a14fcd --- /dev/null +++ b/specs/062-tenant-rbac-v1/tasks.md @@ -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). \ No newline at end of file diff --git a/tests/Feature/TenantRBAC/BreakGlassRecoveryTest.php b/tests/Feature/TenantRBAC/BreakGlassRecoveryTest.php new file mode 100644 index 0000000..8a10561 --- /dev/null +++ b/tests/Feature/TenantRBAC/BreakGlassRecoveryTest.php @@ -0,0 +1,39 @@ +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(); +}); diff --git a/tests/Feature/TenantRBAC/LastOwnerGuardTest.php b/tests/Feature/TenantRBAC/LastOwnerGuardTest.php new file mode 100644 index 0000000..4a8a639 --- /dev/null +++ b/tests/Feature/TenantRBAC/LastOwnerGuardTest.php @@ -0,0 +1,38 @@ +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.'); +}); diff --git a/tests/Feature/TenantRBAC/MembershipAuditLogTest.php b/tests/Feature/TenantRBAC/MembershipAuditLogTest.php new file mode 100644 index 0000000..bcae397 --- /dev/null +++ b/tests/Feature/TenantRBAC/MembershipAuditLogTest.php @@ -0,0 +1,53 @@ +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(); + } +}); diff --git a/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php b/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php new file mode 100644 index 0000000..5f4b3ae --- /dev/null +++ b/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php @@ -0,0 +1,43 @@ +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(); +}); diff --git a/tests/Feature/TenantRBAC/TenantMembershipCrudTest.php b/tests/Feature/TenantRBAC/TenantMembershipCrudTest.php new file mode 100644 index 0000000..24eb8ae --- /dev/null +++ b/tests/Feature/TenantRBAC/TenantMembershipCrudTest.php @@ -0,0 +1,36 @@ +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(), + ]); +}); diff --git a/tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php b/tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php new file mode 100644 index 0000000..741fe04 --- /dev/null +++ b/tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php @@ -0,0 +1,24 @@ +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(); +}); diff --git a/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php b/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php new file mode 100644 index 0000000..e273958 --- /dev/null +++ b/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php @@ -0,0 +1,46 @@ +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()); +}); diff --git a/tests/Unit/Auth/CapabilitiesRegistryTest.php b/tests/Unit/Auth/CapabilitiesRegistryTest.php new file mode 100644 index 0000000..9e7317f --- /dev/null +++ b/tests/Unit/Auth/CapabilitiesRegistryTest.php @@ -0,0 +1,15 @@ +toContain($capability); + } + } +}); diff --git a/tests/Unit/Auth/CapabilityResolverTest.php b/tests/Unit/Auth/CapabilityResolverTest.php new file mode 100644 index 0000000..b3bae81 --- /dev/null +++ b/tests/Unit/Auth/CapabilityResolverTest.php @@ -0,0 +1,36 @@ +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(); +});