diff --git a/app/Filament/Pages/Settings/WorkspaceSettings.php b/app/Filament/Pages/Settings/WorkspaceSettings.php new file mode 100644 index 0000000..4402c73 --- /dev/null +++ b/app/Filament/Pages/Settings/WorkspaceSettings.php @@ -0,0 +1,256 @@ + + */ + public array $data = []; + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return [ + Action::make('save') + ->label('Save') + ->action(function (): void { + $this->save(); + }) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->tooltip(fn (): ?string => $this->currentUserCanManage() + ? null + : 'You do not have permission to manage workspace settings.'), + Action::make('reset') + ->label('Reset to default') + ->color('danger') + ->requiresConfirmation() + ->action(function (): void { + $this->resetSetting(); + }) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->tooltip(fn (): ?string => $this->currentUserCanManage() + ? null + : 'You do not have permission to manage workspace settings.'), + ]; + } + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide save and reset controls for the settings form.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace settings are edited as a singleton form without a record inspect action.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The page does not render table rows with secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The page has no bulk actions because it manages a single settings scope.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'The settings form is always rendered and has no list empty state.'); + } + + public function mount(): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + $this->redirect('/admin/choose-workspace'); + + return; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + $this->workspace = $workspace; + + $this->authorizeWorkspaceView($user); + + $this->loadFormState(); + } + + public function content(Schema $schema): Schema + { + return $schema + ->statePath('data') + ->schema([ + Section::make('Backup settings') + ->description('Workspace defaults used when a schedule has no explicit value.') + ->schema([ + TextInput::make('backup_retention_keep_last_default') + ->label('Default retention keep-last') + ->numeric() + ->integer() + ->minValue(1) + ->required() + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText('Fallback value for backup schedule retention when retention_keep_last is empty.'), + ]), + ]); + } + + public function save(): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $this->authorizeWorkspaceManage($user); + + try { + app(SettingsWriter::class)->updateWorkspaceSetting( + actor: $user, + workspace: $this->workspace, + domain: 'backup', + key: 'retention_keep_last_default', + value: $this->data['backup_retention_keep_last_default'] ?? null, + ); + } catch (ValidationException $exception) { + $errors = $exception->errors(); + + if (isset($errors['value'])) { + throw ValidationException::withMessages([ + 'data.backup_retention_keep_last_default' => $errors['value'], + ]); + } + + throw $exception; + } + + $this->loadFormState(); + + Notification::make() + ->title('Workspace settings saved') + ->success() + ->send(); + } + + public function resetSetting(): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $this->authorizeWorkspaceManage($user); + + app(SettingsWriter::class)->resetWorkspaceSetting( + actor: $user, + workspace: $this->workspace, + domain: 'backup', + key: 'retention_keep_last_default', + ); + + $this->loadFormState(); + + Notification::make() + ->title('Workspace setting reset to default') + ->success() + ->send(); + } + + private function loadFormState(): void + { + $resolvedValue = app(SettingsResolver::class)->resolveValue( + workspace: $this->workspace, + domain: 'backup', + key: 'retention_keep_last_default', + ); + + $this->data = [ + 'backup_retention_keep_last_default' => is_numeric($resolvedValue) ? (int) $resolvedValue : 30, + ]; + } + + private function currentUserCanManage(): bool + { + $user = auth()->user(); + + if (! $user instanceof User || ! $this->workspace instanceof Workspace) { + return false; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $this->workspace) + && $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE); + } + + private function authorizeWorkspaceView(User $user): void + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $this->workspace)) { + abort(404); + } + + if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_VIEW)) { + abort(403); + } + } + + private function authorizeWorkspaceManage(User $user): void + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $this->workspace)) { + abort(404); + } + + if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) { + abort(403); + } + } +} diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index 1c12060..0ab9470 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -94,7 +94,7 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool return true; } - if (in_array($path, ['/admin/choose-workspace', '/admin/no-access', '/admin/onboarding'], true)) { + if (in_array($path, ['/admin/choose-workspace', '/admin/no-access', '/admin/onboarding', '/admin/settings/workspace'], true)) { return true; } diff --git a/app/Jobs/ApplyBackupScheduleRetentionJob.php b/app/Jobs/ApplyBackupScheduleRetentionJob.php index aed8b52..ed197c9 100644 --- a/app/Jobs/ApplyBackupScheduleRetentionJob.php +++ b/app/Jobs/ApplyBackupScheduleRetentionJob.php @@ -6,6 +6,7 @@ use App\Models\BackupSet; use App\Models\OperationRun; use App\Services\Intune\AuditLogger; +use App\Services\Settings\SettingsResolver; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use Illuminate\Contracts\Queue\ShouldQueue; @@ -19,10 +20,10 @@ class ApplyBackupScheduleRetentionJob implements ShouldQueue public function __construct(public int $backupScheduleId) {} - public function handle(AuditLogger $auditLogger): void + public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResolver): void { $schedule = BackupSchedule::query() - ->with('tenant') + ->with(['tenant.workspace']) ->find($this->backupScheduleId); if (! $schedule || ! $schedule->tenant) { @@ -44,7 +45,26 @@ public function handle(AuditLogger $auditLogger): void 'started_at' => now(), ]); - $keepLast = (int) ($schedule->retention_keep_last ?? 30); + $keepLast = $schedule->retention_keep_last; + + if ($keepLast === null && $schedule->tenant->workspace instanceof \App\Models\Workspace) { + $resolved = $settingsResolver->resolveValue( + workspace: $schedule->tenant->workspace, + domain: 'backup', + key: 'retention_keep_last_default', + tenant: $schedule->tenant, + ); + + if (is_numeric($resolved)) { + $keepLast = (int) $resolved; + } + } + + if (! is_numeric($keepLast)) { + $keepLast = 30; + } + + $keepLast = (int) $keepLast; if ($keepLast < 1) { $keepLast = 1; diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index e441238..b2aed43 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -260,6 +260,11 @@ public function auditLogs(): HasMany return $this->hasMany(AuditLog::class); } + public function settings(): HasMany + { + return $this->hasMany(TenantSetting::class); + } + public function permissions(): HasMany { return $this->hasMany(TenantPermission::class); diff --git a/app/Models/TenantSetting.php b/app/Models/TenantSetting.php new file mode 100644 index 0000000..ca73a29 --- /dev/null +++ b/app/Models/TenantSetting.php @@ -0,0 +1,33 @@ +belongsTo(Workspace::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function updatedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by_user_id'); + } +} diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index d351b3c..f14dcba 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -40,4 +40,20 @@ public function tenants(): HasMany { return $this->hasMany(Tenant::class); } + + /** + * @return HasMany + */ + public function settings(): HasMany + { + return $this->hasMany(WorkspaceSetting::class); + } + + /** + * @return HasMany + */ + public function tenantSettings(): HasMany + { + return $this->hasMany(TenantSetting::class); + } } diff --git a/app/Models/WorkspaceSetting.php b/app/Models/WorkspaceSetting.php new file mode 100644 index 0000000..931459d --- /dev/null +++ b/app/Models/WorkspaceSetting.php @@ -0,0 +1,26 @@ +belongsTo(Workspace::class); + } + + public function updatedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by_user_id'); + } +} diff --git a/app/Policies/WorkspaceSettingPolicy.php b/app/Policies/WorkspaceSettingPolicy.php new file mode 100644 index 0000000..1d9cc6a --- /dev/null +++ b/app/Policies/WorkspaceSettingPolicy.php @@ -0,0 +1,84 @@ +authorizeForWorkspace( + user: $user, + workspace: $workspaceSetting->workspace, + capability: Capabilities::WORKSPACE_SETTINGS_VIEW, + ); + } + + public function create(User $user): bool|Response + { + return Response::deny(); + } + + public function update(User $user, WorkspaceSetting $workspaceSetting): bool|Response + { + return $this->authorizeForWorkspace( + user: $user, + workspace: $workspaceSetting->workspace, + capability: Capabilities::WORKSPACE_SETTINGS_MANAGE, + ); + } + + public function delete(User $user, WorkspaceSetting $workspaceSetting): bool|Response + { + return $this->authorizeForWorkspace( + user: $user, + workspace: $workspaceSetting->workspace, + capability: Capabilities::WORKSPACE_SETTINGS_MANAGE, + ); + } + + public function viewForWorkspace(User $user, Workspace $workspace): bool|Response + { + return $this->authorizeForWorkspace( + user: $user, + workspace: $workspace, + capability: Capabilities::WORKSPACE_SETTINGS_VIEW, + ); + } + + public function manageForWorkspace(User $user, Workspace $workspace): bool|Response + { + return $this->authorizeForWorkspace( + user: $user, + workspace: $workspace, + capability: Capabilities::WORKSPACE_SETTINGS_MANAGE, + ); + } + + private function authorizeForWorkspace(User $user, Workspace $workspace, string $capability): bool|Response + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $workspace, $capability) + ? Response::allow() + : Response::deny(); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index bd4e9a4..e4426cd 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -7,7 +7,9 @@ use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; +use App\Models\WorkspaceSetting; use App\Policies\ProviderConnectionPolicy; +use App\Policies\WorkspaceSettingPolicy; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver; use App\Support\Auth\Capabilities; @@ -19,6 +21,7 @@ class AuthServiceProvider extends ServiceProvider { protected $policies = [ ProviderConnection::class => ProviderConnectionPolicy::class, + WorkspaceSetting::class => WorkspaceSettingPolicy::class, ]; public function boot(): void diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index c65918a..7575a55 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -7,6 +7,7 @@ use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\NoAccess; +use App\Filament\Pages\Settings\WorkspaceSettings; use App\Filament\Pages\TenantRequiredPermissions; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\PolicyResource; @@ -14,9 +15,12 @@ use App\Filament\Resources\TenantResource; use App\Filament\Resources\Workspaces\WorkspaceResource; use App\Models\User; +use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Support\Auth\Capabilities; +use App\Support\Workspaces\WorkspaceContext; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -59,6 +63,36 @@ public function panel(Panel $panel): Panel ->group('Settings') ->sort(15) ->visible(fn (): bool => ProviderConnectionResource::canViewAny()), + NavigationItem::make('Settings') + ->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin')) + ->icon('heroicon-o-cog-6-tooth') + ->group('Settings') + ->sort(20) + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW); + }), NavigationItem::make('Manage workspaces') ->url(function (): string { return route('filament.admin.resources.workspaces.index'); @@ -122,6 +156,7 @@ public function panel(Panel $panel): Panel ->pages([ InventoryCoverage::class, TenantRequiredPermissions::class, + WorkspaceSettings::class, ]) ->widgets([ AccountWidget::class, diff --git a/app/Services/Auth/WorkspaceRoleCapabilityMap.php b/app/Services/Auth/WorkspaceRoleCapabilityMap.php index 817dbe3..04ea914 100644 --- a/app/Services/Auth/WorkspaceRoleCapabilityMap.php +++ b/app/Services/Auth/WorkspaceRoleCapabilityMap.php @@ -32,6 +32,8 @@ class WorkspaceRoleCapabilityMap Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE, + Capabilities::WORKSPACE_SETTINGS_VIEW, + Capabilities::WORKSPACE_SETTINGS_MANAGE, ], WorkspaceRole::Manager->value => [ @@ -46,6 +48,8 @@ class WorkspaceRoleCapabilityMap Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP, + Capabilities::WORKSPACE_SETTINGS_VIEW, + Capabilities::WORKSPACE_SETTINGS_MANAGE, ], WorkspaceRole::Operator->value => [ @@ -56,10 +60,12 @@ class WorkspaceRoleCapabilityMap Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP, + Capabilities::WORKSPACE_SETTINGS_VIEW, ], WorkspaceRole::Readonly->value => [ Capabilities::WORKSPACE_VIEW, + Capabilities::WORKSPACE_SETTINGS_VIEW, ], ]; diff --git a/app/Services/Settings/SettingsResolver.php b/app/Services/Settings/SettingsResolver.php new file mode 100644 index 0000000..363c0cd --- /dev/null +++ b/app/Services/Settings/SettingsResolver.php @@ -0,0 +1,137 @@ + + */ + private array $resolved = []; + + public function __construct(private SettingsRegistry $registry) {} + + /** + * @return array{domain: string, key: string, value: mixed, source: 'system_default'|'workspace_override'|'tenant_override', system_default: mixed, workspace_value: mixed, tenant_value: mixed} + */ + public function resolveDetailed(Workspace $workspace, string $domain, string $key, ?Tenant $tenant = null): array + { + if ($tenant instanceof Tenant) { + $this->assertTenantBelongsToWorkspace($workspace, $tenant); + } + + $cacheKey = $this->cacheKey($workspace, $domain, $key, $tenant); + + if (isset($this->resolved[$cacheKey])) { + return $this->resolved[$cacheKey]; + } + + $definition = $this->registry->require($domain, $key); + + $workspaceValue = $this->workspaceOverrideValue($workspace, $domain, $key); + $tenantValue = $tenant instanceof Tenant + ? $this->tenantOverrideValue($workspace, $tenant, $domain, $key) + : null; + + $source = 'system_default'; + $value = $definition->systemDefault; + + if ($workspaceValue !== null) { + $source = 'workspace_override'; + $value = $workspaceValue; + } + + if ($tenantValue !== null) { + $source = 'tenant_override'; + $value = $tenantValue; + } + + return $this->resolved[$cacheKey] = [ + 'domain' => $domain, + 'key' => $key, + 'value' => $value, + 'source' => $source, + 'system_default' => $definition->systemDefault, + 'workspace_value' => $workspaceValue, + 'tenant_value' => $tenantValue, + ]; + } + + public function resolveValue(Workspace $workspace, string $domain, string $key, ?Tenant $tenant = null): mixed + { + return $this->resolveDetailed($workspace, $domain, $key, $tenant)['value']; + } + + public function clearCache(): void + { + $this->resolved = []; + } + + private function workspaceOverrideValue(Workspace $workspace, string $domain, string $key): mixed + { + $setting = WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', $domain) + ->where('key', $key) + ->first(['value']); + + if (! $setting instanceof WorkspaceSetting) { + return null; + } + + return $this->decodeStoredValue($setting->getAttribute('value')); + } + + private function tenantOverrideValue(Workspace $workspace, Tenant $tenant, string $domain, string $key): mixed + { + $setting = TenantSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->where('domain', $domain) + ->where('key', $key) + ->first(['value']); + + if (! $setting instanceof TenantSetting) { + return null; + } + + return $this->decodeStoredValue($setting->getAttribute('value')); + } + + private function decodeStoredValue(mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + $decoded = json_decode($value, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : $value; + } + + private function assertTenantBelongsToWorkspace(Workspace $workspace, Tenant $tenant): void + { + if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) { + throw new NotFoundHttpException('Tenant is outside the selected workspace scope.'); + } + } + + private function cacheKey(Workspace $workspace, string $domain, string $key, ?Tenant $tenant): string + { + return implode(':', [ + (string) $workspace->getKey(), + (string) ($tenant?->getKey() ?? 0), + $domain, + $key, + ]); + } +} diff --git a/app/Services/Settings/SettingsWriter.php b/app/Services/Settings/SettingsWriter.php new file mode 100644 index 0000000..f37a35d --- /dev/null +++ b/app/Services/Settings/SettingsWriter.php @@ -0,0 +1,219 @@ +authorizeManage($actor, $workspace); + + $definition = $this->requireDefinition($domain, $key); + $normalizedValue = $this->validatedValue($definition, $value); + + $existing = WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', $domain) + ->where('key', $key) + ->first(); + + $beforeValue = $existing instanceof WorkspaceSetting + ? $this->decodeStoredValue($existing->getAttribute('value')) + : null; + + $setting = WorkspaceSetting::query()->updateOrCreate([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => $domain, + 'key' => $key, + ], [ + 'value' => $normalizedValue, + 'updated_by_user_id' => (int) $actor->getKey(), + ]); + + $this->resolver->clearCache(); + + $afterValue = $this->resolver->resolveValue($workspace, $domain, $key); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceSettingUpdated->value, + context: [ + 'metadata' => [ + 'scope' => 'workspace', + 'domain' => $domain, + 'key' => $key, + 'before_value' => $beforeValue, + 'after_value' => $afterValue, + ], + ], + actor: $actor, + resourceType: 'workspace_setting', + resourceId: $domain.'.'.$key, + ); + + return $setting; + } + + public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void + { + $this->authorizeManage($actor, $workspace); + + $this->requireDefinition($domain, $key); + + $existing = WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', $domain) + ->where('key', $key) + ->first(); + + $beforeValue = $existing instanceof WorkspaceSetting + ? $this->decodeStoredValue($existing->getAttribute('value')) + : null; + + if ($existing instanceof WorkspaceSetting) { + $existing->delete(); + } + + $this->resolver->clearCache(); + + $afterValue = $this->resolver->resolveValue($workspace, $domain, $key); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceSettingReset->value, + context: [ + 'metadata' => [ + 'scope' => 'workspace', + 'domain' => $domain, + 'key' => $key, + 'before_value' => $beforeValue, + 'after_value' => $afterValue, + ], + ], + actor: $actor, + resourceType: 'workspace_setting', + resourceId: $domain.'.'.$key, + ); + } + + public function updateTenantSetting(User $actor, Workspace $workspace, Tenant $tenant, string $domain, string $key, mixed $value): TenantSetting + { + $this->authorizeManage($actor, $workspace); + $this->assertTenantBelongsToWorkspace($workspace, $tenant); + + $definition = $this->requireDefinition($domain, $key); + $normalizedValue = $this->validatedValue($definition, $value); + + $setting = TenantSetting::query()->updateOrCreate([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'domain' => $domain, + 'key' => $key, + ], [ + 'value' => $normalizedValue, + 'updated_by_user_id' => (int) $actor->getKey(), + ]); + + $this->resolver->clearCache(); + + return $setting; + } + + public function resetTenantSetting(User $actor, Workspace $workspace, Tenant $tenant, string $domain, string $key): void + { + $this->authorizeManage($actor, $workspace); + $this->assertTenantBelongsToWorkspace($workspace, $tenant); + + $this->requireDefinition($domain, $key); + + TenantSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->where('domain', $domain) + ->where('key', $key) + ->delete(); + + $this->resolver->clearCache(); + } + + private function requireDefinition(string $domain, string $key): SettingDefinition + { + $definition = $this->registry->find($domain, $key); + + if ($definition instanceof SettingDefinition) { + return $definition; + } + + throw ValidationException::withMessages([ + 'key' => [sprintf('Unknown setting key: %s.%s', $domain, $key)], + ]); + } + + private function validatedValue(SettingDefinition $definition, mixed $value): mixed + { + $validator = Validator::make( + data: ['value' => $value], + rules: ['value' => $definition->rules], + ); + + if ($validator->fails()) { + throw ValidationException::withMessages($validator->errors()->toArray()); + } + + return $definition->normalize($validator->validated()['value']); + } + + private function authorizeManage(User $actor, Workspace $workspace): void + { + if (! $this->workspaceCapabilityResolver->isMember($actor, $workspace)) { + throw new NotFoundHttpException('Workspace not found.'); + } + + if (! $this->workspaceCapabilityResolver->can($actor, $workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) { + throw new AuthorizationException('Missing workspace settings manage capability.'); + } + } + + private function assertTenantBelongsToWorkspace(Workspace $workspace, Tenant $tenant): void + { + if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) { + throw new NotFoundHttpException('Tenant is outside the selected workspace scope.'); + } + } + + private function decodeStoredValue(mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + $decoded = json_decode($value, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : $value; + } +} diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index 4f180fc..240ebb3 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -30,4 +30,7 @@ enum AuditActionId: string case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation'; case VerificationCompleted = 'verification.completed'; case VerificationCheckAcknowledged = 'verification.check_acknowledged'; + + case WorkspaceSettingUpdated = 'workspace_setting.updated'; + case WorkspaceSettingReset = 'workspace_setting.reset'; } diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index ac2eb9a..b4bfbf9 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -46,6 +46,11 @@ class Capabilities public const WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE = 'workspace_managed_tenant.onboard.activate'; + // Workspace settings + public const WORKSPACE_SETTINGS_VIEW = 'workspace_settings.view'; + + public const WORKSPACE_SETTINGS_MANAGE = 'workspace_settings.manage'; + // Tenants public const TENANT_VIEW = 'tenant.view'; diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index fff692a..038a00b 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -134,7 +134,7 @@ public function handle(Request $request, Closure $next): Response str_starts_with($path, '/admin/w/') || str_starts_with($path, '/admin/workspaces') || str_starts_with($path, '/admin/operations') - || in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding'], true) + || in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace'], true) ) { $this->configureNavigationForRequest($panel); diff --git a/app/Support/Settings/SettingDefinition.php b/app/Support/Settings/SettingDefinition.php new file mode 100644 index 0000000..e84a4fe --- /dev/null +++ b/app/Support/Settings/SettingDefinition.php @@ -0,0 +1,36 @@ + $rules + */ + public function __construct( + public string $domain, + public string $key, + public string $type, + public mixed $systemDefault, + public array $rules, + private ?Closure $normalizer = null, + ) {} + + public function dotKey(): string + { + return $this->domain.'.'.$this->key; + } + + public function normalize(mixed $value): mixed + { + if ($this->normalizer instanceof Closure) { + return ($this->normalizer)($value); + } + + return $value; + } +} diff --git a/app/Support/Settings/SettingsRegistry.php b/app/Support/Settings/SettingsRegistry.php new file mode 100644 index 0000000..4eb831e --- /dev/null +++ b/app/Support/Settings/SettingsRegistry.php @@ -0,0 +1,61 @@ + + */ + private array $definitions; + + public function __construct() + { + $this->definitions = []; + + $this->register(new SettingDefinition( + domain: 'backup', + key: 'retention_keep_last_default', + type: 'int', + systemDefault: 30, + rules: ['required', 'integer', 'min:1', 'max:3650'], + normalizer: static fn (mixed $value): int => (int) $value, + )); + } + + /** + * @return array + */ + public function all(): array + { + return $this->definitions; + } + + public function find(string $domain, string $key): ?SettingDefinition + { + return $this->definitions[$this->cacheKey($domain, $key)] ?? null; + } + + public function require(string $domain, string $key): SettingDefinition + { + $definition = $this->find($domain, $key); + + if ($definition instanceof SettingDefinition) { + return $definition; + } + + throw new \InvalidArgumentException(sprintf('Unknown setting key: %s.%s', $domain, $key)); + } + + private function register(SettingDefinition $definition): void + { + $this->definitions[$this->cacheKey($definition->domain, $definition->key)] = $definition; + } + + private function cacheKey(string $domain, string $key): string + { + return $domain.'.'.$key; + } +} diff --git a/database/factories/TenantSettingFactory.php b/database/factories/TenantSettingFactory.php new file mode 100644 index 0000000..547970d --- /dev/null +++ b/database/factories/TenantSettingFactory.php @@ -0,0 +1,31 @@ + + */ +class TenantSettingFactory extends Factory +{ + protected $model = TenantSetting::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => null, + 'tenant_id' => Tenant::factory(), + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 45, + 'updated_by_user_id' => User::factory(), + ]; + } +} diff --git a/database/factories/WorkspaceSettingFactory.php b/database/factories/WorkspaceSettingFactory.php new file mode 100644 index 0000000..5368623 --- /dev/null +++ b/database/factories/WorkspaceSettingFactory.php @@ -0,0 +1,30 @@ + + */ +class WorkspaceSettingFactory extends Factory +{ + protected $model = WorkspaceSetting::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 30, + 'updated_by_user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2026_02_15_120000_create_workspace_settings_table.php b/database/migrations/2026_02_15_120000_create_workspace_settings_table.php new file mode 100644 index 0000000..a493248 --- /dev/null +++ b/database/migrations/2026_02_15_120000_create_workspace_settings_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('domain'); + $table->string('key'); + $table->json('value'); + $table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['workspace_id', 'domain', 'key']); + $table->index(['workspace_id', 'domain']); + }); + } + + public function down(): void + { + Schema::dropIfExists('workspace_settings'); + } +}; diff --git a/database/migrations/2026_02_15_120001_create_tenant_settings_table.php b/database/migrations/2026_02_15_120001_create_tenant_settings_table.php new file mode 100644 index 0000000..4deab85 --- /dev/null +++ b/database/migrations/2026_02_15_120001_create_tenant_settings_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tenant_id'); + $table->string('domain'); + $table->string('key'); + $table->json('value'); + $table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['tenant_id', 'domain', 'key']); + $table->index(['workspace_id', 'tenant_id']); + + $table->foreign(['tenant_id', 'workspace_id'], 'tenant_settings_tenant_workspace_fk') + ->references(['id', 'workspace_id']) + ->on('tenants') + ->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_settings'); + } +}; diff --git a/database/migrations/2026_02_15_120002_make_backup_schedules_retention_keep_last_nullable.php b/database/migrations/2026_02_15_120002_make_backup_schedules_retention_keep_last_nullable.php new file mode 100644 index 0000000..3522810 --- /dev/null +++ b/database/migrations/2026_02_15_120002_make_backup_schedules_retention_keep_last_nullable.php @@ -0,0 +1,30 @@ +integer('retention_keep_last')->nullable()->default(30)->change(); + }); + } + + public function down(): void + { + if (! Schema::hasTable('backup_schedules') || ! Schema::hasColumn('backup_schedules', 'retention_keep_last')) { + return; + } + + Schema::table('backup_schedules', function (Blueprint $table): void { + $table->integer('retention_keep_last')->nullable(false)->default(30)->change(); + }); + } +}; diff --git a/specs/097-settings-foundation/checklists/requirements.md b/specs/097-settings-foundation/checklists/requirements.md new file mode 100644 index 0000000..27805d9 --- /dev/null +++ b/specs/097-settings-foundation/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: 097 Settings Foundation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-15 +**Feature**: [specs/097-settings-foundation/spec.md](specs/097-settings-foundation/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 + +- This spec intentionally defines a minimal “settings substrate” with one pilot setting to enable incremental adoption. +- Operational run observability is intentionally out of scope for settings mutations; audit entries are required for each successful mutation. +- SC-001 was tightened to a save + reload verification to avoid ambiguous time-based acceptance. +- Tasks explicitly include validation negative-path tests (unknown key + invalid value) to match FR-004/FR-005 edge cases. +- Plan Complexity Tracking table was deduped to a single “None” row. diff --git a/specs/097-settings-foundation/contracts/settings-foundation.openapi.yaml b/specs/097-settings-foundation/contracts/settings-foundation.openapi.yaml new file mode 100644 index 0000000..a04bede --- /dev/null +++ b/specs/097-settings-foundation/contracts/settings-foundation.openapi.yaml @@ -0,0 +1,168 @@ +openapi: 3.0.3 +info: + title: Settings Foundation (097) + version: 0.1.0 + description: | + Conceptual contract for workspace settings read/write/reset. + + NOTE: The first implementation is expected to be driven via Filament/Livewire. + This contract documents the expected domain behaviors (RBAC-UX 404/403 semantics, + precedence, validation) for consistency and testability. + +servers: + - url: https://example.invalid + +paths: + /workspaces/{workspaceId}/settings/{domain}/{key}: + get: + summary: Resolve a setting value + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/Domain' + - $ref: '#/components/parameters/Key' + - name: tenantId + in: query + required: false + schema: + type: integer + description: Optional tenant scope for tenant override resolution. + responses: + '200': + description: Effective value (with source metadata) + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedSetting' + '404': + description: Not found (non-member workspace scope) + '403': + description: Forbidden (member without view capability) + + patch: + summary: Set workspace override (manage capability required) + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/Domain' + - $ref: '#/components/parameters/Key' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SettingWrite' + responses: + '200': + description: Updated effective value + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedSetting' + '422': + description: Validation error (unknown key, wrong type, out-of-range) + '404': + description: Not found (non-member workspace scope) + '403': + description: Forbidden (member without manage capability) + + delete: + summary: Reset workspace override to system default (manage capability required) + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/Domain' + - $ref: '#/components/parameters/Key' + responses: + '204': + description: Reset completed + '404': + description: Not found (non-member workspace scope) + '403': + description: Forbidden (member without manage capability) + + /workspaces/{workspaceId}/tenants/{tenantId}/settings/{domain}/{key}: + patch: + summary: Set tenant override (backend-ready) + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/Domain' + - $ref: '#/components/parameters/Key' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SettingWrite' + responses: + '200': + description: Updated effective value + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedSetting' + '422': + description: Validation error + '404': + description: Not found (non-member workspace scope or tenant/workspace mismatch) + '403': + description: Forbidden (member without manage capability) + +components: + parameters: + WorkspaceId: + name: workspaceId + in: path + required: true + schema: + type: integer + TenantId: + name: tenantId + in: path + required: true + schema: + type: integer + Domain: + name: domain + in: path + required: true + schema: + type: string + example: backup + Key: + name: key + in: path + required: true + schema: + type: string + example: retention_keep_last_default + + schemas: + SettingWrite: + type: object + additionalProperties: false + required: [value] + properties: + value: + description: JSON-serializable value. Validated against the server-side registry. + + ResolvedSetting: + type: object + additionalProperties: false + required: [domain, key, value, source] + properties: + domain: + type: string + key: + type: string + value: + description: Effective value + source: + type: string + enum: [system_default, workspace_override, tenant_override] + system_default: + description: The registry default for reference + workspace_value: + nullable: true + description: The workspace override value if present + tenant_value: + nullable: true + description: The tenant override value if present diff --git a/specs/097-settings-foundation/data-model.md b/specs/097-settings-foundation/data-model.md new file mode 100644 index 0000000..61fcc29 --- /dev/null +++ b/specs/097-settings-foundation/data-model.md @@ -0,0 +1,77 @@ +# Data Model — Settings Foundation (Workspace + Optional Tenant Override) (097) + +## Entities + +### SettingDefinition (code-defined) +Represents a known (domain, key) setting with validation, normalization, and a system default. + +- Fields (conceptual): + - `domain` (string) + - `key` (string) + - `type` (enum-like: int|string|bool|json) + - `system_default` (mixed) + - `rules` (validation rules; code-level) + - `normalize(value) -> value` (optional) + +### WorkspaceSetting (DB: workspace-owned) +A workspace-wide override for a setting. + +- Table: `workspace_settings` +- Fields: + - `id` + - `workspace_id` (FK → `workspaces.id`, NOT NULL) + - `domain` (string, NOT NULL) + - `key` (string, NOT NULL) + - `value` (JSON/JSONB, NOT NULL) + - `updated_by_user_id` (FK → `users.id`, nullable) + - timestamps +- Indexes / constraints: + - UNIQUE (`workspace_id`, `domain`, `key`) + - Index (`workspace_id`, `domain`) + +### TenantSetting (DB: tenant-owned; backend-ready) +A tenant-specific override for a setting. + +- Table: `tenant_settings` +- Fields: + - `id` + - `workspace_id` (FK → `workspaces.id`, NOT NULL) + - `tenant_id` (FK → `tenants.id`, NOT NULL) + - `domain` (string, NOT NULL) + - `key` (string, NOT NULL) + - `value` (JSON/JSONB, NOT NULL) + - `updated_by_user_id` (FK → `users.id`, nullable) + - timestamps +- Indexes / constraints: + - UNIQUE (`tenant_id`, `domain`, `key`) + - Index (`workspace_id`, `tenant_id`) + +## Relationships + +- Workspace has many WorkspaceSettings. +- Workspace has many TenantSettings. +- Tenant has many TenantSettings. + +## Invariants / Rules enforced by this feature + +- Resolution precedence is deterministic: + - tenant override → workspace override → system default. +- Workspace isolation: + - all reads/writes must be scoped to the active workspace context. + - tenant overrides must be rejected if the tenant does not belong to the workspace. +- Unknown keys are rejected: + - writes must only be allowed for keys in the SettingsRegistry. +- Validation is centralized: + - values are validated against the SettingDefinition before persistence. +- Reset semantics: + - reset removes the persisted override row (workspace or tenant) so resolution falls back. + +## Pilot Setting (v1) + +- Domain: `backup` +- Key: `retention_keep_last_default` +- Type: `int` +- System default: `30` +- Validation: + - integer + - minimum 1 (defensive) diff --git a/specs/097-settings-foundation/plan.md b/specs/097-settings-foundation/plan.md new file mode 100644 index 0000000..e132e36 --- /dev/null +++ b/specs/097-settings-foundation/plan.md @@ -0,0 +1,143 @@ +# Implementation Plan: Settings Foundation (Workspace + Optional Tenant Override) + +**Branch**: `097-settings-foundation` | **Date**: 2026-02-15 | **Spec**: `/specs/097-settings-foundation/spec.md` +**Input**: Feature specification from `/specs/097-settings-foundation/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Implement a workspace-scoped settings substrate with a canonical registry + validation, a resolver that applies precedence (tenant override → workspace override → system default), a workspace Settings page in Filament, and audit logging for each successful mutation. + +For v1, the pilot setting is `backup.retention_keep_last_default` (system default `30`), stored as a workspace override and later consumed by backup retention behavior when a schedule has no explicit `retention_keep_last`. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail +**Storage**: PostgreSQL (Sail local) +**Testing**: Pest v4 (PHPUnit 12) +**Target Platform**: Web app (Filament admin panel) +**Project Type**: Laravel monolith (Filament pages + services + jobs) +**Performance Goals**: settings resolution is request-local cached; repeated resolves for the same (workspace, optional tenant, domain, key) do not issue repeated DB reads +**Constraints**: strict workspace isolation (non-member 404), capability-gated mutations (member without capability 403), audit each successful mutation, no secrets in audit metadata +**Scale/Scope**: low-volume admin configuration with high auditability and correctness requirements + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: clarify what is “last observed” vs snapshots/backups +- Read/write separation: any writes require preview + confirmation + audit + tests +- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php` +- Deterministic capabilities: capability derivation is testable (snapshot/golden tests) +- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; tenant-context routes (/admin/t/{tenant}/...) are tenant-scoped; canonical workspace-context routes under /admin remain tenant-safe; non-member tenant/workspace access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks) +- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy +- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text +- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics) +- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked +- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun` +- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter +- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens +- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests +- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted + +**Result (pre-Phase 0)**: PASS. + +- This feature is DB-only and completes in <2s; it intentionally does not create an `OperationRun`, but MUST emit workspace-scoped audit entries on each successful mutation. +- No Graph calls are introduced. +- RBAC enforcement must use the canonical capability registry (`App\Support\Auth\Capabilities`) and workspace UI enforcement helper (`App\Support\Rbac\WorkspaceUiEnforcement`) for Filament surfaces. + +## Project Structure + +### Documentation (this feature) + +```text +specs/097-settings-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/ +│ └── Pages/ +│ └── Settings/ +├── Models/ +├── Policies/ +├── Providers/ +├── Services/ +└── Support/ + ├── Audit/ + ├── Auth/ + ├── Rbac/ + └── Settings/ + +config/ +database/migrations/ +tests/ +``` + +Expected additions (at implementation time): + +```text +app/Support/Settings/SettingDefinition.php +app/Support/Settings/SettingsRegistry.php +app/Services/Settings/SettingsResolver.php +app/Services/Settings/SettingsWriter.php +app/Models/WorkspaceSetting.php +app/Models/TenantSetting.php +app/Filament/Pages/Settings/WorkspaceSettings.php +app/Policies/WorkspaceSettingPolicy.php +database/migrations/*_create_workspace_settings_table.php +database/migrations/*_create_tenant_settings_table.php +tests/Feature/SettingsFoundation/* +tests/Unit/SettingsFoundation/* +``` + +**Structure Decision**: Laravel monolith (Filament admin + Eloquent models + services + Pest tests). No new top-level directories. + +## Phase 0 — Outline & Research + +Deliverable: `research.md` with all implementation decisions resolved. + +Key questions resolved in research: + +- Which existing RBAC enforcement helper to use for workspace-scoped Filament pages. +- Which audit logger to use for workspace-scoped audit events and how to represent stable action IDs. +- How to model workspace defaults + tenant overrides while staying compliant with the constitution’s scope/ownership rules. + +## Phase 1 — Design & Contracts + +Deliverables: `data-model.md`, `contracts/*`, `quickstart.md`. + +- Data model defines workspace settings and tenant overrides with strict workspace isolation. +- Contracts define the conceptual read/write/reset operations (even if the first implementation is driven via Filament/Livewire). +- Quickstart defines how to run migrations, format, and execute focused tests. + +## Phase 2 — Planning + +Implementation sequence (high-level): + +1. Add capability constants in `App\Support\Auth\Capabilities` and map them in `App\Services\Auth\WorkspaceRoleCapabilityMap`. +2. Add settings registry + validation (`SettingDefinition`, `SettingsRegistry`) including the pilot setting. +3. Add storage + resolver + writer services with request-local caching. +4. Add audit action IDs (stable) and emit workspace audit entries for update/reset. +5. Add Filament workspace Settings page with Save + Reset actions, gated using `WorkspaceUiEnforcement`. +6. Add Pest tests for precedence, RBAC (404/403), validation, caching behavior, and audit entries. + +**Constitution re-check (post-design)**: expected PASS (DB-only mutations audited; strict 404/403 semantics; capability registry is canonical). + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None | N/A | N/A | diff --git a/specs/097-settings-foundation/quickstart.md b/specs/097-settings-foundation/quickstart.md new file mode 100644 index 0000000..22e6547 --- /dev/null +++ b/specs/097-settings-foundation/quickstart.md @@ -0,0 +1,54 @@ +# Quickstart — Settings Foundation (097) + +## Prerequisites + +- Docker running +- Laravel Sail available (`vendor/bin/sail`) + +## Local setup + +- Start containers: + - `vendor/bin/sail up -d` + +- Install dependencies (if needed): + - `vendor/bin/sail composer install` + - `vendor/bin/sail npm install` + +## Migrate + +- Run migrations: + - `vendor/bin/sail artisan migrate` + +## Format + +- Format changed files: + - `vendor/bin/sail bin pint --dirty` + +## Tests + +Run the smallest relevant set first: + +- Settings Foundation feature tests: + - `vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation` + +- Settings Foundation unit tests: + - `vendor/bin/sail artisan test --compact tests/Unit/SettingsFoundation` + +Optionally run the full suite: + +- `vendor/bin/sail artisan test --compact` + +## Manual verification checklist (after implementation) + +- As workspace **manager**: + - Open workspace Settings page + - Update `backup.retention_keep_last_default` + - Confirm success notification and audit entry + - Reset to system default (confirmation required) + +- As workspace **operator/readonly**: + - Open Settings page (view-only) + - Verify Save/Reset cannot be executed (server-side 403 on mutation) + +- As non-member: + - Directly visiting the Settings route returns 404 diff --git a/specs/097-settings-foundation/research.md b/specs/097-settings-foundation/research.md new file mode 100644 index 0000000..7a93c29 --- /dev/null +++ b/specs/097-settings-foundation/research.md @@ -0,0 +1,56 @@ +# Research — Settings Foundation (Workspace + Optional Tenant Override) (097) + +## Decisions + +### Decision 1 — Canonical capability registry + role mapping +- **Chosen**: Add new workspace capabilities as constants in `App\Support\Auth\Capabilities`, and map them in `App\Services\Auth\WorkspaceRoleCapabilityMap`. + - `Capabilities::WORKSPACE_SETTINGS_VIEW` + - `Capabilities::WORKSPACE_SETTINGS_MANAGE` +- **Rationale**: The repo already enforces “no raw strings” and uses `Capabilities::isKnown()` checks in `WorkspaceCapabilityResolver`. +- **Alternatives considered**: + - Using raw strings (rejected: violates RBAC-UX-006). + +### Decision 2 — Workspace RBAC-UX enforcement for Filament actions +- **Chosen**: Use `App\Support\Rbac\WorkspaceUiEnforcement` for Filament page actions. +- **Rationale**: This helper already implements the exact semantics required by the constitution: + - non-member → `abort(404)` (deny-as-not-found) + - member missing capability → `abort(403)` + - defense-in-depth server-side guard via `before()`. +- **Alternatives considered**: + - Ad-hoc `abort()` checks in each action (rejected: inconsistent and easier to regress). + +### Decision 3 — Audit logging sink + stable action IDs +- **Chosen**: + - Workspace-scoped audit entries: `App\Services\Audit\WorkspaceAuditLogger` (writes `audit_logs` with `workspace_id`, `tenant_id = null`). + - Stable action identifiers: extend `App\Support\Audit\AuditActionId` enum with two new cases: + - `WorkspaceSettingUpdated = 'workspace_setting.updated'` + - `WorkspaceSettingReset = 'workspace_setting.reset'` +- **Rationale**: The repo already has a stable-action-id convention and tests around audit redaction and scoping; using the existing audit logger preserves sanitizer behavior (no secrets). +- **Alternatives considered**: + - Introducing a new audit sink (rejected: violates “use existing audit sinks” precedent and increases inconsistency risk). + - Using un-enumed string action IDs (possible, but rejected in favor of stronger standardization). + +### Decision 4 — Storage model for workspace defaults + tenant overrides +- **Chosen**: Use two tables (scope-pure) rather than a single polymorphic table: + - `workspace_settings` (workspace-owned; includes `workspace_id`; no `tenant_id`) + - `tenant_settings` (tenant-owned; includes `workspace_id` and `tenant_id` NOT NULL) +- **Rationale**: Aligns with the constitution’s scope/ownership rule: + - “Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.” + - “Tenant-owned tables MUST include workspace_id and tenant_id as NOT NULL.” +- **Alternatives considered**: + - Single `workspace_settings` table with nullable `tenant_id` (rejected: risks violating the constitution and blurs ownership). + +### Decision 5 — Resolver precedence + caching +- **Chosen**: + - Precedence: tenant override → workspace override → system default. + - Caching: request-local only, implemented in-memory inside the resolver (no cross-request cache store in v1). +- **Rationale**: Matches the clarified spec requirements and mirrors existing request-local caching patterns (e.g., capability resolver). +- **Alternatives considered**: + - Cross-request cache with TTL (rejected for v1: adds invalidation complexity and isn’t required). + +## Notes on existing repo patterns (evidence) + +- Canonical capability registry exists: `App\Support\Auth\Capabilities`. +- Workspace capability checks and request-local caching exist: `App\Services\Auth\WorkspaceCapabilityResolver`. +- Workspace RBAC-UX enforcement helper exists: `App\Support\Rbac\WorkspaceUiEnforcement`. +- Workspace audit logger exists (with sanitizer): `App\Services\Audit\WorkspaceAuditLogger`. diff --git a/specs/097-settings-foundation/spec.md b/specs/097-settings-foundation/spec.md new file mode 100644 index 0000000..27b2dec --- /dev/null +++ b/specs/097-settings-foundation/spec.md @@ -0,0 +1,139 @@ +# Feature Specification: Settings Foundation (Workspace + Optional Tenant Override) + +**Feature Branch**: `097-settings-foundation` +**Created**: 2026-02-15 +**Status**: Draft +**Input**: User description: "Settings Foundation (Workspace + optional Tenant override)" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace (with optional tenant overrides for future expansion) +- **Primary Routes**: Admin panel “Settings” page (workspace-scoped) +- **Data Ownership**: Workspace-owned settings records, with optional tenant override records that must belong to the same workspace +- **RBAC**: Workspace membership required (non-members see 404); capability-gated view vs manage (Owner/Manager manage; Operator/Readonly view) + +## Clarifications + +### Session 2026-02-15 + +- Q: Which role-to-capability mapping should this spec enforce for workspace settings? → A: Option A (Owner+Manager manage; Operator+Readonly view) +- Q: What should the system default be for `backup.retention_keep_last_default`? → A: Option A (`30`) +- Q: Should `SettingsResolver` implement cross-request caching in v1? → A: Option A (request-local cache only) +- Q: When a manager clicks “Reset to system default”, what should happen to the stored workspace override? → A: Option A (remove the override so resolution falls back to system default) + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Manage workspace settings safely (Priority: P1) + +Workspace administrators need a single, consistent place to configure workspace-wide defaults that are used by operational features and can be audited later. + +**Why this priority**: Without a settings foundation, features will implement ad-hoc settings patterns that are inconsistent, hard to audit, and risky to operate. + +**Independent Test**: A manager updates the pilot setting, sees the new value reflected, and the system uses it as the workspace default for relevant behavior. + +**Acceptance Scenarios**: + +1. **Given** a workspace member with settings-manage capability, **When** they update the pilot setting value, **Then** the value is persisted for that workspace and an audit entry is recorded. +2. **Given** a workspace member with settings-manage capability and an existing workspace override, **When** they reset the pilot setting to the system default, **Then** the effective value becomes the system default and an audit entry is recorded. + +--- + +### User Story 2 - View settings without the ability to change them (Priority: P2) + +Read-only operators need to see what the workspace defaults are, without being able to modify them. + +**Why this priority**: Visibility supports troubleshooting and governance without expanding write privileges. + +**Independent Test**: A viewer can open the settings page and see values, but cannot persist any changes. + +**Acceptance Scenarios**: + +1. **Given** a workspace member with settings-view capability but without settings-manage capability, **When** they open the settings page, **Then** they can view the current value but cannot save or reset settings. +2. **Given** a workspace member without settings-manage capability, **When** they attempt to submit a settings change by any means, **Then** the request is rejected with 403 and no setting is changed. + +--- + +### User Story 3 - Tenant overrides take precedence (backend-ready) (Priority: P3) + +Some settings may need tenant-specific overrides while still inheriting workspace defaults when no override exists. + +**Why this priority**: Establishes a stable precedence model early, so future features can safely add tenant overrides without redefining rules. + +**Independent Test**: For a chosen tenant in a workspace, resolving a setting returns tenant override when present, otherwise workspace override, otherwise system default. + +**Acceptance Scenarios**: + +1. **Given** no stored values for a setting, **When** the system resolves it for a workspace (and optional tenant), **Then** the system default is returned. +2. **Given** a workspace override exists, **When** the system resolves the setting, **Then** the workspace value is returned. +3. **Given** a tenant override exists within the same workspace, **When** the system resolves the setting for that tenant, **Then** the tenant value is returned even if a workspace value exists. + +### Edge Cases + +- Unknown setting keys: write attempts are rejected and no changes are persisted. +- Invalid values (wrong type / out-of-range): write attempts are rejected and no changes are persisted. +- Tenant/workspace mismatch: the system rejects any attempt to store or resolve a tenant override for a tenant outside the workspace. +- Concurrent updates: the last accepted update wins, and each accepted mutation results in exactly one audit entry. +- Caching: within a single request, repeated reads for the same (workspace, optional tenant, domain, key) do not cause repeated database reads; across requests, no external cache is used in v1. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces workspace-scoped settings mutations that intentionally do not create an operational run record. Each successful settings mutation MUST be auditable via an audit entry, including who changed what and when. + +**Constitution alignment (RBAC-UX):** + +- Authorization planes: workspace-scoped admin panel surface. +- 404 vs 403 semantics: + - non-member / not entitled to the workspace scope → 404 (deny-as-not-found) + - member but missing manage capability for mutations → 403 +- Server-side enforcement: all setting mutations (update/reset) MUST be authorization-checked server-side; UI visibility/disabled states are not sufficient. + +**Role mapping (clarified):** + +- Owners and Managers MUST have settings-manage capability. +- Operators and Readonly members MUST have settings-view capability. + +### Functional Requirements + +- **FR-001**: System MUST provide a single settings substrate that can store workspace-wide defaults. +- **FR-002**: System MUST support optional tenant overrides that are always scoped to the same workspace. +- **FR-003**: System MUST resolve setting values using the precedence order: tenant override → workspace override → system default. +- **FR-004**: System MUST reject writes for unknown setting keys. +- **FR-005**: System MUST validate setting values against centrally-defined rules and reject invalid values. +- **FR-006**: System MUST ensure settings data cannot be read or written across workspace boundaries. +- **FR-007**: System MUST record an audit entry for each successful settings update and each successful reset-to-default. +- **FR-008**: System MUST provide a workspace-scoped Settings UI surface that supports viewing and managing settings based on capabilities. +- **FR-009**: System MUST include a pilot setting: `backup.retention_keep_last_default`. +- **FR-010**: The pilot setting MUST define a system default of `30` (preserves current behavior), and it MUST be overrideable at workspace scope. +- **FR-011**: When the pilot setting is used by backup scheduling/retention behavior, per-schedule overrides (if present) MUST continue to take precedence over workspace defaults. +- **FR-012**: The settings resolver MUST implement request-local caching for resolved values within a single request. +- **FR-013**: A reset-to-default MUST remove the stored override for the setting scope so future resolution falls back to the system default. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Settings Page | Admin panel (workspace scope) | Save, Reset to system default | N/A | N/A | N/A | N/A | N/A | N/A | Yes | Reset is destructive-like and requires explicit confirmation; non-members get 404; members without manage get 403 on mutation | + +### Key Entities *(include if feature involves data)* + +- **Setting Definition**: A centrally-defined (domain, key) entry that declares the system default and validation constraints. +- **Workspace Setting Value**: A stored override for a (domain, key) at workspace scope. +- **Tenant Setting Value**: A stored override for a (domain, key) at tenant scope within the same workspace. +- **Settings Audit Entry**: An immutable record of a settings update or reset, including actor identity, target workspace/tenant, and before/after values. + +### Non-Functional Requirements + +- **NFR-001 (Security / Isolation)**: Non-members MUST receive deny-as-not-found (404) for workspace settings surfaces. +- **NFR-002 (Authorization)**: Members without the required capability MUST receive 403 for forbidden mutations. +- **NFR-003 (Auditability)**: 100% of successful updates and resets MUST create an audit entry including actor, scope, and before/after values. +- **NFR-004 (Data minimization)**: Audit entries MUST NOT include secrets or sensitive raw payloads. +- **NFR-005 (Performance)**: Within a single request, repeated resolutions of the same setting scope MUST not require repeated database reads. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An authorized manager can update the pilot setting, and the Settings UI shows the new effective value after saving and persists it across a page reload. +- **SC-002**: A viewer can open the Settings UI and see current values, and cannot persist changes (mutation attempts are rejected). +- **SC-003**: Non-members cannot discover the Settings UI surface for a workspace (requests return 404). +- **SC-004**: 100% of successful setting updates/resets create an audit entry that includes actor, scope (workspace + optional tenant), and before/after values. diff --git a/specs/097-settings-foundation/tasks.md b/specs/097-settings-foundation/tasks.md new file mode 100644 index 0000000..bce5954 --- /dev/null +++ b/specs/097-settings-foundation/tasks.md @@ -0,0 +1,129 @@ +# Tasks: Settings Foundation (Workspace + Optional Tenant Override) (097) + +**Input**: Design documents from `specs/097-settings-foundation/` (spec.md, plan.md, research.md, data-model.md, contracts/) +**Prerequisites**: specs/097-settings-foundation/plan.md (required), specs/097-settings-foundation/spec.md (required for user stories) + +**Tests**: REQUIRED (Pest) for all runtime behavior changes in this repo. +**RBAC**: Enforce 404 vs 403 semantics via canonical helpers + capability registry (no raw strings). +**Audit**: DB-only mutations intentionally skip OperationRun; every successful update/reset MUST write a workspace-scoped audit entry. + +## Phase 1: Setup (Shared Infrastructure) + +- [X] T001 Re-run SpecKit prerequisites and confirm FEATURE_DIR via .specify/scripts/bash/check-prerequisites.sh +- [X] T002 Review RBAC-UX enforcement patterns in app/Support/Rbac/WorkspaceUiEnforcement.php +- [X] T003 Review existing capability registry + caching patterns in app/Support/Auth/Capabilities.php and app/Services/Auth/WorkspaceCapabilityResolver.php +- [X] T004 Review workspace audit patterns + stable action IDs in app/Services/Audit/WorkspaceAuditLogger.php and app/Support/Audit/AuditActionId.php + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared primitives (capabilities, persistence, policies) needed by all stories. + +- [X] T005 Add settings capabilities in app/Support/Auth/Capabilities.php (WORKSPACE_SETTINGS_VIEW, WORKSPACE_SETTINGS_MANAGE) +- [X] T006 Map new capabilities to roles in app/Services/Auth/WorkspaceRoleCapabilityMap.php (Owner/Manager manage; Operator/Readonly view) +- [X] T007 Create workspace_settings table migration in database/migrations/*_create_workspace_settings_table.php (workspace-owned: MUST NOT include tenant_id) +- [X] T008 [P] Create tenant_settings table migration in database/migrations/*_create_tenant_settings_table.php (tenant-owned: MUST include tenant_id NOT NULL) +- [X] T009 [P] Add Eloquent models in app/Models/WorkspaceSetting.php and app/Models/TenantSetting.php +- [X] T010 [P] Add model factories in database/factories/WorkspaceSettingFactory.php and database/factories/TenantSettingFactory.php +- [X] T011 Add policy for settings writes in app/Policies/WorkspaceSettingPolicy.php (view/manage using WorkspaceCapabilityResolver) +- [X] T012 Register policy mapping in app/Providers/AuthServiceProvider.php + +**Checkpoint**: Capabilities + persistence + policies exist; user story work can begin. + +--- + +## Phase 3: User Story 1 — Manage workspace settings safely (Priority: P1) 🎯 MVP + +**Goal**: Workspace managers can update/reset the pilot setting with validation + audit logging. + +**Independent Test**: A manager changes backup.retention_keep_last_default, sees it reflected, and the retention fallback uses it when schedule retention is null. + +### Tests (write first) + +- [X] T013 [P] [US1] Add manage workflow tests in tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php +- [X] T014 [P] [US1] Add audit logging tests in tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php +- [X] T015 [P] [US1] Add resolver caching tests in tests/Unit/SettingsFoundation/SettingsResolverCacheTest.php +- [X] T016 [P] [US1] Add pilot integration test for retention fallback in tests/Feature/SettingsFoundation/RetentionFallbackUsesWorkspaceDefaultTest.php +- [X] T038 [P] [US1] Add per-schedule override precedence test (schedule override wins) in tests/Feature/SettingsFoundation/RetentionScheduleOverrideWinsTest.php +- [X] T039 [P] [US1] Add validation negative-path test: unknown setting key is rejected; no changes persisted; no audit entry created +- [X] T040 [P] [US1] Add validation negative-path test: invalid value (wrong type/out-of-range) is rejected; no changes persisted; no audit entry created + +### Implementation + +- [X] T017 [P] [US1] Create setting definition DTO in app/Support/Settings/SettingDefinition.php +- [X] T018 [P] [US1] Create registry for known settings in app/Support/Settings/SettingsRegistry.php (include backup.retention_keep_last_default default=30 + validation) +- [X] T019 [P] [US1] Implement resolver with precedence + request-local cache in app/Services/Settings/SettingsResolver.php +- [X] T020 [P] [US1] Add stable audit action IDs in app/Support/Audit/AuditActionId.php (workspace_setting.updated, workspace_setting.reset) +- [X] T021 [US1] Implement writer (validate, persist, reset deletes row, audit before/after) in app/Services/Settings/SettingsWriter.php +- [X] T022 [US1] Add Filament workspace Settings page shell in app/Filament/Pages/Settings/WorkspaceSettings.php (uses WorkspaceUiEnforcement) +- [X] T023 [US1] Register the Settings page in the admin panel navigation in app/Providers/Filament/AdminPanelProvider.php +- [X] T024 [US1] Implement Save action via Action::make(...)->action(...) in app/Filament/Pages/Settings/WorkspaceSettings.php +- [X] T025 [US1] Implement Reset action with ->requiresConfirmation() and ->action(...) in app/Filament/Pages/Settings/WorkspaceSettings.php +- [X] T026 [US1] Wire retention fallback to resolver when schedule retention is null in app/Jobs/ApplyBackupScheduleRetentionJob.php + +--- + +## Phase 4: User Story 2 — View settings without the ability to change them (Priority: P2) + +**Goal**: Workspace operators/readonly can view settings but cannot save/reset; server-side 403 on mutation. + +**Independent Test**: A view-only member opens the Settings page and can see values, but attempts to save/reset return 403 and no audit entry is created. + +### Tests (write first) + +- [X] T027 [P] [US2] Add view-only access tests (view OK, mutation forbidden) in tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php +- [X] T037 [P] [US2] Add non-member deny-as-not-found tests (404) in tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php + +### Implementation + +- [X] T028 [US2] Gate page access by view capability and gate mutations by manage capability in app/Filament/Pages/Settings/WorkspaceSettings.php +- [X] T029 [US2] Ensure Save/Reset actions cannot execute for view-only users (server-side enforcement) in app/Filament/Pages/Settings/WorkspaceSettings.php + +--- + +## Phase 5: User Story 3 — Tenant overrides take precedence (backend-ready) (Priority: P3) + +**Goal**: Resolver supports tenant overrides; tenant override wins over workspace override; rejects tenant/workspace mismatch. + +**Independent Test**: With tenant override present, resolving returns tenant value; without it, returns workspace; without either, returns system default. + +### Tests (write first) + +- [X] T030 [P] [US3] Add tenant precedence tests (default/workspace/tenant) in tests/Unit/SettingsFoundation/SettingsResolverTenantPrecedenceTest.php +- [X] T031 [P] [US3] Add tenant/workspace mismatch rejection test in tests/Feature/SettingsFoundation/TenantOverrideScopeSafetyTest.php + +### Implementation + +- [X] T032 [US3] Extend resolver to read tenant overrides from tenant_settings (same workspace) in app/Services/Settings/SettingsResolver.php +- [X] T033 [US3] Add tenant override write/reset methods (backend-ready) in app/Services/Settings/SettingsWriter.php + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T034 [P] Run formatting for changed files via vendor/bin/sail bin pint --dirty (see specs/097-settings-foundation/quickstart.md) +- [X] T035 Run focused tests for this feature via vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation (see specs/097-settings-foundation/quickstart.md) +- [X] T036 Run the full suite via vendor/bin/sail artisan test --compact (see specs/097-settings-foundation/quickstart.md) + +--- + +## Dependencies & Execution Order + +### User Story Dependency Graph + +- Setup → Foundational +- Foundational → US1 +- US1 → US2 +- US1 → US3 + +### Parallel Opportunities (examples) + +- US1: T013–T016 (tests) and T017–T020 (core classes + enum) can be executed in parallel. +- Foundational: T007–T012 can be split across DB/model/policy work in parallel. + +Example: In US1, execute T013, T014, T017, and T018 concurrently (different files; no dependencies). + +## MVP Scope Suggestion + +- MVP = Phases 1–3 (through US1) to ship a workspace settings foundation with the pilot setting wired into retention fallback. diff --git a/tests/Feature/RequiredPermissions/RequiredPermissionsOverviewTest.php b/tests/Feature/RequiredPermissions/RequiredPermissionsOverviewTest.php index 3ffba20..def5ee6 100644 --- a/tests/Feature/RequiredPermissions/RequiredPermissionsOverviewTest.php +++ b/tests/Feature/RequiredPermissions/RequiredPermissionsOverviewTest.php @@ -26,7 +26,7 @@ ]); $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all") ->assertSuccessful() ->assertSee('Blocked', false) ->assertSee('applyFeatureFilter', false) diff --git a/tests/Feature/SettingsFoundation/RetentionFallbackUsesWorkspaceDefaultTest.php b/tests/Feature/SettingsFoundation/RetentionFallbackUsesWorkspaceDefaultTest.php new file mode 100644 index 0000000..38e12ef --- /dev/null +++ b/tests/Feature/SettingsFoundation/RetentionFallbackUsesWorkspaceDefaultTest.php @@ -0,0 +1,91 @@ +create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 2, + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Nightly fallback', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => null, + ]); + + $sets = collect(range(1, 5))->map(function (int $index) use ($tenant): BackupSet { + return BackupSet::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Set '.$index, + 'status' => 'completed', + 'item_count' => 0, + 'completed_at' => now()->subMinutes(20 - $index), + ]); + }); + + $completedAt = now('UTC')->startOfMinute()->subMinutes(10); + + foreach ($sets as $set) { + OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => 'backup_schedule_run', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => hash('sha256', 'retention-fallback:'.$schedule->id.':'.$set->id), + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + 'succeeded' => 0, + ], + 'failure_summary' => [], + 'context' => [ + 'backup_schedule_id' => (int) $schedule->getKey(), + 'backup_set_id' => (int) $set->getKey(), + ], + 'started_at' => $completedAt, + 'completed_at' => $completedAt, + ]); + + $completedAt = $completedAt->addMinute(); + } + + ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->getKey()); + + $kept = $sets->take(-2); + $deleted = $sets->take(3); + + foreach ($kept as $set) { + $this->assertDatabaseHas('backup_sets', [ + 'id' => (int) $set->getKey(), + 'deleted_at' => null, + ]); + } + + foreach ($deleted as $set) { + $this->assertSoftDeleted('backup_sets', [ + 'id' => (int) $set->getKey(), + ]); + } +}); diff --git a/tests/Feature/SettingsFoundation/RetentionScheduleOverrideWinsTest.php b/tests/Feature/SettingsFoundation/RetentionScheduleOverrideWinsTest.php new file mode 100644 index 0000000..2aa4aa2 --- /dev/null +++ b/tests/Feature/SettingsFoundation/RetentionScheduleOverrideWinsTest.php @@ -0,0 +1,91 @@ +create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 1, + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Nightly override', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 3, + ]); + + $sets = collect(range(1, 5))->map(function (int $index) use ($tenant): BackupSet { + return BackupSet::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Set '.$index, + 'status' => 'completed', + 'item_count' => 0, + 'completed_at' => now()->subMinutes(20 - $index), + ]); + }); + + $completedAt = now('UTC')->startOfMinute()->subMinutes(10); + + foreach ($sets as $set) { + OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => 'backup_schedule_run', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => hash('sha256', 'retention-override:'.$schedule->id.':'.$set->id), + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + 'succeeded' => 0, + ], + 'failure_summary' => [], + 'context' => [ + 'backup_schedule_id' => (int) $schedule->getKey(), + 'backup_set_id' => (int) $set->getKey(), + ], + 'started_at' => $completedAt, + 'completed_at' => $completedAt, + ]); + + $completedAt = $completedAt->addMinute(); + } + + ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->getKey()); + + $kept = $sets->take(-3); + $deleted = $sets->take(2); + + foreach ($kept as $set) { + $this->assertDatabaseHas('backup_sets', [ + 'id' => (int) $set->getKey(), + 'deleted_at' => null, + ]); + } + + foreach ($deleted as $set) { + $this->assertSoftDeleted('backup_sets', [ + 'id' => (int) $set->getKey(), + ]); + } +}); diff --git a/tests/Feature/SettingsFoundation/TenantOverrideScopeSafetyTest.php b/tests/Feature/SettingsFoundation/TenantOverrideScopeSafetyTest.php new file mode 100644 index 0000000..43a7e96 --- /dev/null +++ b/tests/Feature/SettingsFoundation/TenantOverrideScopeSafetyTest.php @@ -0,0 +1,43 @@ +create(); + $workspaceB = Workspace::factory()->create(); + + $tenantInWorkspaceB = Tenant::factory()->create([ + 'workspace_id' => (int) $workspaceB->getKey(), + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspaceA->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'manager', + ]); + + $writer = app(SettingsWriter::class); + + expect(fn () => $writer->updateTenantSetting( + actor: $user, + workspace: $workspaceA, + tenant: $tenantInWorkspaceB, + domain: 'backup', + key: 'retention_keep_last_default', + value: 7, + ))->toThrow(NotFoundHttpException::class); + + expect(TenantSetting::query()->count())->toBe(0); + expect(AuditLog::query()->count())->toBe(0); +}); diff --git a/tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php b/tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php new file mode 100644 index 0000000..ba5d05f --- /dev/null +++ b/tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php @@ -0,0 +1,81 @@ +create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'manager', + ]); + + app(SettingsWriter::class)->updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: 'backup', + key: 'retention_keep_last_default', + value: 44, + ); + + $audit = AuditLog::query()->latest('id')->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->workspace_id)->toBe((int) $workspace->getKey()) + ->and($audit?->tenant_id)->toBeNull() + ->and($audit?->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value) + ->and(data_get($audit?->metadata, 'domain'))->toBe('backup') + ->and(data_get($audit?->metadata, 'key'))->toBe('retention_keep_last_default') + ->and(data_get($audit?->metadata, 'scope'))->toBe('workspace') + ->and(data_get($audit?->metadata, 'before_value'))->toBeNull() + ->and(data_get($audit?->metadata, 'after_value'))->toBe(44); +}); + +it('writes a workspace-scoped audit entry when a workspace setting is reset', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'manager', + ]); + + $writer = app(SettingsWriter::class); + + $writer->updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: 'backup', + key: 'retention_keep_last_default', + value: 48, + ); + + $writer->resetWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: 'backup', + key: 'retention_keep_last_default', + ); + + $audit = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSettingReset->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->workspace_id)->toBe((int) $workspace->getKey()) + ->and($audit?->tenant_id)->toBeNull() + ->and(data_get($audit?->metadata, 'scope'))->toBe('workspace') + ->and(data_get($audit?->metadata, 'before_value'))->toBe(48) + ->and(data_get($audit?->metadata, 'after_value'))->toBe(30); +}); diff --git a/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php b/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php new file mode 100644 index 0000000..fee8cf6 --- /dev/null +++ b/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php @@ -0,0 +1,104 @@ +create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'manager', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + $component = Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->assertSet('data.backup_retention_keep_last_default', 30) + ->set('data.backup_retention_keep_last_default', 55) + ->callAction('save') + ->assertHasNoErrors() + ->assertSet('data.backup_retention_keep_last_default', 55); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', 'backup') + ->where('key', 'retention_keep_last_default') + ->exists())->toBeTrue(); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default')) + ->toBe(55); + + $component + ->callAction('reset') + ->assertHasNoErrors() + ->assertSet('data.backup_retention_keep_last_default', 30); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', 'backup') + ->where('key', 'retention_keep_last_default') + ->exists())->toBeFalse(); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default')) + ->toBe(30); +}); + +it('rejects unknown setting keys and does not persist or audit changes', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'manager', + ]); + + $writer = app(SettingsWriter::class); + + expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'unknown_setting_key', 25)) + ->toThrow(ValidationException::class); + + expect(WorkspaceSetting::query()->count())->toBe(0); + expect(AuditLog::query()->count())->toBe(0); +}); + +it('rejects invalid setting values and does not persist or audit changes', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'manager', + ]); + + $writer = app(SettingsWriter::class); + + expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_keep_last_default', 'not-an-integer')) + ->toThrow(ValidationException::class); + + expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_keep_last_default', 0)) + ->toThrow(ValidationException::class); + + expect(WorkspaceSetting::query()->count())->toBe(0); + expect(AuditLog::query()->count())->toBe(0); +}); diff --git a/tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php b/tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php new file mode 100644 index 0000000..f9034d4 --- /dev/null +++ b/tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php @@ -0,0 +1,24 @@ +create(); + $user = User::factory()->create(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertNotFound(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->assertStatus(404); +}); diff --git a/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php b/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php new file mode 100644 index 0000000..1566282 --- /dev/null +++ b/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php @@ -0,0 +1,62 @@ +create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + WorkspaceSetting::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 27, + 'updated_by_user_id' => null, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->assertSet('data.backup_retention_keep_last_default', 27) + ->assertActionVisible('save') + ->assertActionDisabled('save') + ->assertActionVisible('reset') + ->assertActionDisabled('reset') + ->call('save') + ->assertStatus(403); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->call('resetSetting') + ->assertStatus(403); + + expect(AuditLog::query()->count())->toBe(0); + + $setting = WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', 'backup') + ->where('key', 'retention_keep_last_default') + ->first(); + + expect($setting)->not->toBeNull(); +}); diff --git a/tests/Unit/SettingsFoundation/SettingsResolverCacheTest.php b/tests/Unit/SettingsFoundation/SettingsResolverCacheTest.php new file mode 100644 index 0000000..52bc749 --- /dev/null +++ b/tests/Unit/SettingsFoundation/SettingsResolverCacheTest.php @@ -0,0 +1,77 @@ +create(); + + WorkspaceSetting::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 41, + 'updated_by_user_id' => null, + ]); + + $resolver = app(SettingsResolver::class); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + expect($resolver->resolveValue($workspace, 'backup', 'retention_keep_last_default'))->toBe(41); + expect($resolver->resolveValue($workspace, 'backup', 'retention_keep_last_default'))->toBe(41); + + $workspaceSettingsQueries = collect(DB::getQueryLog()) + ->pluck('query') + ->filter(fn (string $query): bool => str_contains($query, 'workspace_settings')) + ->count(); + + expect($workspaceSettingsQueries)->toBe(1); +}); + +it('resolves tenant override from cache without repeated database reads', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = \App\Models\Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + WorkspaceSetting::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 22, + 'updated_by_user_id' => null, + ]); + + \App\Models\TenantSetting::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 12, + 'updated_by_user_id' => null, + ]); + + $resolver = app(SettingsResolver::class); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + expect($resolver->resolveValue($workspace, 'backup', 'retention_keep_last_default', $tenant))->toBe(12); + expect($resolver->resolveValue($workspace, 'backup', 'retention_keep_last_default', $tenant))->toBe(12); + + $settingsQueries = collect(DB::getQueryLog()) + ->pluck('query') + ->filter(fn (string $query): bool => str_contains($query, 'tenant_settings') || str_contains($query, 'workspace_settings')) + ->count(); + + expect($settingsQueries)->toBe(2); +}); diff --git a/tests/Unit/SettingsFoundation/SettingsResolverTenantPrecedenceTest.php b/tests/Unit/SettingsFoundation/SettingsResolverTenantPrecedenceTest.php new file mode 100644 index 0000000..713b7c6 --- /dev/null +++ b/tests/Unit/SettingsFoundation/SettingsResolverTenantPrecedenceTest.php @@ -0,0 +1,51 @@ + workspace -> system default order', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $resolver = app(SettingsResolver::class); + + expect($resolver->resolveValue($workspace, 'backup', 'retention_keep_last_default', $tenant)) + ->toBe(30); + + WorkspaceSetting::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 45, + 'updated_by_user_id' => null, + ]); + + $resolver->clearCache(); + + expect($resolver->resolveValue($workspace, 'backup', 'retention_keep_last_default', $tenant)) + ->toBe(45); + + TenantSetting::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 9, + 'updated_by_user_id' => null, + ]); + + $resolver->clearCache(); + + expect($resolver->resolveValue($workspace, 'backup', 'retention_keep_last_default', $tenant)) + ->toBe(9); +});