From 6bb39198148666bb659278543c46d17fc47298b4 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 25 Jan 2026 01:55:06 +0100 Subject: [PATCH 1/2] feat: unify provider connection actions and notifications --- .../Resources/ProviderConnectionResource.php | 596 +++++++++++++++++ .../Pages/CreateProviderConnection.php | 74 +++ .../Pages/EditProviderConnection.php | 611 ++++++++++++++++++ .../Pages/ListProviderConnections.php | 19 + app/Jobs/ProviderComplianceSnapshotJob.php | 151 +++++ app/Jobs/ProviderConnectionHealthCheckJob.php | 148 +++++ app/Jobs/ProviderInventorySyncJob.php | 151 +++++ app/Jobs/RunInventorySyncJob.php | 49 -- app/Models/ProviderConnection.php | 51 ++ app/Models/ProviderCredential.php | 27 + app/Models/Tenant.php | 11 + app/Policies/ProviderConnectionPolicy.php | 74 +++ app/Providers/AppServiceProvider.php | 6 +- app/Providers/AuthServiceProvider.php | 46 ++ .../Providers/Contracts/HealthResult.php | 39 ++ .../Contracts/ProviderComplianceCollector.php | 13 + .../Contracts/ProviderDirectoryCollector.php | 13 + .../Contracts/ProviderHealthCheck.php | 10 + .../Contracts/ProviderInventoryCollector.php | 13 + .../Contracts/ProviderScriptExecutor.php | 14 + app/Services/Providers/CredentialManager.php | 77 +++ .../MicrosoftComplianceSnapshotService.php | 137 ++++ .../MicrosoftProviderHealthCheck.php | 110 ++++ .../MicrosoftProviderInventoryCollector.php | 67 ++ app/Services/Providers/ProviderGateway.php | 46 ++ .../Providers/ProviderOperationRegistry.php | 53 ++ .../Providers/ProviderOperationStartGate.php | 113 ++++ .../ProviderOperationStartResult.php | 29 + app/Support/Badges/BadgeCatalog.php | 2 + app/Support/Badges/BadgeDomain.php | 2 + .../Domains/ProviderConnectionHealthBadge.php | 23 + .../Domains/ProviderConnectionStatusBadge.php | 23 + app/Support/OperationCatalog.php | 4 + app/Support/OperationRunLinks.php | 8 + app/Support/OpsUx/OperationSummaryKeys.php | 3 + app/Support/OpsUx/RunFailureSanitizer.php | 31 +- app/Support/TenantRole.php | 24 + bootstrap/providers.php | 1 + config/graph.php | 2 +- config/graph_contracts.php | 5 + .../factories/ProviderConnectionFactory.php | 33 + .../factories/ProviderCredentialFactory.php | 27 + ...0001_create_provider_connections_table.php | 41 ++ ...0002_create_provider_credentials_table.php | 28 + docker-compose.yml | 6 +- .../checklists/requirements.md | 35 + .../contracts/graph-contracts.md | 38 ++ .../contracts/openapi.yaml | 328 ++++++++++ specs/061-provider-foundation/data-model.md | 92 +++ specs/061-provider-foundation/plan.md | 130 ++++ specs/061-provider-foundation/quickstart.md | 38 ++ specs/061-provider-foundation/research.md | 91 +++ specs/061-provider-foundation/spec.md | 155 +++++ specs/061-provider-foundation/tasks.md | 188 ++++++ .../ProviderConnectionsDbOnlyTest.php | 34 + .../Inventory/RunInventorySyncJobTest.php | 19 +- .../OpsUx/OperationCatalogCoverageTest.php | 6 +- .../CredentialLeakGuardTest.php | 71 ++ .../ProviderComplianceSnapshotJobTest.php | 117 ++++ .../ProviderConnectionAuthorizationTest.php | 95 +++ .../ProviderConnectionEnableDisableTest.php | 104 +++ .../ProviderConnectionHealthCheckJobTest.php | 218 +++++++ ...rConnectionHealthCheckStartSurfaceTest.php | 93 +++ .../ProviderCredentialSecurityTest.php | 36 ++ .../ProviderOperationConcurrencyTest.php | 158 +++++ .../ProviderRbacCapabilitiesTest.php | 18 + .../Badges/ProviderConnectionBadgesTest.php | 42 ++ tests/Unit/OpsUx/RunFailureSanitizerTest.php | 29 + .../Unit/Providers/CredentialManagerTest.php | 73 +++ tests/Unit/Providers/ProviderGatewayTest.php | 96 +++ .../ProviderOperationStartGateTest.php | 116 ++++ 71 files changed, 5368 insertions(+), 63 deletions(-) create mode 100644 app/Filament/Resources/ProviderConnectionResource.php create mode 100644 app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php create mode 100644 app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php create mode 100644 app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php create mode 100644 app/Jobs/ProviderComplianceSnapshotJob.php create mode 100644 app/Jobs/ProviderConnectionHealthCheckJob.php create mode 100644 app/Jobs/ProviderInventorySyncJob.php create mode 100644 app/Models/ProviderConnection.php create mode 100644 app/Models/ProviderCredential.php create mode 100644 app/Policies/ProviderConnectionPolicy.php create mode 100644 app/Providers/AuthServiceProvider.php create mode 100644 app/Services/Providers/Contracts/HealthResult.php create mode 100644 app/Services/Providers/Contracts/ProviderComplianceCollector.php create mode 100644 app/Services/Providers/Contracts/ProviderDirectoryCollector.php create mode 100644 app/Services/Providers/Contracts/ProviderHealthCheck.php create mode 100644 app/Services/Providers/Contracts/ProviderInventoryCollector.php create mode 100644 app/Services/Providers/Contracts/ProviderScriptExecutor.php create mode 100644 app/Services/Providers/CredentialManager.php create mode 100644 app/Services/Providers/MicrosoftComplianceSnapshotService.php create mode 100644 app/Services/Providers/MicrosoftProviderHealthCheck.php create mode 100644 app/Services/Providers/MicrosoftProviderInventoryCollector.php create mode 100644 app/Services/Providers/ProviderGateway.php create mode 100644 app/Services/Providers/ProviderOperationRegistry.php create mode 100644 app/Services/Providers/ProviderOperationStartGate.php create mode 100644 app/Services/Providers/ProviderOperationStartResult.php create mode 100644 app/Support/Badges/Domains/ProviderConnectionHealthBadge.php create mode 100644 app/Support/Badges/Domains/ProviderConnectionStatusBadge.php create mode 100644 database/factories/ProviderConnectionFactory.php create mode 100644 database/factories/ProviderCredentialFactory.php create mode 100644 database/migrations/2026_01_24_000001_create_provider_connections_table.php create mode 100644 database/migrations/2026_01_24_000002_create_provider_credentials_table.php create mode 100644 specs/061-provider-foundation/checklists/requirements.md create mode 100644 specs/061-provider-foundation/contracts/graph-contracts.md create mode 100644 specs/061-provider-foundation/contracts/openapi.yaml create mode 100644 specs/061-provider-foundation/data-model.md create mode 100644 specs/061-provider-foundation/plan.md create mode 100644 specs/061-provider-foundation/quickstart.md create mode 100644 specs/061-provider-foundation/research.md create mode 100644 specs/061-provider-foundation/spec.md create mode 100644 specs/061-provider-foundation/tasks.md create mode 100644 tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php create mode 100644 tests/Feature/ProviderConnections/CredentialLeakGuardTest.php create mode 100644 tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php create mode 100644 tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php create mode 100644 tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php create mode 100644 tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php create mode 100644 tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php create mode 100644 tests/Feature/ProviderConnections/ProviderCredentialSecurityTest.php create mode 100644 tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php create mode 100644 tests/Feature/ProviderConnections/ProviderRbacCapabilitiesTest.php create mode 100644 tests/Unit/Badges/ProviderConnectionBadgesTest.php create mode 100644 tests/Unit/OpsUx/RunFailureSanitizerTest.php create mode 100644 tests/Unit/Providers/CredentialManagerTest.php create mode 100644 tests/Unit/Providers/ProviderGatewayTest.php create mode 100644 tests/Unit/Providers/ProviderOperationStartGateTest.php diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php new file mode 100644 index 0000000..dce9cff --- /dev/null +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -0,0 +1,596 @@ +schema([ + TextInput::make('display_name') + ->label('Display name') + ->required() + ->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current())) + ->maxLength(255), + TextInput::make('entra_tenant_id') + ->label('Entra tenant ID') + ->required() + ->maxLength(255) + ->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current())) + ->rules(['uuid']), + Toggle::make('is_default') + ->label('Default connection') + ->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current())) + ->helperText('Exactly one default connection is required per tenant/provider.'), + TextInput::make('status') + ->label('Status') + ->disabled() + ->dehydrated(false), + TextInput::make('health_status') + ->label('Health') + ->disabled() + ->dehydrated(false), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->modifyQueryUsing(function (Builder $query): Builder { + $tenantId = Tenant::current()?->getKey(); + + return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); + }) + ->defaultSort('display_name') + ->columns([ + Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(), + Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(), + Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(), + Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(), + Tables\Columns\TextColumn::make('status') + ->label('Status') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus)) + ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)), + Tables\Columns\TextColumn::make('health_status') + ->label('Health') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth)) + ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth)) + ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)), + Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(), + ]) + ->filters([ + SelectFilter::make('status') + ->label('Status') + ->options([ + 'connected' => 'Connected', + 'needs_consent' => 'Needs consent', + 'error' => 'Error', + 'disabled' => 'Disabled', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if (! is_string($value) || $value === '') { + return $query; + } + + return $query->where('status', $value); + }), + SelectFilter::make('health_status') + ->label('Health') + ->options([ + 'ok' => 'OK', + 'degraded' => 'Degraded', + 'down' => 'Down', + 'unknown' => 'Unknown', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if (! is_string($value) || $value === '') { + return $query; + } + + return $query->where('health_status', $value); + }), + ]) + ->actions([ + Actions\ActionGroup::make([ + Actions\EditAction::make(), + + Actions\Action::make('check_connection') + ->label('Check connection') + ->icon('heroicon-o-check-badge') + ->color('success') + ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current()) + && $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); + + $user = auth()->user(); + abort_unless($user instanceof User, 403); + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'provider.connection.check', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderConnectionHealthCheckJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope busy') + ->body('Another provider operation is already running for this connection.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A connection check is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title('Connection check queued') + ->body('Health check was queued and will run in the background.') + ->success() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + }), + + Actions\Action::make('inventory_sync') + ->label('Inventory sync') + ->icon('heroicon-o-arrow-path') + ->color('info') + ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current()) + && $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); + + $user = auth()->user(); + abort_unless($user instanceof User, 403); + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'inventory.sync', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('An inventory sync is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title('Inventory sync queued') + ->body('Inventory sync was queued and will run in the background.') + ->success() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + }), + + Actions\Action::make('compliance_snapshot') + ->label('Compliance snapshot') + ->icon('heroicon-o-shield-check') + ->color('info') + ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current()) + && $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); + + $user = auth()->user(); + abort_unless($user instanceof User, 403); + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'compliance.snapshot', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderComplianceSnapshotJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A compliance snapshot is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title('Compliance snapshot queued') + ->body('Compliance snapshot was queued and will run in the background.') + ->success() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + }), + + Actions\Action::make('set_default') + ->label('Set as default') + ->icon('heroicon-o-star') + ->color('primary') + ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current()) + && $record->status !== 'disabled' + && ! $record->is_default) + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + + $record->makeDefault(); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.default_set', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Default connection updated') + ->success() + ->send(); + }), + + Actions\Action::make('update_credentials') + ->label('Update credentials') + ->icon('heroicon-o-key') + ->color('primary') + ->modalDescription('Client secret is stored encrypted and will never be shown again.') + ->visible(fn (): bool => Gate::allows('provider.manage', Tenant::current())) + ->form([ + TextInput::make('client_id') + ->label('Client ID') + ->required() + ->maxLength(255), + TextInput::make('client_secret') + ->label('Client secret') + ->password() + ->required() + ->maxLength(255), + ]) + ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + + $credentials->upsertClientSecretCredential( + connection: $record, + clientId: (string) $data['client_id'], + clientSecret: (string) $data['client_secret'], + ); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.credentials_updated', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Credentials updated') + ->success() + ->send(); + }), + + Actions\Action::make('enable_connection') + ->label('Enable connection') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current()) + && $record->status === 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + + $hadCredentials = $record->credential()->exists(); + $status = $hadCredentials ? 'connected' : 'needs_consent'; + $previousStatus = (string) $record->status; + + $record->update([ + 'status' => $status, + 'health_status' => 'unknown', + 'last_health_check_at' => null, + 'last_error_reason_code' => null, + 'last_error_message' => null, + ]); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.enabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + 'to_status' => $status, + 'credentials_present' => $hadCredentials, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + if (! $hadCredentials) { + Notification::make() + ->title('Connection enabled (credentials missing)') + ->body('Add credentials before running checks or operations.') + ->warning() + ->send(); + + return; + } + + Notification::make() + ->title('Provider connection enabled') + ->success() + ->send(); + }), + + Actions\Action::make('disable_connection') + ->label('Disable connection') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current()) + && $record->status !== 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + + $previousStatus = (string) $record->status; + + $record->update([ + 'status' => 'disabled', + ]); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.disabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Provider connection disabled') + ->warning() + ->send(); + }), + ]) + ->label('Actions') + ->icon('heroicon-o-ellipsis-vertical') + ->color('gray'), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()?->getKey(); + + return parent::getEloquentQuery() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + ->latest('id'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListProviderConnections::route('/'), + 'create' => Pages\CreateProviderConnection::route('/create'), + 'edit' => Pages\EditProviderConnection::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php new file mode 100644 index 0000000..8b33dc1 --- /dev/null +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php @@ -0,0 +1,74 @@ +shouldMakeDefault = (bool) ($data['is_default'] ?? false); + + return [ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $data['entra_tenant_id'], + 'display_name' => $data['display_name'], + 'is_default' => false, + ]; + } + + protected function afterCreate(): void + { + $tenant = Tenant::current(); + $record = $this->getRecord(); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'provider_connection.created', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + $hasDefault = $tenant->providerConnections() + ->where('provider', $record->provider) + ->where('is_default', true) + ->exists(); + + if ($this->shouldMakeDefault || ! $hasDefault) { + $record->makeDefault(); + } + + Notification::make() + ->title('Provider connection created') + ->success() + ->send(); + } +} diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php new file mode 100644 index 0000000..18340d9 --- /dev/null +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -0,0 +1,611 @@ +shouldMakeDefault = (bool) ($data['is_default'] ?? false); + unset($data['is_default']); + + return $data; + } + + protected function afterSave(): void + { + $tenant = Tenant::current(); + $record = $this->getRecord(); + + $changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at'])); + + if ($this->shouldMakeDefault && ! $record->is_default) { + $record->makeDefault(); + $this->defaultWasChanged = true; + } + + $hasDefault = $tenant->providerConnections() + ->where('provider', $record->provider) + ->where('is_default', true) + ->exists(); + + if (! $hasDefault) { + $record->makeDefault(); + $this->defaultWasChanged = true; + } + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + if ($changedFields !== []) { + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'provider_connection.updated', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'fields' => $changedFields, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + } + + if ($this->defaultWasChanged) { + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'provider_connection.default_set', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + } + } + + protected function getHeaderActions(): array + { + $tenant = Tenant::current(); + + return [ + Actions\DeleteAction::make() + ->visible(false), + + Actions\ActionGroup::make([ + Action::make('view_last_check_run') + ->label('View last check run') + ->icon('heroicon-o-eye') + ->color('gray') + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant + && Gate::allows('provider.view', $tenant) + && OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->where('context->provider_connection_id', (int) $record->getKey()) + ->exists()) + ->url(function (ProviderConnection $record): ?string { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + $run = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->where('context->provider_connection_id', (int) $record->getKey()) + ->orderByDesc('id') + ->first(); + + if (! $run instanceof OperationRun) { + return null; + } + + return OperationRunLinks::view($run, $tenant); + }), + + Action::make('check_connection') + ->label('Check connection') + ->icon('heroicon-o-check-badge') + ->color('success') + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant + && Gate::allows('provider.run', $tenant) + && $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); + + $user = auth()->user(); + abort_unless($user instanceof User, 403); + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'provider.connection.check', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderConnectionHealthCheckJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope busy') + ->body('Another provider operation is already running for this connection.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A connection check is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title('Connection check queued') + ->body('Health check was queued and will run in the background.') + ->success() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + }), + + Action::make('update_credentials') + ->label('Update credentials') + ->icon('heroicon-o-key') + ->color('primary') + ->modalDescription('Client secret is stored encrypted and will never be shown again.') + ->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows('provider.manage', $tenant)) + ->form([ + TextInput::make('client_id') + ->label('Client ID') + ->required() + ->maxLength(255), + TextInput::make('client_secret') + ->label('Client secret') + ->password() + ->required() + ->maxLength(255), + ]) + ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + + $credentials->upsertClientSecretCredential( + connection: $record, + clientId: (string) $data['client_id'], + clientSecret: (string) $data['client_secret'], + ); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.credentials_updated', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Credentials updated') + ->success() + ->send(); + }), + + Action::make('set_default') + ->label('Set as default') + ->icon('heroicon-o-star') + ->color('primary') + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant + && Gate::allows('provider.manage', $tenant) + && $record->status !== 'disabled' + && ! $record->is_default + && ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->where('provider', $record->provider) + ->count() > 1) + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + + $record->makeDefault(); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.default_set', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Default connection updated') + ->success() + ->send(); + }), + + Action::make('inventory_sync') + ->label('Inventory sync') + ->icon('heroicon-o-arrow-path') + ->color('info') + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant + && Gate::allows('provider.run', $tenant) + && $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); + + $user = auth()->user(); + abort_unless($user instanceof User, 403); + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'inventory.sync', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('An inventory sync is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title('Inventory sync queued') + ->body('Inventory sync was queued and will run in the background.') + ->success() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + }), + + Action::make('compliance_snapshot') + ->label('Compliance snapshot') + ->icon('heroicon-o-shield-check') + ->color('info') + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant + && Gate::allows('provider.run', $tenant) + && $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); + + $user = auth()->user(); + abort_unless($user instanceof User, 403); + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'compliance.snapshot', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderComplianceSnapshotJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A compliance snapshot is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title('Compliance snapshot queued') + ->body('Compliance snapshot was queued and will run in the background.') + ->success() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + }), + + Action::make('enable_connection') + ->label('Enable connection') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant + && Gate::allows('provider.manage', $tenant) + && $record->status === 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + + $hadCredentials = $record->credential()->exists(); + $status = $hadCredentials ? 'connected' : 'needs_consent'; + $previousStatus = (string) $record->status; + + $record->update([ + 'status' => $status, + 'health_status' => 'unknown', + 'last_health_check_at' => null, + 'last_error_reason_code' => null, + 'last_error_message' => null, + ]); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.enabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + 'to_status' => $status, + 'credentials_present' => $hadCredentials, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + if (! $hadCredentials) { + Notification::make() + ->title('Connection enabled (credentials missing)') + ->body('Add credentials before running checks or operations.') + ->warning() + ->send(); + + return; + } + + Notification::make() + ->title('Provider connection enabled') + ->success() + ->send(); + }), + + Action::make('disable_connection') + ->label('Disable connection') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant + && Gate::allows('provider.manage', $tenant) + && $record->status !== 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + + $previousStatus = (string) $record->status; + + $record->update([ + 'status' => 'disabled', + ]); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.disabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Provider connection disabled') + ->warning() + ->send(); + }), + ]) + ->label('Actions') + ->icon('heroicon-o-ellipsis-vertical') + ->color('gray'), + ]; + } + + protected function getFormActions(): array + { + $tenant = Tenant::current(); + + if ($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant)) { + return parent::getFormActions(); + } + + return [ + $this->getCancelFormAction(), + ]; + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + + return parent::handleRecordUpdate($record, $data); + } +} diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php new file mode 100644 index 0000000..9209fca --- /dev/null +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php @@ -0,0 +1,19 @@ +operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle( + MicrosoftComplianceSnapshotService $collector, + ProviderGateway $gateway, + OperationRunService $runs, + ): void { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new RuntimeException('User not found.'); + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->providerConnectionId); + + if (! $connection instanceof ProviderConnection) { + throw new RuntimeException('ProviderConnection not found.'); + } + + try { + $counts = $collector->snapshot($connection); + $entraTenantName = $this->resolveEntraTenantName($connection, $gateway); + + if ($entraTenantName !== null) { + $metadata = is_array($connection->metadata) ? $connection->metadata : []; + $metadata['entra_tenant_name'] = $entraTenantName; + $connection->update(['metadata' => $metadata]); + } + + if ($this->operationRun instanceof OperationRun) { + $this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName); + + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: $counts, + ); + } + } catch (Throwable $throwable) { + if (! $this->operationRun instanceof OperationRun) { + throw $throwable; + } + + $message = RunFailureSanitizer::sanitizeMessage($throwable->getMessage()); + $reasonCode = RunFailureSanitizer::normalizeReasonCode($throwable->getMessage()); + + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [[ + 'code' => 'compliance.snapshot.failed', + 'reason_code' => $reasonCode, + 'message' => $message !== '' ? $message : 'Compliance snapshot failed.', + ]], + ); + } + } + + private function resolveEntraTenantName(ProviderConnection $connection, ProviderGateway $gateway): ?string + { + $metadata = is_array($connection->metadata) ? $connection->metadata : []; + $existing = $metadata['entra_tenant_name'] ?? null; + + if (is_string($existing) && trim($existing) !== '') { + return trim($existing); + } + + try { + $response = $gateway->getOrganization($connection); + } catch (Throwable) { + return null; + } + + if (! $response->successful()) { + return null; + } + + $displayName = $response->data['displayName'] ?? null; + + return is_string($displayName) && trim($displayName) !== '' ? trim($displayName) : null; + } + + private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void + { + $context = is_array($run->context) ? $run->context : []; + $targetScope = $context['target_scope'] ?? []; + $targetScope = is_array($targetScope) ? $targetScope : []; + + $targetScope['entra_tenant_id'] = $connection->entra_tenant_id; + + if (is_string($entraTenantName) && $entraTenantName !== '') { + $targetScope['entra_tenant_name'] = $entraTenantName; + } + + $context['target_scope'] = $targetScope; + + $run->update(['context' => $context]); + } +} diff --git a/app/Jobs/ProviderConnectionHealthCheckJob.php b/app/Jobs/ProviderConnectionHealthCheckJob.php new file mode 100644 index 0000000..8afa684 --- /dev/null +++ b/app/Jobs/ProviderConnectionHealthCheckJob.php @@ -0,0 +1,148 @@ +operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle( + MicrosoftProviderHealthCheck $healthCheck, + OperationRunService $runs, + ): void { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new RuntimeException('User not found.'); + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->providerConnectionId); + + if (! $connection instanceof ProviderConnection) { + throw new RuntimeException('ProviderConnection not found.'); + } + + $result = $healthCheck->check($connection); + + $this->applyHealthResult($connection, $result); + + if (! $this->operationRun instanceof OperationRun) { + return; + } + + $entraTenantName = $this->resolveEntraTenantName($connection, $result); + + if ($entraTenantName !== null) { + $metadata = is_array($connection->metadata) ? $connection->metadata : []; + $metadata['entra_tenant_name'] = $entraTenantName; + $connection->update(['metadata' => $metadata]); + } + + $this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName); + + if ($result->healthy) { + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + ); + + return; + } + + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [[ + 'code' => 'provider.connection.check.failed', + 'reason_code' => $result->reasonCode ?? 'unknown_error', + 'message' => $result->message ?? 'Health check failed.', + ]], + ); + } + + private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string + { + $existing = Arr::get(is_array($connection->metadata) ? $connection->metadata : [], 'entra_tenant_name'); + + if (is_string($existing) && trim($existing) !== '') { + return trim($existing); + } + + $candidate = $result->meta['organization_display_name'] ?? null; + + return is_string($candidate) && trim($candidate) !== '' ? trim($candidate) : null; + } + + private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void + { + $context = is_array($run->context) ? $run->context : []; + $targetScope = $context['target_scope'] ?? []; + $targetScope = is_array($targetScope) ? $targetScope : []; + + $targetScope['entra_tenant_id'] = $connection->entra_tenant_id; + + if (is_string($entraTenantName) && $entraTenantName !== '') { + $targetScope['entra_tenant_name'] = $entraTenantName; + } + + $context['target_scope'] = $targetScope; + + $run->update(['context' => $context]); + } + + private function applyHealthResult(ProviderConnection $connection, HealthResult $result): void + { + $connection->update([ + 'status' => $result->status, + 'health_status' => $result->healthStatus, + 'last_health_check_at' => now(), + 'last_error_reason_code' => $result->healthy ? null : $result->reasonCode, + 'last_error_message' => $result->healthy ? null : $result->message, + ]); + } +} diff --git a/app/Jobs/ProviderInventorySyncJob.php b/app/Jobs/ProviderInventorySyncJob.php new file mode 100644 index 0000000..1aa9474 --- /dev/null +++ b/app/Jobs/ProviderInventorySyncJob.php @@ -0,0 +1,151 @@ +operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle( + MicrosoftProviderInventoryCollector $collector, + ProviderGateway $gateway, + OperationRunService $runs, + ): void { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new RuntimeException('User not found.'); + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->providerConnectionId); + + if (! $connection instanceof ProviderConnection) { + throw new RuntimeException('ProviderConnection not found.'); + } + + try { + $counts = $collector->collect($connection); + $entraTenantName = $this->resolveEntraTenantName($connection, $gateway); + + if ($entraTenantName !== null) { + $metadata = is_array($connection->metadata) ? $connection->metadata : []; + $metadata['entra_tenant_name'] = $entraTenantName; + $connection->update(['metadata' => $metadata]); + } + + if ($this->operationRun instanceof OperationRun) { + $this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName); + + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: $counts, + ); + } + } catch (Throwable $throwable) { + if (! $this->operationRun instanceof OperationRun) { + throw $throwable; + } + + $message = RunFailureSanitizer::sanitizeMessage($throwable->getMessage()); + $reasonCode = RunFailureSanitizer::normalizeReasonCode($throwable->getMessage()); + + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [[ + 'code' => 'inventory.sync.failed', + 'reason_code' => $reasonCode, + 'message' => $message !== '' ? $message : 'Inventory sync failed.', + ]], + ); + } + } + + private function resolveEntraTenantName(ProviderConnection $connection, ProviderGateway $gateway): ?string + { + $metadata = is_array($connection->metadata) ? $connection->metadata : []; + $existing = $metadata['entra_tenant_name'] ?? null; + + if (is_string($existing) && trim($existing) !== '') { + return trim($existing); + } + + try { + $response = $gateway->getOrganization($connection); + } catch (Throwable) { + return null; + } + + if (! $response->successful()) { + return null; + } + + $displayName = $response->data['displayName'] ?? null; + + return is_string($displayName) && trim($displayName) !== '' ? trim($displayName) : null; + } + + private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void + { + $context = is_array($run->context) ? $run->context : []; + $targetScope = $context['target_scope'] ?? []; + $targetScope = is_array($targetScope) ? $targetScope : []; + + $targetScope['entra_tenant_id'] = $connection->entra_tenant_id; + + if (is_string($entraTenantName) && $entraTenantName !== '') { + $targetScope['entra_tenant_name'] = $entraTenantName; + } + + $context['target_scope'] = $targetScope; + + $run->update(['context' => $context]); + } +} diff --git a/app/Jobs/RunInventorySyncJob.php b/app/Jobs/RunInventorySyncJob.php index 530fe57..2e8bcb7 100644 --- a/app/Jobs/RunInventorySyncJob.php +++ b/app/Jobs/RunInventorySyncJob.php @@ -10,10 +10,8 @@ use App\Services\Intune\AuditLogger; use App\Services\Inventory\InventorySyncService; use App\Services\OperationRunService; -use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; -use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -135,18 +133,6 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe resourceId: (string) $run->id, ); - Notification::make() - ->title('Inventory sync completed') - ->body('Inventory sync finished successfully.') - ->success() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - return; } @@ -190,18 +176,6 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe resourceId: (string) $run->id, ); - Notification::make() - ->title('Inventory sync completed with errors') - ->body('Inventory sync finished with some errors. Review the run details for error codes.') - ->warning() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - return; } @@ -243,18 +217,6 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe resourceId: (string) $run->id, ); - Notification::make() - ->title('Inventory sync skipped') - ->body('Inventory sync could not start due to locks or concurrency limits.') - ->warning() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - return; } @@ -297,16 +259,5 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe resourceId: (string) $run->id, ); - Notification::make() - ->title('Inventory sync failed') - ->body('Inventory sync finished with errors.') - ->danger() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); } } diff --git a/app/Models/ProviderConnection.php b/app/Models/ProviderConnection.php new file mode 100644 index 0000000..bc918a7 --- /dev/null +++ b/app/Models/ProviderConnection.php @@ -0,0 +1,51 @@ + 'boolean', + 'scopes_granted' => 'array', + 'metadata' => 'array', + 'last_health_check_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function credential(): HasOne + { + return $this->hasOne(ProviderCredential::class, 'provider_connection_id'); + } + + public function makeDefault(): void + { + DB::transaction(function (): void { + static::query() + ->where('tenant_id', $this->tenant_id) + ->where('provider', $this->provider) + ->where('is_default', true) + ->whereKeyNot($this->getKey()) + ->update(['is_default' => false]); + + static::query() + ->whereKey($this->getKey()) + ->update(['is_default' => true]); + }); + + $this->refresh(); + } +} diff --git a/app/Models/ProviderCredential.php b/app/Models/ProviderCredential.php new file mode 100644 index 0000000..ffc62fe --- /dev/null +++ b/app/Models/ProviderCredential.php @@ -0,0 +1,27 @@ + 'encrypted:array', + ]; + + public function providerConnection(): BelongsTo + { + return $this->belongsTo(ProviderConnection::class, 'provider_connection_id'); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index a973d2e..baf6186 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -215,6 +216,16 @@ public function permissions(): HasMany return $this->hasMany(TenantPermission::class); } + public function providerConnections(): HasMany + { + return $this->hasMany(ProviderConnection::class); + } + + public function providerCredentials(): HasManyThrough + { + return $this->hasManyThrough(ProviderCredential::class, ProviderConnection::class, 'tenant_id', 'provider_connection_id'); + } + public function graphTenantId(): ?string { return $this->tenant_id ?? $this->external_id; diff --git a/app/Policies/ProviderConnectionPolicy.php b/app/Policies/ProviderConnectionPolicy.php new file mode 100644 index 0000000..16a7da3 --- /dev/null +++ b/app/Policies/ProviderConnectionPolicy.php @@ -0,0 +1,74 @@ +allows('provider.view', $tenant); + } + + public function view(User $user, ProviderConnection $connection): Response|bool + { + $tenant = Tenant::current(); + + if (! Gate::forUser($user)->allows('provider.view', $tenant)) { + return false; + } + + if ((int) $connection->tenant_id !== (int) $tenant->getKey()) { + return Response::denyAsNotFound(); + } + + return true; + } + + public function create(User $user): bool + { + $tenant = Tenant::current(); + + return Gate::forUser($user)->allows('provider.manage', $tenant); + } + + public function update(User $user, ProviderConnection $connection): Response|bool + { + $tenant = Tenant::current(); + + if (! Gate::forUser($user)->allows('provider.view', $tenant)) { + return false; + } + + if ((int) $connection->tenant_id !== (int) $tenant->getKey()) { + return Response::denyAsNotFound(); + } + + return true; + } + + public function delete(User $user, ProviderConnection $connection): Response|bool + { + $tenant = Tenant::current(); + + if (! Gate::forUser($user)->allows('provider.manage', $tenant)) { + return false; + } + + if ((int) $connection->tenant_id !== (int) $tenant->getKey()) { + return Response::denyAsNotFound(); + } + + return false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d663e0d..8fd6785 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -49,11 +49,7 @@ public function register(): void $this->app->singleton(GraphClientInterface::class, function ($app) { $config = $app['config']->get('graph'); - $hasCredentials = ! empty($config['client_id']) - && ! empty($config['client_secret']) - && ! empty($config['tenant_id']); - - if (! empty($config['enabled']) && $hasCredentials) { + if (! empty($config['enabled'])) { return $app->make(MicrosoftGraphClient::class); } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php new file mode 100644 index 0000000..9396bf7 --- /dev/null +++ b/app/Providers/AuthServiceProvider.php @@ -0,0 +1,46 @@ + ProviderConnectionPolicy::class, + ]; + + public function boot(): void + { + $this->registerPolicies(); + + Gate::define('provider.view', function (User $user, Tenant $tenant): bool { + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return $user->tenantRole($tenant)?->canViewProviders() ?? false; + }); + + 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; + }); + } +} diff --git a/app/Services/Providers/Contracts/HealthResult.php b/app/Services/Providers/Contracts/HealthResult.php new file mode 100644 index 0000000..af50070 --- /dev/null +++ b/app/Services/Providers/Contracts/HealthResult.php @@ -0,0 +1,39 @@ + $meta + */ + public function __construct( + public readonly bool $healthy, + public readonly string $status, + public readonly string $healthStatus, + public readonly ?string $reasonCode = null, + public readonly ?string $message = null, + public readonly array $meta = [], + ) {} + + /** + * @param array $meta + */ + public static function ok(string $status = 'connected', string $healthStatus = 'ok', array $meta = []): self + { + return new self(true, $status, $healthStatus, null, null, $meta); + } + + /** + * @param array $meta + */ + public static function failed( + string $reasonCode, + string $message, + string $status = 'error', + string $healthStatus = 'down', + array $meta = [], + ): self { + return new self(false, $status, $healthStatus, $reasonCode, $message, $meta); + } +} diff --git a/app/Services/Providers/Contracts/ProviderComplianceCollector.php b/app/Services/Providers/Contracts/ProviderComplianceCollector.php new file mode 100644 index 0000000..411b2b5 --- /dev/null +++ b/app/Services/Providers/Contracts/ProviderComplianceCollector.php @@ -0,0 +1,13 @@ + + */ + public function snapshot(ProviderConnection $connection): array; +} diff --git a/app/Services/Providers/Contracts/ProviderDirectoryCollector.php b/app/Services/Providers/Contracts/ProviderDirectoryCollector.php new file mode 100644 index 0000000..0d4aa03 --- /dev/null +++ b/app/Services/Providers/Contracts/ProviderDirectoryCollector.php @@ -0,0 +1,13 @@ + + */ + public function collect(ProviderConnection $connection): array; +} diff --git a/app/Services/Providers/Contracts/ProviderHealthCheck.php b/app/Services/Providers/Contracts/ProviderHealthCheck.php new file mode 100644 index 0000000..cc95a1d --- /dev/null +++ b/app/Services/Providers/Contracts/ProviderHealthCheck.php @@ -0,0 +1,10 @@ + + */ + public function collect(ProviderConnection $connection): array; +} diff --git a/app/Services/Providers/Contracts/ProviderScriptExecutor.php b/app/Services/Providers/Contracts/ProviderScriptExecutor.php new file mode 100644 index 0000000..02d89d2 --- /dev/null +++ b/app/Services/Providers/Contracts/ProviderScriptExecutor.php @@ -0,0 +1,14 @@ + $script + * @return array + */ + public function execute(ProviderConnection $connection, array $script): array; +} diff --git a/app/Services/Providers/CredentialManager.php b/app/Services/Providers/CredentialManager.php new file mode 100644 index 0000000..a89c50f --- /dev/null +++ b/app/Services/Providers/CredentialManager.php @@ -0,0 +1,77 @@ +credential; + + if (! $credential instanceof ProviderCredential) { + throw new RuntimeException('Provider credentials are missing.'); + } + + if ($credential->type !== 'client_secret') { + throw new RuntimeException('Unsupported provider credential type.'); + } + + $payload = $credential->payload; + + if (! is_array($payload)) { + throw new RuntimeException('Provider credential payload is invalid.'); + } + + $clientId = trim((string) ($payload['client_id'] ?? '')); + $clientSecret = trim((string) ($payload['client_secret'] ?? '')); + + if ($clientId === '' || $clientSecret === '') { + throw new RuntimeException('Provider credential payload is missing required keys.'); + } + + $tenantId = $payload['tenant_id'] ?? null; + + if (is_string($tenantId) && $tenantId !== '' && $tenantId !== $connection->entra_tenant_id) { + throw new InvalidArgumentException('Provider credential tenant_id does not match the connection entra_tenant_id.'); + } + + return [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ]; + } + + public function upsertClientSecretCredential( + ProviderConnection $connection, + string $clientId, + string $clientSecret, + ): ProviderCredential { + $clientId = trim($clientId); + $clientSecret = trim($clientSecret); + + if ($clientId === '' || $clientSecret === '') { + throw new InvalidArgumentException('client_id and client_secret are required.'); + } + + return ProviderCredential::query()->updateOrCreate( + [ + 'provider_connection_id' => $connection->getKey(), + ], + [ + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ], + ], + ); + } +} diff --git a/app/Services/Providers/MicrosoftComplianceSnapshotService.php b/app/Services/Providers/MicrosoftComplianceSnapshotService.php new file mode 100644 index 0000000..496e9b6 --- /dev/null +++ b/app/Services/Providers/MicrosoftComplianceSnapshotService.php @@ -0,0 +1,137 @@ +contracts->resourcePath('managedDevices'); + + if (! is_string($resource) || $resource === '') { + throw new RuntimeException('Graph contract missing for managed devices.'); + } + + $queryInput = [ + '$top' => 999, + '$select' => 'id,complianceState', + ]; + + $sanitized = $this->contracts->sanitizeQuery('managedDevices', $queryInput); + $query = $sanitized['query']; + + $counts = [ + 'total' => 0, + 'compliant' => 0, + 'noncompliant' => 0, + 'unknown' => 0, + ]; + + $path = $resource; + $pages = 0; + + while (true) { + $pages++; + + if ($pages > self::MAX_PAGES) { + throw new RuntimeException('Graph pagination exceeded maximum page limit.'); + } + + $options = $query === [] ? [] : ['query' => $query]; + + $response = $this->gateway->request( + connection: $connection, + method: 'GET', + path: $path, + options: $options, + ); + + $payload = $this->requireSuccess($response); + + $items = $payload['value'] ?? []; + if (! is_array($items)) { + $items = []; + } + + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + + $counts['total']++; + + $state = strtolower((string) ($item['complianceState'] ?? '')); + + if ($state === 'compliant') { + $counts['compliant']++; + } elseif ($state === 'noncompliant') { + $counts['noncompliant']++; + } else { + $counts['unknown']++; + } + } + + $nextLink = $payload['@odata.nextLink'] ?? null; + + if (! is_string($nextLink) || $nextLink === '') { + break; + } + + $path = $nextLink; + $query = []; + } + + return $counts; + } + + /** + * @return array + */ + private function requireSuccess(GraphResponse $response): array + { + if ($response->successful()) { + $data = $response->data; + + return is_array($data) ? $data : []; + } + + $message = $this->messageForResponse($response); + $status = (int) ($response->status ?? 0); + + throw new RuntimeException("Graph request failed (status {$status}): {$message}"); + } + + private function messageForResponse(GraphResponse $response): string + { + $error = $response->errors[0] ?? null; + + if (is_string($error)) { + return $error; + } + + if (is_array($error)) { + $message = $error['message'] ?? null; + + if (is_string($message) && $message !== '') { + return $message; + } + + return json_encode($error, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: 'Request failed.'; + } + + return 'Request failed.'; + } +} diff --git a/app/Services/Providers/MicrosoftProviderHealthCheck.php b/app/Services/Providers/MicrosoftProviderHealthCheck.php new file mode 100644 index 0000000..9c482b0 --- /dev/null +++ b/app/Services/Providers/MicrosoftProviderHealthCheck.php @@ -0,0 +1,110 @@ +gateway->getOrganization($connection); + } catch (Throwable $throwable) { + $message = RunFailureSanitizer::sanitizeMessage($throwable->getMessage()); + $reasonCode = RunFailureSanitizer::normalizeReasonCode($throwable->getMessage()); + + return HealthResult::failed( + reasonCode: $reasonCode, + message: $message !== '' ? $message : 'Health check failed.', + status: $this->statusForReason($reasonCode), + healthStatus: $this->healthForReason($reasonCode), + ); + } + + if ($response->successful()) { + return HealthResult::ok( + status: 'connected', + healthStatus: 'ok', + meta: [ + 'organization_id' => $response->data['id'] ?? null, + 'organization_display_name' => $response->data['displayName'] ?? null, + ], + ); + } + + $reasonCode = $this->reasonCodeForResponse($response); + $message = RunFailureSanitizer::sanitizeMessage($this->messageForResponse($response)); + + return HealthResult::failed( + reasonCode: $reasonCode, + message: $message !== '' ? $message : 'Health check failed.', + status: $this->statusForReason($reasonCode), + healthStatus: $this->healthForReason($reasonCode), + meta: [ + 'http_status' => $response->status, + ], + ); + } + + private function reasonCodeForResponse(GraphResponse $response): string + { + return match ((int) ($response->status ?? 0)) { + 401 => RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED, + 403 => RunFailureSanitizer::REASON_PERMISSION_DENIED, + 429 => RunFailureSanitizer::REASON_GRAPH_THROTTLED, + 500, 502 => RunFailureSanitizer::REASON_PROVIDER_OUTAGE, + 503, 504 => RunFailureSanitizer::REASON_GRAPH_TIMEOUT, + default => RunFailureSanitizer::REASON_UNKNOWN_ERROR, + }; + } + + private function messageForResponse(GraphResponse $response): string + { + $error = $response->errors[0] ?? null; + + if (is_string($error)) { + return $error; + } + + if (is_array($error)) { + $message = $error['message'] ?? null; + + if (is_string($message) && $message !== '') { + return $message; + } + + return json_encode($error, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: 'Health check failed.'; + } + + return 'Health check failed.'; + } + + private function statusForReason(string $reasonCode): string + { + return match ($reasonCode) { + RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED, + RunFailureSanitizer::REASON_PERMISSION_DENIED => 'needs_consent', + default => 'error', + }; + } + + private function healthForReason(string $reasonCode): string + { + return match ($reasonCode) { + RunFailureSanitizer::REASON_GRAPH_THROTTLED => 'degraded', + RunFailureSanitizer::REASON_GRAPH_TIMEOUT, + RunFailureSanitizer::REASON_PROVIDER_OUTAGE => 'down', + RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED, + RunFailureSanitizer::REASON_PERMISSION_DENIED => 'down', + default => 'down', + }; + } +} diff --git a/app/Services/Providers/MicrosoftProviderInventoryCollector.php b/app/Services/Providers/MicrosoftProviderInventoryCollector.php new file mode 100644 index 0000000..3a1c32b --- /dev/null +++ b/app/Services/Providers/MicrosoftProviderInventoryCollector.php @@ -0,0 +1,67 @@ + + */ + private array $policyTypes = [ + 'deviceConfiguration', + 'settingsCatalogPolicy', + 'groupPolicyConfiguration', + ]; + + public function __construct(private readonly ProviderGateway $gateway) {} + + public function collect(ProviderConnection $connection): array + { + $total = 0; + + foreach ($this->policyTypes as $policyType) { + $response = $this->gateway->listPolicies($connection, $policyType); + + if ($response->failed()) { + $message = $this->messageForResponse($response); + $status = (int) ($response->status ?? 0); + + throw new RuntimeException("Graph request failed for {$policyType} (status {$status}): {$message}"); + } + + $items = is_array($response->data) ? $response->data : []; + $total += count($items); + } + + return [ + 'total' => $total, + 'items' => $total, + ]; + } + + private function messageForResponse(GraphResponse $response): string + { + $error = $response->errors[0] ?? null; + + if (is_string($error)) { + return $error; + } + + if (is_array($error)) { + $message = $error['message'] ?? null; + + if (is_string($message) && $message !== '') { + return $message; + } + + return json_encode($error, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: 'Request failed.'; + } + + return 'Request failed.'; + } +} diff --git a/app/Services/Providers/ProviderGateway.php b/app/Services/Providers/ProviderGateway.php new file mode 100644 index 0000000..f099173 --- /dev/null +++ b/app/Services/Providers/ProviderGateway.php @@ -0,0 +1,46 @@ +graph->getOrganization($this->graphOptions($connection)); + } + + public function listPolicies(ProviderConnection $connection, string $policyType, array $options = []): GraphResponse + { + return $this->graph->listPolicies($policyType, $this->graphOptions($connection, $options)); + } + + public function request(ProviderConnection $connection, string $method, string $path, array $options = []): GraphResponse + { + return $this->graph->request($method, $path, $this->graphOptions($connection, $options)); + } + + /** + * @return array + */ + public function graphOptions(ProviderConnection $connection, array $overrides = []): array + { + $clientCredentials = $this->credentials->getClientCredentials($connection); + + return array_merge([ + 'tenant' => $connection->entra_tenant_id, + 'client_id' => $clientCredentials['client_id'], + 'client_secret' => $clientCredentials['client_secret'], + 'client_request_id' => (string) Str::uuid(), + ], $overrides); + } +} diff --git a/app/Services/Providers/ProviderOperationRegistry.php b/app/Services/Providers/ProviderOperationRegistry.php new file mode 100644 index 0000000..f75070e --- /dev/null +++ b/app/Services/Providers/ProviderOperationRegistry.php @@ -0,0 +1,53 @@ + + */ + public function all(): array + { + return [ + 'provider.connection.check' => [ + 'provider' => 'microsoft', + 'module' => 'health_check', + 'label' => 'Provider connection check', + ], + 'inventory.sync' => [ + 'provider' => 'microsoft', + 'module' => 'inventory', + 'label' => 'Inventory sync', + ], + 'compliance.snapshot' => [ + 'provider' => 'microsoft', + 'module' => 'compliance', + 'label' => 'Compliance snapshot', + ], + ]; + } + + public function isAllowed(string $operationType): bool + { + return array_key_exists($operationType, $this->all()); + } + + /** + * @return array{provider: string, module: string, label: string} + */ + public function get(string $operationType): array + { + $operationType = trim($operationType); + + $definition = $this->all()[$operationType] ?? null; + + if (! is_array($definition)) { + throw new InvalidArgumentException("Unknown provider operation type: {$operationType}"); + } + + return $definition; + } +} diff --git a/app/Services/Providers/ProviderOperationStartGate.php b/app/Services/Providers/ProviderOperationStartGate.php new file mode 100644 index 0000000..24ebca2 --- /dev/null +++ b/app/Services/Providers/ProviderOperationStartGate.php @@ -0,0 +1,113 @@ + $extraContext + */ + public function start( + Tenant $tenant, + ProviderConnection $connection, + string $operationType, + callable $dispatcher, + ?User $initiator = null, + array $extraContext = [], + ): ProviderOperationStartResult { + if ((int) $connection->tenant_id !== (int) $tenant->getKey()) { + throw new InvalidArgumentException('ProviderConnection does not belong to the given tenant.'); + } + + $definition = $this->registry->get($operationType); + + return DB::transaction(function () use ($tenant, $connection, $operationType, $dispatcher, $initiator, $extraContext, $definition): ProviderOperationStartResult { + $lockedConnection = ProviderConnection::query() + ->whereKey($connection->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + $activeRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->active() + ->where('context->provider_connection_id', (int) $lockedConnection->getKey()) + ->orderByDesc('id') + ->first(); + + if ($activeRun instanceof OperationRun) { + if ($activeRun->type === $operationType) { + return ProviderOperationStartResult::deduped($activeRun); + } + + return ProviderOperationStartResult::scopeBusy($activeRun); + } + + $context = array_merge($extraContext, [ + 'provider' => $lockedConnection->provider, + 'module' => $definition['module'], + 'provider_connection_id' => (int) $lockedConnection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $lockedConnection->entra_tenant_id, + ], + ]); + + $run = $this->runs->ensureRunWithIdentity( + tenant: $tenant, + type: $operationType, + identityInputs: [ + 'provider_connection_id' => (int) $lockedConnection->getKey(), + ], + context: $context, + initiator: $initiator, + ); + + $dispatched = false; + + if ($run->wasRecentlyCreated) { + $this->invokeDispatcher($dispatcher, $run); + $dispatched = true; + } + + return ProviderOperationStartResult::started($run, $dispatched); + }); + } + + private function invokeDispatcher(callable $dispatcher, OperationRun $run): void + { + $ref = null; + + if (is_array($dispatcher) && count($dispatcher) === 2) { + $ref = new ReflectionMethod($dispatcher[0], (string) $dispatcher[1]); + } elseif (is_string($dispatcher) && str_contains($dispatcher, '::')) { + [$class, $method] = explode('::', $dispatcher, 2); + $ref = new ReflectionMethod($class, $method); + } elseif ($dispatcher instanceof \Closure) { + $ref = new ReflectionFunction($dispatcher); + } elseif (is_object($dispatcher) && method_exists($dispatcher, '__invoke')) { + $ref = new ReflectionMethod($dispatcher, '__invoke'); + } + + if ($ref && $ref->getNumberOfParameters() >= 1) { + $dispatcher($run); + + return; + } + + $dispatcher(); + } +} diff --git a/app/Services/Providers/ProviderOperationStartResult.php b/app/Services/Providers/ProviderOperationStartResult.php new file mode 100644 index 0000000..ddcf79e --- /dev/null +++ b/app/Services/Providers/ProviderOperationStartResult.php @@ -0,0 +1,29 @@ +value => Domains\IgnoredAtBadge::class, BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class, BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, + BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class, + BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class, ]; /** diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php index 43162eb..8e0710a 100644 --- a/app/Support/Badges/BadgeDomain.php +++ b/app/Support/Badges/BadgeDomain.php @@ -26,4 +26,6 @@ enum BadgeDomain: string case IgnoredAt = 'ignored_at'; case RestorePreviewDecision = 'restore_preview_decision'; case RestoreResultStatus = 'restore_result_status'; + case ProviderConnectionStatus = 'provider_connection.status'; + case ProviderConnectionHealth = 'provider_connection.health'; } diff --git a/app/Support/Badges/Domains/ProviderConnectionHealthBadge.php b/app/Support/Badges/Domains/ProviderConnectionHealthBadge.php new file mode 100644 index 0000000..9cc8a24 --- /dev/null +++ b/app/Support/Badges/Domains/ProviderConnectionHealthBadge.php @@ -0,0 +1,23 @@ + new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'), + 'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'), + 'down' => new BadgeSpec('Down', 'danger', 'heroicon-m-x-circle'), + 'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Badges/Domains/ProviderConnectionStatusBadge.php b/app/Support/Badges/Domains/ProviderConnectionStatusBadge.php new file mode 100644 index 0000000..83c696b --- /dev/null +++ b/app/Support/Badges/Domains/ProviderConnectionStatusBadge.php @@ -0,0 +1,23 @@ + new BadgeSpec('Connected', 'success', 'heroicon-m-check-circle'), + 'needs_consent' => new BadgeSpec('Needs consent', 'warning', 'heroicon-m-exclamation-triangle'), + 'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'), + 'disabled' => new BadgeSpec('Disabled', 'gray', 'heroicon-m-minus-circle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php index b2baffa..ce8f38e 100644 --- a/app/Support/OperationCatalog.php +++ b/app/Support/OperationCatalog.php @@ -18,7 +18,9 @@ public static function labels(): array 'policy.delete' => 'Delete policies', 'policy.unignore' => 'Restore policies', 'policy.export' => 'Export policies to backup', + 'provider.connection.check' => 'Provider connection check', 'inventory.sync' => 'Inventory sync', + 'compliance.snapshot' => 'Compliance snapshot', 'directory_groups.sync' => 'Directory groups sync', 'drift.generate' => 'Drift generation', 'backup_set.add_policies' => 'Backup set update', @@ -54,8 +56,10 @@ public static function expectedDurationSeconds(string $operationType): ?int { return match (trim($operationType)) { 'policy.sync', 'policy.sync_one' => 90, + 'provider.connection.check' => 30, 'policy.export' => 120, 'inventory.sync' => 180, + 'compliance.snapshot' => 180, 'directory_groups.sync' => 120, 'drift.generate' => 240, default => null, diff --git a/app/Support/OperationRunLinks.php b/app/Support/OperationRunLinks.php index 622642a..19222ca 100644 --- a/app/Support/OperationRunLinks.php +++ b/app/Support/OperationRunLinks.php @@ -9,6 +9,7 @@ use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\OperationRunResource; use App\Filament\Resources\PolicyResource; +use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\RestoreRunResource; use App\Models\OperationRun; use App\Models\Tenant; @@ -36,6 +37,13 @@ public static function related(OperationRun $run, Tenant $tenant): array $links['Operations'] = self::index($tenant); + $providerConnectionId = $context['provider_connection_id'] ?? null; + + if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) { + $links['Provider Connections'] = ProviderConnectionResource::getUrl('index', tenant: $tenant); + $links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['record' => (int) $providerConnectionId], tenant: $tenant); + } + if ($run->type === 'inventory.sync') { $links['Inventory'] = InventoryLanding::getUrl(tenant: $tenant); } diff --git a/app/Support/OpsUx/OperationSummaryKeys.php b/app/Support/OpsUx/OperationSummaryKeys.php index c2f925d..0892dd6 100644 --- a/app/Support/OpsUx/OperationSummaryKeys.php +++ b/app/Support/OpsUx/OperationSummaryKeys.php @@ -16,6 +16,9 @@ public static function all(): array 'succeeded', 'failed', 'skipped', + 'compliant', + 'noncompliant', + 'unknown', 'created', 'updated', 'deleted', diff --git a/app/Support/OpsUx/RunFailureSanitizer.php b/app/Support/OpsUx/RunFailureSanitizer.php index cba491e..1bba444 100644 --- a/app/Support/OpsUx/RunFailureSanitizer.php +++ b/app/Support/OpsUx/RunFailureSanitizer.php @@ -10,6 +10,10 @@ final class RunFailureSanitizer public const string REASON_PERMISSION_DENIED = 'permission_denied'; + public const string REASON_PROVIDER_AUTH_FAILED = 'provider_auth_failed'; + + public const string REASON_PROVIDER_OUTAGE = 'provider_outage'; + public const string REASON_VALIDATION_ERROR = 'validation_error'; public const string REASON_CONFLICT_DETECTED = 'conflict_detected'; @@ -39,6 +43,8 @@ public static function normalizeReasonCode(string $candidate): string self::REASON_GRAPH_THROTTLED, self::REASON_GRAPH_TIMEOUT, self::REASON_PERMISSION_DENIED, + self::REASON_PROVIDER_AUTH_FAILED, + self::REASON_PROVIDER_OUTAGE, self::REASON_VALIDATION_ERROR, self::REASON_CONFLICT_DETECTED, self::REASON_UNKNOWN_ERROR, @@ -65,10 +71,18 @@ public static function normalizeReasonCode(string $candidate): string return self::REASON_GRAPH_THROTTLED; } + if (str_contains($candidate, 'invalid_client') || str_contains($candidate, 'invalid_grant') || str_contains($candidate, '401') || str_contains($candidate, 'aadsts')) { + return self::REASON_PROVIDER_AUTH_FAILED; + } + if (str_contains($candidate, 'timeout') || str_contains($candidate, 'transient') || str_contains($candidate, '503') || str_contains($candidate, '504')) { return self::REASON_GRAPH_TIMEOUT; } + if (str_contains($candidate, 'outage') || str_contains($candidate, '500') || str_contains($candidate, '502') || str_contains($candidate, 'bad_gateway')) { + return self::REASON_PROVIDER_OUTAGE; + } + if (str_contains($candidate, 'forbidden') || str_contains($candidate, 'permission') || str_contains($candidate, 'unauthorized') || str_contains($candidate, '403')) { return self::REASON_PERMISSION_DENIED; } @@ -91,13 +105,24 @@ public static function sanitizeMessage(string $message): string // Redact obvious PII (emails). $message = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $message) ?? $message; - // Redact obvious bearer tokens / secrets. - $message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message; - $message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message; + // Redact obvious auth headers. + $message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message; + $message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message; + + // Redact common secret-like key/value patterns. + $message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message; + $message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message; // Redact long opaque blobs that look token-like. $message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message; + // Ensure forbidden substrings never leak into stored messages. + $message = str_ireplace( + ['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '], + '[REDACTED]', + $message, + ); + return substr($message, 0, 120); } } diff --git a/app/Support/TenantRole.php b/app/Support/TenantRole.php index 38c8a00..16e0db6 100644 --- a/app/Support/TenantRole.php +++ b/app/Support/TenantRole.php @@ -37,4 +37,28 @@ public function canRunBackupSchedules(): bool self::Readonly => false, }; } + + public function canViewProviders(): bool + { + return true; + } + + public function canManageProviders(): bool + { + return match ($this) { + self::Owner, + self::Manager => true, + default => false, + }; + } + + public function canRunProviderOperations(): bool + { + return match ($this) { + self::Owner, + self::Manager, + self::Operator => true, + self::Readonly => false, + }; + } } diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 22744d1..0842610 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,5 +2,6 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\AuthServiceProvider::class, App\Providers\Filament\AdminPanelProvider::class, ]; diff --git a/config/graph.php b/config/graph.php index 9028b5b..ddb09ba 100644 --- a/config/graph.php +++ b/config/graph.php @@ -1,7 +1,7 @@ (bool) (env('GRAPH_CLIENT_ID') && env('GRAPH_CLIENT_SECRET') && env('GRAPH_TENANT_ID')), + 'enabled' => (bool) env('GRAPH_ENABLED', (bool) (env('GRAPH_CLIENT_ID') && env('GRAPH_CLIENT_SECRET') && env('GRAPH_TENANT_ID'))), 'tenant_id' => env('GRAPH_TENANT_ID', ''), 'client_id' => env('GRAPH_CLIENT_ID', ''), diff --git a/config/graph_contracts.php b/config/graph_contracts.php index d140419..66932ff 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -17,6 +17,11 @@ 'allowed_select' => ['id', 'displayName', 'groupTypes', 'securityEnabled', 'mailEnabled'], 'allowed_expand' => [], ], + 'managedDevices' => [ + 'resource' => 'deviceManagement/managedDevices', + 'allowed_select' => ['id', 'complianceState'], + 'allowed_expand' => [], + ], 'deviceConfiguration' => [ 'resource' => 'deviceManagement/deviceConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], diff --git a/database/factories/ProviderConnectionFactory.php b/database/factories/ProviderConnectionFactory.php new file mode 100644 index 0000000..55f4a61 --- /dev/null +++ b/database/factories/ProviderConnectionFactory.php @@ -0,0 +1,33 @@ + + */ +class ProviderConnectionFactory extends Factory +{ + protected $model = ProviderConnection::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'display_name' => fake()->company(), + 'is_default' => false, + 'status' => 'needs_consent', + 'health_status' => 'unknown', + 'scopes_granted' => [], + 'last_health_check_at' => null, + 'last_error_reason_code' => null, + 'last_error_message' => null, + 'metadata' => [], + ]; + } +} diff --git a/database/factories/ProviderCredentialFactory.php b/database/factories/ProviderCredentialFactory.php new file mode 100644 index 0000000..c0d1708 --- /dev/null +++ b/database/factories/ProviderCredentialFactory.php @@ -0,0 +1,27 @@ + + */ +class ProviderCredentialFactory extends Factory +{ + protected $model = ProviderCredential::class; + + public function definition(): array + { + return [ + 'provider_connection_id' => ProviderConnection::factory(), + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => fake()->uuid(), + 'client_secret' => fake()->sha1(), + ], + ]; + } +} diff --git a/database/migrations/2026_01_24_000001_create_provider_connections_table.php b/database/migrations/2026_01_24_000001_create_provider_connections_table.php new file mode 100644 index 0000000..bd71530 --- /dev/null +++ b/database/migrations/2026_01_24_000001_create_provider_connections_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('entra_tenant_id'); + $table->string('display_name'); + $table->boolean('is_default')->default(false); + $table->string('status')->default('needs_consent'); + $table->string('health_status')->default('unknown'); + $table->jsonb('scopes_granted')->default('{}'); + $table->timestamp('last_health_check_at')->nullable(); + $table->string('last_error_reason_code')->nullable(); + $table->string('last_error_message')->nullable(); + $table->jsonb('metadata')->default('{}'); + $table->timestamps(); + + $table->unique(['tenant_id', 'provider', 'entra_tenant_id']); + + $table->index(['tenant_id', 'provider', 'status']); + $table->index(['tenant_id', 'provider', 'health_status']); + }); + + DB::statement('CREATE UNIQUE INDEX provider_connections_default_unique ON provider_connections (tenant_id, provider) WHERE is_default = true'); + } + + public function down(): void + { + Schema::dropIfExists('provider_connections'); + } +}; diff --git a/database/migrations/2026_01_24_000002_create_provider_credentials_table.php b/database/migrations/2026_01_24_000002_create_provider_credentials_table.php new file mode 100644 index 0000000..02bfab1 --- /dev/null +++ b/database/migrations/2026_01_24_000002_create_provider_credentials_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('provider_connection_id') + ->constrained('provider_connections') + ->cascadeOnDelete(); + $table->string('type')->default('client_secret'); + $table->text('payload'); + $table->timestamps(); + + $table->unique('provider_connection_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('provider_credentials'); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 18e7141..fe0ab93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,7 +42,11 @@ services: WWWGROUP: '${WWWGROUP:-1000}' LARAVEL_SAIL: 1 APP_SERVICE: queue - entrypoint: ["/bin/sh", "-c", "mkdir -p /var/www/html/node_modules && chown ${WWWUSER:-1000}:${WWWGROUP:-1000} /var/www/html/node_modules || true; exec start-container"] + entrypoint: + - /bin/sh + - -c + - mkdir -p /var/www/html/node_modules && chown ${WWWUSER:-1000}:${WWWGROUP:-1000} /var/www/html/node_modules || true; exec start-container "$@" + - -- volumes: - '.:/var/www/html' - '/var/www/html/node_modules' diff --git a/specs/061-provider-foundation/checklists/requirements.md b/specs/061-provider-foundation/checklists/requirements.md new file mode 100644 index 0000000..7b6f875 --- /dev/null +++ b/specs/061-provider-foundation/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Provider Foundation v1 (Microsoft-first, Security-first) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-23 +**Feature**: [spec.md](../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 + +- Validation run: 2026-01-23 +- All checks passed; spec is ready for `/speckit.plan`. diff --git a/specs/061-provider-foundation/contracts/graph-contracts.md b/specs/061-provider-foundation/contracts/graph-contracts.md new file mode 100644 index 0000000..c58cc62 --- /dev/null +++ b/specs/061-provider-foundation/contracts/graph-contracts.md @@ -0,0 +1,38 @@ +# Contracts: Graph Contract Registry Updates (Provider Foundation v1) + +**Branch**: `061-provider-foundation` +**Date**: 2026-01-24 + +## Purpose + +Provider foundation operations must not introduce ad-hoc Microsoft Graph endpoints. Any new Graph resources required by provider modules should be added to the central registry (`config/graph_contracts.php`) and consumed via `GraphClientInterface`. + +## Required additions (v1) + +### 1) Connection health check + +**Operation**: Provider connection health check (“ping”) +**Graph call**: `GET /organization` (basic org metadata) +**Registry note**: Ensure the org endpoint is modeled/approved in the contract registry (either as a first-class type or as an explicitly allowed internal call within the Graph client). + +--- + +### 2) Compliance snapshot (counts) + +**Operation**: Compliance snapshot (device compliance state counts) +**Graph calls** (one of the contract-approved patterns): + +- Option A: List managed devices with a minimal select and compute counts client-side. + - Resource: `deviceManagement/managedDevices` + - Required fields: `id`, `complianceState` + +- Option B: Use Graph count endpoints / filtered counts if supported reliably. + - Resource: `deviceManagement/managedDevices/$count` (with filters) + +**Registry note**: Add a contract entry for managed devices (and any count strategy used) with allowed selects/filters to prevent accidental over-fetching. + +--- + +## Existing registry reuse + +Provider inventory collection should prefer existing policy-type contracts already present in `config/graph_contracts.php` (e.g., Intune policy types and directory group listing), rather than introducing new “quick endpoints”. diff --git a/specs/061-provider-foundation/contracts/openapi.yaml b/specs/061-provider-foundation/contracts/openapi.yaml new file mode 100644 index 0000000..11a431d --- /dev/null +++ b/specs/061-provider-foundation/contracts/openapi.yaml @@ -0,0 +1,328 @@ +openapi: 3.0.3 +info: + title: Provider Foundation v1 (Internal) + version: 1.0.0 + description: > + Conceptual API contract for Provider Connections and Provider Operations. + This is an internal planning artifact for the admin suite. + +servers: + - url: https://example.invalid + +paths: + /tenants/{tenantId}/provider-connections: + get: + summary: List provider connections + parameters: + - $ref: '#/components/parameters/TenantId' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ProviderConnection' + post: + summary: Create provider connection + parameters: + - $ref: '#/components/parameters/TenantId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProviderConnectionRequest' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnection' + '409': + description: Duplicate connection + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /tenants/{tenantId}/provider-connections/{connectionId}: + get: + summary: Get provider connection + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ConnectionId' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnection' + patch: + summary: Update provider connection (display, default, disable) + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ConnectionId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProviderConnectionRequest' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnection' + + /tenants/{tenantId}/provider-connections/{connectionId}/credentials: + put: + summary: Attach / rotate credentials (secret never returned) + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ConnectionId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertCredentialsRequest' + responses: + '204': + description: Updated + + /tenants/{tenantId}/provider-connections/{connectionId}/operations/health-check: + post: + summary: Start connection health check (OperationRun-backed) + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ConnectionId' + responses: + '201': + description: Run created + content: + application/json: + schema: + $ref: '#/components/schemas/OperationRunRef' + '200': + description: Returned existing active run (dedupe) + content: + application/json: + schema: + $ref: '#/components/schemas/OperationRunRef' + '409': + description: Scope busy (different operation already active for same scope) + content: + application/json: + schema: + $ref: '#/components/schemas/ScopeBusyResponse' + + /tenants/{tenantId}/provider-connections/{connectionId}/operations/inventory: + post: + summary: Start inventory collection (OperationRun-backed) + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ConnectionId' + responses: + '201': + description: Run created + content: + application/json: + schema: + $ref: '#/components/schemas/OperationRunRef' + '200': + description: Returned existing active run (dedupe) + content: + application/json: + schema: + $ref: '#/components/schemas/OperationRunRef' + '409': + description: Scope busy (different operation already active for same scope) + content: + application/json: + schema: + $ref: '#/components/schemas/ScopeBusyResponse' + + /tenants/{tenantId}/provider-connections/{connectionId}/operations/compliance-snapshot: + post: + summary: Start compliance snapshot (counts) (OperationRun-backed) + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ConnectionId' + responses: + '201': + description: Run created + content: + application/json: + schema: + $ref: '#/components/schemas/OperationRunRef' + '200': + description: Returned existing active run (dedupe) + content: + application/json: + schema: + $ref: '#/components/schemas/OperationRunRef' + '409': + description: Scope busy (different operation already active for same scope) + content: + application/json: + schema: + $ref: '#/components/schemas/ScopeBusyResponse' + +components: + parameters: + TenantId: + name: tenantId + in: path + required: true + schema: + type: integer + format: int64 + ConnectionId: + name: connectionId + in: path + required: true + schema: + type: string + format: uuid + + schemas: + ProviderConnection: + type: object + required: + - id + - tenant_id + - provider + - entra_tenant_id + - display_name + - is_default + - status + - health_status + properties: + id: + type: string + format: uuid + tenant_id: + type: integer + format: int64 + provider: + type: string + enum: [microsoft] + entra_tenant_id: + type: string + description: Entra tenant ID (GUID) + display_name: + type: string + is_default: + type: boolean + status: + type: string + enum: [connected, needs_consent, error, disabled] + health_status: + type: string + enum: [ok, degraded, down] + last_health_check_at: + type: string + format: date-time + nullable: true + last_error_reason_code: + type: string + nullable: true + last_error_message: + type: string + nullable: true + + CreateProviderConnectionRequest: + type: object + required: + - provider + - entra_tenant_id + - display_name + properties: + provider: + type: string + enum: [microsoft] + entra_tenant_id: + type: string + display_name: + type: string + is_default: + type: boolean + default: false + + UpdateProviderConnectionRequest: + type: object + properties: + display_name: + type: string + is_default: + type: boolean + status: + type: string + enum: [connected, needs_consent, error, disabled] + + UpsertCredentialsRequest: + type: object + required: + - type + - client_id + - client_secret + properties: + type: + type: string + enum: [client_secret] + client_id: + type: string + client_secret: + type: string + format: password + + OperationRunRef: + type: object + required: + - id + - type + - status + - outcome + properties: + id: + type: integer + format: int64 + type: + type: string + status: + type: string + outcome: + type: string + view_url: + type: string + nullable: true + + ScopeBusyResponse: + type: object + required: + - error + - active_run + properties: + error: + type: string + enum: [scope_busy] + message: + type: string + active_run: + $ref: '#/components/schemas/OperationRunRef' + + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string + message: + type: string diff --git a/specs/061-provider-foundation/data-model.md b/specs/061-provider-foundation/data-model.md new file mode 100644 index 0000000..53a7601 --- /dev/null +++ b/specs/061-provider-foundation/data-model.md @@ -0,0 +1,92 @@ +# Data Model: Provider Foundation v1 + +**Branch**: `061-provider-foundation` +**Date**: 2026-01-24 +**Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md` + +## Entities + +### ProviderConnection + +Represents a tenant-scoped connection to an external provider (v1: Microsoft). + +**Identity** +- Scoped to a Suite Tenant (`tenant_id`). +- Canonical target scope identifier for Microsoft: `entra_tenant_id` (GUID). +- Uniqueness: `(tenant_id, provider, entra_tenant_id)` must be unique. + +**Fields (suggested)** +- `id` (UUID) +- `tenant_id` (FK → tenants) +- `provider` (string; v1: `microsoft`) +- `entra_tenant_id` (string; GUID) +- `display_name` (string) +- `is_default` (boolean; exactly one default per `(tenant_id, provider)`) +- `status` (string enum): `connected | needs_consent | error | disabled` +- `health_status` (string enum): `ok | degraded | down` +- `scopes_granted` (json/jsonb; optional; stores observed granted scopes/permissions metadata) +- `last_health_check_at` (timestamp nullable) +- `last_error_reason_code` (string nullable; stable reason code) +- `last_error_message` (string nullable; sanitized short message) +- `metadata` (json/jsonb nullable; optional non-sensitive provider metadata) +- timestamps + +**Indexes (suggested)** +- Unique index: `(tenant_id, provider, entra_tenant_id)` +- Partial unique index: `(tenant_id, provider)` where `is_default = true` +- Indexes for filtering: `(tenant_id, provider, status)`, `(tenant_id, provider, health_status)` + +**State transitions (v1)** +- `status`: typically `needs_consent`/`error` → `connected` after successful health check; `disabled` is an explicit admin action. +- `health_status`: `ok` on successful check; `degraded/down` based on categorized failures (throttling vs outage vs auth). + +--- + +### ProviderCredential + +Represents securely stored credentials for exactly one provider connection. + +**Identity** +- 1:1 with ProviderConnection (`provider_connection_id` unique). + +**Fields (suggested)** +- `id` (UUID) +- `provider_connection_id` (FK → provider_connections, unique) +- `type` (string; v1: `client_secret`) +- `payload` (encrypted JSON/array) + - required keys (v1): `client_id`, `client_secret` + - optional key: `tenant_id` (should match ProviderConnection `entra_tenant_id` if stored) +- timestamps + +**Constraints (suggested)** +- `payload` must be encrypted at rest. +- `payload` must never be exposed via UI/API serialization. +- If `payload.tenant_id` is stored, validate it matches `provider_connections.entra_tenant_id`. + +--- + +### DefaultProviderConnection (concept) + +Not a separate table by default; represented by `provider_connections.is_default = true`. + +Rules: +- Multiple Microsoft connections per Suite Tenant are allowed. +- Exactly one default connection exists per `(tenant_id, provider)` and is used when starting operations without an explicit connection selection. + +--- + +### OperationRun (existing) + +Provider operations are tracked as `operation_runs` with provider context stored in the `context` JSON. + +**Provider context fields (suggested)** +- `provider`: `microsoft` +- `provider_connection_id`: UUID +- `target_scope`: `{ "entra_tenant_id": "" }` +- `module`: `health_check | inventory | compliance` +- `selection`: optional selectors/filters for the operation (DB-only) +- `idempotency`: fingerprint/hash inputs used for dedupe + +**Concurrency rules (from spec clarifications)** +- Same operation type + same scope: dedupe → return the active run. +- Different operation type while any run is active for the same scope: block as “scope busy” and link to the active run. diff --git a/specs/061-provider-foundation/plan.md b/specs/061-provider-foundation/plan.md new file mode 100644 index 0000000..68c7a9d --- /dev/null +++ b/specs/061-provider-foundation/plan.md @@ -0,0 +1,130 @@ +# Implementation Plan: Provider Foundation v1 (Microsoft-first, Security-first) + +**Branch**: `061-provider-foundation` | **Date**: 2026-01-24 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Introduce tenant-scoped Microsoft provider connections (multiple allowed) with a required default connection and isolated credentials. +- Centralize all Microsoft Graph access through the existing Graph contract path (`GraphClientInterface` + `config/graph_contracts.php`) while standardizing provider operation context and failure handling. +- Add provider capability interfaces (health check, inventory, compliance, placeholders) so future provider modules can be introduced without scattering provider-specific logic. +- Run all provider operations asynchronously as canonical `OperationRun`s with clear UX rules: same-operation dedupe returns the active run; different-operation requests are blocked as “scope busy” with a link to the active run. +- Guarantee DB-only render for Provider Connections and Monitoring surfaces; outbound calls occur only in queued jobs. +- Add regression tests for tenant isolation, role authorization, dedupe/scope-busy behavior, and secret redaction in run failures. + +## Technical Context + + + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 +**Storage**: PostgreSQL (Sail) with JSONB where appropriate +**Testing**: Pest (PHPUnit) +**Target Platform**: Web application (Sail/Docker locally; container deploy via Dokploy) +**Project Type**: web +**Performance Goals**: DB-only pages render fast (no outbound calls); operation start surfaces respond quickly after enqueueing a run +**Constraints**: No outbound HTTP during render/poll/hydration; max 1 active provider run per Entra tenant scope; secrets/tokens never persisted in runs/logs. Remote provider calls are read-only (Graph fetches), but the platform will persist local state (snapshots/summary_counts/health) as DB writes. +**Scale/Scope**: Multiple suite tenants; multiple Microsoft connections per tenant; operations history grows over time (indexing required) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first, snapshots-second: PASS (provider inventory is treated as “last observed”; no backup/restore semantics introduced in v1). +- Read/write separation: PASS (v1 provider operations perform remote reads only, while persisting local DB state such as health and snapshot summaries. Any future remote writes (e.g., script deploy/app publish) remain out of scope and would require preview + confirmation + audit + tests). +- Single contract path to Graph: PASS (all Microsoft Graph calls stay behind `GraphClientInterface` and are modeled in `config/graph_contracts.php`; no ad-hoc HTTP in UI/render). +- Deterministic capabilities: PASS (provider operations are registered centrally and validated/tested; no “quick endpoints” scattered across features). +- Tenant isolation: PASS (provider connections, credentials, and runs are scoped to the current Suite Tenant; cross-tenant access is denied-as-not-found). +- Operations / run observability standard: PASS (all provider operations create/reuse a canonical `OperationRun`; start surfaces enqueue-only; Monitoring remains DB-only). +- Automation (locks + idempotency): PASS (same-operation starts dedupe to the active run; different-operation starts for the same scope are blocked as “scope busy”; Graph throttling handled via existing retry/backoff+jitter). +- Data minimization & safe logging: PASS (no secrets/tokens persisted or logged; failures use stable reason codes + sanitized short messages). +- Badge semantics (BADGE-001): PASS (any status-like UI badges for connection status/health must render via `BadgeCatalog` / `BadgeRenderer` with tests; otherwise status is shown as plain text). + +## Project Structure + +### Documentation (this feature) + +```text +specs/061-provider-foundation/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +├── Jobs/ +├── Models/ +├── Policies/ +├── Services/ +│ ├── Graph/ +│ └── Operations/ +└── Support/ + +config/ +database/ +resources/ +routes/ +tests/ +``` + +**Structure Decision**: Single Laravel web application with Filament admin panel; provider foundation lives in `app/Models`, `app/Services`, `app/Jobs`, `app/Policies`, and integrates with the existing Graph + Operations infrastructure. + +## Complexity Tracking + +No constitution violations required for this feature. + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/research.md` + +Goals: +- Confirm the existing Graph contract path and reuse it (no new bespoke HTTP client in UI/features). +- Confirm token acquisition is app-only (client credentials) and is sourced from `provider_credentials` via `CredentialManager` inside `ProviderGateway` (no secrets in config/env beyond bootstrap). +- Ensure provider operations supply auth context to `GraphClientInterface` via `ProviderGateway` (the only decryptor), not from UI/service layers. +- Confirm `GraphClientInterface` binding can be enabled without env secrets when `ProviderGateway` supplies per-request credentials (e.g., via a `GRAPH_ENABLED` override). +- Confirm existing `OperationRun` idempotency patterns and align provider operations with them. +- Confirm existing sanitization / reason-code utilities for run failures and reuse them. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/contracts/` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/quickstart.md` + +Design focus: +- Data model for provider connections + isolated credentials with a required default connection. +- Credential isolation: `provider_credentials` stores encrypted payload; only ProviderGateway/CredentialManager can decrypt; never expose in resources/logs/runs. +- Provider module contracts: define capability interfaces and ensure Microsoft implementations depend on `ProviderGateway` rather than calling Graph directly. +- OperationRun identity + scope-busy behavior consistent with clarified UX rules. +- Explicit Graph contract registry updates required for new provider operations. + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +- Schema: create provider connection + credential tables, indexes, and state fields. +- Authorization (capabilities-first): implement gates/policies `provider.view`, `provider.manage`, `provider.run`. +- Owner/Manager: view+manage+run; Operator: view+run; Readonly: view only. +- No `role == X` checks in feature code; enforce via policies/gates + tests. +- Provider foundation services: connection resolution (default/explicit), credential resolution, and operation registry. +- Provider gateway + modules: implement `ProviderGateway`, `CredentialManager`, and capability interfaces used by all provider operations. +- Operations: implement health check, inventory collection (minimal), and compliance snapshot (counts) as queued `OperationRun`s. +- Concurrency: same-operation dedupe to active run; different-operation blocked as “scope busy” with active-run link; enforce race-safety with a DB lock/transaction around start-gate decisions. +- UI: Provider Connections management is DB-only at render; “Check connection” and any provider operations are enqueue-only (jobs perform outbound HTTP). Add a DB-only render test with Http::fake() asserting zero outbound requests during render/poll/hydration. +- Guardrails/tests: DB-only render tests, secret-redaction tests, reason-code mapping tests, concurrency behavior tests. + +## Constitution Check (Post-Design) + +Re-check result: PASS. Design artifacts explicitly keep Graph calls behind `GraphClientInterface` + contract registry and standardize provider operations via `OperationRun` with tenant isolation, idempotency, and safe failure handling. diff --git a/specs/061-provider-foundation/quickstart.md b/specs/061-provider-foundation/quickstart.md new file mode 100644 index 0000000..116d2ce --- /dev/null +++ b/specs/061-provider-foundation/quickstart.md @@ -0,0 +1,38 @@ +# Quickstart: Provider Foundation v1 + +**Branch**: `061-provider-foundation` +**Date**: 2026-01-24 +**Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md` + +## Local setup + +1. Start the app stack: + - `./vendor/bin/sail up -d` +2. Install dependencies (if needed): + - `./vendor/bin/sail composer install` +3. Run migrations: + - `./vendor/bin/sail artisan migrate` + +## Manual QA (once implemented) + +1. Select a Suite Tenant in the admin UI. +2. Create a Microsoft Provider Connection: + - Enter `entra_tenant_id` (GUID) and `display_name` + - Mark it as the default connection (or ensure exactly one default exists) +3. Attach credentials (Owner/Manager only): + - Enter `client_id` + `client_secret` + - Verify secrets are never shown again after saving +4. Run “Check connection” (Owner/Manager/Operator): + - Verify an `OperationRun` is created and visible in Monitoring → Operations + - Verify failures show a stable reason code + short sanitized message (no tokens/secrets/emails) +5. Start inventory collection and compliance snapshot: + - Re-start the same operation while it’s running → returns the existing active run + - Start a different operation while a run is active for the same scope → blocked as “scope busy” with a link to the active run +6. Confirm DB-only render: + - Provider Connections and Operations pages should load/poll without triggering outbound provider calls. + +## Test run (once implemented) + +- Run targeted tests: + - `./vendor/bin/sail artisan test tests/Feature` + - `./vendor/bin/sail artisan test tests/Unit` diff --git a/specs/061-provider-foundation/research.md b/specs/061-provider-foundation/research.md new file mode 100644 index 0000000..9389d8c --- /dev/null +++ b/specs/061-provider-foundation/research.md @@ -0,0 +1,91 @@ +y# Research: Provider Foundation v1 (Microsoft-first, Security-first) + +**Branch**: `061-provider-foundation` +**Date**: 2026-01-24 +**Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md` +**Plan**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/plan.md` + +## Decisions + +### D-001 — Keep the Graph contract path as the only Microsoft Graph entry + +**Decision**: All Microsoft Graph calls remain behind `GraphClientInterface` and are modeled in `config/graph_contracts.php`; provider modules/services must not introduce ad-hoc HTTP clients or hardcoded Graph paths. + +**Rationale**: This preserves the repo’s constitution (“Single Contract Path to Graph”) and prevents endpoint sprawl. It also reuses the existing retry/backoff/logging behavior already centralized in the Graph client. + +**Alternatives considered**: +- Build a new provider-specific HTTP gateway: rejected because it risks bypassing the existing Graph contract + logging path and reintroduces “two Graph clients”. +- Allow `GraphClientInterface::request()` with hardcoded paths in feature code: rejected because it undermines contract registry governance. + +--- + +### D-002 — Canonical Microsoft target scope identifier + +**Decision**: The canonical target scope identifier for Microsoft provider connections is the Entra tenant ID (GUID). Domains may be stored as display labels only. + +**Rationale**: Tenant IDs are stable and unambiguous; domains can change and multiple domains can exist. + +**Alternatives considered**: +- Domain as canonical identifier: rejected due to ambiguity and change risk. +- Allow either: rejected for v1 to avoid inconsistent locking/routing keys. + +--- + +### D-003 — Multiple Microsoft connections per Suite Tenant with a required default + +**Decision**: A Suite Tenant may have multiple Microsoft provider connections (distinct Entra tenant IDs). Exactly one connection is marked as the default; operations use the default unless a connection is explicitly selected. + +**Rationale**: Supports real-world multi-tenant management while keeping day-to-day usage predictable. + +**Alternatives considered**: +- Exactly one Microsoft connection per Suite Tenant: rejected as too restrictive for long-term roadmap (M365/security suite expansion). + +--- + +### D-004 — OperationRun identity, dedupe, and “scope busy” behavior + +**Decision**: Provider operations are tracked as `OperationRun`s with two concurrency rules: +1) Re-starting the same operation type for the same scope returns the active run (dedupe; no new run). +2) Starting a different operation type while any run is active for that scope is blocked (“scope busy”) and links to the active run. + +**Rationale**: Prevents accidental overlap, avoids run spam, and keeps user expectations simple and consistent. + +**Alternatives considered**: +- Queue the second operation to run later: rejected for v1 because it creates “silent delays” and unexpected ordering. +- Always reuse an active run even for a different operation: rejected because it would attach unrelated work to the wrong run. + +--- + +### D-005 — Authorization aligns with existing tenant roles + +**Decision**: +- Owner/Manager can manage provider connections and credentials. +- Owner/Manager/Operator can start provider operations (health check, inventory, compliance). +- Readonly is view-only. + +**Rationale**: Credential/connection management is security-sensitive; operations are operational work that Operators need for daily workflow. + +**Alternatives considered**: +- Restrict operations to Owner/Manager only: rejected because it blocks day-to-day operations. + +--- + +### D-006 — Failure reason codes and redaction reuse existing run sanitization + +**Decision**: Provider operation failures use stable reason codes and short sanitized messages via the existing run failure sanitization utilities; extend reason codes only when needed for clear operator feedback (e.g., authentication misconfiguration). + +**Rationale**: Keeps error handling consistent suite-wide and prevents token/secret/PII leakage. + +**Alternatives considered**: +- Persist raw Graph error payloads for debugging: rejected due to secret/PII leak risk and constitution requirements. + +--- + +### D-007 — DB-only render is enforced by design and tests + +**Decision**: Provider Connections and Monitoring/Operations surfaces must be DB-only at render/poll time; outbound provider calls occur only in queued jobs started via explicit user actions. + +**Rationale**: Prevents accidental “poll storms” against Graph and preserves predictable UI performance. + +**Alternatives considered**: +- Inline health checks during page load: rejected as a render-side effect and operationally unsafe. diff --git a/specs/061-provider-foundation/spec.md b/specs/061-provider-foundation/spec.md new file mode 100644 index 0000000..bb446c7 --- /dev/null +++ b/specs/061-provider-foundation/spec.md @@ -0,0 +1,155 @@ +# Feature Specification: Provider Foundation v1 (Microsoft-first, Security-first) + +**Feature Branch**: `061-provider-foundation` +**Created**: 2026-01-23 +**Status**: Draft +**Input**: Build a provider integration foundation (starting with Microsoft) that centralizes provider communication, enables safe tenant-scoped connections, runs operations in the background with a tracked run record, prevents overlapping runs per provider tenant by default, and prevents credential leakage. + +## Clarifications + +### Session 2026-01-23 + +- Q: When an admin starts a provider operation for a target scope that already has an active run, what should the system do by default? → A: Dedupe: reuse/return the active run (no new run created). +- Q: For Microsoft in v1, what should the admin enter (and we store) as the canonical target scope identifier for a connection? → A: Entra tenant ID (GUID); domains may be stored only as a display label, not as the canonical identifier. +- Q: In v1, should a tenant be able to have multiple Microsoft provider connections, or exactly one? → A: Multiple connections allowed, but one “default” connection is required for operations unless explicitly selected. +- Q: In v1, which tenant roles should be allowed to (a) manage provider connections/credentials and (b) start provider operations (health check, inventory, compliance)? → A: Owner/Manager manage connections and credentials; Owner/Manager/Operator can start operations; Readonly is view-only. +- Q: If a provider run is already active for a target scope, and a user tries to start a different provider operation type, what should happen by default? → A: Block (“scope busy”) and link to the active run. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Set up a provider connection safely (Priority: P1) + +An Owner or Manager can create and manage a provider connection for a tenant, attach credentials, and see the connection’s current state without ever exposing secrets. + +**Why this priority**: All provider-backed capabilities depend on a secure, tenant-scoped connection and a safe way to manage credentials. + +**Independent Test**: An Owner/Manager can create a connection, attach credentials, and later view/manage the connection without any secret value being displayed or leaked in the UI. + +**Acceptance Scenarios**: + +1. **Given** a tenant with no provider connections, **When** an Owner/Manager creates a new Microsoft provider connection with a display name and provider tenant identifier, **Then** the connection is saved and is uniquely identifiable within the tenant. +2. **Given** an existing provider connection with credentials attached, **When** an Owner/Manager views the connection details, **Then** secret values are never displayed and only safe metadata is shown. + +--- + +### User Story 2 - Verify connection health without blocking the UI (Priority: P2) + +An Owner, Manager, or Operator can trigger a connection health check and see a tracked run with a clear outcome, including a safe, categorized error when the check fails. + +**Why this priority**: Admins need a reliable way to confirm connectivity/permissions and to troubleshoot failures without guesswork. + +**Independent Test**: Triggering “Check connection” creates a new run, completes asynchronously, updates the connection’s health state, and records a stable failure category when applicable. + +**Acceptance Scenarios**: + +1. **Given** a configured provider connection, **When** an Owner/Manager/Operator runs a health check, **Then** the system creates a visible operation run and updates the connection’s health status when the run completes. +2. **Given** invalid or revoked credentials, **When** an Owner/Manager/Operator runs a health check, **Then** the run ends in a failure state with a stable reason code and a short, sanitized message (no secrets or raw payloads). + +--- + +### User Story 3 - Run provider operations with safety and observability (Priority: P3) + +An Owner, Manager, or Operator can run provider-backed operations (such as inventory collection and compliance snapshots) that are tenant-scoped, safe by default, limited to one active run per provider tenant (by default), and fully tracked through Operations monitoring. + +**Why this priority**: Provider operations can be long-running and failure-prone; they must be safe by default and easy to audit and troubleshoot. + +**Independent Test**: Starting a provider operation results in a single observable run, respects the per-scope concurrency limit, and surfaces summary counts and categorized failures. + +**Acceptance Scenarios**: + +1. **Given** a valid provider connection, **When** an Owner/Manager/Operator initiates an inventory collection run, **Then** the run is queued/executed asynchronously and appears in Operations monitoring with provider + scope context. +2. **Given** an active run for the same provider tenant identifier and the same operation type, **When** an Owner/Manager/Operator starts that operation again targeting the same scope, **Then** the system returns the active run (no new run created) and communicates the outcome clearly. +3. **Given** an active run for the same provider tenant identifier, **When** an Owner/Manager/Operator starts a different provider operation type targeting the same scope, **Then** the system blocks the request (“scope busy”) and links to the active run. + +### Edge Cases + +- A provider tenant identifier is entered that does not match the attached credentials: the system prevents unsafe configuration and guides the admin to fix it. +- Provider access is revoked or consent changes: health checks and operations fail with a clear, stable reason code and a safe message. +- Provider throttling/transient outages: operations behave predictably, remain tracked as a single run, and provide a clear outcome without repeated noise. +- Provider service downtime: runs fail safely and do not cause repeated background failures. +- An admin views Provider Connections or Operations pages: pages render using stored data only and never trigger provider calls during render/poll. +- A user attempts to start a different operation while a run is active for the same scope: the system blocks with “scope busy” and links to the active run. + +## Requirements *(mandatory)* + +**Constitution alignment:** This feature introduces tenant-scoped external-provider operations. It must enforce tenant isolation, safe run observability, and sanitized failure handling, and it must include automated tests for these guarantees. + +### Scope + +**In scope (v1)** + +- Provider connections for Microsoft as the first supported provider. +- Multiple Microsoft connections per tenant are supported; one connection is marked as the default for operations unless the user explicitly selects another connection. +- Secure credential attachment/rotation without exposing secret values. +- A single controlled outbound-provider communication path (gateway) used by all provider-backed operations. +- Observable asynchronous operations (operation runs) for health checks and provider-backed data collection. +- Central concurrency limiting per provider tenant identifier (default: 1 concurrent run per scope). +- A minimal set of provider capabilities: inventory collection and compliance snapshot (counts). + +**Out of scope (v1)** + +- User-delegated sign-in flows. +- Certificate-based credentials and external secret managers. +- Cross-tenant “global MSP” dashboards. +- Provider-backed remediation/script execution and evidence-collection suites. + +### Functional Requirements + +- **FR-001**: System MUST store provider connections as tenant-scoped records, including provider type, a canonical provider tenant identifier (target scope) (v1 Microsoft: Entra tenant ID (GUID)), display name, and connection state (e.g., connected / needs consent / error / disabled). +- **FR-002**: System MUST enforce uniqueness of provider connections within a tenant by provider type + provider tenant identifier. +- **FR-003**: System MUST store provider credentials separately from provider connection identity and MUST support credential rotation without changing the connection identity. +- **FR-004**: System MUST never display stored secret values after they are submitted, and MUST not expose secrets in UI, notifications, operation runs, or logs. +- **FR-005**: System MUST categorize provider failures using stable reason codes and short, sanitized messages suitable for audit and support triage. +- **FR-006**: System MUST route all outbound provider communication through a single controlled gateway that enforces consistent authentication, tracking identifiers for support, and safe handling of throttling and transient failures. +- **FR-007**: System MUST ensure that viewing admin pages (including Provider Connections and Operations monitoring) never triggers outbound provider calls during page render/poll. +- **FR-008**: System MUST execute provider operations asynchronously when they involve provider communication or may exceed normal UI response times. +- **FR-009**: System MUST create or reuse a canonical operation run for each provider operation and MUST record provider, target scope, module/capability, timestamps, and outcome for Monitoring → Operations. +- **FR-010**: System MUST enforce a central per-scope concurrency limit for provider operations (default: 1 concurrent run per provider tenant identifier). If a run is already active for a scope: (a) re-starting the same operation type MUST return the active run (no new run created), and (b) starting a different operation type MUST be blocked with “scope busy” and a link to the active run. +- **FR-011**: System MUST provide a “Check connection” operation that updates connection health state based on the result and records the outcome as an operation run. +- **FR-012**: System MUST define provider capability interfaces that allow adding new provider-backed modules (inventory, compliance, directory, scripts) without scattering provider-specific logic across unrelated features. +- **FR-013**: System MUST ship at least one Microsoft provider implementation that supports (a) inventory collection and (b) compliance snapshot runs, producing stored results and summary counts. +- **FR-014**: Security-relevant configuration changes (creating/updating connections and credentials, disabling connections) MUST be recorded in an audit trail with actor + tenant + timestamp. +- **FR-015**: System MUST maintain a centralized, reviewed registry of allowed provider operations, so new provider calls cannot be introduced ad-hoc outside the approved integration path. +- **FR-016**: System MUST allow multiple Microsoft provider connections per tenant (distinguished by Entra tenant ID (GUID)) and MUST require exactly one default Microsoft connection that is used when starting provider operations without an explicit connection selection. +- **FR-017**: System MUST enforce tenant-role based access: Owner/Manager can create/update/disable provider connections and manage credentials; Owner/Manager/Operator can start provider operations; Readonly is view-only and cannot start operations. + +### Acceptance Criteria + +- Connection setup is tenant-scoped, unique per provider + scope, uses Entra tenant ID (GUID) as the canonical scope identifier, and does not expose secrets. +- Health checks and provider operations run in the background and are tracked as operation runs with clear outcomes. +- Provider operations do not overlap for the same target scope by default. +- When a run is already in progress for a target scope, starting the same operation returns the active run (no new run created). +- When a run is already in progress for a target scope, starting a different operation is blocked with “scope busy” and links to the active run. +- When multiple Microsoft connections exist for a tenant, one is designated as the default; starting an operation without selecting a connection uses the default. +- Only Owner/Manager can manage connections and credentials; Owner/Manager/Operator can start operations; Readonly can only view. +- Monitoring/Connections pages are “read-only at render time” and do not call providers while loading or polling. + +### Key Entities *(include if feature involves data)* + +- **Provider Connection**: A tenant-scoped representation of a relationship to an external provider, including target scope identifier, display name, state, and health indicators. +- **Provider Credential**: A securely stored credential set linked to exactly one provider connection, used only for provider communication. +- **Target Scope**: A stable identifier that defines which external tenant/environment a provider operation targets (v1 Microsoft: Entra tenant ID (GUID)); domains may be stored only as labels. +- **Default Provider Connection**: The tenant’s selected default connection for a provider, used when starting provider operations without an explicit connection selection. +- **Operation Run**: A canonical record of an initiated provider operation, including initiator, scope, timestamps, outcome, summary counts, and categorized failures. +- **Provider Capability (Module)**: A named area of functionality (e.g., inventory, compliance) that can be implemented per provider and executed as an operation run. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A tenant admin can create a provider connection and attach credentials in under 5 minutes without support intervention. +- **SC-002**: 100% of provider-backed operations initiated by users create an operation run visible in Operations monitoring within 5 seconds of initiation. +- **SC-003**: For a given target scope, the system prevents overlapping provider operations by default (max 1 concurrent run per scope), with clear user feedback. +- **SC-004**: Automated tests demonstrate zero secret leakage in UI surfaces and operation run messages (no tokens/secrets/PII displayed or persisted). +- **SC-005**: 95% of connection health checks complete and update connection health status within 2 minutes under normal provider conditions. + +## Assumptions + +- v1 targets a single provider (Microsoft) but must be designed to add additional providers and modules later without reworking existing features. +- Provider operations are initiated by authorized administrators and must respect tenant scoping and audit requirements. +- Monitoring and “read-only” admin pages prioritize predictable load and safety over real-time provider querying. + +## Dependencies + +- A tenant model and authorization boundaries exist so provider connections and runs can be scoped correctly. +- Operations monitoring and an audit trail mechanism exist (or are introduced alongside this feature) to record runs and security-relevant configuration changes. diff --git a/specs/061-provider-foundation/tasks.md b/specs/061-provider-foundation/tasks.md new file mode 100644 index 0000000..a2b8d53 --- /dev/null +++ b/specs/061-provider-foundation/tasks.md @@ -0,0 +1,188 @@ +--- + +description: "Task list for Provider Foundation v1" + +--- + +# Tasks: Provider Foundation v1 (Microsoft-first, Security-first) + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/research.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/contracts/`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/quickstart.md` + +**Tests**: REQUIRED (Pest) for runtime behavior changes +**Operations**: Provider operations MUST create/reuse a canonical `OperationRun`, be enqueue-only, and keep Monitoring/ProviderConnections pages DB-only at render/poll time +**Badges**: If ProviderConnection status/health is rendered as a badge, use `BadgeCatalog` / `BadgeRenderer` (BADGE-001) and add mapping tests + +**Organization**: Tasks are grouped by user story (US1, US2, US3) to enable independent delivery. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare local environment + validate baseline + +- [X] T001 Start containers with `./vendor/bin/sail up -d` (script: `./vendor/bin/sail`) +- [X] T002 Run baseline Ops-UX DB-only tests with `./vendor/bin/sail artisan test tests/Feature/Monitoring/OperationsDbOnlyTest.php` (test: `tests/Feature/Monitoring/OperationsDbOnlyTest.php`) +- [X] T003 [P] Review existing idempotency + run tracking patterns in `app/Services/OperationRunService.php` and `app/Jobs/Middleware/TrackOperationRun.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core provider foundation that all stories depend on (schema, models, policies, ops primitives) + +**Checkpoint**: Migrations + models + policies exist; provider operation types are registered; shared run-gating utilities exist + +- [X] T004 Create provider connections migration in `database/migrations/2026_01_24_000001_create_provider_connections_table.php` +- [X] T005 Create provider credentials migration in `database/migrations/2026_01_24_000002_create_provider_credentials_table.php` +- [X] T005a Add DB-level invariant: partial unique index ensuring only one default per (tenant_id, provider) (example: `unique (tenant_id, provider) WHERE is_default = true`; ensure default flipping is atomic/transactional) +- [X] T006 [P] Create `App\Models\ProviderConnection` in `app/Models/ProviderConnection.php` (relations, casts, default-connection invariant) +- [X] T007 [P] Create `App\Models\ProviderCredential` in `app/Models/ProviderCredential.php` (encrypted payload cast, hidden attributes, 1:1 relation) +- [X] T008 [P] Add tenant relationship for connections in `app/Models/Tenant.php` (e.g., `providerConnections()` / `providerCredentials()` as needed) +- [X] T009 [P] Add factories in `database/factories/ProviderConnectionFactory.php` and `database/factories/ProviderCredentialFactory.php` +- [X] T010 Create authorization policy + gates for `provider.view`, `provider.manage`, `provider.run` (capabilities-first; tenant-scoped). Map roles to capabilities: Owner/Manager=view+manage+run; Operator=view+run; Readonly=view only. No `role == X` checks in feature code. +- [X] T010b [P] Add RBAC capability tests in `tests/Feature/ProviderConnections/ProviderRbacCapabilitiesTest.php` (Operator can start operations but cannot manage; Readonly is view-only) +- [X] T011 Register the policy + gates in `app/Providers/AuthServiceProvider.php` (create it and register in `bootstrap/providers.php` if missing; otherwise use the project’s canonical policy registration provider) +- [X] T012 Add provider operation labels in `app/Support/OperationCatalog.php` using canonical operation types: `provider.connection.check`, `inventory.sync`, `compliance.snapshot` (ensure UI/actions/jobs use these exact strings; update `tests/Feature/OpsUx/OperationCatalogCoverageTest.php` to detect multi-dot types) +- [X] T013 Add provider related links in `app/Support/OperationRunLinks.php` (link provider runs to Provider Connections pages when `context.provider_connection_id` exists) +- [X] T014 Create provider operation registry in `app/Services/Providers/ProviderOperationRegistry.php` (central allowlist + metadata for v1 operations) +- [X] T014a [P] Define provider capability interfaces + DTOs in `app/Services/Providers/Contracts/ProviderHealthCheck.php`, `app/Services/Providers/Contracts/HealthResult.php`, `app/Services/Providers/Contracts/ProviderInventoryCollector.php`, `app/Services/Providers/Contracts/ProviderComplianceCollector.php`, `app/Services/Providers/Contracts/ProviderDirectoryCollector.php`, `app/Services/Providers/Contracts/ProviderScriptExecutor.php` +- [X] T014b [P] Implement credential retrieval/rotation service in `app/Services/Providers/CredentialManager.php` (reads `provider_credentials`, validates required keys, never logs decrypted payload) +- [X] T014c [P] Implement provider gateway in `app/Services/Providers/ProviderGateway.php` (build Graph request context from ProviderConnection + CredentialManager, centralize correlation IDs + failure mapping, call `GraphClientInterface`) +- [X] T014d Enable Graph client binding for per-request credentials in `config/graph.php` and `app/Providers/AppServiceProvider.php` (e.g., `GRAPH_ENABLED` override; do not require env client_secret for binding when ProviderGateway supplies request context) +- [X] T014e [P] Add unit tests for provider gateway + credential manager in `tests/Unit/Providers/CredentialManagerTest.php` and `tests/Unit/Providers/ProviderGatewayTest.php` +- [X] T015 Create run gating service in `app/Services/Providers/ProviderOperationStartGate.php` (DB transaction + `lockForUpdate()` on ProviderConnection; dedupe same-operation; block different-operation as “scope busy”; returns active-run link) +- [X] T016 [P] Extend failure sanitization in `app/Support/OpsUx/RunFailureSanitizer.php` (provider auth/throttling/outage reason codes + stronger secret redaction) +- [X] T017 [P] Add unit tests for failure sanitization in `tests/Unit/OpsUx/RunFailureSanitizerTest.php` +- [X] T018 [P] Add unit tests for run gating in `tests/Unit/Providers/ProviderOperationStartGateTest.php` +- [X] T019 Run foundational tests with `./vendor/bin/sail artisan test tests/Unit/OpsUx/RunFailureSanitizerTest.php` (test: `tests/Unit/OpsUx/RunFailureSanitizerTest.php`) + +--- + +## Phase 3: User Story 1 — Set up a provider connection safely (Priority: P1) 🎯 MVP + +**Goal**: Owner/Manager can create/manage Microsoft provider connections, attach credentials, set a default connection, and never expose secrets. + +**Independent Test**: An Owner/Manager can create a connection + credentials, later view/edit it, and no secret values are displayed or leaked; pages remain DB-only at render/poll time. + +### Tests for User Story 1 + +- [X] T020 [P] [US1] Add DB-only render test for Provider Connections in `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php` +- [X] T021 [P] [US1] Add role authorization test in `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php` +- [X] T022 [P] [US1] Add credential encryption/non-disclosure test in `tests/Feature/ProviderConnections/ProviderCredentialSecurityTest.php` +- [X] T022b [P] [US1] Add credential leak guard test in `tests/Feature/ProviderConnections/CredentialLeakGuardTest.php` (assert no secrets appear in OperationRun failure records or captured logs; forbidden substrings: `client_secret`, `Bearer `, `access_token`, `refresh_token`, `Authorization`) + +### Implementation for User Story 1 + +- [X] T023 [US1] Create Filament resource skeleton in `app/Filament/Resources/ProviderConnectionResource.php` +- [X] T024 [P] [US1] Create pages in `app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php`, `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` +- [X] T025 [US1] Implement list/table + filters in `app/Filament/Resources/ProviderConnectionResource.php` (tenant-scoped; DB-only) +- [X] T026 [US1] Implement create/edit forms in `app/Filament/Resources/ProviderConnectionResource.php` (provider=microsoft, `entra_tenant_id`, `display_name`, `is_default`, status/health read-only fields) +- [X] T027 [US1] Implement credential upsert action (Owner/Manager only; secrets never shown) in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` +- [X] T028 [US1] Implement disable connection action with confirmation + audit log in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` +- [X] T029 [US1] Write audit log entries for connection + credential changes using `app/Services/Intune/AuditLogger.php` (called from `app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`) +- [X] T030 [US1] Run story tests with `./vendor/bin/sail artisan test tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php` (test: `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`) + +--- + +## Phase 4: User Story 2 — Verify connection health without blocking the UI (Priority: P2) + +**Goal**: Owner/Manager/Operator can enqueue a health check that creates an `OperationRun`, updates health state, and shows stable reason codes/messages on failure. + +**Independent Test**: Clicking “Check connection” enqueues exactly one run (dedupe), never calls Graph in the request cycle, and produces a visible run outcome + updates connection health. + +### Tests for User Story 2 + +- [X] T031 [P] [US2] Add start-surface test (no Graph in request; job queued; run created) in `tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php` +- [X] T032 [P] [US2] Add job behavior test (success + categorized failure) in `tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php` +- [X] T032b [P] [US2] Assert OperationRun context contract for connection health checks in `tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php` (`context.provider`, `context.module`, `context.provider_connection_id`, `context.target_scope.entra_tenant_id`) + +### Implementation for User Story 2 + +- [X] T033 [US2] Add “Check connection” Filament action (enqueue-only) in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` (MUST call `ProviderOperationStartGate` first: same-operation returns existing active run; different-operation for same scope blocks “scope busy” + link to active run) +- [X] T034 [US2] Create queued job in `app/Jobs/ProviderConnectionHealthCheckJob.php` (uses `app/Jobs/Middleware/TrackOperationRun.php`, updates `provider_connections` health fields, updates `operation_runs`) +- [X] T035 [US2] Create health check module in `app/Services/Providers/MicrosoftProviderHealthCheck.php` (implements `app/Services/Providers/Contracts/ProviderHealthCheck.php`, uses `ProviderGateway`, maps failures to reason codes via `app/Support/OpsUx/RunFailureSanitizer.php`) +- [X] T035b [P] [US2] Ensure health check uses ProviderConnection context (`entra_tenant_id`) and obtains tokens via ProviderGateway/CredentialManager (ProviderGateway is the only decryptor; no secrets in config/env beyond bootstrap) +- [X] T036 [US2] Run story tests with `./vendor/bin/sail artisan test tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php` (test: `tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`) + +--- + +## Phase 5: User Story 3 — Run provider operations with safety and observability (Priority: P3) + +**Goal**: Owner/Manager/Operator can run provider operations (inventory + compliance snapshot) as observable runs, with per-scope concurrency rules (dedupe vs scope-busy) and summary counts. + +**Independent Test**: Starting inventory/compliance creates runs, obeys dedupe/scope-busy rules, never calls Graph in the request cycle, and writes summary counts + failures safely. + +### Tests for User Story 3 + +- [X] T037 [P] [US3] Add scope-busy + dedupe behavior tests in `tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php` +- [X] T038 [P] [US3] Add compliance snapshot summary_counts tests in `tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php` +- [X] T038b [P] [US3] Assert OperationRun context contract for inventory/compliance runs in `tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php` and `tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php` (`context.provider`, `context.module`, `context.provider_connection_id`, `context.target_scope.entra_tenant_id`) + +### Implementation for User Story 3 + +- [X] T039 [US3] Add managed devices contract entry for compliance snapshot in `config/graph_contracts.php` (resource `deviceManagement/managedDevices`, select includes compliance state) +- [X] T040 [US3] Add allowed summary keys for compliance counts in `app/Support/OpsUx/OperationSummaryKeys.php` (add `compliant`, `noncompliant`, `unknown`) +- [X] T041 [US3] Create compliance snapshot collector in `app/Services/Providers/MicrosoftComplianceSnapshotService.php` (implements `app/Services/Providers/Contracts/ProviderComplianceCollector.php`, uses `ProviderGateway`, Graph list + count compliance states) +- [X] T042 [US3] Create queued job in `app/Jobs/ProviderComplianceSnapshotJob.php` (writes `OperationRun` context + summary_counts; updates failures with sanitized reason codes) +- [X] T043 [US3] Add “Compliance snapshot” Filament action (enqueue-only) in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` (MUST call `ProviderOperationStartGate` first: same-operation returns existing active run; different-operation for same scope blocks “scope busy” + link to active run) +- [X] T044 [US3] Create minimal inventory collector in `app/Services/Providers/MicrosoftProviderInventoryCollector.php` (implements `app/Services/Providers/Contracts/ProviderInventoryCollector.php`, uses `ProviderGateway`, contract-backed listing + summary counts only) +- [X] T045 [US3] Create queued job in `app/Jobs/ProviderInventorySyncJob.php` (writes `OperationRun` context + summary_counts; obeys scope-busy rules via start gate) +- [X] T046 [US3] Add “Inventory sync” Filament action (enqueue-only) in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` (MUST call `ProviderOperationStartGate` first: same-operation returns existing active run; different-operation for same scope blocks “scope busy” + link to active run) +- [X] T047 [US3] Run story tests with `./vendor/bin/sail artisan test tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php` (test: `tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php`) + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Reduce regressions and improve consistency across stories + +- [X] T048 [P] Ensure provider operations set `context.target_scope.entra_tenant_id` and `context.target_scope.entra_tenant_name` in `app/Jobs/ProviderConnectionHealthCheckJob.php`, `app/Jobs/ProviderComplianceSnapshotJob.php`, and `app/Jobs/ProviderInventorySyncJob.php` +- [X] T049 [P] If ProviderConnection status/health is shown as badges, add a badge mapper + tests in `app/Support/Badges/Domains/` and `tests/Unit/Badges/` +- [X] T050 Run formatting with `./vendor/bin/sail php ./vendor/bin/pint --dirty` (script: `./vendor/bin/pint`) +- [X] T051 Run focused feature tests with `./vendor/bin/sail artisan test tests/Feature/ProviderConnections` (folder: `tests/Feature/ProviderConnections`) + +--- + +## Dependencies & Execution Order + +### User Story Dependency Graph + +```text +Phase 1 (Setup) + ↓ +Phase 2 (Foundation: schema/models/policy + ops primitives) + ↓ +US1 (Connections + credentials UI) ─┬─→ US2 (Health check run) + └─→ US3 (Inventory + compliance runs) +``` + +### Parallel Opportunities + +- Phase 2 tasks marked `[P]` can be done in parallel (different files). +- Within each user story phase, `[P]` tests can be written in parallel. +- Caution: US2 and US3 both extend `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` (avoid parallel edits to that file unless coordinated). + +--- + +## Parallel Example: User Story 1 + +```bash +Task: "Add DB-only render test for Provider Connections in tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php" +Task: "Add role authorization test in tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php" +Task: "Add credential encryption/non-disclosure test in tests/Feature/ProviderConnections/ProviderCredentialSecurityTest.php" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1) + +1. Complete Phase 1 + Phase 2 +2. Complete US1 (Provider Connections + credentials management) +3. Validate with `tests/Feature/ProviderConnections/*` and DB-only render checks + +### Incremental Delivery + +1. US1 → demo/manage connections safely (no provider calls) +2. US2 → add health check (first real provider call, but enqueue-only + observable) +3. US3 → add inventory + compliance snapshot operations (summary counts + scope-busy rules) diff --git a/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php b/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php new file mode 100644 index 0000000..1a43ad5 --- /dev/null +++ b/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php @@ -0,0 +1,34 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'display_name' => 'Contoso', + 'entra_tenant_id' => fake()->uuid(), + 'provider' => 'microsoft', + ]); + + $this->actingAs($user); + + Bus::fake(); + + assertNoOutboundHttp(function () use ($tenant, $connection): void { + $this->get(ProviderConnectionResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee('Contoso'); + + $this->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->assertOk() + ->assertSee('Contoso'); + }); + + Bus::assertNothingDispatched(); +}); diff --git a/tests/Feature/Inventory/RunInventorySyncJobTest.php b/tests/Feature/Inventory/RunInventorySyncJobTest.php index 772dd9e..d014d24 100644 --- a/tests/Feature/Inventory/RunInventorySyncJobTest.php +++ b/tests/Feature/Inventory/RunInventorySyncJobTest.php @@ -2,11 +2,12 @@ use App\Jobs\RunInventorySyncJob; use App\Models\InventorySyncRun; -use App\Services\OperationRunService; +use App\Notifications\OperationRunCompleted; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\AuditLogger; use App\Services\Inventory\InventorySyncService; +use App\Services\OperationRunService; use Mockery\MockInterface; it('executes a pending inventory sync run and updates bulk progress + initiator attribution', function () { @@ -59,6 +60,14 @@ expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes)); expect((int) ($counts['succeeded'] ?? 0))->toBe(count($policyTypes)); expect((int) ($counts['failed'] ?? 0))->toBe(0); + + expect($user->notifications()->count())->toBe(1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunCompleted::class, + 'data->title' => 'Inventory sync completed', + ]); }); it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () { @@ -125,4 +134,12 @@ expect($failures)->toBeArray(); expect($failures[0]['code'] ?? null)->toBe('inventory.skipped'); expect($failures[0]['message'] ?? null)->toBe('locked'); + + expect($user->notifications()->count())->toBe(1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunCompleted::class, + 'data->title' => 'Inventory sync failed', + ]); }); diff --git a/tests/Feature/OpsUx/OperationCatalogCoverageTest.php b/tests/Feature/OpsUx/OperationCatalogCoverageTest.php index b6c1365..6e67ed6 100644 --- a/tests/Feature/OpsUx/OperationCatalogCoverageTest.php +++ b/tests/Feature/OpsUx/OperationCatalogCoverageTest.php @@ -27,14 +27,14 @@ // Capture common patterns where operation type strings are produced in code. // Example: ensureRun(type: 'inventory.sync', ...) - if (preg_match_all("/(?:\btype\s*:\s*|\btype\b\s*=>\s*)'([a-z0-9_]+\.[a-z0-9_]+)'/i", $contents, $matches)) { + if (preg_match_all("/(?:\btype\s*:\s*|\btype\b\s*=>\s*)'([a-z0-9_]+(?:\.[a-z0-9_]+)+)'/i", $contents, $matches)) { foreach ($matches[1] as $type) { $discoveredTypes[] = $type; } } // Example: if ($run->type === 'inventory.sync') - if (preg_match_all("/\btype\s*(?:===|!==|==|!=)\s*'([a-z0-9_]+\.[a-z0-9_]+)'/i", $contents, $matches)) { + if (preg_match_all("/\btype\s*(?:===|!==|==|!=)\s*'([a-z0-9_]+(?:\.[a-z0-9_]+)+)'/i", $contents, $matches)) { foreach ($matches[1] as $type) { $discoveredTypes[] = $type; } @@ -43,7 +43,7 @@ // Example: in_array($run->type, ['a.b', 'c.d'], true) if (preg_match_all("/\bin_array\([^\)]*\[([^\]]+)\]/i", $contents, $matches)) { foreach ($matches[1] as $list) { - if (preg_match_all("/'([a-z0-9_]+\.[a-z0-9_]+)'/i", $list, $inner)) { + if (preg_match_all("/'([a-z0-9_]+(?:\.[a-z0-9_]+)+)'/i", $list, $inner)) { foreach ($inner[1] as $type) { $discoveredTypes[] = $type; } diff --git a/tests/Feature/ProviderConnections/CredentialLeakGuardTest.php b/tests/Feature/ProviderConnections/CredentialLeakGuardTest.php new file mode 100644 index 0000000..b7ac6ca --- /dev/null +++ b/tests/Feature/ProviderConnections/CredentialLeakGuardTest.php @@ -0,0 +1,71 @@ +actingAs($user); + + Filament::setTenant($tenant, true); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => [ + 'provider' => 'microsoft', + 'provider_connection_id' => 123, + ], + ]); + + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $service->updateRun( + $run, + status: 'completed', + outcome: 'failed', + failures: [[ + 'code' => 'provider.auth', + 'message' => 'Authorization: Bearer super-secret-token access_token=abc refresh_token=def client_secret=ghi', + ]], + ); + + $run->refresh(); + + $failuresJson = json_encode($run->failure_summary); + + expect($failuresJson) + ->not->toContain('Authorization') + ->not->toContain('Bearer ') + ->not->toContain('access_token') + ->not->toContain('refresh_token') + ->not->toContain('client_secret') + ->not->toContain('super-secret-token') + ->not->toContain('abc') + ->not->toContain('def') + ->not->toContain('ghi'); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + + $body = (string) ($notification->data['body'] ?? ''); + + expect($body) + ->not->toContain('Authorization') + ->not->toContain('Bearer ') + ->not->toContain('access_token') + ->not->toContain('refresh_token') + ->not->toContain('client_secret') + ->not->toContain('super-secret-token') + ->not->toContain('abc') + ->not->toContain('def') + ->not->toContain('ghi'); +})->group('ops-ux'); diff --git a/tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php b/tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php new file mode 100644 index 0000000..eac9f0b --- /dev/null +++ b/tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php @@ -0,0 +1,117 @@ +instance(GraphClientInterface::class, new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, data: ['displayName' => 'Contoso']); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, data: [ + 'value' => [ + ['id' => '1', 'complianceState' => 'compliant'], + ['id' => '2', 'complianceState' => 'noncompliant'], + ['id' => '3', 'complianceState' => null], + ], + ]); + } + }); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'compliance.snapshot', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => [ + 'provider' => 'microsoft', + 'module' => 'compliance', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + ], + ], + ]); + + $job = new ProviderComplianceSnapshotJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + operationRun: $run, + ); + + $job->handle( + app(\App\Services\Providers\MicrosoftComplianceSnapshotService::class), + app(\App\Services\Providers\ProviderGateway::class), + app(OperationRunService::class), + ); + + $run->refresh(); + + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + expect($run->summary_counts)->toMatchArray([ + 'total' => 3, + 'compliant' => 1, + 'noncompliant' => 1, + 'unknown' => 1, + ]); + + expect($run->context)->toMatchArray([ + 'provider' => 'microsoft', + 'module' => 'compliance', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + 'entra_tenant_name' => 'Contoso', + ], + ]); +}); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php new file mode 100644 index 0000000..4db82c7 --- /dev/null +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php @@ -0,0 +1,95 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'display_name' => 'Contoso', + ]); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee(ProviderConnectionResource::getUrl('create', tenant: $tenant)); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('create', tenant: $tenant)) + ->assertOk(); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->assertOk() + ->assertSee('Contoso'); +}); + +test('operators can view provider connections but cannot manage them', function () { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + ]); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertDontSee(ProviderConnectionResource::getUrl('create', tenant: $tenant)); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('create', tenant: $tenant)) + ->assertForbidden(); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->assertOk() + ->assertDontSee('Update credentials') + ->assertDontSee('Disable connection'); +}); + +test('readonly users can view provider connections but cannot manage them', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + ]); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertDontSee(ProviderConnectionResource::getUrl('create', tenant: $tenant)); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('create', tenant: $tenant)) + ->assertForbidden(); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->assertOk() + ->assertDontSee('Update credentials') + ->assertDontSee('Disable connection'); +}); + +test('provider connection edit is not accessible cross-tenant', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $connectionB = ProviderConnection::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'display_name' => 'Tenant B Connection', + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connectionB], tenant: $tenantA)) + ->assertNotFound(); +}); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php b/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php new file mode 100644 index 0000000..9247889 --- /dev/null +++ b/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php @@ -0,0 +1,104 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'status' => 'disabled', + 'health_status' => 'down', + 'last_health_check_at' => now(), + 'last_error_reason_code' => 'provider_auth_failed', + 'last_error_message' => 'Some failure', + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->callAction('enable_connection'); + + $connection->refresh(); + + expect($connection->status)->toBe('needs_consent'); + expect($connection->health_status)->toBe('unknown'); + expect($connection->last_health_check_at)->toBeNull(); + expect($connection->last_error_reason_code)->toBeNull(); + expect($connection->last_error_message)->toBeNull(); +}); + +it('enables a disabled connection and sets connected when credentials are present', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'status' => 'disabled', + 'health_status' => 'down', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->callAction('enable_connection'); + + $connection->refresh(); + + expect($connection->status)->toBe('connected'); + expect($connection->health_status)->toBe('unknown'); +}); + +it('shows a link to the last connection check run when present', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'context' => [ + 'provider' => 'microsoft', + 'module' => 'health_check', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + ], + ], + ]); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->assertOk() + ->assertSee(OperationRunLinks::view($run, $tenant)); +}); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php new file mode 100644 index 0000000..f942bf6 --- /dev/null +++ b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php @@ -0,0 +1,218 @@ +instance(GraphClientInterface::class, new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, data: ['id' => 'org-id', 'displayName' => 'Contoso']); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + }); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'status' => 'needs_consent', + 'health_status' => 'unknown', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => [ + 'provider' => 'microsoft', + 'module' => 'health_check', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + ], + ], + ]); + + $job = new ProviderConnectionHealthCheckJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + operationRun: $run, + ); + + $job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class)); + + $connection->refresh(); + $run->refresh(); + + expect($connection->status)->toBe('connected'); + expect($connection->health_status)->toBe('ok'); + expect($connection->last_health_check_at)->not->toBeNull(); + expect($connection->last_error_reason_code)->toBeNull(); + expect($connection->last_error_message)->toBeNull(); + + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + expect($run->context)->toMatchArray([ + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + 'entra_tenant_name' => 'Contoso', + ], + ]); + + expect($connection->metadata)->toMatchArray([ + 'entra_tenant_name' => 'Contoso', + ]); +}); + +it('categorizes auth failures and stores sanitized reason codes and messages', function (): void { + app()->instance(GraphClientInterface::class, new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse( + success: false, + data: [], + status: 401, + errors: ['invalid_client Authorization: Bearer super-secret-token client_secret=ghi'], + ); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + }); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'status' => 'needs_consent', + 'health_status' => 'unknown', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => [ + 'provider' => 'microsoft', + 'module' => 'health_check', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + ], + ], + ]); + + $job = new ProviderConnectionHealthCheckJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + operationRun: $run, + ); + + $job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class)); + + $connection->refresh(); + $run->refresh(); + + expect($connection->status)->toBe('needs_consent'); + expect($connection->health_status)->toBe('down'); + expect($connection->last_error_reason_code)->toBe('provider_auth_failed'); + expect((string) $connection->last_error_message) + ->not->toContain('Authorization') + ->not->toContain('Bearer ') + ->not->toContain('client_secret'); + + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('failed'); + + $failures = $run->failure_summary; + expect($failures)->toBeArray()->not->toBeEmpty(); + + $message = (string) ($failures[0]['message'] ?? ''); + expect($failures[0]['reason_code'] ?? null)->toBe('provider_auth_failed'); + expect($message) + ->not->toContain('Authorization') + ->not->toContain('Bearer ') + ->not->toContain('client_secret'); +}); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php new file mode 100644 index 0000000..fb669f3 --- /dev/null +++ b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php @@ -0,0 +1,93 @@ +mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->callAction('check_connection'); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + expect($opRun?->status)->toBe('queued'); + expect($opRun?->outcome)->toBe('pending'); + + expect($opRun?->context)->toMatchArray([ + 'provider' => 'microsoft', + 'module' => 'health_check', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + ], + ]); + + $notifications = session('filament.notifications', []); + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($opRun, $tenant)); + + Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); +}); + +it('dedupes connection checks and does not enqueue a second job', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + ]); + + $component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]); + + $component->callAction('check_connection'); + $component->callAction('check_connection'); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->count())->toBe(1); + + Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); +}); diff --git a/tests/Feature/ProviderConnections/ProviderCredentialSecurityTest.php b/tests/Feature/ProviderConnections/ProviderCredentialSecurityTest.php new file mode 100644 index 0000000..24d8e20 --- /dev/null +++ b/tests/Feature/ProviderConnections/ProviderCredentialSecurityTest.php @@ -0,0 +1,36 @@ +create([ + 'tenant_id' => $tenant->getKey(), + ]); + + $this->actingAs($user); + + /** @var CredentialManager $manager */ + $manager = app(CredentialManager::class); + $manager->upsertClientSecretCredential( + connection: $connection, + clientId: 'client-id', + clientSecret: 'super-secret', + ); + + $credential = ProviderCredential::query() + ->where('provider_connection_id', $connection->getKey()) + ->first(); + + expect($credential)->not->toBeNull(); + expect((string) $credential->getRawOriginal('payload'))->not->toContain('super-secret'); + + $this->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->assertOk() + ->assertDontSee('super-secret') + ->assertDontSee('client_secret'); +}); diff --git a/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php b/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php new file mode 100644 index 0000000..687f5cb --- /dev/null +++ b/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php @@ -0,0 +1,158 @@ +mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + ]); + + $component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]); + $component->callAction('inventory_sync'); + $component->callAction('inventory_sync'); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + expect($opRun?->context)->toMatchArray([ + 'provider' => 'microsoft', + 'module' => 'inventory', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + ], + ]); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'inventory.sync') + ->count())->toBe(1); + + Queue::assertPushed(ProviderInventorySyncJob::class, 1); +}); + +it('dedupes compliance snapshot runs and does not call Graph during start', function (): void { + Queue::fake(); + + $this->mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + ]); + + $component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]); + $component->callAction('compliance_snapshot'); + $component->callAction('compliance_snapshot'); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'compliance.snapshot') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + expect($opRun?->context)->toMatchArray([ + 'provider' => 'microsoft', + 'module' => 'compliance', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + ], + ]); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'compliance.snapshot') + ->count())->toBe(1); + + Queue::assertPushed(ProviderComplianceSnapshotJob::class, 1); +}); + +it('blocks different provider operations for the same scope as scope busy', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + ]); + + $component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]); + + $component->callAction('inventory_sync'); + $component->callAction('compliance_snapshot'); + + $inventoryRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($inventoryRun)->not->toBeNull(); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'compliance.snapshot') + ->count())->toBe(0); + + Queue::assertPushed(ProviderInventorySyncJob::class, 1); + Queue::assertPushed(ProviderComplianceSnapshotJob::class, 0); + + $notifications = session('filament.notifications', []); + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($inventoryRun, $tenant)); +}); diff --git a/tests/Feature/ProviderConnections/ProviderRbacCapabilitiesTest.php b/tests/Feature/ProviderConnections/ProviderRbacCapabilitiesTest.php new file mode 100644 index 0000000..a2f3d84 --- /dev/null +++ b/tests/Feature/ProviderConnections/ProviderRbacCapabilitiesTest.php @@ -0,0 +1,18 @@ +actingAs($user); + + expect(Gate::allows('provider.view', $tenant))->toBe($canView); + expect(Gate::allows('provider.manage', $tenant))->toBe($canManage); + expect(Gate::allows('provider.run', $tenant))->toBe($canRun); +})->with([ + 'owner' => ['owner', true, true, true], + 'manager' => ['manager', true, true, true], + 'operator' => ['operator', true, false, true], + 'readonly' => ['readonly', true, false, false], +]); diff --git a/tests/Unit/Badges/ProviderConnectionBadgesTest.php b/tests/Unit/Badges/ProviderConnectionBadgesTest.php new file mode 100644 index 0000000..9fbefcb --- /dev/null +++ b/tests/Unit/Badges/ProviderConnectionBadgesTest.php @@ -0,0 +1,42 @@ +color)->toBe('success'); + expect($connected->label)->toBe('Connected'); + + $needsConsent = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'needs_consent'); + expect($needsConsent->color)->toBe('warning'); + expect($needsConsent->label)->toBe('Needs consent'); + + $error = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'error'); + expect($error->color)->toBe('danger'); + expect($error->label)->toBe('Error'); + + $disabled = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'disabled'); + expect($disabled->color)->toBe('gray'); + expect($disabled->label)->toBe('Disabled'); +}); + +it('maps provider connection health safely', function (): void { + $ok = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'ok'); + expect($ok->color)->toBe('success'); + expect($ok->label)->toBe('OK'); + + $degraded = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'degraded'); + expect($degraded->color)->toBe('warning'); + expect($degraded->label)->toBe('Degraded'); + + $down = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'down'); + expect($down->color)->toBe('danger'); + expect($down->label)->toBe('Down'); + + $unknown = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'unknown'); + expect($unknown->color)->toBe('gray'); + expect($unknown->label)->toBe('Unknown'); +}); diff --git a/tests/Unit/OpsUx/RunFailureSanitizerTest.php b/tests/Unit/OpsUx/RunFailureSanitizerTest.php new file mode 100644 index 0000000..0123a16 --- /dev/null +++ b/tests/Unit/OpsUx/RunFailureSanitizerTest.php @@ -0,0 +1,29 @@ +toBe(RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED); + expect(RunFailureSanitizer::normalizeReasonCode('AADSTS700016'))->toBe(RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED); + expect(RunFailureSanitizer::normalizeReasonCode('bad_gateway'))->toBe(RunFailureSanitizer::REASON_PROVIDER_OUTAGE); + expect(RunFailureSanitizer::normalizeReasonCode('500'))->toBe(RunFailureSanitizer::REASON_PROVIDER_OUTAGE); +}); + +it('redacts common secret patterns and forbidden substrings', function (): void { + $message = 'Authorization: Bearer super-secret-token access_token=abc refresh_token=def client_secret=ghi password=jkl'; + + $sanitized = RunFailureSanitizer::sanitizeMessage($message); + + expect($sanitized) + ->not->toContain('Authorization') + ->not->toContain('Bearer ') + ->not->toContain('access_token') + ->not->toContain('refresh_token') + ->not->toContain('client_secret') + ->not->toContain('password') + ->not->toContain('super-secret-token') + ->not->toContain('abc') + ->not->toContain('def') + ->not->toContain('ghi') + ->not->toContain('jkl'); +}); diff --git a/tests/Unit/Providers/CredentialManagerTest.php b/tests/Unit/Providers/CredentialManagerTest.php new file mode 100644 index 0000000..becbac7 --- /dev/null +++ b/tests/Unit/Providers/CredentialManagerTest.php @@ -0,0 +1,73 @@ +create([ + 'entra_tenant_id' => fake()->uuid(), + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + $manager = app(CredentialManager::class); + + expect($manager->getClientCredentials($connection)) + ->toBe([ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ]); +}); + +it('rejects credential payload that does not match the connection scope', function (): void { + $connection = ProviderConnection::factory()->create([ + 'entra_tenant_id' => 'tenant-a', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'tenant_id' => 'tenant-b', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + $manager = app(CredentialManager::class); + + $manager->getClientCredentials($connection); +})->throws(InvalidArgumentException::class); + +it('upserts client secret credentials and never serializes the payload', function (): void { + $connection = ProviderConnection::factory()->create(); + + $manager = app(CredentialManager::class); + + $credential = $manager->upsertClientSecretCredential( + connection: $connection, + clientId: 'client-id', + clientSecret: 'client-secret', + ); + + expect($credential->type)->toBe('client_secret'); + expect($credential->payload)->toBe([ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ]); + + expect($credential->toArray())->not->toHaveKey('payload'); + + expect((string) $credential->getRawOriginal('payload')) + ->not->toContain('client-secret') + ->not->toContain('client_id'); +}); diff --git a/tests/Unit/Providers/ProviderGatewayTest.php b/tests/Unit/Providers/ProviderGatewayTest.php new file mode 100644 index 0000000..a3a090a --- /dev/null +++ b/tests/Unit/Providers/ProviderGatewayTest.php @@ -0,0 +1,96 @@ +create([ + 'entra_tenant_id' => 'entra-tenant-id', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + $graph = new class implements GraphClientInterface + { + public array $calls = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->calls[] = ['fn' => 'listPolicies', 'policyType' => $policyType, 'options' => $options]; + + return new GraphResponse(true); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->calls[] = ['fn' => 'getPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options]; + + return new GraphResponse(true); + } + + public function getOrganization(array $options = []): GraphResponse + { + $this->calls[] = ['fn' => 'getOrganization', 'options' => $options]; + + return new GraphResponse(true); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->calls[] = ['fn' => 'applyPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'payload' => $payload, 'options' => $options]; + + return new GraphResponse(true); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + $this->calls[] = ['fn' => 'getServicePrincipalPermissions', 'options' => $options]; + + return new GraphResponse(true); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->calls[] = ['fn' => 'request', 'method' => $method, 'path' => $path, 'options' => $options]; + + return new GraphResponse(true); + } + }; + + $gateway = new ProviderGateway( + graph: $graph, + credentials: app(CredentialManager::class), + ); + + $gateway->getOrganization($connection); + $gateway->request($connection, 'GET', 'organization', ['query' => ['a' => 'b']]); + + expect($graph->calls)->toHaveCount(2); + + $first = $graph->calls[0]['options']; + $second = $graph->calls[1]['options']; + + expect($first['tenant'])->toBe('entra-tenant-id'); + expect($first['client_id'])->toBe('client-id'); + expect($first['client_secret'])->toBe('client-secret'); + expect($first['client_request_id'])->toBeString()->not->toBeEmpty(); + + expect($second['tenant'])->toBe('entra-tenant-id'); + expect($second['client_id'])->toBe('client-id'); + expect($second['client_secret'])->toBe('client-secret'); + expect($second['client_request_id'])->toBeString()->not->toBeEmpty(); + expect($second['query'])->toBe(['a' => 'b']); +}); diff --git a/tests/Unit/Providers/ProviderOperationStartGateTest.php b/tests/Unit/Providers/ProviderOperationStartGateTest.php new file mode 100644 index 0000000..ad23949 --- /dev/null +++ b/tests/Unit/Providers/ProviderOperationStartGateTest.php @@ -0,0 +1,116 @@ +create(); + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => 'entra-tenant-id', + ]); + + $dispatched = 0; + + $gate = app(ProviderOperationStartGate::class); + + $result = $gate->start( + tenant: $tenant, + connection: $connection, + operationType: 'provider.connection.check', + dispatcher: function (OperationRun $run) use (&$dispatched): void { + $dispatched++; + expect($run->type)->toBe('provider.connection.check'); + }, + ); + + expect($dispatched)->toBe(1); + expect($result->status)->toBe('started'); + expect($result->dispatched)->toBeTrue(); + + $run = $result->run->fresh(); + expect($run)->not->toBeNull(); + expect($run->type)->toBe('provider.connection.check'); + expect($run->status)->toBe('queued'); + + expect($run->context)->toMatchArray([ + 'provider' => 'microsoft', + 'module' => 'health_check', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => 'entra-tenant-id', + ], + ]); +}); + +it('dedupes when the same operation is already active for the scope', function (): void { + $tenant = Tenant::factory()->create(); + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + ]); + + $existing = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'running', + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $dispatched = 0; + $gate = app(ProviderOperationStartGate::class); + + $result = $gate->start( + tenant: $tenant, + connection: $connection, + operationType: 'provider.connection.check', + dispatcher: function () use (&$dispatched): void { + $dispatched++; + }, + ); + + expect($dispatched)->toBe(0); + expect($result->status)->toBe('deduped'); + expect($result->run->getKey())->toBe($existing->getKey()); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(1); +}); + +it('blocks when a different operation is already active for the scope', function (): void { + $tenant = Tenant::factory()->create(); + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + ]); + + $blocking = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $dispatched = 0; + $gate = app(ProviderOperationStartGate::class); + + $result = $gate->start( + tenant: $tenant, + connection: $connection, + operationType: 'provider.connection.check', + dispatcher: function () use (&$dispatched): void { + $dispatched++; + }, + ); + + expect($dispatched)->toBe(0); + expect($result->status)->toBe('scope_busy'); + expect($result->run->getKey())->toBe($blocking->getKey()); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(1); +}); -- 2.45.2 From 3b1dd98f52151d80614d56471924bc157964d3ef Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 25 Jan 2026 16:01:50 +0100 Subject: [PATCH 2/2] 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(); +}); -- 2.45.2