feat: implement settings foundation workspace controls
This commit is contained in:
parent
9ce5febe7f
commit
b9ca8c579d
256
app/Filament/Pages/Settings/WorkspaceSettings.php
Normal file
256
app/Filament/Pages/Settings/WorkspaceSettings.php
Normal file
@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Settings;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use UnitEnum;
|
||||
|
||||
class WorkspaceSettings extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'settings/workspace';
|
||||
|
||||
protected static ?string $title = 'Workspace settings';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
public Workspace $workspace;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $data = [];
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
33
app/Models/TenantSetting.php
Normal file
33
app/Models/TenantSetting.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TenantSetting extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->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');
|
||||
}
|
||||
}
|
||||
@ -40,4 +40,20 @@ public function tenants(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<WorkspaceSetting, $this>
|
||||
*/
|
||||
public function settings(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkspaceSetting::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TenantSetting, $this>
|
||||
*/
|
||||
public function tenantSettings(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantSetting::class);
|
||||
}
|
||||
}
|
||||
|
||||
26
app/Models/WorkspaceSetting.php
Normal file
26
app/Models/WorkspaceSetting.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorkspaceSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function updatedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by_user_id');
|
||||
}
|
||||
}
|
||||
84
app/Policies/WorkspaceSettingPolicy.php
Normal file
84
app/Policies/WorkspaceSettingPolicy.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class WorkspaceSettingPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool|Response
|
||||
{
|
||||
return Response::allow();
|
||||
}
|
||||
|
||||
public function view(User $user, WorkspaceSetting $workspaceSetting): bool|Response
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
137
app/Services/Settings/SettingsResolver.php
Normal file
137
app/Services/Settings/SettingsResolver.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Settings;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantSetting;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Support\Settings\SettingsRegistry;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class SettingsResolver
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{domain: string, key: string, value: mixed, source: 'system_default'|'workspace_override'|'tenant_override', system_default: mixed, workspace_value: mixed, tenant_value: mixed}>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
219
app/Services/Settings/SettingsWriter.php
Normal file
219
app/Services/Settings/SettingsWriter.php
Normal file
@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Settings;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantSetting;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Settings\SettingDefinition;
|
||||
use App\Support\Settings\SettingsRegistry;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class SettingsWriter
|
||||
{
|
||||
public function __construct(
|
||||
private SettingsRegistry $registry,
|
||||
private SettingsResolver $resolver,
|
||||
private WorkspaceAuditLogger $auditLogger,
|
||||
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
) {}
|
||||
|
||||
public function updateWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key, mixed $value): WorkspaceSetting
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
36
app/Support/Settings/SettingDefinition.php
Normal file
36
app/Support/Settings/SettingDefinition.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Settings;
|
||||
|
||||
use Closure;
|
||||
|
||||
final readonly class SettingDefinition
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $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;
|
||||
}
|
||||
}
|
||||
61
app/Support/Settings/SettingsRegistry.php
Normal file
61
app/Support/Settings/SettingsRegistry.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Settings;
|
||||
|
||||
final class SettingsRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<string, SettingDefinition>
|
||||
*/
|
||||
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<string, SettingDefinition>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
31
database/factories/TenantSettingFactory.php
Normal file
31
database/factories/TenantSettingFactory.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<TenantSetting>
|
||||
*/
|
||||
class TenantSettingFactory extends Factory
|
||||
{
|
||||
protected $model = TenantSetting::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
30
database/factories/WorkspaceSettingFactory.php
Normal file
30
database/factories/WorkspaceSettingFactory.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<WorkspaceSetting>
|
||||
*/
|
||||
class WorkspaceSettingFactory extends Factory
|
||||
{
|
||||
protected $model = WorkspaceSetting::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'domain' => 'backup',
|
||||
'key' => 'retention_keep_last_default',
|
||||
'value' => 30,
|
||||
'updated_by_user_id' => User::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workspace_settings', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_settings', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
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()->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
38
specs/097-settings-foundation/checklists/requirements.md
Normal file
38
specs/097-settings-foundation/checklists/requirements.md
Normal file
@ -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.
|
||||
@ -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
|
||||
77
specs/097-settings-foundation/data-model.md
Normal file
77
specs/097-settings-foundation/data-model.md
Normal file
@ -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)
|
||||
143
specs/097-settings-foundation/plan.md
Normal file
143
specs/097-settings-foundation/plan.md
Normal file
@ -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 |
|
||||
54
specs/097-settings-foundation/quickstart.md
Normal file
54
specs/097-settings-foundation/quickstart.md
Normal file
@ -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
|
||||
56
specs/097-settings-foundation/research.md
Normal file
56
specs/097-settings-foundation/research.md
Normal file
@ -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`.
|
||||
139
specs/097-settings-foundation/spec.md
Normal file
139
specs/097-settings-foundation/spec.md
Normal file
@ -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.
|
||||
129
specs/097-settings-foundation/tasks.md
Normal file
129
specs/097-settings-foundation/tasks.md
Normal file
@ -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.
|
||||
@ -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)
|
||||
|
||||
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ApplyBackupScheduleRetentionJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\WorkspaceSetting;
|
||||
|
||||
it('uses workspace retention default when schedule retention is null', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
WorkspaceSetting::factory()->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(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ApplyBackupScheduleRetentionJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\WorkspaceSetting;
|
||||
|
||||
it('prefers schedule retention_keep_last over workspace default when set', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
WorkspaceSetting::factory()->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(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantSetting;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
it('rejects tenant override writes when tenant does not belong to the provided workspace', function (): void {
|
||||
$workspaceA = Workspace::factory()->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);
|
||||
});
|
||||
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
|
||||
it('writes a workspace-scoped audit entry when a workspace setting is updated', 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',
|
||||
]);
|
||||
|
||||
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);
|
||||
});
|
||||
104
tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php
Normal file
104
tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('allows workspace managers to save and reset the workspace retention default', 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',
|
||||
]);
|
||||
|
||||
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);
|
||||
});
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('returns 404 for non-members when opening workspace settings', function (): void {
|
||||
$workspace = Workspace::factory()->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);
|
||||
});
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('allows view-only members to view workspace settings but forbids save and reset mutations', function (): void {
|
||||
$workspace = Workspace::factory()->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();
|
||||
});
|
||||
77
tests/Unit/SettingsFoundation/SettingsResolverCacheTest.php
Normal file
77
tests/Unit/SettingsFoundation/SettingsResolverCacheTest.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('caches repeated workspace setting resolution within a request', function (): void {
|
||||
$workspace = Workspace::factory()->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);
|
||||
});
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantSetting;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('resolves setting values in tenant -> 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user