feat(spec-276): implement support access governance — commit all changes
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m29s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m29s
This commit is contained in:
parent
50bc44cfa0
commit
37105653a1
@ -5,6 +5,7 @@
|
|||||||
namespace App\Filament\Pages\Monitoring;
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
use App\Models\AuditLog as AuditLogModel;
|
use App\Models\AuditLog as AuditLogModel;
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
@ -38,6 +39,7 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -59,6 +61,16 @@ class AuditLog extends Page implements HasTable
|
|||||||
'tenantSensitive' => false,
|
'tenantSensitive' => false,
|
||||||
'invalidFallback' => 'clear_selection_and_continue',
|
'invalidFallback' => 'clear_selection_and_continue',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'stateKey' => 'supportAccess',
|
||||||
|
'stateClass' => 'contextual_prefilter',
|
||||||
|
'carrier' => 'query_param',
|
||||||
|
'queryRole' => 'durable_restorable',
|
||||||
|
'shareable' => true,
|
||||||
|
'restorableOnRefresh' => true,
|
||||||
|
'tenantSensitive' => false,
|
||||||
|
'invalidFallback' => 'discard_and_continue',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'stateKey' => 'tenant_id',
|
'stateKey' => 'tenant_id',
|
||||||
'stateClass' => 'contextual_prefilter',
|
'stateClass' => 'contextual_prefilter',
|
||||||
@ -95,12 +107,14 @@ class AuditLog extends Page implements HasTable
|
|||||||
'shareable' => true,
|
'shareable' => true,
|
||||||
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
||||||
],
|
],
|
||||||
'shareableStateKeys' => ['event'],
|
'shareableStateKeys' => ['event', 'supportAccess'],
|
||||||
'localOnlyStateKeys' => [],
|
'localOnlyStateKeys' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
public ?int $selectedAuditLogId = null;
|
public ?int $selectedAuditLogId = null;
|
||||||
|
|
||||||
|
public bool $supportAccessOnly = false;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
@ -147,6 +161,7 @@ public static function monitoringPageStateContract(): array
|
|||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->authorizePageAccess();
|
$this->authorizePageAccess();
|
||||||
|
$this->supportAccessOnly = request()->boolean('supportAccess');
|
||||||
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||||
@ -180,6 +195,22 @@ protected function getHeaderActions(): array
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
array_splice($actions, 1, 0, [
|
||||||
|
Action::make('support_access_history_filter')
|
||||||
|
->label($this->supportAccessOnly ? 'Show all audit events' : 'Support access history')
|
||||||
|
->icon($this->supportAccessOnly ? 'heroicon-o-list-bullet' : 'heroicon-o-lifebuoy')
|
||||||
|
->color('gray')
|
||||||
|
->url($this->auditLogUrl([
|
||||||
|
'supportAccess' => $this->supportAccessOnly ? null : true,
|
||||||
|
'event' => null,
|
||||||
|
])),
|
||||||
|
Action::make('export_support_access_history')
|
||||||
|
->label('Export support access history')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->color('gray')
|
||||||
|
->action(fn (): StreamedResponse => $this->exportSupportAccessHistory()),
|
||||||
|
]);
|
||||||
|
|
||||||
$selectedAudit = $this->selectedAuditRecord();
|
$selectedAudit = $this->selectedAuditRecord();
|
||||||
$selectedAuditLink = $selectedAudit instanceof AuditLogModel
|
$selectedAuditLink = $selectedAudit instanceof AuditLogModel
|
||||||
? $this->auditTargetLink($selectedAudit)
|
? $this->auditTargetLink($selectedAudit)
|
||||||
@ -372,6 +403,9 @@ private function auditBaseQuery(): Builder
|
|||||||
$query->orWhereIn('tenant_id', $authorizedTenantIds);
|
$query->orWhereIn('tenant_id', $authorizedTenantIds);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
->when($this->supportAccessOnly, function (Builder $query): void {
|
||||||
|
$query->whereIn('action', SupportAccessGrant::supportAccessAuditActions());
|
||||||
|
})
|
||||||
->latestFirst();
|
->latestFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -433,6 +467,7 @@ private function auditLogUrl(array $overrides = []): string
|
|||||||
{
|
{
|
||||||
$parameters = array_merge(
|
$parameters = array_merge(
|
||||||
$this->navigationContext()?->toQuery() ?? [],
|
$this->navigationContext()?->toQuery() ?? [],
|
||||||
|
['supportAccess' => $this->supportAccessOnly ? true : null],
|
||||||
['event' => $this->selectedAuditLogId],
|
['event' => $this->selectedAuditLogId],
|
||||||
$overrides,
|
$overrides,
|
||||||
);
|
);
|
||||||
@ -508,6 +543,13 @@ private function currentTableSearchState(): string
|
|||||||
|
|
||||||
private function matchesSelectedAuditFilters(AuditLogModel $record): bool
|
private function matchesSelectedAuditFilters(AuditLogModel $record): bool
|
||||||
{
|
{
|
||||||
|
if (
|
||||||
|
$this->supportAccessOnly
|
||||||
|
&& ! in_array((string) $record->action, SupportAccessGrant::supportAccessAuditActions(), true)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$filters = $this->currentTableFiltersState();
|
$filters = $this->currentTableFiltersState();
|
||||||
|
|
||||||
$tenantFilter = data_get($filters, 'tenant_id.value');
|
$tenantFilter = data_get($filters, 'tenant_id.value');
|
||||||
@ -632,4 +674,69 @@ private function targetTypeFilterOptions(): array
|
|||||||
|
|
||||||
return FilterOptionCatalog::auditTargetTypes($values);
|
return FilterOptionCatalog::auditTargetTypes($values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function exportSupportAccessHistory(): StreamedResponse
|
||||||
|
{
|
||||||
|
$filename = 'support-access-history-'.now()->format('Ymd-His').'.csv';
|
||||||
|
|
||||||
|
return response()->streamDownload(function (): void {
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($handle, [
|
||||||
|
'recorded_at',
|
||||||
|
'action',
|
||||||
|
'outcome',
|
||||||
|
'actor',
|
||||||
|
'target',
|
||||||
|
'scope',
|
||||||
|
'reason',
|
||||||
|
'grant_id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->supportAccessAuditQuery()
|
||||||
|
->reorder()
|
||||||
|
->orderBy('recorded_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->cursor()
|
||||||
|
->each(function (AuditLogModel $record) use ($handle): void {
|
||||||
|
$metadata = is_array($record->metadata) ? $record->metadata : [];
|
||||||
|
|
||||||
|
fputcsv($handle, [
|
||||||
|
$this->csvCell((string) $record->recorded_at?->toISOString()),
|
||||||
|
$this->csvCell((string) $record->action),
|
||||||
|
$this->csvCell($record->normalizedOutcome()->value),
|
||||||
|
$this->csvCell($record->actorDisplayLabel()),
|
||||||
|
$this->csvCell($record->targetDisplayLabel() ?? ''),
|
||||||
|
$this->csvCell((string) ($metadata['scope'] ?? '')),
|
||||||
|
$this->csvCell((string) ($metadata['reason'] ?? '')),
|
||||||
|
$this->csvCell((string) ($metadata['support_access_grant_id'] ?? '')),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
}, $filename, [
|
||||||
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportAccessAuditQuery(): Builder
|
||||||
|
{
|
||||||
|
return $this->auditBaseQuery()
|
||||||
|
->whereIn('action', SupportAccessGrant::supportAccessAuditActions());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function csvCell(string $value): string
|
||||||
|
{
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
if ($trimmed !== '' && in_array($trimmed[0], ['=', '+', '-', '@'], true)) {
|
||||||
|
return "'".$trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,12 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Settings;
|
namespace App\Filament\Pages\Settings;
|
||||||
|
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSetting;
|
use App\Models\WorkspaceSetting;
|
||||||
use App\Support\Ai\AiPolicyMode;
|
use App\Services\Auth\SupportAccessGrantManager;
|
||||||
use App\Support\Ai\AiUseCaseCatalog;
|
use App\Services\Auth\SupportAccessGrantResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
@ -16,7 +17,10 @@
|
|||||||
use App\Services\Localization\LocaleResolver;
|
use App\Services\Localization\LocaleResolver;
|
||||||
use App\Services\Settings\SettingsResolver;
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Services\Settings\SettingsWriter;
|
use App\Services\Settings\SettingsWriter;
|
||||||
|
use App\Support\Ai\AiPolicyMode;
|
||||||
|
use App\Support\Ai\AiUseCaseCatalog;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
use App\Support\Settings\SettingDefinition;
|
use App\Support\Settings\SettingDefinition;
|
||||||
use App\Support\Settings\SettingsRegistry;
|
use App\Support\Settings\SettingsRegistry;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
@ -251,6 +255,18 @@ public function content(Schema $schema): Schema
|
|||||||
->content(fn (): string => $this->commercialPostureReasonText())
|
->content(fn (): string => $this->commercialPostureReasonText())
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
|
Section::make('Support access approval')
|
||||||
|
->description('Review current support-access posture and decide pending workspace recovery requests.')
|
||||||
|
->columns(2)
|
||||||
|
->afterHeader(fn (): array => $this->supportAccessApprovalActions())
|
||||||
|
->schema([
|
||||||
|
Placeholder::make('support_access_posture')
|
||||||
|
->label('Current support access')
|
||||||
|
->content(fn (): string => $this->supportAccessPostureText()),
|
||||||
|
Placeholder::make('support_access_pending_recovery')
|
||||||
|
->label('Pending recovery requests')
|
||||||
|
->content(fn (): string => $this->pendingSupportAccessRequestsText()),
|
||||||
|
]),
|
||||||
Section::make('Workspace entitlements')
|
Section::make('Workspace entitlements')
|
||||||
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@ -722,6 +738,125 @@ private function loadDomainLastModified(): void
|
|||||||
$this->domainLastModified = $domainInfo;
|
$this->domainLastModified = $domainInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
private function supportAccessApprovalActions(): array
|
||||||
|
{
|
||||||
|
$grant = $this->currentPendingSupportAccessGrant();
|
||||||
|
|
||||||
|
if (! $grant instanceof SupportAccessGrant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('approve_support_access')
|
||||||
|
->label('Approve recovery access')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Approve recovery support access')
|
||||||
|
->modalDescription('This activates a time-limited workspace recovery support grant. Owner repair will still require active break-glass mode.')
|
||||||
|
->visible(fn (): bool => $this->currentUserCanApproveSupportAccess())
|
||||||
|
->action(function (): void {
|
||||||
|
$this->approveSupportAccess();
|
||||||
|
}),
|
||||||
|
Action::make('deny_support_access')
|
||||||
|
->label('Deny request')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Deny recovery support access')
|
||||||
|
->modalDescription('This denies the pending recovery support request and keeps owner repair blocked for this workspace.')
|
||||||
|
->visible(fn (): bool => $this->currentUserCanApproveSupportAccess())
|
||||||
|
->action(function (): void {
|
||||||
|
$this->denySupportAccess();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approveSupportAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$grant = $this->currentPendingSupportAccessGrant();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $grant instanceof SupportAccessGrant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
app(SupportAccessGrantManager::class)->approve($grant, $user);
|
||||||
|
$this->loadFormState();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Recovery access approved')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function denySupportAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$grant = $this->currentPendingSupportAccessGrant();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $grant instanceof SupportAccessGrant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
app(SupportAccessGrantManager::class)->deny($grant, $user);
|
||||||
|
$this->loadFormState();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Recovery access denied')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportAccessPostureText(): string
|
||||||
|
{
|
||||||
|
$summary = app(SupportAccessGrantResolver::class)->summaryFor($this->workspace);
|
||||||
|
|
||||||
|
if (($summary['grant_id'] ?? null) === null) {
|
||||||
|
return 'No active or pending support access is recorded for this workspace.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [
|
||||||
|
(string) $summary['status_label'],
|
||||||
|
(string) $summary['scope_label'],
|
||||||
|
'requested by '.(string) $summary['requester_label'],
|
||||||
|
'TTL '.$summary['requested_ttl_label'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (is_string($summary['expires_label'] ?? null)) {
|
||||||
|
$parts[] = 'expires '.$summary['expires_label'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' · ', array_filter($parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pendingSupportAccessRequestsText(): string
|
||||||
|
{
|
||||||
|
$requests = app(SupportAccessGrantResolver::class)->pendingRecoveryRequestsFor($this->workspace);
|
||||||
|
|
||||||
|
if ($requests->isEmpty()) {
|
||||||
|
return 'No workspace recovery request is waiting for owner approval.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $requests
|
||||||
|
->map(fn (SupportAccessGrant $grant): string => sprintf(
|
||||||
|
'#%d · %s · %s · %d minutes',
|
||||||
|
(int) $grant->getKey(),
|
||||||
|
$grant->requester?->name ?? $grant->requester?->email ?? 'Platform support',
|
||||||
|
(string) $grant->reason,
|
||||||
|
(int) $grant->ttl_minutes,
|
||||||
|
))
|
||||||
|
->implode("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentPendingSupportAccessGrant(): ?SupportAccessGrant
|
||||||
|
{
|
||||||
|
return app(SupportAccessGrantResolver::class)
|
||||||
|
->pendingRecoveryRequestsFor($this->workspace)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a section description that appends "last modified" info when available.
|
* Build a section description that appends "last modified" info when available.
|
||||||
*/
|
*/
|
||||||
@ -1608,6 +1743,22 @@ private function currentUserCanManage(): bool
|
|||||||
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
|
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function currentUserCanApproveSupportAccess(): 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)
|
||||||
|
&& $resolver->getRole($user, $this->workspace) === WorkspaceRole::Owner;
|
||||||
|
}
|
||||||
|
|
||||||
private function authorizeWorkspaceView(User $user): void
|
private function authorizeWorkspaceView(User $user): void
|
||||||
{
|
{
|
||||||
/** @var WorkspaceCapabilityResolver $resolver */
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
|||||||
@ -7,9 +7,12 @@
|
|||||||
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
|
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSubscription;
|
use App\Models\WorkspaceSubscription;
|
||||||
|
use App\Services\Auth\SupportAccessGrantManager;
|
||||||
|
use App\Services\Auth\SupportAccessGrantResolver;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
use App\Services\Entitlements\WorkspaceSubscriptionResolver;
|
use App\Services\Entitlements\WorkspaceSubscriptionResolver;
|
||||||
@ -120,6 +123,111 @@ public function workspaceCommercialLifecycleSummary(): array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
Action::make('request_support_access')
|
||||||
|
->label('Request support access')
|
||||||
|
->icon('heroicon-o-lifebuoy')
|
||||||
|
->visible(fn (): bool => $this->canManageSupportAccess())
|
||||||
|
->disabled(fn (): bool => $this->supportAccessRequestDisabled())
|
||||||
|
->tooltip(fn (): ?string => $this->supportAccessRequestDisabled()
|
||||||
|
? 'This workspace already has active or pending support access for the current support flow.'
|
||||||
|
: null)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Request support access')
|
||||||
|
->modalDescription('Support access is workspace-scoped, time-limited, and audit-backed. Workspace recovery remains separate from break-glass mode.')
|
||||||
|
->form([
|
||||||
|
Select::make('scope')
|
||||||
|
->label('Support scope')
|
||||||
|
->options(SupportAccessGrant::scopeLabels())
|
||||||
|
->default(SupportAccessGrant::SCOPE_AUDIT_VIEW)
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(function (Set $set): void {
|
||||||
|
$set('waiver_reason', null);
|
||||||
|
}),
|
||||||
|
Textarea::make('reason')
|
||||||
|
->label('Reason')
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(500)
|
||||||
|
->rows(4),
|
||||||
|
TextInput::make('ttl_minutes')
|
||||||
|
->label('TTL')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(fn (): int => $this->supportAccessMaxTtlMinutes())
|
||||||
|
->default(60)
|
||||||
|
->suffix('minutes')
|
||||||
|
->required(),
|
||||||
|
Textarea::make('waiver_reason')
|
||||||
|
->label('Ownerless recovery waiver reason')
|
||||||
|
->helperText('Required only when workspace recovery is requested for a workspace with zero owners. Break-glass must already be active.')
|
||||||
|
->required(fn (Get $get): bool => $this->requiresOwnerlessWaiver((string) $get('scope')))
|
||||||
|
->visible(fn (Get $get): bool => $this->requiresOwnerlessWaiver((string) $get('scope')))
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(500)
|
||||||
|
->rows(4),
|
||||||
|
])
|
||||||
|
->action(function (array $data, SupportAccessGrantManager $manager): void {
|
||||||
|
$actor = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $actor instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$grant = $manager->request(
|
||||||
|
workspace: $this->workspace,
|
||||||
|
actor: $actor,
|
||||||
|
scope: (string) ($data['scope'] ?? ''),
|
||||||
|
reason: (string) ($data['reason'] ?? ''),
|
||||||
|
ttlMinutes: (int) ($data['ttl_minutes'] ?? 0),
|
||||||
|
waiverReason: isset($data['waiver_reason']) ? (string) $data['waiver_reason'] : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->workspace = $this->workspace->fresh()->loadCount('tenants');
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($grant->status === SupportAccessGrant::STATUS_ACTIVE
|
||||||
|
? 'Support access active'
|
||||||
|
: 'Support access requested')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Action::make('end_support_access')
|
||||||
|
->label('End support access')
|
||||||
|
->icon('heroicon-o-no-symbol')
|
||||||
|
->color('warning')
|
||||||
|
->visible(fn (): bool => $this->canManageSupportAccess() && $this->currentActiveSupportAccessGrant() instanceof SupportAccessGrant)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('End support access')
|
||||||
|
->modalDescription('This immediately ends the active support-access grant for this workspace and records an audit event.')
|
||||||
|
->action(function (SupportAccessGrantManager $manager): void {
|
||||||
|
$actor = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $actor instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$grant = $this->currentActiveSupportAccessGrant();
|
||||||
|
|
||||||
|
if (! $grant instanceof SupportAccessGrant) {
|
||||||
|
Notification::make()
|
||||||
|
->title('No active support access')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager->end($grant, $actor);
|
||||||
|
|
||||||
|
$this->workspace = $this->workspace->fresh()->loadCount('tenants');
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Support access ended')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Action::make('update_subscription_truth')
|
Action::make('update_subscription_truth')
|
||||||
->label('Update subscription truth')
|
->label('Update subscription truth')
|
||||||
->icon('heroicon-o-credit-card')
|
->icon('heroicon-o-credit-card')
|
||||||
@ -289,6 +397,47 @@ private function canManageCommercialLifecycle(): bool
|
|||||||
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
|
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canManageSupportAccess(): bool
|
||||||
|
{
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
return $user instanceof PlatformUser
|
||||||
|
&& $user->hasCapability(PlatformCapabilities::SUPPORT_ACCESS_MANAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function supportAccessSummary(): array
|
||||||
|
{
|
||||||
|
return app(SupportAccessGrantResolver::class)->summaryFor($this->workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportAccessMaxTtlMinutes(): int
|
||||||
|
{
|
||||||
|
return app(SupportAccessGrantResolver::class)->maxTtlMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requiresOwnerlessWaiver(string $scope): bool
|
||||||
|
{
|
||||||
|
return $scope === SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY
|
||||||
|
&& ! app(SupportAccessGrantResolver::class)->workspaceHasOwners($this->workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportAccessRequestDisabled(): bool
|
||||||
|
{
|
||||||
|
return app(SupportAccessGrantResolver::class)->currentOpenGrantFor($this->workspace) instanceof SupportAccessGrant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentActiveSupportAccessGrant(): ?SupportAccessGrant
|
||||||
|
{
|
||||||
|
$grant = app(SupportAccessGrantResolver::class)->currentOpenGrantFor($this->workspace);
|
||||||
|
|
||||||
|
return $grant instanceof SupportAccessGrant && $grant->status === SupportAccessGrant::STATUS_ACTIVE
|
||||||
|
? $grant
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private function currentWorkspaceSubscription(): ?WorkspaceSubscription
|
private function currentWorkspaceSubscription(): ?WorkspaceSubscription
|
||||||
{
|
{
|
||||||
$this->workspace->loadMissing('subscription');
|
$this->workspace->loadMissing('subscription');
|
||||||
|
|||||||
@ -7,11 +7,13 @@
|
|||||||
use App\Filament\System\Widgets\RepairWorkspaceOwnersStats;
|
use App\Filament\System\Widgets\RepairWorkspaceOwnersStats;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\BreakGlassSession;
|
use App\Services\Auth\BreakGlassSession;
|
||||||
|
use App\Services\Auth\SupportAccessGrantResolver;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Auth\WorkspaceRole;
|
use App\Support\Auth\WorkspaceRole;
|
||||||
@ -27,6 +29,7 @@
|
|||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
use Filament\Widgets\WidgetConfiguration;
|
use Filament\Widgets\WidgetConfiguration;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class RepairWorkspaceOwners extends Page implements HasTable
|
class RepairWorkspaceOwners extends Page implements HasTable
|
||||||
{
|
{
|
||||||
@ -117,6 +120,13 @@ public function table(Table $table): Table
|
|||||||
TextColumn::make('tenant_count')
|
TextColumn::make('tenant_count')
|
||||||
->label('Tenants')
|
->label('Tenants')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
TextColumn::make('recovery_support_access')
|
||||||
|
->label('Recovery support')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (Workspace $record): string => app(SupportAccessGrantResolver::class)->activeRecoveryGrantFor($record) instanceof SupportAccessGrant
|
||||||
|
? 'Active'
|
||||||
|
: 'Missing')
|
||||||
|
->color(fn (string $state): string => $state === 'Active' ? 'success' : 'warning'),
|
||||||
TextColumn::make('updated_at')
|
TextColumn::make('updated_at')
|
||||||
->label('Last activity')
|
->label('Last activity')
|
||||||
->since()
|
->since()
|
||||||
@ -131,9 +141,21 @@ public function table(Table $table): Table
|
|||||||
* @return array<array{action: string, actor: string|null, workspace: string|null, recorded_at: string}>
|
* @return array<array{action: string, actor: string|null, workspace: string|null, recorded_at: string}>
|
||||||
*/
|
*/
|
||||||
public function getRecentBreakGlassActions(): array
|
public function getRecentBreakGlassActions(): array
|
||||||
|
{
|
||||||
|
return $this->getRecentRecoveryGovernanceActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<array{action: string, actor: string|null, workspace: string|null, recorded_at: string}>
|
||||||
|
*/
|
||||||
|
public function getRecentRecoveryGovernanceActions(): array
|
||||||
{
|
{
|
||||||
return AuditLog::query()
|
return AuditLog::query()
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
->where('action', 'like', '%break_glass%')
|
->where('action', 'like', '%break_glass%')
|
||||||
|
->orWhere('action', 'like', 'support_access.%');
|
||||||
|
})
|
||||||
->orderByDesc('recorded_at')
|
->orderByDesc('recorded_at')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get()
|
->get()
|
||||||
@ -162,7 +184,7 @@ protected function getHeaderActions(): array
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Assign workspace owner')
|
->modalHeading('Assign workspace owner')
|
||||||
->modalDescription('This is a recovery action. It is audited and should only be used when the workspace owner set is broken.')
|
->modalDescription('This is a recovery action. It requires active break-glass mode and an active workspace recovery support-access grant for the selected workspace.')
|
||||||
->form([
|
->form([
|
||||||
Select::make('workspace_id')
|
Select::make('workspace_id')
|
||||||
->label('Workspace')
|
->label('Workspace')
|
||||||
@ -222,16 +244,26 @@ protected function getHeaderActions(): array
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $breakGlass->isActive()) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = (int) ($data['workspace_id'] ?? 0);
|
$workspaceId = (int) ($data['workspace_id'] ?? 0);
|
||||||
$targetUserId = (int) ($data['target_user_id'] ?? 0);
|
$targetUserId = (int) ($data['target_user_id'] ?? 0);
|
||||||
$reason = (string) ($data['reason'] ?? '');
|
$reason = (string) ($data['reason'] ?? '');
|
||||||
|
|
||||||
$workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail();
|
$workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail();
|
||||||
$targetUser = User::query()->whereKey($targetUserId)->firstOrFail();
|
$targetUser = User::query()->whereKey($targetUserId)->firstOrFail();
|
||||||
|
$supportAccessResolver = app(SupportAccessGrantResolver::class);
|
||||||
|
$supportGrant = $supportAccessResolver->activeRecoveryGrantFor($workspace);
|
||||||
|
|
||||||
|
if (! $breakGlass->isActive()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'workspace_id' => 'Activate break-glass mode before assigning a workspace owner.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $supportGrant instanceof SupportAccessGrant) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'workspace_id' => 'Active workspace recovery support access is required before assigning a workspace owner.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$membership = WorkspaceMembership::query()->firstOrNew([
|
$membership = WorkspaceMembership::query()->firstOrNew([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
@ -256,6 +288,9 @@ protected function getHeaderActions(): array
|
|||||||
'from_role' => $fromRole,
|
'from_role' => $fromRole,
|
||||||
'reason' => trim($reason),
|
'reason' => trim($reason),
|
||||||
'source' => 'break_glass',
|
'source' => 'break_glass',
|
||||||
|
'support_access_grant_id' => (int) $supportGrant->getKey(),
|
||||||
|
'support_access_scope' => (string) $supportGrant->scope,
|
||||||
|
'support_access_expires_at' => $supportGrant->expires_at?->toISOString(),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actor: null,
|
actor: null,
|
||||||
@ -273,7 +308,18 @@ protected function getHeaderActions(): array
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->disabled(fn (): bool => ! $breakGlass->isActive())
|
->disabled(fn (): bool => ! $breakGlass->isActive())
|
||||||
->tooltip(fn (): ?string => ! $breakGlass->isActive() ? 'Activate break-glass mode on the Dashboard first.' : null),
|
->tooltip(fn (): ?string => ! $breakGlass->isActive() ? 'Activate break-glass mode on the Dashboard first. Workspace recovery support access is also required for the selected workspace.' : null),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function recoveryBoundarySummary(): string
|
||||||
|
{
|
||||||
|
$breakGlass = app(BreakGlassSession::class);
|
||||||
|
|
||||||
|
if (! $breakGlass->isActive()) {
|
||||||
|
return 'Blocked: break-glass mode is inactive. Activate break-glass on the system dashboard and confirm the selected workspace has active recovery-scoped support access.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Break-glass mode is active. The selected workspace must still have active recovery-scoped support access before owner repair can execute.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,7 +65,8 @@ public function table(Table $table): Table
|
|||||||
->where(function (Builder $query): void {
|
->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
->where('action', 'platform.auth.login')
|
->where('action', 'platform.auth.login')
|
||||||
->orWhere('action', 'like', 'platform.break_glass.%');
|
->orWhere('action', 'like', 'platform.break_glass.%')
|
||||||
|
->orWhere('action', 'like', 'support_access.%');
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
->columns([
|
->columns([
|
||||||
@ -81,8 +82,12 @@ public function table(Table $table): Table
|
|||||||
TextColumn::make('actor_email')
|
TextColumn::make('actor_email')
|
||||||
->label('Actor')
|
->label('Actor')
|
||||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Unknown'),
|
->formatStateUsing(fn (?string $state): string => $state ?: 'Unknown'),
|
||||||
|
TextColumn::make('target_label')
|
||||||
|
->label('Target')
|
||||||
|
->formatStateUsing(fn (?string $state): string => $state ?: 'System')
|
||||||
|
->toggleable(),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No access logs found')
|
->emptyStateHeading('No access logs found')
|
||||||
->emptyStateDescription('Platform login and break-glass events will appear here.');
|
->emptyStateDescription('Platform login, break-glass, and support-access events will appear here.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
190
apps/platform/app/Models/SupportAccessGrant.php
Normal file
190
apps/platform/app/Models/SupportAccessGrant.php
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SupportAccessGrant extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\SupportAccessGrantFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const SCOPE_AUDIT_VIEW = 'audit_view';
|
||||||
|
|
||||||
|
public const SCOPE_WORKSPACE_RECOVERY = 'workspace_recovery';
|
||||||
|
|
||||||
|
public const STATUS_REQUESTED = 'requested';
|
||||||
|
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const STATUS_DENIED = 'denied';
|
||||||
|
|
||||||
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
public const STATUS_ENDED = 'ended';
|
||||||
|
|
||||||
|
public const APPROVAL_MODE_AUTO = 'auto';
|
||||||
|
|
||||||
|
public const APPROVAL_MODE_OWNER_REQUIRED = 'owner_required';
|
||||||
|
|
||||||
|
public const APPROVAL_MODE_OWNERLESS_WAIVER = 'ownerless_waiver';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'requested_at' => 'datetime',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
|
'starts_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'ended_at' => 'datetime',
|
||||||
|
'denied_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Workspace, $this>
|
||||||
|
*/
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<PlatformUser, $this>
|
||||||
|
*/
|
||||||
|
public function requester(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PlatformUser::class, 'requested_by_platform_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function approver(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'approved_by_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOpen(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereIn('status', [
|
||||||
|
self::STATUS_REQUESTED,
|
||||||
|
self::STATUS_ACTIVE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_ACTIVE
|
||||||
|
&& $this->expires_at !== null
|
||||||
|
&& $this->expires_at->isFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPending(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_REQUESTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function needsBreakGlass(): bool
|
||||||
|
{
|
||||||
|
return $this->scope === self::SCOPE_WORKSPACE_RECOVERY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function scopeLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::SCOPE_AUDIT_VIEW => 'Audit trail review',
|
||||||
|
self::SCOPE_WORKSPACE_RECOVERY => 'Workspace recovery',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function scopeLabel(string $scope): string
|
||||||
|
{
|
||||||
|
return self::scopeLabels()[$scope] ?? str($scope)->replace('_', ' ')->title()->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function statusLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STATUS_REQUESTED => 'Pending approval',
|
||||||
|
self::STATUS_ACTIVE => 'Active',
|
||||||
|
self::STATUS_DENIED => 'Denied',
|
||||||
|
self::STATUS_EXPIRED => 'Expired',
|
||||||
|
self::STATUS_ENDED => 'Ended',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function statusLabel(string $status): string
|
||||||
|
{
|
||||||
|
return self::statusLabels()[$status] ?? str($status)->replace('_', ' ')->title()->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function approvalModeLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::APPROVAL_MODE_AUTO => 'Auto-approved low-risk access',
|
||||||
|
self::APPROVAL_MODE_OWNER_REQUIRED => 'Workspace owner approval required',
|
||||||
|
self::APPROVAL_MODE_OWNERLESS_WAIVER => 'Ownerless recovery waiver',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function approvalModeLabel(string $approvalMode): string
|
||||||
|
{
|
||||||
|
return self::approvalModeLabels()[$approvalMode] ?? str($approvalMode)->replace('_', ' ')->title()->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function scopes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::SCOPE_AUDIT_VIEW,
|
||||||
|
self::SCOPE_WORKSPACE_RECOVERY,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function terminalStatuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STATUS_DENIED,
|
||||||
|
self::STATUS_EXPIRED,
|
||||||
|
self::STATUS_ENDED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function supportAccessAuditActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
\App\Support\Audit\AuditActionId::SupportAccessRequested->value,
|
||||||
|
\App\Support\Audit\AuditActionId::SupportAccessActivated->value,
|
||||||
|
\App\Support\Audit\AuditActionId::SupportAccessApproved->value,
|
||||||
|
\App\Support\Audit\AuditActionId::SupportAccessDenied->value,
|
||||||
|
\App\Support\Audit\AuditActionId::SupportAccessEnded->value,
|
||||||
|
\App\Support\Audit\AuditActionId::SupportAccessExpired->value,
|
||||||
|
\App\Support\Audit\AuditActionId::SupportAccessOwnerlessWaiverUsed->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,6 +58,14 @@ public function subscription(): HasOne
|
|||||||
return $this->hasOne(WorkspaceSubscription::class);
|
return $this->hasOne(WorkspaceSubscription::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<SupportAccessGrant, $this>
|
||||||
|
*/
|
||||||
|
public function supportAccessGrants(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SupportAccessGrant::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<TenantSetting, $this>
|
* @return HasMany<TenantSetting, $this>
|
||||||
*/
|
*/
|
||||||
|
|||||||
268
apps/platform/app/Services/Auth/SupportAccessGrantManager.php
Normal file
268
apps/platform/app/Services/Auth/SupportAccessGrantManager.php
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Audit\AuditActorType;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use DomainException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class SupportAccessGrantManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SupportAccessGrantResolver $resolver,
|
||||||
|
private readonly WorkspaceAuditLogger $auditLogger,
|
||||||
|
private readonly WorkspaceCapabilityResolver $workspaceCapabilities,
|
||||||
|
private readonly BreakGlassSession $breakGlassSession,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function request(
|
||||||
|
Workspace $workspace,
|
||||||
|
PlatformUser $actor,
|
||||||
|
string $scope,
|
||||||
|
string $reason,
|
||||||
|
int $ttlMinutes,
|
||||||
|
?string $waiverReason = null,
|
||||||
|
): SupportAccessGrant {
|
||||||
|
$this->authorizePlatformActor($actor);
|
||||||
|
|
||||||
|
$scope = $this->resolver->validateScope($scope);
|
||||||
|
$reason = $this->validatedText($reason, 'reason');
|
||||||
|
$ttlMinutes = $this->validatedTtl($ttlMinutes);
|
||||||
|
$approvalMode = $this->resolver->approvalModeFor($workspace, $scope);
|
||||||
|
$waiverReason = $waiverReason !== null ? trim($waiverReason) : null;
|
||||||
|
|
||||||
|
if ($approvalMode === SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER) {
|
||||||
|
if (! $this->breakGlassSession->isActive()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'scope' => 'Ownerless workspace recovery requires active break-glass mode.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$waiverReason = $this->validatedText((string) $waiverReason, 'waiver_reason');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($approvalMode !== SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER) {
|
||||||
|
$waiverReason = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resolver->expireStaleActiveGrants($workspace);
|
||||||
|
|
||||||
|
$existing = SupportAccessGrant::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('requested_by_platform_user_id', (int) $actor->getKey())
|
||||||
|
->where('scope', $scope)
|
||||||
|
->open()
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof SupportAccessGrant) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($workspace, $actor, $scope, $reason, $ttlMinutes, $approvalMode, $waiverReason): SupportAccessGrant {
|
||||||
|
$now = CarbonImmutable::now();
|
||||||
|
$activeImmediately = in_array($approvalMode, [
|
||||||
|
SupportAccessGrant::APPROVAL_MODE_AUTO,
|
||||||
|
SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER,
|
||||||
|
], true);
|
||||||
|
|
||||||
|
$grant = SupportAccessGrant::query()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'requested_by_platform_user_id' => (int) $actor->getKey(),
|
||||||
|
'approved_by_user_id' => null,
|
||||||
|
'scope' => $scope,
|
||||||
|
'status' => $activeImmediately
|
||||||
|
? SupportAccessGrant::STATUS_ACTIVE
|
||||||
|
: SupportAccessGrant::STATUS_REQUESTED,
|
||||||
|
'approval_mode' => $approvalMode,
|
||||||
|
'reason' => $reason,
|
||||||
|
'waiver_reason' => $waiverReason,
|
||||||
|
'ttl_minutes' => $ttlMinutes,
|
||||||
|
'requested_at' => $now,
|
||||||
|
'approved_at' => null,
|
||||||
|
'starts_at' => $activeImmediately ? $now : null,
|
||||||
|
'expires_at' => $activeImmediately ? $now->addMinutes($ttlMinutes) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->audit($grant, AuditActionId::SupportAccessRequested, $actor, 'Support access requested for '.$workspace->name);
|
||||||
|
|
||||||
|
if ($approvalMode === SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER) {
|
||||||
|
$this->audit($grant, AuditActionId::SupportAccessOwnerlessWaiverUsed, $actor, 'Ownerless support-access waiver used for '.$workspace->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($activeImmediately) {
|
||||||
|
$this->audit($grant, AuditActionId::SupportAccessActivated, $actor, 'Support access activated for '.$workspace->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $grant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(SupportAccessGrant $grant, User $actor): SupportAccessGrant
|
||||||
|
{
|
||||||
|
$this->authorizeWorkspaceOwnerApproval($grant, $actor);
|
||||||
|
|
||||||
|
if ($grant->status !== SupportAccessGrant::STATUS_REQUESTED) {
|
||||||
|
throw new DomainException('Only pending support-access requests may be approved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($grant->scope !== SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY) {
|
||||||
|
throw new DomainException('Only recovery support-access requests require owner approval.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($grant, $actor): SupportAccessGrant {
|
||||||
|
$now = CarbonImmutable::now();
|
||||||
|
|
||||||
|
$grant->forceFill([
|
||||||
|
'status' => SupportAccessGrant::STATUS_ACTIVE,
|
||||||
|
'approved_by_user_id' => (int) $actor->getKey(),
|
||||||
|
'approved_at' => $now,
|
||||||
|
'starts_at' => $now,
|
||||||
|
'expires_at' => $now->addMinutes((int) $grant->ttl_minutes),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->audit($grant->fresh(), AuditActionId::SupportAccessApproved, $actor, 'Support access approved for '.$grant->workspace->name);
|
||||||
|
$this->audit($grant->fresh(), AuditActionId::SupportAccessActivated, $actor, 'Support access activated for '.$grant->workspace->name);
|
||||||
|
|
||||||
|
return $grant->fresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deny(SupportAccessGrant $grant, User $actor): SupportAccessGrant
|
||||||
|
{
|
||||||
|
$this->authorizeWorkspaceOwnerApproval($grant, $actor);
|
||||||
|
|
||||||
|
if ($grant->status !== SupportAccessGrant::STATUS_REQUESTED) {
|
||||||
|
throw new DomainException('Only pending support-access requests may be denied.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($grant, $actor): SupportAccessGrant {
|
||||||
|
$grant->forceFill([
|
||||||
|
'status' => SupportAccessGrant::STATUS_DENIED,
|
||||||
|
'denied_at' => CarbonImmutable::now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->audit($grant->fresh(), AuditActionId::SupportAccessDenied, $actor, 'Support access denied for '.$grant->workspace->name);
|
||||||
|
|
||||||
|
return $grant->fresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function end(SupportAccessGrant $grant, PlatformUser $actor): SupportAccessGrant
|
||||||
|
{
|
||||||
|
$this->authorizePlatformActor($actor);
|
||||||
|
$this->resolver->expireStaleActiveGrants($grant->workspace);
|
||||||
|
|
||||||
|
$grant = $grant->fresh();
|
||||||
|
|
||||||
|
if (! $grant instanceof SupportAccessGrant || $grant->status !== SupportAccessGrant::STATUS_ACTIVE) {
|
||||||
|
throw new DomainException('Only active support-access grants may be ended.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($grant, $actor): SupportAccessGrant {
|
||||||
|
$grant->forceFill([
|
||||||
|
'status' => SupportAccessGrant::STATUS_ENDED,
|
||||||
|
'ended_at' => CarbonImmutable::now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->audit($grant->fresh(), AuditActionId::SupportAccessEnded, $actor, 'Support access ended for '.$grant->workspace->name);
|
||||||
|
|
||||||
|
return $grant->fresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizePlatformActor(PlatformUser $actor): void
|
||||||
|
{
|
||||||
|
if (! $actor->hasCapability(PlatformCapabilities::SUPPORT_ACCESS_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeWorkspaceOwnerApproval(SupportAccessGrant $grant, User $actor): void
|
||||||
|
{
|
||||||
|
$workspace = $grant->workspace;
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->workspaceCapabilities->isMember($actor, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->workspaceCapabilities->can($actor, $workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->workspaceCapabilities->getRole($actor, $workspace) !== WorkspaceRole::Owner) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatedText(string $value, string $field): string
|
||||||
|
{
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
if (mb_strlen($trimmed) < 5) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$field => 'Enter at least 5 characters.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($trimmed) > 500) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$field => 'Enter no more than 500 characters.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatedTtl(int $ttlMinutes): int
|
||||||
|
{
|
||||||
|
$max = $this->resolver->maxTtlMinutes();
|
||||||
|
|
||||||
|
if ($ttlMinutes < 1 || $ttlMinutes > $max) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'ttl_minutes' => 'TTL must be between 1 and '.$max.' minutes.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ttlMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function audit(SupportAccessGrant $grant, AuditActionId $action, User|PlatformUser $actor, string $summary): void
|
||||||
|
{
|
||||||
|
$workspace = $grant->workspace;
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: $action,
|
||||||
|
context: $this->resolver->auditContext($grant),
|
||||||
|
actor: $actor,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'support_access_grant',
|
||||||
|
resourceId: (string) $grant->getKey(),
|
||||||
|
actorType: $actor instanceof PlatformUser ? AuditActorType::Platform : null,
|
||||||
|
targetLabel: $this->resolver->targetLabel($grant),
|
||||||
|
summary: $summary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
apps/platform/app/Services/Auth/SupportAccessGrantResolver.php
Normal file
228
apps/platform/app/Services/Auth/SupportAccessGrantResolver.php
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class SupportAccessGrantResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WorkspaceAuditLogger $auditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function maxTtlMinutes(): int
|
||||||
|
{
|
||||||
|
return max(1, (int) config('tenantpilot.support_access.max_ttl_minutes', 240));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateScope(string $scope): string
|
||||||
|
{
|
||||||
|
$normalized = trim($scope);
|
||||||
|
|
||||||
|
if (! in_array($normalized, SupportAccessGrant::scopes(), true)) {
|
||||||
|
throw new InvalidArgumentException('Unsupported support-access scope.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approvalModeFor(Workspace $workspace, string $scope): string
|
||||||
|
{
|
||||||
|
$scope = $this->validateScope($scope);
|
||||||
|
|
||||||
|
if ($scope === SupportAccessGrant::SCOPE_AUDIT_VIEW) {
|
||||||
|
return SupportAccessGrant::APPROVAL_MODE_AUTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->workspaceHasOwners($workspace)
|
||||||
|
? SupportAccessGrant::APPROVAL_MODE_OWNER_REQUIRED
|
||||||
|
: SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspaceHasOwners(Workspace $workspace): bool
|
||||||
|
{
|
||||||
|
return WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('role', WorkspaceRole::Owner->value)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeGrantFor(Workspace $workspace, string $scope): ?SupportAccessGrant
|
||||||
|
{
|
||||||
|
$scope = $this->validateScope($scope);
|
||||||
|
|
||||||
|
$this->expireStaleActiveGrants($workspace);
|
||||||
|
|
||||||
|
return SupportAccessGrant::query()
|
||||||
|
->with(['requester', 'approver'])
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('scope', $scope)
|
||||||
|
->where('status', SupportAccessGrant::STATUS_ACTIVE)
|
||||||
|
->where('expires_at', '>', CarbonImmutable::now())
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeRecoveryGrantFor(Workspace $workspace): ?SupportAccessGrant
|
||||||
|
{
|
||||||
|
return $this->activeGrantFor($workspace, SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasActiveRecoveryGrant(Workspace $workspace): bool
|
||||||
|
{
|
||||||
|
return $this->activeRecoveryGrantFor($workspace) instanceof SupportAccessGrant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentOpenGrantFor(Workspace $workspace): ?SupportAccessGrant
|
||||||
|
{
|
||||||
|
$this->expireStaleActiveGrants($workspace);
|
||||||
|
|
||||||
|
return SupportAccessGrant::query()
|
||||||
|
->with(['requester', 'approver'])
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->open()
|
||||||
|
->orderByRaw("CASE WHEN status = 'active' THEN 0 ELSE 1 END")
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, SupportAccessGrant>
|
||||||
|
*/
|
||||||
|
public function pendingRecoveryRequestsFor(Workspace $workspace): Collection
|
||||||
|
{
|
||||||
|
$this->expireStaleActiveGrants($workspace);
|
||||||
|
|
||||||
|
return SupportAccessGrant::query()
|
||||||
|
->with(['requester'])
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('scope', SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY)
|
||||||
|
->where('status', SupportAccessGrant::STATUS_REQUESTED)
|
||||||
|
->orderBy('requested_at')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function summaryFor(Workspace $workspace): array
|
||||||
|
{
|
||||||
|
$grant = $this->currentOpenGrantFor($workspace);
|
||||||
|
|
||||||
|
if (! $grant instanceof SupportAccessGrant) {
|
||||||
|
return [
|
||||||
|
'status' => 'none',
|
||||||
|
'status_label' => 'No active support access',
|
||||||
|
'status_color' => 'gray',
|
||||||
|
'scope' => null,
|
||||||
|
'scope_label' => null,
|
||||||
|
'grant_id' => null,
|
||||||
|
'reason' => null,
|
||||||
|
'requester_label' => null,
|
||||||
|
'approval_mode' => null,
|
||||||
|
'approval_label' => null,
|
||||||
|
'approver_label' => null,
|
||||||
|
'requested_ttl_label' => null,
|
||||||
|
'expires_at' => null,
|
||||||
|
'expires_label' => null,
|
||||||
|
'needs_break_glass' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => (string) $grant->status,
|
||||||
|
'status_label' => SupportAccessGrant::statusLabel((string) $grant->status),
|
||||||
|
'status_color' => $grant->status === SupportAccessGrant::STATUS_ACTIVE ? 'success' : 'warning',
|
||||||
|
'scope' => (string) $grant->scope,
|
||||||
|
'scope_label' => SupportAccessGrant::scopeLabel((string) $grant->scope),
|
||||||
|
'grant_id' => (int) $grant->getKey(),
|
||||||
|
'reason' => (string) $grant->reason,
|
||||||
|
'requester_label' => $grant->requester?->name ?? $grant->requester?->email ?? 'Platform support',
|
||||||
|
'approval_mode' => (string) $grant->approval_mode,
|
||||||
|
'approval_label' => SupportAccessGrant::approvalModeLabel((string) $grant->approval_mode),
|
||||||
|
'approver_label' => $grant->approver?->name ?? $grant->approver?->email,
|
||||||
|
'requested_ttl_label' => $grant->ttl_minutes.' minutes',
|
||||||
|
'expires_at' => $grant->expires_at,
|
||||||
|
'expires_label' => $grant->expires_at?->diffForHumans(),
|
||||||
|
'needs_break_glass' => $grant->needsBreakGlass(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expireStaleActiveGrants(?Workspace $workspace = null): void
|
||||||
|
{
|
||||||
|
$query = SupportAccessGrant::query()
|
||||||
|
->with(['workspace', 'requester'])
|
||||||
|
->where('status', SupportAccessGrant::STATUS_ACTIVE)
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', CarbonImmutable::now());
|
||||||
|
|
||||||
|
if ($workspace instanceof Workspace) {
|
||||||
|
$query->where('workspace_id', (int) $workspace->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->get()->each(function (SupportAccessGrant $grant): void {
|
||||||
|
$grant->forceFill([
|
||||||
|
'status' => SupportAccessGrant::STATUS_EXPIRED,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$workspace = $grant->workspace;
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::SupportAccessExpired,
|
||||||
|
context: $this->auditContext($grant),
|
||||||
|
actor: null,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'support_access_grant',
|
||||||
|
resourceId: (string) $grant->getKey(),
|
||||||
|
targetLabel: $this->targetLabel($grant),
|
||||||
|
summary: 'Support access expired for '.$workspace->name,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function auditContext(SupportAccessGrant $grant, array $extra = []): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'support_access_grant_id' => (int) $grant->getKey(),
|
||||||
|
'workspace_id' => (int) $grant->workspace_id,
|
||||||
|
'scope' => (string) $grant->scope,
|
||||||
|
'scope_label' => SupportAccessGrant::scopeLabel((string) $grant->scope),
|
||||||
|
'status' => (string) $grant->status,
|
||||||
|
'approval_mode' => (string) $grant->approval_mode,
|
||||||
|
'reason' => (string) $grant->reason,
|
||||||
|
'waiver_reason' => $grant->waiver_reason,
|
||||||
|
'ttl_minutes' => (int) $grant->ttl_minutes,
|
||||||
|
'requested_by_platform_user_id' => (int) $grant->requested_by_platform_user_id,
|
||||||
|
'approved_by_user_id' => is_numeric($grant->approved_by_user_id) ? (int) $grant->approved_by_user_id : null,
|
||||||
|
'requested_at' => $grant->requested_at?->toISOString(),
|
||||||
|
'approved_at' => $grant->approved_at?->toISOString(),
|
||||||
|
'starts_at' => $grant->starts_at?->toISOString(),
|
||||||
|
'expires_at' => $grant->expires_at?->toISOString(),
|
||||||
|
'ended_at' => $grant->ended_at?->toISOString(),
|
||||||
|
'denied_at' => $grant->denied_at?->toISOString(),
|
||||||
|
] + $extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetLabel(SupportAccessGrant $grant): string
|
||||||
|
{
|
||||||
|
return SupportAccessGrant::scopeLabel((string) $grant->scope).' #'.$grant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -115,6 +115,13 @@ enum AuditActionId: string
|
|||||||
case SupportRequestExternalTicketCreated = 'support_request.external_ticket_created';
|
case SupportRequestExternalTicketCreated = 'support_request.external_ticket_created';
|
||||||
case SupportRequestExternalTicketLinked = 'support_request.external_ticket_linked';
|
case SupportRequestExternalTicketLinked = 'support_request.external_ticket_linked';
|
||||||
case SupportRequestExternalHandoffFailed = 'support_request.external_handoff_failed';
|
case SupportRequestExternalHandoffFailed = 'support_request.external_handoff_failed';
|
||||||
|
case SupportAccessRequested = 'support_access.requested';
|
||||||
|
case SupportAccessActivated = 'support_access.activated';
|
||||||
|
case SupportAccessApproved = 'support_access.approved';
|
||||||
|
case SupportAccessDenied = 'support_access.denied';
|
||||||
|
case SupportAccessEnded = 'support_access.ended';
|
||||||
|
case SupportAccessExpired = 'support_access.expired';
|
||||||
|
case SupportAccessOwnerlessWaiverUsed = 'support_access.ownerless_waiver_used';
|
||||||
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
|
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
|
||||||
case OperationalControlPaused = 'operational_control.paused';
|
case OperationalControlPaused = 'operational_control.paused';
|
||||||
case OperationalControlUpdated = 'operational_control.updated';
|
case OperationalControlUpdated = 'operational_control.updated';
|
||||||
@ -271,6 +278,13 @@ private static function labels(): array
|
|||||||
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
|
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
|
||||||
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
|
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
|
||||||
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
|
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
|
||||||
|
self::SupportAccessRequested->value => 'Support access requested',
|
||||||
|
self::SupportAccessActivated->value => 'Support access activated',
|
||||||
|
self::SupportAccessApproved->value => 'Support access approved',
|
||||||
|
self::SupportAccessDenied->value => 'Support access denied',
|
||||||
|
self::SupportAccessEnded->value => 'Support access ended',
|
||||||
|
self::SupportAccessExpired->value => 'Support access expired',
|
||||||
|
self::SupportAccessOwnerlessWaiverUsed->value => 'Support access ownerless waiver used',
|
||||||
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
||||||
self::OperationalControlPaused->value => 'Operational control paused',
|
self::OperationalControlPaused->value => 'Operational control paused',
|
||||||
self::OperationalControlUpdated->value => 'Operational control updated',
|
self::OperationalControlUpdated->value => 'Operational control updated',
|
||||||
@ -372,6 +386,13 @@ private static function summaries(): array
|
|||||||
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
|
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
|
||||||
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
|
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
|
||||||
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
|
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
|
||||||
|
self::SupportAccessRequested->value => 'Support access requested',
|
||||||
|
self::SupportAccessActivated->value => 'Support access activated',
|
||||||
|
self::SupportAccessApproved->value => 'Support access approved',
|
||||||
|
self::SupportAccessDenied->value => 'Support access denied',
|
||||||
|
self::SupportAccessEnded->value => 'Support access ended',
|
||||||
|
self::SupportAccessExpired->value => 'Support access expired',
|
||||||
|
self::SupportAccessOwnerlessWaiverUsed->value => 'Support access ownerless waiver used',
|
||||||
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
||||||
self::OperationalControlPaused->value => 'Operational control paused',
|
self::OperationalControlPaused->value => 'Operational control paused',
|
||||||
self::OperationalControlUpdated->value => 'Operational control updated',
|
self::OperationalControlUpdated->value => 'Operational control updated',
|
||||||
|
|||||||
@ -20,6 +20,8 @@ class PlatformCapabilities
|
|||||||
|
|
||||||
public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage';
|
public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage';
|
||||||
|
|
||||||
|
public const SUPPORT_ACCESS_MANAGE = 'platform.support_access.manage';
|
||||||
|
|
||||||
public const OPERATIONS_VIEW = 'platform.operations.view';
|
public const OPERATIONS_VIEW = 'platform.operations.view';
|
||||||
|
|
||||||
public const OPERATIONS_MANAGE = 'platform.operations.manage';
|
public const OPERATIONS_MANAGE = 'platform.operations.manage';
|
||||||
|
|||||||
@ -6,6 +6,10 @@
|
|||||||
'ttl_minutes' => (int) env('BREAK_GLASS_TTL_MINUTES', 15),
|
'ttl_minutes' => (int) env('BREAK_GLASS_TTL_MINUTES', 15),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'support_access' => [
|
||||||
|
'max_ttl_minutes' => (int) env('TENANTPILOT_SUPPORT_ACCESS_MAX_TTL_MINUTES', 240),
|
||||||
|
],
|
||||||
|
|
||||||
'system_console' => [
|
'system_console' => [
|
||||||
'stuck_thresholds' => [
|
'stuck_thresholds' => [
|
||||||
'queued_minutes' => (int) env('TENANTPILOT_SYSTEM_CONSOLE_STUCK_QUEUED_MINUTES', 15),
|
'queued_minutes' => (int) env('TENANTPILOT_SYSTEM_CONSOLE_STUCK_QUEUED_MINUTES', 15),
|
||||||
|
|||||||
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<SupportAccessGrant>
|
||||||
|
*/
|
||||||
|
class SupportAccessGrantFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = SupportAccessGrant::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$requestedAt = now()->subMinutes(5);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => Workspace::factory(),
|
||||||
|
'requested_by_platform_user_id' => PlatformUser::factory(),
|
||||||
|
'approved_by_user_id' => null,
|
||||||
|
'scope' => SupportAccessGrant::SCOPE_AUDIT_VIEW,
|
||||||
|
'status' => SupportAccessGrant::STATUS_ACTIVE,
|
||||||
|
'approval_mode' => SupportAccessGrant::APPROVAL_MODE_AUTO,
|
||||||
|
'reason' => 'Review audit history for a support case',
|
||||||
|
'waiver_reason' => null,
|
||||||
|
'ttl_minutes' => 60,
|
||||||
|
'requested_at' => $requestedAt,
|
||||||
|
'approved_at' => null,
|
||||||
|
'starts_at' => $requestedAt,
|
||||||
|
'expires_at' => now()->addMinutes(55),
|
||||||
|
'ended_at' => null,
|
||||||
|
'denied_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pendingRecovery(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'scope' => SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY,
|
||||||
|
'status' => SupportAccessGrant::STATUS_REQUESTED,
|
||||||
|
'approval_mode' => SupportAccessGrant::APPROVAL_MODE_OWNER_REQUIRED,
|
||||||
|
'approved_at' => null,
|
||||||
|
'starts_at' => null,
|
||||||
|
'expires_at' => null,
|
||||||
|
'ended_at' => null,
|
||||||
|
'denied_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeRecovery(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'scope' => SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY,
|
||||||
|
'status' => SupportAccessGrant::STATUS_ACTIVE,
|
||||||
|
'approval_mode' => SupportAccessGrant::APPROVAL_MODE_OWNER_REQUIRED,
|
||||||
|
'approved_at' => now()->subMinutes(4),
|
||||||
|
'starts_at' => now()->subMinutes(4),
|
||||||
|
'expires_at' => now()->addMinutes(56),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expired(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'status' => SupportAccessGrant::STATUS_ACTIVE,
|
||||||
|
'starts_at' => now()->subHours(2),
|
||||||
|
'expires_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('support_access_grants', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||||
|
$table->foreignId('requested_by_platform_user_id')->constrained('platform_users')->restrictOnDelete();
|
||||||
|
$table->foreignId('approved_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('scope');
|
||||||
|
$table->string('status');
|
||||||
|
$table->string('approval_mode');
|
||||||
|
$table->text('reason');
|
||||||
|
$table->text('waiver_reason')->nullable();
|
||||||
|
$table->unsignedInteger('ttl_minutes');
|
||||||
|
$table->timestamp('requested_at');
|
||||||
|
$table->timestamp('approved_at')->nullable();
|
||||||
|
$table->timestamp('starts_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamp('ended_at')->nullable();
|
||||||
|
$table->timestamp('denied_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['workspace_id', 'scope', 'status']);
|
||||||
|
$table->index(['requested_by_platform_user_id', 'scope']);
|
||||||
|
$table->index(['expires_at', 'status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DB::connection()->getDriverName() === 'pgsql') {
|
||||||
|
DB::statement("ALTER TABLE support_access_grants ADD CONSTRAINT support_access_grants_scope_check CHECK (scope IN ('audit_view', 'workspace_recovery'))");
|
||||||
|
DB::statement("ALTER TABLE support_access_grants ADD CONSTRAINT support_access_grants_status_check CHECK (status IN ('requested', 'active', 'denied', 'expired', 'ended'))");
|
||||||
|
DB::statement("ALTER TABLE support_access_grants ADD CONSTRAINT support_access_grants_approval_mode_check CHECK (approval_mode IN ('auto', 'owner_required', 'ownerless_waiver'))");
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::statement("CREATE UNIQUE INDEX support_access_grants_open_unique ON support_access_grants (workspace_id, requested_by_platform_user_id, scope) WHERE status IN ('requested', 'active')");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('support_access_grants');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -36,6 +36,7 @@ public function run(): void
|
|||||||
PlatformCapabilities::USE_BREAK_GLASS,
|
PlatformCapabilities::USE_BREAK_GLASS,
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
PlatformCapabilities::CONSOLE_VIEW,
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
PlatformCapabilities::DIRECTORY_VIEW,
|
||||||
|
PlatformCapabilities::SUPPORT_ACCESS_MANAGE,
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
PlatformCapabilities::OPERATIONS_VIEW,
|
||||||
PlatformCapabilities::OPERATIONS_MANAGE,
|
PlatformCapabilities::OPERATIONS_MANAGE,
|
||||||
PlatformCapabilities::OPS_VIEW,
|
PlatformCapabilities::OPS_VIEW,
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
|
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
|
||||||
$managedTenantDecision = $entitlementDecisions['managed_tenant_activation_limit'] ?? null;
|
$managedTenantDecision = $entitlementDecisions['managed_tenant_activation_limit'] ?? null;
|
||||||
$reviewPackDecision = $entitlementDecisions['review_pack_generation_enabled'] ?? null;
|
$reviewPackDecision = $entitlementDecisions['review_pack_generation_enabled'] ?? null;
|
||||||
|
$supportAccess = $this->supportAccessSummary();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@ -50,6 +51,79 @@
|
|||||||
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
|
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<x-filament::section>
|
||||||
|
<x-slot name="heading">
|
||||||
|
Support access
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<x-slot name="description">
|
||||||
|
Workspace-scoped support access stays separate from break-glass recovery.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Current posture</p>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="$supportAccess['status_color']">
|
||||||
|
{{ $supportAccess['status_label'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if ($supportAccess['scope_label'])
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
{{ $supportAccess['scope_label'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
@if ($supportAccess['reason'])
|
||||||
|
{{ $supportAccess['reason'] }}
|
||||||
|
@else
|
||||||
|
No platform support grant is currently active or pending for this workspace.
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Governance detail</p>
|
||||||
|
@if ($supportAccess['grant_id'])
|
||||||
|
<dl class="mt-2 grid grid-cols-1 gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-950 dark:text-white">Requester</dt>
|
||||||
|
<dd>{{ $supportAccess['requester_label'] }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-950 dark:text-white">Approval</dt>
|
||||||
|
<dd>
|
||||||
|
{{ $supportAccess['approval_label'] }}
|
||||||
|
@if ($supportAccess['approver_label'])
|
||||||
|
by {{ $supportAccess['approver_label'] }}
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-950 dark:text-white">TTL</dt>
|
||||||
|
<dd>
|
||||||
|
{{ $supportAccess['requested_ttl_label'] }}
|
||||||
|
@if ($supportAccess['expires_label'])
|
||||||
|
· expires {{ $supportAccess['expires_label'] }}
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@if ($supportAccess['needs_break_glass'])
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-950 dark:text-white">Recovery boundary</dt>
|
||||||
|
<dd>Owner repair still requires active break-glass mode.</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</dl>
|
||||||
|
@else
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Request ordinary audit review access here, or request recovery access when owner repair needs a customer-visible approval path.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
Commercial lifecycle
|
Commercial lifecycle
|
||||||
|
|||||||
@ -14,7 +14,10 @@
|
|||||||
<p class="font-medium">Purpose</p>
|
<p class="font-medium">Purpose</p>
|
||||||
<p class="mt-1">
|
<p class="mt-1">
|
||||||
This page exists to recover from broken workspace ownership state (e.g. a workspace with zero owners due to manual DB edits).
|
This page exists to recover from broken workspace ownership state (e.g. a workspace with zero owners due to manual DB edits).
|
||||||
Actions here require break-glass mode and are fully audited.
|
Actions here require break-glass mode, active workspace recovery support access, and full audit logging.
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-warning-700 dark:text-warning-300">
|
||||||
|
{{ $this->recoveryBoundarySummary() }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -22,23 +25,23 @@
|
|||||||
{{-- Workspace table --}}
|
{{-- Workspace table --}}
|
||||||
{{ $this->table }}
|
{{ $this->table }}
|
||||||
|
|
||||||
{{-- Recent break-glass actions --}}
|
{{-- Recent break-glass and support-access actions --}}
|
||||||
@php
|
@php
|
||||||
$recentActions = $this->getRecentBreakGlassActions();
|
$recentActions = $this->getRecentRecoveryGovernanceActions();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
Recent break-glass actions
|
Recent recovery governance actions
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="description">
|
<x-slot name="description">
|
||||||
Last 10 break-glass audit log entries.
|
Last 10 break-glass and support-access audit log entries.
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
@if (empty($recentActions))
|
@if (empty($recentActions))
|
||||||
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
||||||
No break-glass actions recorded yet.
|
No break-glass or support-access actions recorded yet.
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
||||||
|
|||||||
@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Directory\ViewWorkspace;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
|
||||||
|
pest()->browser()->timeout(20_000);
|
||||||
|
|
||||||
|
it('smokes system support-access request and end actions', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create([
|
||||||
|
'name' => 'Browser Support Access Workspace',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$platformUser = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::DIRECTORY_VIEW,
|
||||||
|
PlatformCapabilities::SUPPORT_ACCESS_MANAGE,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
auth('web')->logout();
|
||||||
|
$this->flushSession();
|
||||||
|
$this->actingAs($platformUser, 'platform');
|
||||||
|
|
||||||
|
$page = visit(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]));
|
||||||
|
|
||||||
|
$page
|
||||||
|
->waitForText('Support access')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs()
|
||||||
|
->assertSee('No active support access')
|
||||||
|
->assertSee('Request support access')
|
||||||
|
->click('Request support access')
|
||||||
|
->waitForText('Support access is workspace-scoped, time-limited, and audit-backed.');
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
(() => {
|
||||||
|
const field = (labelText) => {
|
||||||
|
const label = Array.from(document.querySelectorAll('label')).find((element) => element.textContent?.replace('*', '').trim() === labelText);
|
||||||
|
|
||||||
|
if (! label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetId = label.getAttribute('for');
|
||||||
|
|
||||||
|
if (targetId) {
|
||||||
|
return document.getElementById(targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return label.parentElement?.querySelector('input, textarea, select') ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reason = field('Reason');
|
||||||
|
const ttl = field('TTL');
|
||||||
|
|
||||||
|
if (! reason || ! ttl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
reason.value = 'Browser smoke support access review.';
|
||||||
|
reason.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
reason.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
|
ttl.value = '30';
|
||||||
|
ttl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
ttl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
(() => {
|
||||||
|
const confirmButton = Array.from(document.querySelectorAll('button')).find((element) => element.textContent?.trim() === 'Confirm');
|
||||||
|
|
||||||
|
confirmButton?.click();
|
||||||
|
})();
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page
|
||||||
|
->waitForText('Browser smoke support access review.')
|
||||||
|
->assertSee('Audit trail review')
|
||||||
|
->assertSee('End support access')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs()
|
||||||
|
->click('End support access')
|
||||||
|
->waitForText('This immediately ends the active support-access grant for this workspace and records an audit event.');
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
(() => {
|
||||||
|
const confirmButton = Array.from(document.querySelectorAll('button')).find((element) => element.textContent?.trim() === 'Confirm');
|
||||||
|
|
||||||
|
confirmButton?.click();
|
||||||
|
})();
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page
|
||||||
|
->waitForText('No active support access')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs();
|
||||||
|
});
|
||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
@ -58,6 +59,11 @@
|
|||||||
'reason' => 'Recover workspace ownership',
|
'reason' => 'Recover workspace ownership',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$supportGrant = SupportAccessGrant::factory()->activeRecovery()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'requested_by_platform_user_id' => (int) $platformUser->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
Livewire::test(RepairWorkspaceOwners::class)
|
Livewire::test(RepairWorkspaceOwners::class)
|
||||||
->callAction('assign_owner', data: [
|
->callAction('assign_owner', data: [
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
@ -86,5 +92,7 @@
|
|||||||
'target_user_id' => (int) $targetUser->getKey(),
|
'target_user_id' => (int) $targetUser->getKey(),
|
||||||
'attempted_role' => WorkspaceRole::Owner->value,
|
'attempted_role' => WorkspaceRole::Owner->value,
|
||||||
'source' => 'break_glass',
|
'source' => 'break_glass',
|
||||||
|
'support_access_grant_id' => (int) $supportGrant->getKey(),
|
||||||
|
'support_access_scope' => SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
function spec276_pending_recovery_request(Workspace $workspace): SupportAccessGrant
|
||||||
|
{
|
||||||
|
return SupportAccessGrant::factory()->pendingRecovery()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'requested_by_platform_user_id' => (int) PlatformUser::factory()->create()->getKey(),
|
||||||
|
'reason' => 'Recover workspace ownership for support case',
|
||||||
|
'ttl_minutes' => 30,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec276_workspace_member(string $role): array
|
||||||
|
{
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => $role,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
return [$workspace, $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows pending recovery requests on workspace settings and allows an owner to approve them', function (): void {
|
||||||
|
[$workspace, $owner] = spec276_workspace_member(WorkspaceRole::Owner->value);
|
||||||
|
$grant = spec276_pending_recovery_request($workspace);
|
||||||
|
|
||||||
|
$this->actingAs($owner)
|
||||||
|
->get(WorkspaceSettings::getUrl(panel: 'admin'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Support access approval')
|
||||||
|
->assertSee('Recover workspace ownership for support case')
|
||||||
|
->assertSee('Approve recovery access')
|
||||||
|
->assertSee('Deny request');
|
||||||
|
|
||||||
|
Livewire::actingAs($owner)
|
||||||
|
->test(WorkspaceSettings::class)
|
||||||
|
->call('approveSupportAccess')
|
||||||
|
->assertNotified('Recovery access approved');
|
||||||
|
|
||||||
|
expect($grant->fresh()->status)->toBe(SupportAccessGrant::STATUS_ACTIVE)
|
||||||
|
->and($grant->fresh()->approved_by_user_id)->toBe((int) $owner->getKey());
|
||||||
|
|
||||||
|
expect(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->whereIn('action', [
|
||||||
|
AuditActionId::SupportAccessApproved->value,
|
||||||
|
AuditActionId::SupportAccessActivated->value,
|
||||||
|
])
|
||||||
|
->count())->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a workspace owner to deny pending recovery support access', function (): void {
|
||||||
|
[$workspace, $owner] = spec276_workspace_member(WorkspaceRole::Owner->value);
|
||||||
|
$grant = spec276_pending_recovery_request($workspace);
|
||||||
|
|
||||||
|
Livewire::actingAs($owner)
|
||||||
|
->test(WorkspaceSettings::class)
|
||||||
|
->call('denySupportAccess')
|
||||||
|
->assertNotified('Recovery access denied');
|
||||||
|
|
||||||
|
expect($grant->fresh()->status)->toBe(SupportAccessGrant::STATUS_DENIED);
|
||||||
|
|
||||||
|
expect(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('action', AuditActionId::SupportAccessDenied->value)
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids non-owner workspace managers from approving recovery support access', function (): void {
|
||||||
|
[$workspace, $manager] = spec276_workspace_member(WorkspaceRole::Manager->value);
|
||||||
|
$grant = spec276_pending_recovery_request($workspace);
|
||||||
|
|
||||||
|
Livewire::actingAs($manager)
|
||||||
|
->test(WorkspaceSettings::class)
|
||||||
|
->call('approveSupportAccess')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect($grant->fresh()->status)->toBe(SupportAccessGrant::STATUS_REQUESTED);
|
||||||
|
});
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
it('filters support-access history to the active workspace only', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$supportAccess = AuditLog::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'actor_email' => 'support@example.com',
|
||||||
|
'actor_name' => 'Support',
|
||||||
|
'actor_type' => 'platform',
|
||||||
|
'action' => AuditActionId::SupportAccessRequested->value,
|
||||||
|
'status' => 'success',
|
||||||
|
'resource_type' => 'support_access_grant',
|
||||||
|
'resource_id' => '101',
|
||||||
|
'target_label' => 'Audit-view support #101',
|
||||||
|
'summary' => 'Support access requested for Current workspace',
|
||||||
|
'metadata' => ['reason' => 'Current workspace support'],
|
||||||
|
'recorded_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$unrelated = AuditLog::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'action' => 'workspace.selected',
|
||||||
|
'status' => 'success',
|
||||||
|
'summary' => 'Workspace selected',
|
||||||
|
'metadata' => [],
|
||||||
|
'recorded_at' => now()->addSecond(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$foreignTenant = Tenant::factory()->create();
|
||||||
|
$foreign = AuditLog::query()->create([
|
||||||
|
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'action' => AuditActionId::SupportAccessRequested->value,
|
||||||
|
'status' => 'success',
|
||||||
|
'summary' => 'Support access requested for Foreign workspace',
|
||||||
|
'metadata' => ['reason' => 'Foreign workspace support'],
|
||||||
|
'recorded_at' => now()->addSeconds(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::withQueryParams(['supportAccess' => true])
|
||||||
|
->actingAs($user)
|
||||||
|
->test(AuditLogPage::class)
|
||||||
|
->assertSet('supportAccessOnly', true)
|
||||||
|
->assertCanSeeTableRecords([$supportAccess])
|
||||||
|
->assertCanNotSeeTableRecords([$unrelated, $foreign])
|
||||||
|
->assertActionVisible('export_support_access_history');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports support-access history for the active workspace only', function (): void {
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-05-05 12:00:00'));
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
AuditLog::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'actor_email' => 'support@example.com',
|
||||||
|
'actor_name' => 'Support',
|
||||||
|
'actor_type' => 'platform',
|
||||||
|
'action' => AuditActionId::SupportAccessRequested->value,
|
||||||
|
'status' => 'success',
|
||||||
|
'resource_type' => 'support_access_grant',
|
||||||
|
'resource_id' => '101',
|
||||||
|
'target_label' => 'Audit-view support #101',
|
||||||
|
'summary' => 'Support access requested for Current workspace',
|
||||||
|
'metadata' => ['reason' => 'Current workspace support'],
|
||||||
|
'recorded_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLog::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'action' => 'workspace.selected',
|
||||||
|
'status' => 'success',
|
||||||
|
'summary' => 'Workspace selected',
|
||||||
|
'metadata' => [],
|
||||||
|
'recorded_at' => now()->addSecond(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$component = Livewire::withQueryParams(['supportAccess' => true])
|
||||||
|
->actingAs($user)
|
||||||
|
->test(AuditLogPage::class);
|
||||||
|
|
||||||
|
$response = $component->instance()->exportSupportAccessHistory();
|
||||||
|
|
||||||
|
expect($response)->toBeInstanceOf(StreamedResponse::class)
|
||||||
|
->and($response->headers->get('content-type'))->toContain('text/csv');
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$response->sendContent();
|
||||||
|
$csv = (string) ob_get_clean();
|
||||||
|
|
||||||
|
expect($csv)->toContain(AuditActionId::SupportAccessRequested->value)
|
||||||
|
->and($csv)->toContain('Current workspace support')
|
||||||
|
->and($csv)->not->toContain('workspace.selected');
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
@ -62,7 +62,7 @@ function monitoringPageStateFieldSummary(array $contract): array
|
|||||||
[
|
[
|
||||||
'surfaceKey' => 'audit_log',
|
'surfaceKey' => 'audit_log',
|
||||||
'surfaceType' => 'selected_record_monitoring',
|
'surfaceType' => 'selected_record_monitoring',
|
||||||
'shareableStateKeys' => ['event'],
|
'shareableStateKeys' => ['event', 'supportAccess'],
|
||||||
'localOnlyStateKeys' => [],
|
'localOnlyStateKeys' => [],
|
||||||
'inspectContract' => [
|
'inspectContract' => [
|
||||||
'primaryModel' => AuditLogModel::class,
|
'primaryModel' => AuditLogModel::class,
|
||||||
@ -72,6 +72,7 @@ function monitoringPageStateFieldSummary(array $contract): array
|
|||||||
],
|
],
|
||||||
'stateFields' => [
|
'stateFields' => [
|
||||||
'event' => ['stateClass' => 'inspect', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
'event' => ['stateClass' => 'inspect', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||||
|
'supportAccess' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||||
'tenant_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => false, 'restorableOnRefresh' => true],
|
'tenant_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||||
'tableSearch' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
'tableSearch' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||||
],
|
],
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
@ -42,6 +43,16 @@
|
|||||||
'recorded_at' => now(),
|
'recorded_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
AuditLog::query()->create([
|
||||||
|
'tenant_id' => null,
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'action' => AuditActionId::SupportAccessRequested->value,
|
||||||
|
'status' => 'success',
|
||||||
|
'target_label' => 'Audit-view support #42',
|
||||||
|
'metadata' => ['reason' => 'Support case review'],
|
||||||
|
'recorded_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
AuditLog::query()->create([
|
AuditLog::query()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
@ -66,5 +77,7 @@
|
|||||||
->assertSee('success')
|
->assertSee('success')
|
||||||
->assertSee('failed')
|
->assertSee('failed')
|
||||||
->assertSee('platform.break_glass.enter')
|
->assertSee('platform.break_glass.enter')
|
||||||
|
->assertSee(AuditActionId::SupportAccessRequested->value)
|
||||||
|
->assertSee('Audit-view support #42')
|
||||||
->assertDontSee('platform.unrelated.event');
|
->assertDontSee('platform.unrelated.event');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Dashboard;
|
||||||
|
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Filament::setCurrentPanel('system');
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
|
||||||
|
Tenant::factory()->create([
|
||||||
|
'tenant_id' => null,
|
||||||
|
'external_id' => 'platform',
|
||||||
|
'name' => 'Platform',
|
||||||
|
]);
|
||||||
|
|
||||||
|
config()->set('tenantpilot.break_glass.enabled', true);
|
||||||
|
config()->set('tenantpilot.break_glass.ttl_minutes', 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
function spec276_recovery_platform_user(): PlatformUser
|
||||||
|
{
|
||||||
|
return PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::CONSOLE_VIEW,
|
||||||
|
PlatformCapabilities::USE_BREAK_GLASS,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec276_ownerless_workspace_with_target(): array
|
||||||
|
{
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$targetUser = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $targetUser->getKey(),
|
||||||
|
'role' => WorkspaceRole::Operator->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$workspace, $targetUser];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('blocks owner repair when break-glass is active but recovery-scoped support access is missing', function (): void {
|
||||||
|
$platformUser = spec276_recovery_platform_user();
|
||||||
|
[$workspace, $targetUser] = spec276_ownerless_workspace_with_target();
|
||||||
|
|
||||||
|
$this->actingAs($platformUser, 'platform');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->callAction('enter_break_glass', data: [
|
||||||
|
'reason' => 'Recover workspace ownership',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(RepairWorkspaceOwners::class)
|
||||||
|
->callAction('assign_owner', data: [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'target_user_id' => (int) $targetUser->getKey(),
|
||||||
|
'reason' => 'Fix ownerless workspace',
|
||||||
|
])
|
||||||
|
->assertHasErrors();
|
||||||
|
|
||||||
|
expect(WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('user_id', (int) $targetUser->getKey())
|
||||||
|
->value('role'))->toBe(WorkspaceRole::Operator->value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow audit-view support access to satisfy the owner-repair boundary', function (): void {
|
||||||
|
$platformUser = spec276_recovery_platform_user();
|
||||||
|
[$workspace, $targetUser] = spec276_ownerless_workspace_with_target();
|
||||||
|
|
||||||
|
SupportAccessGrant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'requested_by_platform_user_id' => (int) $platformUser->getKey(),
|
||||||
|
'scope' => SupportAccessGrant::SCOPE_AUDIT_VIEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($platformUser, 'platform');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->callAction('enter_break_glass', data: [
|
||||||
|
'reason' => 'Recover workspace ownership',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(RepairWorkspaceOwners::class)
|
||||||
|
->callAction('assign_owner', data: [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'target_user_id' => (int) $targetUser->getKey(),
|
||||||
|
'reason' => 'Fix ownerless workspace',
|
||||||
|
])
|
||||||
|
->assertHasErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows owner repair only when break-glass and active workspace-recovery support access are both present', function (): void {
|
||||||
|
$platformUser = spec276_recovery_platform_user();
|
||||||
|
[$workspace, $targetUser] = spec276_ownerless_workspace_with_target();
|
||||||
|
|
||||||
|
$supportGrant = SupportAccessGrant::factory()->activeRecovery()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'requested_by_platform_user_id' => (int) $platformUser->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($platformUser, 'platform');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->callAction('enter_break_glass', data: [
|
||||||
|
'reason' => 'Recover workspace ownership',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(RepairWorkspaceOwners::class)
|
||||||
|
->callAction('assign_owner', data: [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'target_user_id' => (int) $targetUser->getKey(),
|
||||||
|
'reason' => 'Fix ownerless workspace',
|
||||||
|
])
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Owner assigned');
|
||||||
|
|
||||||
|
expect(WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('user_id', (int) $targetUser->getKey())
|
||||||
|
->value('role'))->toBe(WorkspaceRole::Owner->value);
|
||||||
|
|
||||||
|
expect(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('action', 'workspace_membership.break_glass.assign_owner')
|
||||||
|
->whereJsonContains('metadata->support_access_grant_id', (int) $supportGrant->getKey())
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Directory\ViewWorkspace;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\BreakGlassSession;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Filament::setCurrentPanel('system');
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
|
||||||
|
config()->set('tenantpilot.support_access.max_ttl_minutes', 120);
|
||||||
|
config()->set('tenantpilot.break_glass.enabled', true);
|
||||||
|
config()->set('tenantpilot.break_glass.ttl_minutes', 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
function spec276_system_platform_user(array $extraCapabilities = []): PlatformUser
|
||||||
|
{
|
||||||
|
return PlatformUser::factory()->create([
|
||||||
|
'capabilities' => array_values(array_unique([
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::DIRECTORY_VIEW,
|
||||||
|
...$extraCapabilities,
|
||||||
|
])),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lets an authorized platform operator start and end audit-view support access from the workspace detail page', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create(['name' => 'Acme Recovery']);
|
||||||
|
$platformUser = spec276_system_platform_user([
|
||||||
|
PlatformCapabilities::SUPPORT_ACCESS_MANAGE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($platformUser, 'platform')
|
||||||
|
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
||||||
|
->assertActionVisible('request_support_access')
|
||||||
|
->assertActionExists('request_support_access', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->callAction('request_support_access', data: [
|
||||||
|
'scope' => SupportAccessGrant::SCOPE_AUDIT_VIEW,
|
||||||
|
'reason' => 'Investigate managed tenant audit evidence',
|
||||||
|
'ttl_minutes' => 45,
|
||||||
|
])
|
||||||
|
->assertNotified('Support access active')
|
||||||
|
->assertActionVisible('end_support_access')
|
||||||
|
->assertActionExists('end_support_access', fn (Action $action): bool => $action->isConfirmationRequired());
|
||||||
|
|
||||||
|
$grant = SupportAccessGrant::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
expect($grant->status)->toBe(SupportAccessGrant::STATUS_ACTIVE)
|
||||||
|
->and($grant->reason)->toBe('Investigate managed tenant audit evidence');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->callAction('end_support_access')
|
||||||
|
->assertNotified('Support access ended');
|
||||||
|
|
||||||
|
expect($grant->fresh()->status)->toBe(SupportAccessGrant::STATUS_ENDED);
|
||||||
|
|
||||||
|
expect(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->whereIn('action', [
|
||||||
|
AuditActionId::SupportAccessRequested->value,
|
||||||
|
AuditActionId::SupportAccessActivated->value,
|
||||||
|
AuditActionId::SupportAccessEnded->value,
|
||||||
|
])
|
||||||
|
->count())->toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides support-access mutations from platform users without support-access capability', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$platformUser = spec276_system_platform_user();
|
||||||
|
|
||||||
|
Livewire::actingAs($platformUser, 'platform')
|
||||||
|
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
||||||
|
->assertActionHidden('request_support_access')
|
||||||
|
->assertActionHidden('end_support_access');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates ownerless workspace-recovery support access only through a break-glass waiver', function (): void {
|
||||||
|
Tenant::factory()->create([
|
||||||
|
'tenant_id' => null,
|
||||||
|
'external_id' => 'platform',
|
||||||
|
'name' => 'Platform',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workspace = Workspace::factory()->create(['name' => 'Ownerless Support Workspace']);
|
||||||
|
$platformUser = spec276_system_platform_user([
|
||||||
|
PlatformCapabilities::SUPPORT_ACCESS_MANAGE,
|
||||||
|
PlatformCapabilities::USE_BREAK_GLASS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($platformUser, 'platform');
|
||||||
|
app(BreakGlassSession::class)->start($platformUser, 'Ownerless recovery support access');
|
||||||
|
|
||||||
|
Livewire::actingAs($platformUser, 'platform')
|
||||||
|
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
||||||
|
->callAction('request_support_access', data: [
|
||||||
|
'scope' => SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY,
|
||||||
|
'reason' => 'Recover ownerless workspace safely',
|
||||||
|
'ttl_minutes' => 30,
|
||||||
|
'waiver_reason' => 'Verified the workspace has no owner membership',
|
||||||
|
])
|
||||||
|
->assertNotified('Support access active');
|
||||||
|
|
||||||
|
$grant = SupportAccessGrant::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('scope', SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
expect($grant->status)->toBe(SupportAccessGrant::STATUS_ACTIVE)
|
||||||
|
->and($grant->approval_mode)->toBe(SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER)
|
||||||
|
->and($grant->waiver_reason)->toBe('Verified the workspace has no owner membership');
|
||||||
|
|
||||||
|
expect(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('action', AuditActionId::SupportAccessOwnerlessWaiverUsed->value)
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders current support-access posture on the system workspace detail page', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create(['name' => 'Northwind Workspace']);
|
||||||
|
$platformUser = spec276_system_platform_user([
|
||||||
|
PlatformCapabilities::SUPPORT_ACCESS_MANAGE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SupportAccessGrant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'requested_by_platform_user_id' => (int) $platformUser->getKey(),
|
||||||
|
'scope' => SupportAccessGrant::SCOPE_AUDIT_VIEW,
|
||||||
|
'reason' => 'Inspect audit evidence for a support case',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($platformUser, 'platform')
|
||||||
|
->get(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Support access')
|
||||||
|
->assertSee('Audit trail review')
|
||||||
|
->assertSee('Inspect audit evidence for a support case');
|
||||||
|
});
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\BreakGlassSession;
|
||||||
|
use App\Services\Auth\SupportAccessGrantManager;
|
||||||
|
use App\Services\Auth\SupportAccessGrantResolver;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Tenant::factory()->create([
|
||||||
|
'tenant_id' => null,
|
||||||
|
'external_id' => 'platform',
|
||||||
|
'name' => 'Platform',
|
||||||
|
]);
|
||||||
|
|
||||||
|
config()->set('tenantpilot.break_glass.enabled', true);
|
||||||
|
config()->set('tenantpilot.break_glass.ttl_minutes', 15);
|
||||||
|
config()->set('tenantpilot.support_access.max_ttl_minutes', 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
function spec276_resolver_platform_user(): PlatformUser
|
||||||
|
{
|
||||||
|
return PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::DIRECTORY_VIEW,
|
||||||
|
PlatformCapabilities::SUPPORT_ACCESS_MANAGE,
|
||||||
|
PlatformCapabilities::USE_BREAK_GLASS,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('auto activates audit-view support access and dedupes overlapping requests from the same platform actor', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$platformUser = spec276_resolver_platform_user();
|
||||||
|
|
||||||
|
$grant = app(SupportAccessGrantManager::class)->request(
|
||||||
|
workspace: $workspace,
|
||||||
|
actor: $platformUser,
|
||||||
|
scope: SupportAccessGrant::SCOPE_AUDIT_VIEW,
|
||||||
|
reason: 'Review support case audit history',
|
||||||
|
ttlMinutes: 45,
|
||||||
|
);
|
||||||
|
|
||||||
|
$duplicate = app(SupportAccessGrantManager::class)->request(
|
||||||
|
workspace: $workspace,
|
||||||
|
actor: $platformUser,
|
||||||
|
scope: SupportAccessGrant::SCOPE_AUDIT_VIEW,
|
||||||
|
reason: 'Review support case audit history again',
|
||||||
|
ttlMinutes: 60,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($grant->status)->toBe(SupportAccessGrant::STATUS_ACTIVE)
|
||||||
|
->and($grant->approval_mode)->toBe(SupportAccessGrant::APPROVAL_MODE_AUTO)
|
||||||
|
->and($grant->starts_at)->not->toBeNull()
|
||||||
|
->and($grant->expires_at)->not->toBeNull()
|
||||||
|
->and($duplicate->getKey())->toBe($grant->getKey())
|
||||||
|
->and(SupportAccessGrant::query()->count())->toBe(1);
|
||||||
|
|
||||||
|
expect(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->whereIn('action', [
|
||||||
|
AuditActionId::SupportAccessRequested->value,
|
||||||
|
AuditActionId::SupportAccessActivated->value,
|
||||||
|
])
|
||||||
|
->count())->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps workspace-recovery support access pending until a workspace owner approves it', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$platformUser = spec276_resolver_platform_user();
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $owner->getKey(),
|
||||||
|
'role' => WorkspaceRole::Owner->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$grant = app(SupportAccessGrantManager::class)->request(
|
||||||
|
workspace: $workspace,
|
||||||
|
actor: $platformUser,
|
||||||
|
scope: SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY,
|
||||||
|
reason: 'Recover workspace ownership safely',
|
||||||
|
ttlMinutes: 30,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($grant->status)->toBe(SupportAccessGrant::STATUS_REQUESTED)
|
||||||
|
->and($grant->approval_mode)->toBe(SupportAccessGrant::APPROVAL_MODE_OWNER_REQUIRED)
|
||||||
|
->and($grant->starts_at)->toBeNull()
|
||||||
|
->and($grant->expires_at)->toBeNull();
|
||||||
|
|
||||||
|
$approved = app(SupportAccessGrantManager::class)->approve($grant, $owner);
|
||||||
|
|
||||||
|
expect($approved->status)->toBe(SupportAccessGrant::STATUS_ACTIVE)
|
||||||
|
->and($approved->approved_by_user_id)->toBe((int) $owner->getKey())
|
||||||
|
->and($approved->expires_at)->not->toBeNull()
|
||||||
|
->and(app(SupportAccessGrantResolver::class)->activeRecoveryGrantFor($workspace)?->getKey())->toBe($grant->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires stale active grants before returning active support access', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$grant = SupportAccessGrant::factory()->expired()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'scope' => SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(app(SupportAccessGrantResolver::class)->activeRecoveryGrantFor($workspace))->toBeNull()
|
||||||
|
->and($grant->fresh()->status)->toBe(SupportAccessGrant::STATUS_EXPIRED);
|
||||||
|
|
||||||
|
expect(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('action', AuditActionId::SupportAccessExpired->value)
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires break-glass and a waiver reason for ownerless workspace recovery', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$platformUser = spec276_resolver_platform_user();
|
||||||
|
|
||||||
|
expect(fn () => app(SupportAccessGrantManager::class)->request(
|
||||||
|
workspace: $workspace,
|
||||||
|
actor: $platformUser,
|
||||||
|
scope: SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY,
|
||||||
|
reason: 'Recover ownerless workspace',
|
||||||
|
ttlMinutes: 30,
|
||||||
|
waiverReason: 'No owners exist',
|
||||||
|
))->toThrow(ValidationException::class);
|
||||||
|
|
||||||
|
$this->actingAs($platformUser, 'platform');
|
||||||
|
app(BreakGlassSession::class)->start($platformUser, 'Recover ownerless workspace');
|
||||||
|
|
||||||
|
$grant = app(SupportAccessGrantManager::class)->request(
|
||||||
|
workspace: $workspace,
|
||||||
|
actor: $platformUser,
|
||||||
|
scope: SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY,
|
||||||
|
reason: 'Recover ownerless workspace',
|
||||||
|
ttlMinutes: 30,
|
||||||
|
waiverReason: 'Validated no workspace owners remain',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($grant->status)->toBe(SupportAccessGrant::STATUS_ACTIVE)
|
||||||
|
->and($grant->approval_mode)->toBe(SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER)
|
||||||
|
->and($grant->waiver_reason)->toBe('Validated no workspace owners remain');
|
||||||
|
|
||||||
|
expect(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('action', AuditActionId::SupportAccessOwnerlessWaiverUsed->value)
|
||||||
|
->whereJsonContains('metadata->waiver_reason', 'Validated no workspace owners remain')
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
# Specification Quality Checklist: Enterprise Access Boundary & Support Access Governance v1
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
|
||||||
|
**Created**: 2026-05-05
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] The package stays on repo-real support and recovery seams instead of inventing a full impersonation or delegated admin bridge.
|
||||||
|
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level code diff.
|
||||||
|
- [x] The package explicitly names the repo-real anchors it builds on: `ViewWorkspace`, `RepairWorkspaceOwners`, `BreakGlassSession`, `AccessLogs`, `WorkspaceSettings`, and `AuditLog`.
|
||||||
|
- [x] Mandatory repo sections for scope, RBAC, shared-pattern reuse, testing, proportionality, and candidate rationale are completed.
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No `[NEEDS CLARIFICATION]` markers remain.
|
||||||
|
- [x] Requirements are testable and bounded to one new grant history, two support scopes, one approval path, existing history surfaces, and existing recovery enforcement.
|
||||||
|
- [x] The package makes break-glass separation explicit and does not let support access replace emergency recovery.
|
||||||
|
- [x] The package forbids unrestricted impersonation and a second support console.
|
||||||
|
- [x] Canonical proof commands match across `spec.md`, `plan.md`, `quickstart.md`, and `tasks.md`.
|
||||||
|
|
||||||
|
## Candidate Selection Gate
|
||||||
|
|
||||||
|
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and `docs/product/roadmap.md` as `Enterprise Access Boundary & Support Access Governance v1`.
|
||||||
|
- [x] Related nearby specs were checked for completion or active scope and treated as context only: Specs 065, 066, 274, and current system console work remain adjacent context, not refresh targets.
|
||||||
|
- [x] The chosen slice is smaller and safer than deferred alternatives such as delegated admin browsing, impersonation, SCIM, or full IAM.
|
||||||
|
- [x] The selected slice explicitly closes the current support-access governance gap called out by audit and handover material.
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] The package justifies a new persisted entity and explains why session-only break-glass or audit-log-only reconstruction is insufficient.
|
||||||
|
- [x] The package keeps Filament on Livewire v4, provider registration unchanged in `apps/platform/bootstrap/providers.php`, global search unchanged, and assets unchanged.
|
||||||
|
- [x] The package keeps `/system` as the mutation plane for support access and `/admin` as the approval plus history plane for workspace actors.
|
||||||
|
- [x] The package keeps support access workspace-scoped and explicitly defers impersonation.
|
||||||
|
|
||||||
|
## Test Governance
|
||||||
|
|
||||||
|
- [x] Planned proof stays bounded to one new `Unit` family plus focused extensions to existing `Feature` suites.
|
||||||
|
- [x] No new heavy-governance or browser family is introduced by default.
|
||||||
|
- [x] Fixture growth remains bounded to one new grant factory plus existing platform user, workspace, and audit fixtures.
|
||||||
|
- [x] The review outcome, workflow outcome, and test-governance outcome are carried into `plan.md` and `tasks.md`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Reviewed against `.specify/memory/constitution.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md`, `docs/HANDOVER.md`, `specs/065-tenant-rbac-v1/spec.md`, `specs/066-rbac-ui-enforcement-helper/spec.md`, and current support or recovery code under `apps/platform` on 2026-05-05.
|
||||||
|
- No application implementation was performed while preparing this package.
|
||||||
|
|
||||||
|
## Review Outcome
|
||||||
|
|
||||||
|
- **Outcome class**: `acceptable-special-case`
|
||||||
|
- **Workflow outcome**: `keep`
|
||||||
|
- **Test-governance outcome**: `keep`
|
||||||
|
- **Reason**: The package promotes one currently exposed support and recovery gap into a bounded workspace-scoped governance slice, keeps break-glass separate, and stops before impersonation or IAM expansion.
|
||||||
|
- **Workflow result**: Ready for implementation.
|
||||||
@ -0,0 +1,402 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: TenantPilot System/Admin - Support Access Governance (Conceptual)
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Conceptual contract for the bounded workspace-scoped support-access package.
|
||||||
|
|
||||||
|
NOTE: These routes are implemented as existing Filament pages, resources,
|
||||||
|
and Livewire-backed actions. Exact Livewire payload shapes are not part of
|
||||||
|
this contract. The file captures logical route boundaries, plane separation,
|
||||||
|
approval flow, and the explicit rule that recovery requires both support
|
||||||
|
access and break-glass.
|
||||||
|
paths:
|
||||||
|
/directory/workspaces/{workspace}:
|
||||||
|
servers:
|
||||||
|
- url: /system
|
||||||
|
get:
|
||||||
|
summary: View current support-access posture for one workspace in the system plane
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/WorkspaceId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: System workspace detail rendered
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
x-logical-view-model:
|
||||||
|
$ref: '#/components/schemas/WorkspaceSupportAccessSummaryView'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/directory/workspaces/{workspace}/actions/request-support-access:
|
||||||
|
servers:
|
||||||
|
- url: /system
|
||||||
|
post:
|
||||||
|
summary: Request bounded workspace-scoped support access from the system detail page
|
||||||
|
description: |
|
||||||
|
`audit_view` may activate immediately for an authorized platform actor.
|
||||||
|
|
||||||
|
`workspace_recovery` follows one of two logical branches:
|
||||||
|
- If the workspace still has at least one owner, the request becomes pending and must be approved from the admin workspace settings surface.
|
||||||
|
- If the workspace has zero owners, the request may only use the ownerless waiver branch when break-glass is already active and the caller supplies a distinct `waiver_reason`.
|
||||||
|
|
||||||
|
The ownerless waiver branch is blocked when break-glass is not active and is invalid when no waiver reason is supplied.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/WorkspaceId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RequestSupportAccessCommand'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Support-access request created or immediately activated
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
description: Active or pending grant already exists for the same workspace, actor, and scope, or ownerless recovery waiver prerequisites are blocked because break-glass is not active
|
||||||
|
'422':
|
||||||
|
$ref: '#/components/responses/ValidationError'
|
||||||
|
x-ownerless-waiver-preconditions:
|
||||||
|
applies_when:
|
||||||
|
scope: workspace_recovery
|
||||||
|
workspace_has_owners: false
|
||||||
|
requires:
|
||||||
|
- active_break_glass
|
||||||
|
- waiver_reason
|
||||||
|
failure_modes:
|
||||||
|
- status: 409
|
||||||
|
reason: ownerless waiver branch is blocked until break-glass is active
|
||||||
|
- status: 422
|
||||||
|
reason: ownerless waiver branch is invalid without a waiver reason
|
||||||
|
/directory/workspaces/{workspace}/support-access/{grant}/actions/end:
|
||||||
|
servers:
|
||||||
|
- url: /system
|
||||||
|
post:
|
||||||
|
summary: End an active support-access grant early
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/WorkspaceId'
|
||||||
|
- $ref: '#/components/parameters/GrantId'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Active support access ended successfully
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/settings/workspace:
|
||||||
|
servers:
|
||||||
|
- url: /admin
|
||||||
|
get:
|
||||||
|
summary: View pending recovery requests and the current support-access summary for the active workspace
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Workspace settings support-access context rendered
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
x-logical-view-model:
|
||||||
|
$ref: '#/components/schemas/WorkspaceSupportApprovalView'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/settings/workspace/support-access/{grant}/actions/approve:
|
||||||
|
servers:
|
||||||
|
- url: /admin
|
||||||
|
post:
|
||||||
|
summary: Approve a pending recovery-scoped support-access request from workspace settings
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/GrantId'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Pending recovery request approved and activated
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
description: The request is no longer pending or no longer approvable in the current workspace context
|
||||||
|
/settings/workspace/support-access/{grant}/actions/deny:
|
||||||
|
servers:
|
||||||
|
- url: /admin
|
||||||
|
post:
|
||||||
|
summary: Deny a pending recovery-scoped support-access request from workspace settings
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/GrantId'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Pending recovery request denied
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
description: The request is no longer pending or no longer denyable in the current workspace context
|
||||||
|
/audit-log:
|
||||||
|
servers:
|
||||||
|
- url: /admin
|
||||||
|
get:
|
||||||
|
summary: View support-access history through the existing workspace audit-log page
|
||||||
|
parameters:
|
||||||
|
- name: supportAccess
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Audit log rendered
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
x-logical-view-model:
|
||||||
|
$ref: '#/components/schemas/SupportAccessAuditHistoryView'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/audit-log/actions/export-support-access-history:
|
||||||
|
servers:
|
||||||
|
- url: /admin
|
||||||
|
post:
|
||||||
|
summary: Export support-access history for the active workspace
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Export accepted or streamed according to the existing audit export pattern
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/repair-workspace-owners:
|
||||||
|
servers:
|
||||||
|
- url: /system
|
||||||
|
get:
|
||||||
|
summary: View the current recovery blocker state before attempting owner repair
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Recovery utility page rendered with current prerequisite state
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
x-logical-view-model:
|
||||||
|
$ref: '#/components/schemas/RecoveryBoundaryView'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/repair-workspace-owners/actions/assign-owner:
|
||||||
|
servers:
|
||||||
|
- url: /system
|
||||||
|
post:
|
||||||
|
summary: Execute owner repair only when both support access and break-glass are active
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AssignOwnerCommand'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Owner repair succeeded
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/BusinessStateBlocked'
|
||||||
|
/security/access-logs:
|
||||||
|
servers:
|
||||||
|
- url: /system
|
||||||
|
get:
|
||||||
|
summary: View system access logs including support-access events
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Access-log page rendered
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
x-logical-view-model:
|
||||||
|
$ref: '#/components/schemas/SystemAccessLogView'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
WorkspaceId:
|
||||||
|
name: workspace
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
GrantId:
|
||||||
|
name: grant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
Forbidden:
|
||||||
|
description: Actor is in-scope but missing the required capability
|
||||||
|
NotFound:
|
||||||
|
description: Wrong plane, wrong workspace, or non-member access is hidden as not found
|
||||||
|
ValidationError:
|
||||||
|
description: Submitted support-access data is invalid for the requested scope or approval mode, including missing waiver detail for the ownerless recovery branch
|
||||||
|
BusinessStateBlocked:
|
||||||
|
description: Actor is otherwise authorized, but support-access state or break-glass state blocks the action
|
||||||
|
schemas:
|
||||||
|
RequestSupportAccessCommand:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- scope
|
||||||
|
- reason
|
||||||
|
- ttl_minutes
|
||||||
|
properties:
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- audit_view
|
||||||
|
- workspace_recovery
|
||||||
|
description: `workspace_recovery` may require owner approval or the explicit ownerless waiver branch.
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
ttl_minutes:
|
||||||
|
type: integer
|
||||||
|
waiver_reason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Required only when `workspace_recovery` is requested for a workspace with zero owners and break-glass is already active.
|
||||||
|
AssignOwnerCommand:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- workspace_id
|
||||||
|
- target_user_id
|
||||||
|
- reason
|
||||||
|
properties:
|
||||||
|
workspace_id:
|
||||||
|
type: integer
|
||||||
|
target_user_id:
|
||||||
|
type: integer
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
WorkspaceSupportAccessSummaryView:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
workspace_id:
|
||||||
|
type: integer
|
||||||
|
active_grant_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
pending_grant_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
scope_label:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
requester_label:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
approval_mode:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
approver_label:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
needs_break_glass:
|
||||||
|
type: boolean
|
||||||
|
WorkspaceSupportApprovalView:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
workspace_id:
|
||||||
|
type: integer
|
||||||
|
current_support_summary:
|
||||||
|
$ref: '#/components/schemas/WorkspaceSupportAccessSummaryView'
|
||||||
|
pending_recovery_requests:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/PendingRecoveryRequestView'
|
||||||
|
PendingRecoveryRequestView:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
grant_id:
|
||||||
|
type: integer
|
||||||
|
requester_label:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
ttl_minutes:
|
||||||
|
type: integer
|
||||||
|
requested_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
approval_mode:
|
||||||
|
type: string
|
||||||
|
waiver_reason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
SupportAccessAuditHistoryView:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
workspace_id:
|
||||||
|
type: integer
|
||||||
|
support_access_filter_active:
|
||||||
|
type: boolean
|
||||||
|
export_available:
|
||||||
|
type: boolean
|
||||||
|
SystemAccessLogView:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
includes_platform_auth:
|
||||||
|
type: boolean
|
||||||
|
includes_break_glass:
|
||||||
|
type: boolean
|
||||||
|
includes_support_access:
|
||||||
|
type: boolean
|
||||||
|
RecoveryBoundaryView:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
workspace_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
has_active_break_glass:
|
||||||
|
type: boolean
|
||||||
|
has_active_recovery_grant:
|
||||||
|
type: boolean
|
||||||
|
recovery_grant_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
recovery_grant_expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
approver_label:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
blocker_state:
|
||||||
|
type: string
|
||||||
|
blocker_message:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
131
specs/276-support-access-governance/data-model.md
Normal file
131
specs/276-support-access-governance/data-model.md
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# Data Model: Enterprise Access Boundary & Support Access Governance v1
|
||||||
|
|
||||||
|
**Date**: 2026-05-05
|
||||||
|
**Branch**: `276-support-access-governance`
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This slice adds one new workspace-owned truth: support-access grant history. Existing `audit_logs`, `BreakGlassSession`, and workspace settings remain in place. The new grant history drives support-access state, approval, expiry, and recovery gating.
|
||||||
|
|
||||||
|
## Persisted Truth
|
||||||
|
|
||||||
|
### 1. Support Access Grant
|
||||||
|
|
||||||
|
**Persistence**: New `support_access_grants` table
|
||||||
|
**Ownership**: Workspace-owned
|
||||||
|
**Scope**: Many historical rows per workspace, with dedupe preventing overlapping active or pending grants for the same workspace, platform actor, and scope.
|
||||||
|
|
||||||
|
| Field | Type | Nullable | Validation | Notes |
|
||||||
|
|-------|------|----------|------------|-------|
|
||||||
|
| `id` | bigint | no | primary key | Internal record id |
|
||||||
|
| `workspace_id` | bigint | no | foreign key | Primary customer scope anchor |
|
||||||
|
| `requested_by_platform_user_id` | bigint | no | foreign key | Platform actor who requested support access |
|
||||||
|
| `approved_by_user_id` | bigint | yes | foreign key | Workspace owner who approved the risky recovery path |
|
||||||
|
| `scope` | string | no | one of `audit_view`, `workspace_recovery` | Bounded v1 support scope catalog |
|
||||||
|
| `status` | string | no | one of `requested`, `active`, `denied`, `expired`, `ended` | Lifecycle state |
|
||||||
|
| `approval_mode` | string | no | one of `auto`, `owner_required`, `ownerless_waiver` | Explains why approval did or did not occur |
|
||||||
|
| `reason` | text | no | trimmed, min length 5 | Operator-supplied reason |
|
||||||
|
| `waiver_reason` | text | yes | required when `approval_mode=ownerless_waiver` | Explicit ownerless recovery waiver context |
|
||||||
|
| `ttl_minutes` | integer | no | positive integer, bounded max from config or policy | Requested active duration |
|
||||||
|
| `requested_at` | datetime | no | set on creation | Request timestamp |
|
||||||
|
| `approved_at` | datetime | yes | required when owner approval occurs | Approval timestamp |
|
||||||
|
| `starts_at` | datetime | yes | required when grant becomes active | Active timestamp |
|
||||||
|
| `expires_at` | datetime | yes | required when grant becomes active | Grant expiry |
|
||||||
|
| `ended_at` | datetime | yes | set when support access is ended manually | Manual end timestamp |
|
||||||
|
| `denied_at` | datetime | yes | set when owner denies a request | Denial timestamp |
|
||||||
|
| `created_at` | datetime | no | standard timestamp | Row creation |
|
||||||
|
| `updated_at` | datetime | no | standard timestamp | Row update |
|
||||||
|
|
||||||
|
**Write rules**:
|
||||||
|
|
||||||
|
- `workspace_id`, `requested_by_platform_user_id`, and `scope` are immutable once created.
|
||||||
|
- `requested` is the initial state for every new row.
|
||||||
|
- `audit_view` may move directly from `requested` to `active` with `approval_mode=auto`.
|
||||||
|
- `workspace_recovery` moves from `requested` to `active` only after owner approval or ownerless waiver.
|
||||||
|
- Active grants may transition to `expired` or `ended`. Requested grants may transition to `denied`.
|
||||||
|
|
||||||
|
**Dedupe rule**:
|
||||||
|
|
||||||
|
- At most one non-terminal row may exist for the same `workspace_id`, `requested_by_platform_user_id`, and `scope` while `status` is `requested` or `active`.
|
||||||
|
|
||||||
|
## Existing Persisted Truth Reused
|
||||||
|
|
||||||
|
### 2. Audit Log
|
||||||
|
|
||||||
|
**Persistence**: Existing `audit_logs` table
|
||||||
|
**Owner**: Existing audit infrastructure
|
||||||
|
|
||||||
|
This slice reuses `audit_logs` for customer-visible history and export. Support-access events become a first-class audit action family.
|
||||||
|
|
||||||
|
### 3. Break-Glass Session
|
||||||
|
|
||||||
|
**Persistence**: Existing session-backed `BreakGlassSession`
|
||||||
|
**Owner**: Existing system-plane emergency control
|
||||||
|
|
||||||
|
Break-glass remains session-backed and system-wide. It is not replaced by support-access grant history, but recovery gating now depends on both.
|
||||||
|
|
||||||
|
## Code-Owned Truth
|
||||||
|
|
||||||
|
### 4. Support Scope Catalog
|
||||||
|
|
||||||
|
**Persistence**: none, code-owned
|
||||||
|
|
||||||
|
| Scope | Label | Risk class | Requires owner approval? | Allows mutation? |
|
||||||
|
|-------|-------|------------|--------------------------|------------------|
|
||||||
|
| `audit_view` | Audit trail review | low | no | no |
|
||||||
|
| `workspace_recovery` | Workspace recovery | high | yes when owners exist | yes |
|
||||||
|
|
||||||
|
### 5. Approval Mode Catalog
|
||||||
|
|
||||||
|
**Persistence**: none, code-owned
|
||||||
|
|
||||||
|
| Mode | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `auto` | Low-risk support access activated immediately |
|
||||||
|
| `owner_required` | Workspace owner must approve before activation |
|
||||||
|
| `ownerless_waiver` | No owner existed, so recovery required explicit break-glass-backed waiver |
|
||||||
|
|
||||||
|
## Derived Truth
|
||||||
|
|
||||||
|
### 6. Active Support Access Summary
|
||||||
|
|
||||||
|
**Persistence**: none, derived at runtime
|
||||||
|
**Owner**: `SupportAccessGrantResolver`
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `workspace_id` | int | yes | Current workspace |
|
||||||
|
| `active_grant_id` | int | no | Active grant when present |
|
||||||
|
| `pending_grant_id` | int | no | Pending risky grant when present |
|
||||||
|
| `scope` | string | no | Current or pending scope |
|
||||||
|
| `scope_label` | string | no | Operator-facing scope label |
|
||||||
|
| `status` | string | yes | Current support-access state summary |
|
||||||
|
| `reason` | string | no | Active or pending reason |
|
||||||
|
| `requester_label` | string | no | Platform requester display label |
|
||||||
|
| `approval_mode` | string | no | Explains activation path |
|
||||||
|
| `approver_label` | string | no | Owner approver display label |
|
||||||
|
| `expires_at` | datetime | no | Current expiry |
|
||||||
|
| `needs_break_glass` | bool | yes | True only for `workspace_recovery` |
|
||||||
|
|
||||||
|
### 7. Support Access Export Filter
|
||||||
|
|
||||||
|
**Persistence**: none, derived at runtime
|
||||||
|
**Owner**: existing admin audit-log page
|
||||||
|
|
||||||
|
The filter preset isolates audit actions that belong to the support-access family and remain in the active workspace scope only.
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
| From | To | Trigger | Consequence |
|
||||||
|
|------|----|---------|-------------|
|
||||||
|
| none | `requested` | platform operator requests support access | grant becomes pending |
|
||||||
|
| `requested` | `active` | auto activation or explicit approval | support access becomes active until expiry or manual end |
|
||||||
|
| `requested` | `denied` | workspace owner denies recovery request | no active support access is granted |
|
||||||
|
| `active` | `expired` | current time passes `expires_at` | recovery and audit-view access end automatically |
|
||||||
|
| `active` | `ended` | platform operator ends support access | support access ends early |
|
||||||
|
|
||||||
|
## Boundaries Explicitly Preserved
|
||||||
|
|
||||||
|
- No tenant member impersonation, no direct customer session bridge, and no unrestricted admin browsing are introduced.
|
||||||
|
- `workspace_recovery` does not bypass break-glass and `audit_view` does not mutate customer state.
|
||||||
|
- Support-access history remains workspace-scoped even when audit rows include tenant references.
|
||||||
318
specs/276-support-access-governance/plan.md
Normal file
318
specs/276-support-access-governance/plan.md
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
# Implementation Plan: Enterprise Access Boundary & Support Access Governance v1
|
||||||
|
|
||||||
|
**Branch**: `276-support-access-governance` | **Date**: 2026-05-05 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `specs/276-support-access-governance/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Prepare one bounded support-access governance slice on top of the repo’s current system support and recovery seams. The narrow implementation path is to introduce one workspace-owned support-access grant history with exactly two scopes, reuse the current system workspace detail page as the request and active-state surface, reuse workspace settings as the owner-approval surface, extend existing system and admin audit views for support-access history, and harden the existing owner-repair utility so it requires both active break-glass and an active recovery-scoped support grant.
|
||||||
|
|
||||||
|
This slice stays explicitly narrow. Filament remains v5 on Livewire v4, panel-provider registration stays in `apps/platform/bootstrap/providers.php`, no new globally searchable resource is introduced, and no asset registration change is expected. The plan does not create impersonation, a delegated admin browsing bridge, SCIM, or a second support console.
|
||||||
|
|
||||||
|
## Inherited Baseline / Explicit Delta
|
||||||
|
|
||||||
|
### Inherited baseline
|
||||||
|
|
||||||
|
- `BreakGlassSession`, the system dashboard break-glass actions, and the current system break-glass banner already provide a global emergency recovery control with TTL and audit.
|
||||||
|
- `RepairWorkspaceOwners` already exposes the current owner-repair utility and audit behavior, but today it only requires platform capability plus active break-glass.
|
||||||
|
- `ViewWorkspace` already acts as the system-plane workspace detail surface and already owns bounded workspace-scoped admin actions.
|
||||||
|
- `WorkspaceSettings` already acts as the admin-plane workspace singleton page and already supports workspace-owner-only actions.
|
||||||
|
- `AuditLog` already acts as the admin-plane customer-visible history surface.
|
||||||
|
- `AccessLogs` already acts as the system security history surface for platform auth and break-glass events.
|
||||||
|
|
||||||
|
### Explicit delta in this plan
|
||||||
|
|
||||||
|
- Add one workspace-owned support-access grant history with bounded lifecycle and expiry.
|
||||||
|
- Add one support-scope catalog with exactly `audit_view` and `workspace_recovery`.
|
||||||
|
- Add one shared `SupportAccessGrantResolver` or manager so current detail, settings, recovery, and history surfaces consume the same support-access truth.
|
||||||
|
- Add a request and end flow on `ViewWorkspace`.
|
||||||
|
- Add owner approval or denial for `workspace_recovery` on `WorkspaceSettings`.
|
||||||
|
- Extend `AuditLog` with a support-access filter and export path.
|
||||||
|
- Extend `AccessLogs` with support-access lifecycle events.
|
||||||
|
- Refactor `RepairWorkspaceOwners` so owner repair requires both active break-glass and an active recovery-scoped support grant for the chosen workspace.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4, Laravel 12
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `BreakGlassSession`, existing audit infrastructure, existing workspace settings and system workspace detail surfaces
|
||||||
|
**Storage**: PostgreSQL via a new workspace-owned `support_access_grants` table plus existing `audit_logs` and current session-backed break-glass state
|
||||||
|
**Testing**: Pest v4 `Unit` plus focused `Feature` coverage
|
||||||
|
**Validation Lanes**: fast-feedback, confidence
|
||||||
|
**Target Platform**: Laravel monolith in `apps/platform`, reusing existing admin and system Filament pages
|
||||||
|
**Project Type**: Web application (Laravel monolith with Filament panels)
|
||||||
|
**Performance Goals**: no new queue family, no Graph calls, and DB-only support-access resolution on current detail, settings, and history surfaces
|
||||||
|
**Constraints**: no impersonation, no delegated admin browsing bridge, no SCIM, no new support console, no new global-search resource, and no asset registration changes
|
||||||
|
**Scale/Scope**: 1 new workspace-owned entity, 1 bounded resolver or manager, 4 existing page families, and focused extensions to existing auth or audit tests
|
||||||
|
|
||||||
|
## Likely Affected Repo Surfaces
|
||||||
|
|
||||||
|
- `apps/platform/app/Models/SupportAccessGrant.php` as the new workspace-owned grant model.
|
||||||
|
- `apps/platform/database/migrations/*_create_support_access_grants_table.php` for new persistence.
|
||||||
|
- `apps/platform/database/factories/SupportAccessGrantFactory.php` for bounded test setup.
|
||||||
|
- `apps/platform/app/Services/Auth/SupportAccessGrantResolver.php` and `SupportAccessGrantManager.php` as the shared lifecycle and mutation layer.
|
||||||
|
- `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` for request, end, and active-state summary.
|
||||||
|
- `apps/platform/app/Filament/System/Pages/RepairWorkspaceOwners.php` for the combined support-access plus break-glass recovery boundary.
|
||||||
|
- `apps/platform/app/Filament/System/Pages/Security/AccessLogs.php` for system-side support-access history.
|
||||||
|
- `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` for owner approval or denial and a current support-access summary.
|
||||||
|
- `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php` for the support-access filter and export path.
|
||||||
|
- `apps/platform/app/Support/Auth/PlatformCapabilities.php` for new bounded platform-side support-access manage capability.
|
||||||
|
- `apps/platform/app/Support/Audit/AuditActionId.php`, `WorkspaceAuditLogger`, and `SystemConsoleAuditLogger` for stable support-access audit action IDs and structured metadata.
|
||||||
|
- `apps/platform/tests/Unit/System/SupportAccessGrantResolverTest.php` plus focused system, auth, settings, monitoring, and access-log feature tests.
|
||||||
|
|
||||||
|
## Support Access Fit
|
||||||
|
|
||||||
|
- Treat support access as a workspace-owned governance truth, not as an extension of `BreakGlassSession`.
|
||||||
|
- Keep exactly two scopes in v1 so the slice stays on current repo-real support work:
|
||||||
|
- `audit_view`
|
||||||
|
- `workspace_recovery`
|
||||||
|
- Keep support-access lifecycle distinct from break-glass lifecycle.
|
||||||
|
- Keep owner repair as the one high-risk mutation that requires both active break-glass and active recovery-scoped support access.
|
||||||
|
- Keep admin history export on the current audit surface rather than adding a second support-history page.
|
||||||
|
|
||||||
|
## UI / Filament & Livewire Fit
|
||||||
|
|
||||||
|
- Existing operator-facing surfaces remain native Filament surfaces under Livewire v4.
|
||||||
|
- No new Filament resource or globally searchable resource is required; current pages are sufficient.
|
||||||
|
- `ViewWorkspace` keeps one dominant support-access action family: request or end support access.
|
||||||
|
- `WorkspaceSettings` keeps approval scoped to pending `workspace_recovery` requests and must not become a second platform-control plane.
|
||||||
|
- `AuditLog` and `AccessLogs` stay read-only history surfaces and only expand their filter or event scope.
|
||||||
|
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, and no new asset strategy is planned. If a later shared asset becomes necessary, deployment remains the standard `cd apps/platform && php artisan filament:assets` path.
|
||||||
|
|
||||||
|
## RBAC / Policy Fit
|
||||||
|
|
||||||
|
- Workspace and tenant membership remain the primary admin-plane isolation boundaries. Wrong-plane or non-member requests stay `404`; in-scope actors missing capability stay `403`.
|
||||||
|
- Platform-side request and end actions should use one new bounded `PlatformCapabilities::SUPPORT_ACCESS_MANAGE` capability instead of reusing broad console visibility alone.
|
||||||
|
- Workspace-owner approval should remain on `WorkspaceSettings` and should reuse existing owner-level manage semantics rather than adding a separate customer support role.
|
||||||
|
- `AuditLog` export remains tied to the current admin-plane audit view capability.
|
||||||
|
- Business-state blocking on the recovery page must remain distinguishable from authorization failure.
|
||||||
|
|
||||||
|
## Audit / Logging Fit
|
||||||
|
|
||||||
|
- Every support-access request, approval, denial, activation, expiry, end, and ownerless waiver must write a stable audit action ID with structured metadata.
|
||||||
|
- `AuditLog` remains the customer-visible history substrate.
|
||||||
|
- `AccessLogs` remains the system-side security history substrate and expands to include support-access events.
|
||||||
|
- No second audit subsystem is needed.
|
||||||
|
|
||||||
|
## Data & Query Fit
|
||||||
|
|
||||||
|
- `support_access_grants.workspace_id` must be NOT NULL and indexed.
|
||||||
|
- Prevent overlapping `requested` or `active` grants for the same workspace, platform actor, and scope.
|
||||||
|
- Keep the v1 table bounded to the fields in `data-model.md`; do not add broad ticketing, provider, or impersonation fields.
|
||||||
|
- `SupportAccessGrantResolver` should expose a single current summary for the active workspace plus current page context.
|
||||||
|
- `RepairWorkspaceOwners` should query only the targeted workspace’s active recovery grant, not scan across workspaces.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native Filament
|
||||||
|
- **Shared-family relevance**: system detail summaries, workspace settings approvals, audit-history filters, system security history, and recovery state messaging
|
||||||
|
- **State layers in scope**: page, detail
|
||||||
|
- **Audience modes in scope**: operator-MSP, support-platform
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
|
||||||
|
- **Raw/support gating plan**: raw audit detail remains on existing history surfaces; default-visible content stays on current support posture and approval need
|
||||||
|
- **One-primary-action / duplicate-truth control**: `ViewWorkspace` remains the one place to start or end support access; `WorkspaceSettings` remains the one place to approve or deny risky recovery; history pages explain rather than re-decide
|
||||||
|
- **Handling modes by drift class or surface**: review-mandatory
|
||||||
|
- **Repository-signal treatment**: review-mandatory now; future hard-stop candidate if a second support console or impersonation bridge appears inside this slice
|
||||||
|
- **Special surface test profiles**: standard-native-filament, shared-detail-family, monitoring-state-page, exception-coded-surface
|
||||||
|
- **Required tests or manual smoke**: functional-core, state-contract, exception-fallback
|
||||||
|
- **Exception path and spread control**: the recovery page remains the named separately governed exception and must not spread its combined prerequisite logic to unrelated surfaces
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes
|
||||||
|
- **Systems touched**: `ViewWorkspace`, `WorkspaceSettings`, `AuditLog`, `AccessLogs`, `RepairWorkspaceOwners`, `BreakGlassSession`, audit action IDs, and current audit loggers
|
||||||
|
- **Shared abstractions reused**: current system detail pattern, current singleton settings pattern, current admin audit history, current system access logs, and current audit infrastructure
|
||||||
|
- **New abstraction introduced? why?**: one bounded `SupportAccessGrantResolver` or manager, because current repo truth has no support-access lifecycle abstraction and several surfaces need the same grant state and dedupe rules
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: current break-glass and audit abstractions are sufficient for emergency recovery and history rendering, but insufficient for one workspace-scoped ordinary support-access lifecycle with approval and expiry
|
||||||
|
- **Bounded deviation / spread control**: no local page-specific support-access helper is allowed outside the shared resolver or manager path
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no
|
||||||
|
- **Central contract reused**: `N/A`
|
||||||
|
- **Delegated UX behaviors**: `N/A`
|
||||||
|
- **Surface-owned behavior kept local**: request or approval inputs only
|
||||||
|
- **Queued DB-notification policy**: `N/A`
|
||||||
|
- **Terminal notification path**: `N/A`
|
||||||
|
- **Exception path**: none
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: no
|
||||||
|
- **Provider-owned seams**: none in this slice
|
||||||
|
- **Platform-core seams**: support-access grant truth, approval flow, break-glass separation, and workspace-scoped history
|
||||||
|
- **Neutral platform terms / contracts preserved**: `support access`, `break-glass`, `workspace recovery`, `audit trail review`
|
||||||
|
- **Retained provider-specific semantics and why**: none
|
||||||
|
- **Bounded extraction or follow-up path**: future delegated support or impersonation only if promoted by a separate spec
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before implementation begins and again before merge.*
|
||||||
|
|
||||||
|
- Inventory-first / snapshot truth: PASS. This slice does not change tenant inventory or snapshot semantics.
|
||||||
|
- Read/write separation: PASS. Every mutation is bounded, confirmation-protected where high risk, and audit-backed.
|
||||||
|
- Graph contract path: PASS. No Graph or provider calls are introduced.
|
||||||
|
- Deterministic capabilities: PASS. Existing canonical capability registries remain authoritative.
|
||||||
|
- Workspace and tenant isolation: PASS. Workspace context and wrong-plane `404` remain unchanged.
|
||||||
|
- RBAC-UX plane separation: PASS. `/system` handles request or end; `/admin` handles approval or history.
|
||||||
|
- Destructive action discipline: PASS. High-risk approval and recovery remain confirmation-aware.
|
||||||
|
- Global search safety: PASS. No new searchable resource is added.
|
||||||
|
- OperationRun / Ops-UX: PASS. No new run family or `OperationRun` start surface is introduced.
|
||||||
|
- Data minimization: PASS. The new entity stores bounded support-access truth only.
|
||||||
|
- Test governance: PASS. Proof remains in one new unit family plus focused feature families.
|
||||||
|
- Proportionality / no premature abstraction: PASS. One new entity and one bounded resolver or manager are the narrowest viable path.
|
||||||
|
- Persisted truth: PASS. The new table represents independent product truth with its own lifecycle and audit need.
|
||||||
|
- Behavioral state: PASS. Grant status and approval mode change recovery behavior, expiry, and audit outcomes.
|
||||||
|
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing detail, settings, recovery, and history surfaces remain authoritative.
|
||||||
|
- Provider boundary: PASS. No provider-specific vocabulary or dependency is added.
|
||||||
|
- Filament / Laravel planning contract: PASS. Filament stays v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, no globally searchable resource is added, and no asset registration change is planned.
|
||||||
|
|
||||||
|
**Gate evaluation**: PASS.
|
||||||
|
|
||||||
|
**Post-design re-check**: PASS. `research.md`, `data-model.md`, `quickstart.md`, `contracts/workspace-support-access-governance.logical.openapi.yaml`, `checklists/requirements.md`, and `tasks.md` are present and aligned with the package.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: `Unit` for grant lifecycle, expiry, and dedupe rules; `Feature` for system detail request flow, owner approval, recovery boundary, and history surfaces
|
||||||
|
- **Affected validation lanes**: fast-feedback, confidence
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: the slice reuses native Filament and current audit surfaces, so focused unit and feature tests can prove the new truth without browser automation
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/System/SupportAccessGrantResolverTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/SupportAccessRequestFlowTest.php tests/Feature/System/SupportAccessRecoveryBoundaryTest.php tests/Feature/Auth/BreakGlassModeTest.php tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php tests/Feature/System/Spec114/AccessLogsTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceSupportAccessApprovalTest.php tests/Feature/Monitoring/AuditLogSupportAccessHistoryTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; one new factory is needed, but existing platform user, workspace, membership, and audit fixtures can be reused
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none planned
|
||||||
|
- **Surface-class relief / special coverage rule**: standard-native-filament relief for detail, settings, and history surfaces; exception-coded coverage for the recovery boundary
|
||||||
|
- **Closing validation and reviewer handoff**: reviewers should rely on the exact commands above, confirm that no impersonation bridge or second console appears, verify that recovery still needs active break-glass plus active recovery support access, and verify that support history export remains workspace-safe
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
|
||||||
|
- **Review-stop questions**: did the slice add more than two support scopes, did it bypass break-glass on recovery, did it create a second history surface, and did it let admin history leak outside the workspace scope
|
||||||
|
- **Escalation path**: `document-in-feature` for contained naming drift; `reject-or-split` if the slice widens into impersonation, SCIM, or a second support console
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Why no dedicated follow-up spec is needed**: routine support-access upkeep should stay inside this feature unless future delegated support or impersonation is explicitly promoted as a separate slice
|
||||||
|
- **Test-governance outcome**: keep
|
||||||
|
|
||||||
|
## Review Checklist Status
|
||||||
|
|
||||||
|
- **Review checklist artifact**: `checklists/requirements.md`
|
||||||
|
- **Review outcome class**: `acceptable-special-case`
|
||||||
|
- **Workflow outcome**: `keep`
|
||||||
|
- **Test-governance outcome**: `keep`
|
||||||
|
- **Escalation rule**: if implementation adds admin-plane browsing, impersonation, SCIM, or a second support console, flip the workflow outcome to `split` before continuing
|
||||||
|
|
||||||
|
## Rollout Considerations
|
||||||
|
|
||||||
|
- Land persistence and shared resolver or manager first.
|
||||||
|
- Add workspace detail request or end flow next.
|
||||||
|
- Add workspace-owner approval next.
|
||||||
|
- Harden recovery enforcement only after support-access truth exists.
|
||||||
|
- Add history filter and export last so the customer-visible trail reflects the final lifecycle semantics.
|
||||||
|
- Keep Filament v5 on Livewire v4, provider registration in `apps/platform/bootstrap/providers.php`, global search unchanged, and assets unchanged.
|
||||||
|
|
||||||
|
## Risk Controls
|
||||||
|
|
||||||
|
- Reject any implementation that adds impersonation or delegated admin browsing.
|
||||||
|
- Reject any implementation that lets `workspace_recovery` proceed without active break-glass.
|
||||||
|
- Reject any implementation that adds more than the two scoped support capabilities in this slice.
|
||||||
|
- Reject any implementation that creates a second support history or approval surface outside the current detail, settings, and audit pages.
|
||||||
|
- Reject browser-heavy proof as the default validation lane.
|
||||||
|
|
||||||
|
## Research & Design Outputs
|
||||||
|
|
||||||
|
- `research.md` resolves the key design choices: workspace-scoped grants, two-scope catalog, recovery plus break-glass coupling, existing-surface reuse, and admin history export.
|
||||||
|
- `data-model.md` records the new grant entity, state and approval semantics, dedupe rule, and derived summary shape.
|
||||||
|
- `quickstart.md` provides the bounded reviewer flow, explicit approval and waiver expectations, and focused validation commands.
|
||||||
|
- `contracts/workspace-support-access-governance.logical.openapi.yaml` captures the logical route and action boundaries for request, approval, end, history, export, and recovery gating.
|
||||||
|
- `checklists/requirements.md` records the prep-time review outcome, workflow outcome, and test-governance outcome.
|
||||||
|
- `tasks.md` keeps implementation bounded to current support and recovery surfaces.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/276-support-access-governance/
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── workspace-support-access-governance.logical.openapi.yaml
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (expected implementation surfaces)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── Pages/
|
||||||
|
│ │ │ ├── Monitoring/
|
||||||
|
│ │ │ │ └── AuditLog.php
|
||||||
|
│ │ │ └── Settings/
|
||||||
|
│ │ │ └── WorkspaceSettings.php
|
||||||
|
│ │ └── System/Pages/
|
||||||
|
│ │ ├── Directory/
|
||||||
|
│ │ │ └── ViewWorkspace.php
|
||||||
|
│ │ ├── RepairWorkspaceOwners.php
|
||||||
|
│ │ └── Security/
|
||||||
|
│ │ └── AccessLogs.php
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ └── SupportAccessGrant.php
|
||||||
|
│ ├── Services/
|
||||||
|
│ │ ├── Audit/
|
||||||
|
│ │ │ └── WorkspaceAuditLogger.php
|
||||||
|
│ │ ├── Auth/
|
||||||
|
│ │ │ ├── BreakGlassSession.php
|
||||||
|
│ │ │ ├── SupportAccessGrantManager.php
|
||||||
|
│ │ │ └── SupportAccessGrantResolver.php
|
||||||
|
│ │ └── SystemConsole/
|
||||||
|
│ │ └── SystemConsoleAuditLogger.php
|
||||||
|
│ └── Support/
|
||||||
|
│ ├── Audit/
|
||||||
|
│ │ └── AuditActionId.php
|
||||||
|
│ └── Auth/
|
||||||
|
│ └── PlatformCapabilities.php
|
||||||
|
├── database/
|
||||||
|
│ ├── factories/
|
||||||
|
│ │ └── SupportAccessGrantFactory.php
|
||||||
|
│ └── migrations/
|
||||||
|
│ └── *_create_support_access_grants_table.php
|
||||||
|
└── tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Auth/
|
||||||
|
│ │ ├── BreakGlassModeTest.php
|
||||||
|
│ │ └── BreakGlassWorkspaceOwnerRecoveryTest.php
|
||||||
|
│ ├── Filament/Settings/
|
||||||
|
│ │ └── WorkspaceSupportAccessApprovalTest.php
|
||||||
|
│ ├── Monitoring/
|
||||||
|
│ │ └── AuditLogSupportAccessHistoryTest.php
|
||||||
|
│ └── System/
|
||||||
|
│ ├── Spec114/AccessLogsTest.php
|
||||||
|
│ ├── SupportAccessRecoveryBoundaryTest.php
|
||||||
|
│ └── SupportAccessRequestFlowTest.php
|
||||||
|
└── Unit/
|
||||||
|
└── System/
|
||||||
|
└── SupportAccessGrantResolverTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| New persisted support-access entity | Support access now needs its own lifecycle, expiry, and approval lineage | Session-only or audit-log-only approaches could not gate recovery and customer-visible approval safely |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: current support and recovery seams lack one bounded workspace-scoped support lifecycle that is both customer-visible and strong enough to gate recovery
|
||||||
|
- **Existing structure is insufficient because**: `BreakGlassSession` is global and current pages can only infer support state indirectly through scattered audit events
|
||||||
|
- **Narrowest correct implementation**: one grant history table plus one shared resolver or manager, added to the current system detail, settings, history, and recovery pages only
|
||||||
|
- **Ownership cost**: one new entity, one new resolver or manager, one new factory, one migration, and focused tests
|
||||||
|
- **Alternative intentionally rejected**: break-glass-only governance and broad impersonation bridge
|
||||||
|
- **Release truth**: current-release truth with bounded future preparation only
|
||||||
|
|
||||||
93
specs/276-support-access-governance/quickstart.md
Normal file
93
specs/276-support-access-governance/quickstart.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Quickstart: Enterprise Access Boundary & Support Access Governance v1
|
||||||
|
|
||||||
|
**Date**: 2026-05-05
|
||||||
|
**Branch**: `276-support-access-governance`
|
||||||
|
|
||||||
|
This quickstart is the intended reviewer flow after implementation. It stays bounded to workspace-scoped support access, recovery approval, support history, and break-glass separation.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start the local platform stack.
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
2. Ensure one platform user has system-panel access, directory visibility, and the new support-access manage capability.
|
||||||
|
3. Ensure one workspace owner can manage workspace settings and one workspace operator can view the audit log.
|
||||||
|
4. Seed or factory-create:
|
||||||
|
- one workspace with at least one owner
|
||||||
|
- one ownerless workspace for the waiver scenario
|
||||||
|
- one platform tenant row for current system audit logging
|
||||||
|
- one platform user who will request support access
|
||||||
|
|
||||||
|
## Scenario 1: Request and end low-risk support access from the system workspace detail page
|
||||||
|
|
||||||
|
1. Open `/system/directory/workspaces/{workspace}` as the authorized platform user.
|
||||||
|
2. Confirm the page shows current support-access posture for that workspace.
|
||||||
|
3. Request `audit_view` support access with a reason and TTL.
|
||||||
|
4. Confirm the page updates immediately to show an active support grant with scope, reason, requester, and expiry.
|
||||||
|
5. Open `/system/security/access-logs` and confirm the support-access activation event appears next to platform auth and break-glass history.
|
||||||
|
6. End the active support access from the same workspace detail page.
|
||||||
|
7. Confirm the active summary disappears and the end event is audited.
|
||||||
|
|
||||||
|
## Scenario 2: Approve and execute high-risk recovery support
|
||||||
|
|
||||||
|
1. Open `/system/directory/workspaces/{workspace}` for a workspace that still has an owner.
|
||||||
|
2. Request `workspace_recovery` support access with a reason and TTL.
|
||||||
|
3. Confirm the request remains pending rather than activating immediately.
|
||||||
|
4. Open `/admin/settings/workspace` as a workspace owner.
|
||||||
|
5. Confirm the page shows the pending recovery request with requester, scope, reason, and requested TTL.
|
||||||
|
6. Approve the request.
|
||||||
|
7. Open `/system` and activate break-glass using the existing dashboard action.
|
||||||
|
8. Open `/system/repair-workspace-owners` and confirm the recovery page now shows both active recovery support access and active break-glass.
|
||||||
|
9. Execute `Emergency: Assign Owner` and confirm the action succeeds only while both prerequisites remain active.
|
||||||
|
|
||||||
|
## Scenario 3: Exercise the ownerless recovery waiver path
|
||||||
|
|
||||||
|
1. Use a workspace with zero owners.
|
||||||
|
2. Activate break-glass from the system dashboard first.
|
||||||
|
3. Request `workspace_recovery` from the system workspace detail page.
|
||||||
|
4. Confirm the product requires an explicit ownerless waiver reason rather than pretending approval exists.
|
||||||
|
5. Confirm the resulting active grant and recovery audit history clearly indicate the waiver path.
|
||||||
|
|
||||||
|
## Scenario 4: Inspect and export customer-visible support-access history
|
||||||
|
|
||||||
|
1. Open `/admin/audit-log` as an authorized workspace operator.
|
||||||
|
2. Apply the support-access filter preset.
|
||||||
|
3. Confirm the page shows only support-access events for the active workspace.
|
||||||
|
4. Export the filtered history.
|
||||||
|
5. Confirm the export contains request, approval, denial, activation, expiry, end, and waiver events for the current workspace only.
|
||||||
|
|
||||||
|
## Scenario 5: Preserve support-access and break-glass separation
|
||||||
|
|
||||||
|
1. Activate `audit_view` only.
|
||||||
|
2. Open `/system/repair-workspace-owners`.
|
||||||
|
3. Confirm the emergency owner-repair action remains blocked and the page explains that a recovery-scoped support grant is still missing.
|
||||||
|
4. Let a recovery-scoped support grant expire while break-glass remains active.
|
||||||
|
5. Confirm the recovery page blocks the action again and shows expiry rather than silently relying on break-glass.
|
||||||
|
|
||||||
|
## RBAC and Plane Semantics Checks
|
||||||
|
|
||||||
|
1. Attempt to request support access from the admin plane and confirm no platform mutation controls exist there.
|
||||||
|
2. Attempt to approve a recovery request from the system plane and confirm that the owner approval surface remains on the workspace settings page only.
|
||||||
|
3. Attempt to view or export support-access history from the wrong workspace context and confirm no support state leaks.
|
||||||
|
4. Attempt the same actions as an in-scope actor missing the relevant capability and confirm `403` behavior remains distinct from business-state blocking.
|
||||||
|
|
||||||
|
## Targeted Validation Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/System/SupportAccessGrantResolverTest.php
|
||||||
|
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/SupportAccessRequestFlowTest.php tests/Feature/System/SupportAccessRecoveryBoundaryTest.php tests/Feature/Auth/BreakGlassModeTest.php tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php tests/Feature/System/Spec114/AccessLogsTest.php
|
||||||
|
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceSupportAccessApprovalTest.php tests/Feature/Monitoring/AuditLogSupportAccessHistoryTest.php
|
||||||
|
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of Scope Confirmations
|
||||||
|
|
||||||
|
While validating this slice, confirm that the implementation does not add or imply:
|
||||||
|
|
||||||
|
- unrestricted admin-plane delegated support browsing
|
||||||
|
- customer-user impersonation or session takeover
|
||||||
|
- SCIM or broader SSO expansion
|
||||||
|
- a second support console or history page
|
||||||
|
- a new `OperationRun` or queue family for support access
|
||||||
59
specs/276-support-access-governance/research.md
Normal file
59
specs/276-support-access-governance/research.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Research: Enterprise Access Boundary & Support Access Governance v1
|
||||||
|
|
||||||
|
**Date**: 2026-05-05
|
||||||
|
**Branch**: `276-support-access-governance`
|
||||||
|
|
||||||
|
## Decision 1: Keep support access workspace-scoped and tied to repo-real support seams
|
||||||
|
|
||||||
|
- **Decision**: V1 support access is workspace-scoped and only governs two current repo-real scopes: `audit_view` and `workspace_recovery`.
|
||||||
|
- **Rationale**: The repository already has workspace detail, workspace settings, audit history, break-glass, and owner-repair seams. Those seams are enough to deliver customer-safe support governance without inventing a broad delegated admin bridge.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Full impersonation or delegated admin browsing: rejected because the repo has no current bounded bridge for that and the slice would become an IAM project.
|
||||||
|
- Keep break-glass as the only support mechanism: rejected because it is global, session-based, and not customer-visible enough for ordinary support access.
|
||||||
|
|
||||||
|
## Decision 2: Use one workspace-owned grant history, not session-only state
|
||||||
|
|
||||||
|
- **Decision**: Persist support-access lifecycle in a new workspace-owned `support_access_grants` table.
|
||||||
|
- **Rationale**: Support access now needs approval lineage, expiry, reason capture, and customer-visible history that outlives one PHP session.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Session-only state layered onto `BreakGlassSession`: rejected because approval and workspace-visible history would drift or disappear.
|
||||||
|
- Audit-log-only reconstruction with no first-class entity: rejected because lifecycle and dedupe rules would become hard to reason about and hard to gate correctly.
|
||||||
|
|
||||||
|
## Decision 3: Make `workspace_recovery` require both support access and break-glass
|
||||||
|
|
||||||
|
- **Decision**: The existing `RepairWorkspaceOwners` action requires both an active `workspace_recovery` grant for the target workspace and active break-glass.
|
||||||
|
- **Rationale**: This keeps ordinary support access and emergency recovery visibly separate while preventing the owner-repair utility from relying on platform capability plus break-glass alone.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Support access alone enables recovery: rejected because it would collapse ordinary support and emergency recovery.
|
||||||
|
- Break-glass alone enables recovery: rejected because it preserves today’s governance gap.
|
||||||
|
|
||||||
|
## Decision 4: Reuse existing system and admin surfaces instead of adding a new support console
|
||||||
|
|
||||||
|
- **Decision**: Reuse `ViewWorkspace`, `WorkspaceSettings`, `AuditLog`, and `AccessLogs`.
|
||||||
|
- **Rationale**: These surfaces already own workspace detail, owner approval, customer-visible history, and system security history. A new support console would duplicate navigation and create a second control plane.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- New support-access resource or page family: rejected because it would duplicate existing detail and history semantics.
|
||||||
|
- Approval through email or out-of-band workflow only: rejected because it would leave the product without one inspectable approval truth.
|
||||||
|
|
||||||
|
## Decision 5: Keep approval optional only where the risk profile changes
|
||||||
|
|
||||||
|
- **Decision**: `audit_view` may auto-activate for authorized platform operators. `workspace_recovery` requires workspace-owner approval when owners exist, and uses an explicit waiver path only when the workspace is ownerless and break-glass is already active.
|
||||||
|
- **Rationale**: This keeps the slice narrow while still giving the high-risk recovery path the stronger human approval boundary it needs.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Require approval for every scope: rejected because it would add friction to low-risk history inspection and overcomplicate the first slice.
|
||||||
|
- Never require approval: rejected because recovery support would remain too close to today’s emergency bypass.
|
||||||
|
|
||||||
|
## Decision 6: Export customer-visible history from the existing admin audit page
|
||||||
|
|
||||||
|
- **Decision**: Support-access export belongs on the current admin audit-log surface with a support-access filter preset.
|
||||||
|
- **Rationale**: The admin audit log is already the customer-visible history surface and already understands workspace-scoped event inspection.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Export from the system access-log page only: rejected because that stays platform-side and does not satisfy customer-visible history.
|
||||||
|
- Add a dedicated support-history export page: rejected because it would duplicate the existing audit-history family.
|
||||||
|
|
||||||
|
## Final Research Outcome
|
||||||
|
|
||||||
|
- Current-release truth justifies one workspace-owned support-access grant history.
|
||||||
|
- The slice stays on two repo-real support scopes and explicitly defers impersonation.
|
||||||
|
- Recovery becomes the one place where support access and break-glass must both be present.
|
||||||
|
- Existing detail, settings, and history surfaces are sufficient for the first governed package.
|
||||||
314
specs/276-support-access-governance/spec.md
Normal file
314
specs/276-support-access-governance/spec.md
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
# Feature Specification: Enterprise Access Boundary & Support Access Governance v1
|
||||||
|
|
||||||
|
**Feature Branch**: `276-support-access-governance`
|
||||||
|
**Created**: 2026-05-05
|
||||||
|
**Status**: Ready for implementation
|
||||||
|
**Input**: User description: "Promote the remaining support-access governance gap into one bounded enterprise slice. Reuse the repo's current break-glass, system access logs, workspace detail, workspace settings, and audit surfaces. Add request reason, TTL, bounded support scopes, optional approval where risk is high, customer-visible history, exportable support-access audit, and clear separation between ordinary support access and emergency break-glass recovery without introducing a full IAM suite, unrestricted impersonation, or SCIM work."
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: The repo already has platform break-glass, the `RepairWorkspaceOwners` recovery utility, system access logs, a system workspace detail page, and a tenant-plane audit log, but it still has no bounded workspace-scoped support-access package that explains who requested access, why it was granted, how long it lasts, whether customer approval was required, and which support scope was active.
|
||||||
|
- **Today's failure**: A platform operator can enter break-glass and perform workspace-owner recovery with platform-side audit trails, yet workspace owners still do not get one governed support-access lifecycle, one pending approval path for high-risk recovery, one customer-visible history filter, or one exportable support-access log. Support access and emergency recovery remain too easy to blur together.
|
||||||
|
- **User-visible improvement**: Platform support becomes workspace-scoped, reasoned, time-bounded, and auditable; workspace owners can approve or deny the risky recovery path; admin-plane operators can inspect and export support-access history; and emergency break-glass remains visibly separate from ordinary support access.
|
||||||
|
- **Smallest enterprise-capable version**: Add one workspace-owned support-access grant record with two bounded scopes (`audit_view` and `workspace_recovery`), request/start/end it from the existing system workspace detail page, require explicit workspace-owner approval only for the recovery scope when owners exist, surface pending approvals on the existing workspace settings page, extend the current admin audit log and system access logs with support-access events, and require both an active recovery grant and active break-glass for the existing owner-repair action.
|
||||||
|
- **Explicit non-goals**: No unrestricted admin-plane bridge, no customer-user impersonation, no tenant browsing as a customer user, no full IAM suite, no SCIM or group provisioning, no second support console, no support billing workflow, and no broad action-by-action support matrix across all admin pages.
|
||||||
|
- **Permanent complexity imported**: One new workspace-owned support-access entity, one bounded support-scope catalog, one approval-mode rule, one resolver or manager layer, new audit action identifiers, focused system and admin Filament changes, and unit plus feature coverage.
|
||||||
|
- **Why now**: Roadmap and audit material already call out break-glass and support-access seams as a blocker before broader delegated-access or SSO or SCIM work. Without this boundary, later delegated support work will either duplicate local guardrails or extend today’s emergency path beyond its intended role.
|
||||||
|
- **Why not local**: The same support-access truth has to gate system recovery, workspace-owner approval, workspace-visible history, system access logs, and export behavior. Local page-only flags would drift immediately.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: New persisted truth, new state family, dual-plane surface changes, and a high-risk recovery workflow. Defense: the slice stays on current repo-real support seams only, keeps support scopes to two concrete cases, and explicitly defers impersonation and unrestricted delegated admin access.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/system/directory/workspaces/{workspace}` on `App\Filament\System\Pages\Directory\ViewWorkspace`
|
||||||
|
- `/system` on `App\Filament\System\Pages\Dashboard` for existing break-glass context only
|
||||||
|
- `/system/repair-workspace-owners` on `App\Filament\System\Pages\RepairWorkspaceOwners`
|
||||||
|
- `/system/security/access-logs` on `App\Filament\System\Pages\Security\AccessLogs`
|
||||||
|
- `/admin/settings/workspace` on `App\Filament\Pages\Settings\WorkspaceSettings`
|
||||||
|
- `/admin/audit-log` on `App\Filament\Pages\Monitoring\AuditLog`
|
||||||
|
- **Data Ownership**: One new workspace-owned support-access grant history becomes the durable source of truth. Existing `audit_logs` remain the customer-visible history and export substrate. No new tenant-owned truth is introduced.
|
||||||
|
- **RBAC**: Platform users with `PlatformCapabilities::ACCESS_SYSTEM_PANEL`, `PlatformCapabilities::DIRECTORY_VIEW`, and a new bounded support-access manage capability may request, start, and end support access from the system plane. Workspace owners with `Capabilities::WORKSPACE_SETTINGS_MANAGE` may approve or deny high-risk recovery requests in the admin plane. Workspace members with `Capabilities::WORKSPACE_SETTINGS_VIEW` may inspect current support-access posture, and members with `Capabilities::AUDIT_VIEW` may inspect or export support-access history. Wrong-plane and non-member access remain `404`; in-scope actors missing capability remain `403`.
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: N/A - this slice stays on workspace-scoped support access and does not introduce a new canonical tenant collection.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Current workspace context and tenant-safe audit-log filters remain authoritative. Support-access history must never expose entries outside the active workspace.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes
|
||||||
|
- **Interaction class(es)**: system detail header actions, status messaging, banners, workspace settings approval affordances, audit-log filtering, export actions, and access-log history
|
||||||
|
- **Systems touched**: `ViewWorkspace`, `RepairWorkspaceOwners`, `AccessLogs`, `WorkspaceSettings`, `AuditLog`, `BreakGlassSession`, `WorkspaceAuditLogger`, `SystemConsoleAuditLogger`, and the current system or admin audit surfaces
|
||||||
|
- **Existing pattern(s) to extend**: existing system workspace detail mutation pattern, the current break-glass separation pattern, the current workspace settings singleton surface, and the existing admin or system audit-history surfaces
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: reuse the current Filament detail and singleton-page patterns, existing audit infrastructure, and current system access-log page instead of creating a second support console; add one bounded `SupportAccessGrantResolver` or manager for shared support-access state
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: current surfaces are sufficient for presenting and approving support-access truth, but insufficient because there is no shared support-access lifecycle or workspace-scoped grant truth behind them today
|
||||||
|
- **Allowed deviation and why**: none. This slice must not create a parallel support UI language, a second history console, or a local one-off approval widget outside the shared surfaces above.
|
||||||
|
- **Consistency impact**: support scope labels, approval wording, expiry wording, banner copy, and audit action names must stay aligned between system detail, recovery, workspace settings, admin audit history, and system access logs
|
||||||
|
- **Review focus**: reviewers must verify that support access is presented through the existing detail, settings, and audit families, that break-glass remains visibly separate, and that no second support-control plane appears
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
N/A - no `OperationRun` start, link, dedupe, or completion semantics are added or changed in this slice. Support-access lifecycle is DB-backed and audit-backed only.
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
N/A - this slice stays on platform-core support governance and does not change provider or Graph boundaries.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| System workspace support-access section | yes | Native Filament system detail page | detail summary, header actions, banner messaging | detail page | no | Extends the existing workspace detail page rather than adding a new support console |
|
||||||
|
| Workspace owner approval section | yes | Native Filament singleton settings page | singleton approval list, support summary | page, section | no | Keeps admin-plane approval on an existing workspace-scoped page |
|
||||||
|
| Admin audit-log support-access filter and export | yes | Native Filament monitoring page | history filter, export action | page, inspect | no | Reuses the current audit history surface instead of creating a dedicated support history page |
|
||||||
|
| Recovery boundary on `RepairWorkspaceOwners` | yes | Native Filament recovery page | action gating, warning copy, context banner | page, header action | no | The page stays a separately governed recovery utility |
|
||||||
|
| System access logs support-access expansion | yes | Native Filament system audit table | history scope, read-only list | page | no | Keeps platform auth, break-glass, and support-access events in one security log family |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| System workspace support-access section | Primary Decision Surface | Platform operator decides whether a workspace needs bounded support access now | Current workspace support posture, active grant summary, pending request state, scope, reason, and expiry | Audit trail, approval lineage, and recent support events remain secondary | Primary because this is the one place where platform support starts or ends workspace-scoped access | Follows the current system workspace triage workflow | Removes the need to correlate dashboard break-glass, recovery page state, and audit history manually |
|
||||||
|
| Workspace owner approval section | Primary Decision Surface | Workspace owner decides whether to approve or deny a high-risk recovery request | Pending requester, reason, scope, requested TTL, and waiver state | Broader support history stays secondary | Primary because the owner’s approval is the human gate for risky recovery support | Keeps approval inside current workspace settings rather than email or side-channel approval | Removes off-product approval ambiguity |
|
||||||
|
| Admin audit-log support-access filter and export | Secondary Context Surface | Workspace operator inspects recent support history or exports it | Filtered support-access events with actor, action, scope, and recorded time | Selected-event detail remains on demand | Not primary because it explains what happened after or around the approval decision | Follows the existing audit-history workflow | Avoids building a second history page |
|
||||||
|
| Recovery boundary on `RepairWorkspaceOwners` | Secondary Context Surface | Platform operator confirms whether recovery is truly allowed now | Active support grant, active break-glass, and missing prerequisite warnings | Full audit lineage remains secondary | Not primary because the decision should already have been made on the workspace detail and settings surfaces | Keeps the emergency action narrow and execution-focused | Makes failure reasons explicit instead of silent 403-only behavior |
|
||||||
|
|
||||||
|
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| System workspace support-access section | support-platform | Current support posture, scope, reason, requester, approval state, and expiry | Recent support events and waiver details | Raw audit payloads stay on the history surfaces | `Request support access`, `End support access`, or `Open approval context` | Historical raw audit JSON stays hidden behind the existing history pages | The detail summary states the current support state once; history surfaces provide proof rather than repeating the summary |
|
||||||
|
| Workspace owner approval section | customer-read-only, operator-MSP | Pending requester, scope, reason, TTL, and the exact decision required | Recent support-access summary and waiver context | Raw audit payloads remain on audit pages only | `Approve recovery access` or `Deny request` | Platform-internal break-glass detail stays hidden; only the customer-relevant support request is shown | The approval card shows the current pending request once and links history instead of re-rendering the full timeline |
|
||||||
|
| Admin audit-log support-access filter and export | operator-MSP | Filtered support-access events for the current workspace | Selected-event detail and related target links | Raw payload detail stays inspect-only | `Export support access history` | Non-support events stay filtered out by default when using the support filter | The page reuses current audit summary rows rather than adding a second support-specific summary header |
|
||||||
|
| Recovery boundary on `RepairWorkspaceOwners` | support-platform | Whether both active recovery grant and active break-glass exist for the target workspace | Why access is blocked, who approved, and when the grant expires | Full history remains secondary | `Emergency: Assign Owner` | Recovery waiver metadata remains secondary | The page keeps one explicit blocker message instead of duplicating support and break-glass state in multiple places |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| System workspace support-access section | System / Detail / Diagnostics | Read-only detail with bounded mutation actions | Start or end workspace-scoped support access | Dedicated workspace detail page | forbidden | Secondary history and admin-workspace links stay on the page | None; ending access is high-impact but not destructive | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Current workspace identity scopes every support action | Support access | Active or pending support access, scope, reason, approval state, and expiry | Existing system-detail exception remains bounded |
|
||||||
|
| Workspace owner approval section | Config / Settings / Singleton | Singleton workspace settings page | Approve or deny a pending recovery request | In-page approval section | forbidden | History links remain secondary | Deny action stays inline with the pending request | `/admin/settings/workspace` | `/admin/settings/workspace` | Active workspace context | Support access approval | Pending decision, requester, scope, reason, and requested TTL | Existing singleton settings exception remains valid |
|
||||||
|
| Admin audit-log support-access filter and export | Monitoring / History / Audit | Audit history page | Inspect or export support-access history | Current selected-event inspect model | optional current inspect affordance | Existing header actions remain secondary to export | none | `/admin/audit-log` | `/admin/audit-log?event={auditLogId}` | Active workspace filter and support-access preset | Audit log | Support-access history rows for the current workspace | Existing audit-history inspect model remains authoritative |
|
||||||
|
| Recovery boundary on `RepairWorkspaceOwners` | System / Recovery Utility | High-risk utility page | Execute owner repair once both prerequisites are satisfied | Dedicated recovery utility page | forbidden | Recent break-glass and support history remain secondary | `Emergency: Assign Owner` stays in the header | `/system/repair-workspace-owners` | `/system/repair-workspace-owners` | Recovery scope and workspace selection stay explicit | Repair workspace owners | Missing support grant or break-glass prerequisite is visible by default | Existing separately governed recovery exception remains valid |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| System workspace support-access section | Platform support operator | Decide whether bounded support access should start or end for one workspace | System detail page | Does this workspace currently need support access, at what scope, and for how long? | Active or pending support-access state, scope, requester, reason, approval, and expiry | Recent support-access events and waiver detail | support-access lifecycle, approval state, expiry state | TenantPilot only | Request support access, End support access | none |
|
||||||
|
| Workspace owner approval section | Workspace owner | Decide whether risky recovery support may proceed | Singleton settings page | Should this recovery request be approved for this workspace now? | Pending requester, scope, reason, requested TTL, and any waiver need | Recent support-access summary and relevant history links | approval state, support-access lifecycle | TenantPilot only | Approve recovery access, Deny request | Deny request |
|
||||||
|
| Admin audit-log support-access filter and export | Workspace operator or manager | Review or export support-access history | Monitoring page | What support access happened for this workspace, by whom, and when? | Filtered support-access history rows | Selected-event detail and related target links | audit outcome, support-access lifecycle | none | Export support access history | none |
|
||||||
|
| Recovery boundary on `RepairWorkspaceOwners` | Platform support operator in emergency recovery | Decide whether owner repair is legally and operationally allowed now | Recovery utility page | Do I have the required support grant and active break-glass for this workspace? | Current blocker or allow state, approval source, and expiry | Recent break-glass actions and support-access history | support-access lifecycle, break-glass state | TenantPilot only | Emergency: Assign Owner | Emergency: Assign Owner |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: yes
|
||||||
|
- **New persisted entity/table/artifact?**: yes
|
||||||
|
- **New abstraction?**: yes
|
||||||
|
- **New enum/state/reason family?**: yes
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: The product can currently prove break-glass and owner-repair events after the fact, but it cannot express one bounded support-access lifecycle that starts on a workspace, requests approval for risky recovery, and remains inspectable or exportable by the affected workspace.
|
||||||
|
- **Existing structure is insufficient because**: `BreakGlassSession` is global and session-based, `RepairWorkspaceOwners` only checks platform capability plus active break-glass, and current history surfaces do not distinguish ordinary support access from emergency recovery in a workspace-scoped lifecycle.
|
||||||
|
- **Narrowest correct implementation**: One workspace-owned support-access grant history plus two concrete scopes, one shared resolver or manager, and extensions to current workspace detail, settings, recovery, and history surfaces.
|
||||||
|
- **Ownership cost**: One new entity, one new migration, one bounded resolver or manager, one new support-scope catalog, new audit action IDs, and focused feature plus unit coverage.
|
||||||
|
- **Alternative intentionally rejected**: Reusing break-glass alone was rejected because it stays system-global, customer-invisible, and too broad for ordinary support access. A full impersonation or delegated-access bridge was rejected because it would widen the slice beyond current repo-real support surfaces.
|
||||||
|
- **Release truth**: current-release truth and bounded future-release preparation for later delegated access or impersonation follow-ups
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment.
|
||||||
|
|
||||||
|
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||||
|
|
||||||
|
Canonical replacement is preferred over preservation.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Unit, Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: One new unit family proves support-access grant validation, support-scope mapping, approval behavior, and expiry logic. Focused feature coverage proves system detail request flow, workspace approval, recovery enforcement, and history export without widening into browser or heavy-governance lanes.
|
||||||
|
- **New or expanded test families**: one new unit family for support-access grant logic plus focused extensions to existing system, auth, settings, monitoring, and access-log feature families
|
||||||
|
- **Fixture / helper cost impact**: bounded. The slice needs one new grant factory plus existing platform user, workspace, membership, and audit fixtures. No browser harness, provider mock, or queue harness is required.
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: standard-native-filament, shared-detail-family, monitoring-state-page, exception-coded-surface
|
||||||
|
- **Standard-native relief or required special coverage**: standard Filament feature coverage is sufficient for workspace detail and settings approval. Recovery requires explicit exception-coded coverage because it combines support-access and break-glass prerequisites on a separately governed utility page.
|
||||||
|
- **Reviewer handoff**: Reviewers must confirm that support access stays workspace-scoped, that `workspace_recovery` never bypasses break-glass, that `audit_view` never mutates customer state, that support-access history is workspace-filtered and exportable, and that the exact proof commands below stay consistent across artifacts.
|
||||||
|
- **Budget / baseline / trend impact**: low feature-local increase only
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/System/SupportAccessGrantResolverTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/SupportAccessRequestFlowTest.php tests/Feature/System/SupportAccessRecoveryBoundaryTest.php tests/Feature/Auth/BreakGlassModeTest.php tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php tests/Feature/System/Spec114/AccessLogsTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceSupportAccessApprovalTest.php tests/Feature/Monitoring/AuditLogSupportAccessHistoryTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
|
||||||
|
## Scope Boundaries *(required for this slice)*
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- One workspace-owned support-access grant history with bounded states and expiry
|
||||||
|
- Exactly two support scopes in v1: `audit_view` and `workspace_recovery`
|
||||||
|
- Support-access request/start/end from the existing system workspace detail surface
|
||||||
|
- Workspace-owner approval or denial for `workspace_recovery` on the existing workspace settings page
|
||||||
|
- Support-access banner or support-state summary on current system detail and recovery surfaces
|
||||||
|
- Support-access history on the existing admin audit log with workspace-safe filtering and export
|
||||||
|
- Support-access events added to the existing system access logs
|
||||||
|
- Recovery enforcement that requires both active break-glass and active recovery-scoped support access for the target workspace
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
- Admin-plane impersonation or unrestricted delegated support browsing
|
||||||
|
- Customer-user session takeover or acting as a tenant member
|
||||||
|
- SCIM, Entra group provisioning, or SSO expansion
|
||||||
|
- A full support workflow engine, assignment queue, or ticketing system
|
||||||
|
- New queue or `OperationRun` families for support access
|
||||||
|
- Cross-workspace support dashboards beyond the current system detail and history surfaces
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Ordinary support access in v1 should stay on current repo-real surfaces only; future delegated access or impersonation can extend the grant model later.
|
||||||
|
- `audit_view` is the low-risk scope and can auto-activate once requested by an authorized platform operator.
|
||||||
|
- `workspace_recovery` is the high-risk scope and should require explicit workspace-owner approval when at least one owner exists.
|
||||||
|
- If a workspace has zero owners, `workspace_recovery` may proceed only through an explicit waiver path while break-glass is active; the waiver must be separately audited.
|
||||||
|
- Break-glass remains a system-wide emergency control and never becomes the ordinary support-access grant itself.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Approval logic can become ambiguous if the product tries to combine ordinary support access and emergency recovery into one state label. The spec avoids that by keeping support scope and break-glass state distinct.
|
||||||
|
- Support-access history could duplicate current audit summaries if export and filter behavior is not kept on the existing audit pages.
|
||||||
|
- Recovery support could drift into a de facto impersonation bridge if later work adds more scopes without a new follow-up spec.
|
||||||
|
- The ownerless-workspace waiver path is intentionally risky and requires extra audit clarity to avoid becoming a silent bypass.
|
||||||
|
|
||||||
|
## Deferred Adjacent Candidates
|
||||||
|
|
||||||
|
- Delegated admin-plane support browsing and explicit impersonation follow-through
|
||||||
|
- Workspace-level support policy editing beyond the bounded v1 approval rules
|
||||||
|
- Ticket or case integration for support access requests
|
||||||
|
- SCIM or broader SSO work that depends on a stronger support-access boundary
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Request and activate bounded workspace support access (Priority: P1)
|
||||||
|
|
||||||
|
As a platform support operator, I want to request workspace-scoped support access with a reason, scope, and expiry so ordinary support work stops depending on the global break-glass mechanism.
|
||||||
|
|
||||||
|
**Why this priority**: Without a bounded workspace-scoped grant, the product still cannot distinguish ordinary support access from emergency recovery.
|
||||||
|
|
||||||
|
**Independent Test**: Open the existing system workspace detail page, request `audit_view` support access with a reason and TTL, and confirm the page shows the active support grant plus audit history without touching recovery or impersonation flows.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an authorized platform operator opens `ViewWorkspace`, **When** they request `audit_view` with a reason and TTL, **Then** the workspace gets one active support-access grant, the page shows the active support state, and the request is audited.
|
||||||
|
2. **Given** an active `audit_view` grant exists, **When** the operator ends support access from the same workspace detail page, **Then** the grant ends, the active summary disappears, and the end event is audited.
|
||||||
|
3. **Given** an unauthorized or wrong-plane actor attempts the same request, **When** the request is evaluated, **Then** existing `404` and `403` semantics remain authoritative and no support-access grant is created.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Approve risky recovery access and keep break-glass separate (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace owner and platform recovery operator, I want the risky recovery path to require both workspace approval and emergency break-glass so customer-impacting recovery is explicit, time-bounded, and still visibly distinct from ordinary support access.
|
||||||
|
|
||||||
|
**Why this priority**: The current owner-repair utility is exactly where the gap between ordinary support access and emergency recovery is operationally acute.
|
||||||
|
|
||||||
|
**Independent Test**: Request `workspace_recovery` on a workspace with an owner, approve it from workspace settings, activate break-glass from the system dashboard, and confirm `RepairWorkspaceOwners` only succeeds when both prerequisites are active.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a workspace has at least one owner, **When** a platform operator requests `workspace_recovery`, **Then** the request stays pending until a workspace owner approves or denies it.
|
||||||
|
2. **Given** a workspace owner approves the pending recovery request, **When** the platform operator opens `RepairWorkspaceOwners` without active break-glass, **Then** the recovery action remains blocked with a clear prerequisite message.
|
||||||
|
3. **Given** both active break-glass and an active recovery-scoped support grant exist for the selected workspace, **When** the operator executes `Emergency: Assign Owner`, **Then** the action succeeds and records both recovery and support-access audit context.
|
||||||
|
4. **Given** a workspace has zero owners, **When** the platform operator requests `workspace_recovery`, **Then** the product may use the explicit waiver path only while break-glass is active and must audit the waiver reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Inspect and export customer-visible support-access history (Priority: P2)
|
||||||
|
|
||||||
|
As a workspace operator or owner, I want to inspect and export support-access history from current admin audit surfaces so customer-visible support governance does not depend on system-only logs.
|
||||||
|
|
||||||
|
**Why this priority**: Customer-safe support governance is incomplete unless the affected workspace can inspect and export the same history the platform can see.
|
||||||
|
|
||||||
|
**Independent Test**: Open the current admin audit log in a workspace with support-access events, filter to support-access entries, export the filtered history, and confirm the output stays workspace-scoped.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** support-access events exist for the current workspace, **When** an authorized admin-plane actor filters the audit log to support-access events, **Then** the page shows only workspace-scoped support-access history.
|
||||||
|
2. **Given** the same filtered support-access view, **When** the actor exports history, **Then** the export contains support-access events for the current workspace only.
|
||||||
|
3. **Given** a non-member or wrong-workspace actor attempts the same history view or export, **When** the request is evaluated, **Then** the product preserves existing `404` or `403` behavior and leaks no support-access state.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- An active `audit_view` grant must never satisfy the recovery boundary on `RepairWorkspaceOwners`.
|
||||||
|
- Break-glass may remain active after a support-access grant expires, but recovery must still block when the grant is no longer active.
|
||||||
|
- Multiple overlapping requests from the same platform user for the same workspace and scope should dedupe into one active or pending grant rather than stack conflicting timers.
|
||||||
|
- Ownerless-workspace recovery needs an explicit waiver path with its own audit detail so the system does not pretend an approval happened when no owner existed.
|
||||||
|
- Support-access history export must remain workspace-scoped even when the current admin audit log includes tenant-specific records.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature adds security-relevant DB-backed support-access truth, approval workflow, and system/admin UI behavior. It adds no Microsoft Graph calls and no new `OperationRun` family. All state changes are audit-backed and confirmation-protected where high risk.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces a new persisted grant history because support access now needs an independent lifecycle, expiry, approval lineage, and customer-visible history that existing `BreakGlassSession` and ad hoc audit events cannot provide.
|
||||||
|
|
||||||
|
**Constitution alignment (XCUT-001):** All new support-access state must flow through one bounded resolver or manager and the existing detail, settings, and audit surfaces. No page may invent its own support-access state or approval wording.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The system workspace detail page remains the primary support-access decision surface, workspace settings remains the primary approval surface, and current history pages stay secondary. Default-visible content stays decision-first and keeps raw audit detail progressive.
|
||||||
|
|
||||||
|
**Constitution alignment (PROV-001):** No provider or Graph boundary changes are introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-GOV-001):** Proof stays in one new unit family plus focused feature extensions to existing system, auth, settings, monitoring, and access-log families.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** Two planes are involved: platform `/system` for support-access request and recovery execution, and workspace `/admin` for approval and history. Wrong-plane and non-member access stay `404`. In-scope actors missing capability stay `403`. High-risk actions require `->requiresConfirmation()`.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** If support-access state or approval state is badge-rendered, labels and colors must come from one bounded mapping rather than page-local semantics.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** This slice extends existing Filament pages only. It must not add a second support console or a custom support design language.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-276-001 Workspace-owned support-access truth**: The product MUST persist support-access requests and active or ended grants in one new workspace-owned history table rather than in session-only state or free-form audit metadata.
|
||||||
|
- **FR-276-002 Bounded support-scope catalog**: V1 MUST support exactly two support scopes: `audit_view` and `workspace_recovery`.
|
||||||
|
- **FR-276-003 Required request fields**: Every support-access request MUST capture workspace, platform actor, scope, reason, requested TTL, requested-at timestamp, and expiry target.
|
||||||
|
- **FR-276-004 Dedupe rule**: The system MUST prevent overlapping active or pending grants for the same workspace, platform actor, and scope from creating conflicting support timers.
|
||||||
|
- **FR-276-005 Ordinary support flow**: `audit_view` MUST be requestable from the existing system workspace detail page and may activate immediately for an authorized platform operator because it does not mutate customer state.
|
||||||
|
- **FR-276-006 Recovery approval rule**: `workspace_recovery` MUST create a pending request that requires explicit workspace-owner approval when the workspace still has at least one owner.
|
||||||
|
- **FR-276-007 Ownerless recovery waiver**: If a workspace has zero owners, `workspace_recovery` MAY be activated only through an explicit waiver path while break-glass is active, and the waiver reason MUST be audited distinctly.
|
||||||
|
- **FR-276-008 Break-glass separation**: Support access MUST remain separate from break-glass. `workspace_recovery` never implicitly activates break-glass, and break-glass alone never satisfies the support-access grant requirement.
|
||||||
|
- **FR-276-009 Recovery boundary enforcement**: `RepairWorkspaceOwners` MUST require both active break-glass and an active `workspace_recovery` grant for the selected workspace before `Emergency: Assign Owner` may execute.
|
||||||
|
- **FR-276-010 Support-access summary**: `ViewWorkspace` MUST show current support-access posture, including active grant, pending request, scope, requester, approval state, reason, and expiry.
|
||||||
|
- **FR-276-011 Approval surface**: `WorkspaceSettings` MUST show pending `workspace_recovery` requests to workspace owners with explicit approve or deny actions.
|
||||||
|
- **FR-276-012 Customer-visible history**: The current admin audit-log page MUST support a support-access filter preset that shows support-access history for the active workspace only.
|
||||||
|
- **FR-276-013 Exportable history**: Authorized admin-plane actors MUST be able to export current support-access history for the active workspace from the existing audit-log surface.
|
||||||
|
- **FR-276-014 System security history**: The current system access-log page MUST include support-access lifecycle events alongside platform auth and break-glass events.
|
||||||
|
- **FR-276-015 Audit action family**: Request, approval, denial, activation, expiry, end, and waiver events MUST write stable audit action IDs and structured metadata.
|
||||||
|
- **FR-276-016 Capability and membership enforcement**: Platform-side request or end actions MUST use new bounded platform capabilities; admin-side approval or export actions MUST use the current canonical capability registry and workspace membership checks.
|
||||||
|
- **FR-276-017 No impersonation bridge**: This slice MUST NOT allow platform users to browse or act as customer users across the admin plane outside the bounded support history and approval surfaces.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| System workspace support-access section | `app/Filament/System/Pages/Directory/ViewWorkspace.php` | `Request support access`, `End support access` | dedicated workspace detail route only | none | none | N/A | same page owns the bounded mutation actions | N/A | yes | Reuses the current system detail page and keeps one dominant support action family |
|
||||||
|
| Workspace owner approval section | `app/Filament/Pages/Settings/WorkspaceSettings.php` | none | N/A - singleton settings page | `Approve`, `Deny` on pending request cards only | none | N/A | none | existing settings save flow remains unrelated | yes | Approval stays on the current singleton settings surface |
|
||||||
|
| Admin support-access history | `app/Filament/Pages/Monitoring/AuditLog.php` | `Export support access history` | existing selected-event inspect affordance | existing inspect action only | none | current clear-filter CTA remains | existing detail header actions remain | N/A | no new audit event for read-only export | The current audit-history page remains authoritative |
|
||||||
|
| Recovery boundary | `app/Filament/System/Pages/RepairWorkspaceOwners.php` | existing `Emergency: Assign Owner` only | dedicated recovery page only | none | none | current empty state unchanged | same page owns the recovery action | N/A | yes | Existing separately governed recovery utility remains a named exception |
|
||||||
|
| System access logs | `app/Filament/System/Pages/Security/AccessLogs.php` | none | inline history only | none | none | current empty state unchanged | none | N/A | no new audit event for read-only history | Expand current page scope instead of adding a second system security log |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- `SupportAccessGrant` (new workspace-owned persisted entity)
|
||||||
|
- `Workspace` (existing owner and scope anchor)
|
||||||
|
- `PlatformUser` (existing system actor)
|
||||||
|
- `AuditLog` (existing history substrate)
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-276-001**: An authorized platform operator can request `audit_view` from the existing workspace detail page with a reason and TTL, see the active support summary on that same workspace context, and end the grant again without using break-glass.
|
||||||
|
- **SC-276-002**: `RepairWorkspaceOwners` remains blocked in every prerequisite combination except the one where active break-glass and an active `workspace_recovery` grant both exist for the selected workspace.
|
||||||
|
- **SC-276-003**: A workspace owner can approve or deny a pending `workspace_recovery` request from the existing workspace settings page without needing a second approval surface or out-of-band workflow.
|
||||||
|
- **SC-276-004**: A workspace operator can filter and export support-access history from the existing admin audit-log surface, and the exported history remains limited to the active workspace.
|
||||||
184
specs/276-support-access-governance/tasks.md
Normal file
184
specs/276-support-access-governance/tasks.md
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
---
|
||||||
|
description: "Task list for Enterprise Access Boundary & Support Access Governance v1"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Enterprise Access Boundary & Support Access Governance v1
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/276-support-access-governance/`
|
||||||
|
**Prerequisites**: `specs/276-support-access-governance/spec.md`, `specs/276-support-access-governance/plan.md`, `specs/276-support-access-governance/checklists/requirements.md`, `specs/276-support-access-governance/research.md`, `specs/276-support-access-governance/data-model.md`, `specs/276-support-access-governance/quickstart.md`, `specs/276-support-access-governance/contracts/workspace-support-access-governance.logical.openapi.yaml`
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest). Keep proof bounded to one new `Unit` family under `tests/Unit/System/` plus focused extensions to existing `Feature` families for system, auth, settings, monitoring, and access-log behavior.
|
||||||
|
**Operations**: No new `OperationRun` family. Support-access lifecycle is DB-backed and audit-backed only.
|
||||||
|
**RBAC**: Wrong-plane and non-member access remain `404`; in-scope actors missing capability remain `403`. `/system` owns request or end; `/admin` owns approval and workspace-visible history.
|
||||||
|
**Shared Pattern Reuse**: Reuse `ViewWorkspace`, `WorkspaceSettings`, `AuditLog`, `AccessLogs`, `BreakGlassSession`, and existing audit infrastructure. Do not create a second support console.
|
||||||
|
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, no new globally searchable resource, and no new asset strategy are allowed.
|
||||||
|
**Organization**: Tasks are grouped by user story so request flow, approval and recovery boundary, and customer-visible history remain independently implementable and testable. This package is a bounded follow-through over current support seams, not an IAM rewrite.
|
||||||
|
**Review Outcome**: `acceptable-special-case`
|
||||||
|
**Workflow Outcome**: `keep`
|
||||||
|
**Test-governance Outcome**: `keep`
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment stays `fast-feedback` and `confidence` and remains the narrowest sufficient proof.
|
||||||
|
- [x] New or changed tests stay in `apps/platform/tests/Unit/System/`, `apps/platform/tests/Feature/System/`, `apps/platform/tests/Feature/Auth/`, `apps/platform/tests/Feature/Filament/Settings/`, `apps/platform/tests/Feature/Monitoring/`, and one narrow `apps/platform/tests/Browser/` smoke file required by the implementation-loop gate.
|
||||||
|
- [x] Shared helpers stay cheap by default; only one new grant factory is expected.
|
||||||
|
- [x] Planned validation commands cover grant lifecycle, request flow, approval, recovery boundary, history export, and one narrow browser smoke without widening into heavy-governance lanes.
|
||||||
|
- [x] The declared surface test profile remains `standard-native-filament`, `shared-detail-family`, `monitoring-state-page`, and `exception-coded-surface` only.
|
||||||
|
- [x] Any drift toward impersonation, SCIM, or a second support console resolves as `reject-or-split`, not hidden scope.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Context)
|
||||||
|
|
||||||
|
**Purpose**: Confirm the current support and recovery seams before any implementation change.
|
||||||
|
|
||||||
|
- [x] T001 Review `specs/276-support-access-governance/spec.md`, `specs/276-support-access-governance/plan.md`, `specs/276-support-access-governance/checklists/requirements.md`, `specs/276-support-access-governance/research.md`, `specs/276-support-access-governance/data-model.md`, `specs/276-support-access-governance/quickstart.md`, `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md`, and `docs/HANDOVER.md` together so the slice stays on current support seams.
|
||||||
|
- [x] T002 [P] Confirm the current request and recovery surfaces in `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`, `apps/platform/app/Filament/System/Pages/RepairWorkspaceOwners.php`, and `apps/platform/app/Services/Auth/BreakGlassSession.php`.
|
||||||
|
- [x] T003 [P] Confirm the current customer-visible history seams in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` and `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php`.
|
||||||
|
- [x] T004 [P] Confirm the current system security history seams in `apps/platform/app/Filament/System/Pages/Security/AccessLogs.php`, `apps/platform/app/Services/SystemConsole/SystemConsoleAuditLogger.php`, and `apps/platform/app/Support/Audit/AuditActionId.php`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Lock the new grant truth, audit action family, and shared resolver before surface changes begin.
|
||||||
|
|
||||||
|
**Critical**: No user-story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [x] T005 [P] Add failing unit coverage in `apps/platform/tests/Unit/System/SupportAccessGrantResolverTest.php` to prove scope validation, dedupe rules, approval-mode selection, active summary output, and expiry behavior.
|
||||||
|
- [x] T006 [P] Add or extend feature coverage in `apps/platform/tests/Feature/System/SupportAccessRequestFlowTest.php` and `apps/platform/tests/Feature/System/Spec114/AccessLogsTest.php` to lock request, end, and system history behavior.
|
||||||
|
- [x] T007 [P] Add or extend feature coverage in `apps/platform/tests/Feature/Auth/BreakGlassModeTest.php` and `apps/platform/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php` to lock break-glass separation and the combined recovery boundary. Existing `BreakGlassModeTest.php` required no edit; it was re-run, while combined boundary regression coverage was added around recovery.
|
||||||
|
- [x] T008 [P] Add or extend feature coverage in `apps/platform/tests/Feature/Filament/Settings/WorkspaceSupportAccessApprovalTest.php` and `apps/platform/tests/Feature/Monitoring/AuditLogSupportAccessHistoryTest.php` to lock owner approval and workspace-scoped history export.
|
||||||
|
- [x] T009 Create `apps/platform/database/migrations/*_create_support_access_grants_table.php`, `apps/platform/app/Models/SupportAccessGrant.php`, and `apps/platform/database/factories/SupportAccessGrantFactory.php` for workspace-owned grant history.
|
||||||
|
- [x] T010 Implement `apps/platform/app/Services/Auth/SupportAccessGrantResolver.php` and `apps/platform/app/Services/Auth/SupportAccessGrantManager.php` to own request, approval, denial, activation, expiry, end, and dedupe behavior.
|
||||||
|
- [x] T011 Extend `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and `apps/platform/app/Services/SystemConsole/SystemConsoleAuditLogger.php` for stable support-access audit actions and the new platform capability. Repo truth: the existing audit loggers already accept stable action IDs, so no direct logger edit was required.
|
||||||
|
|
||||||
|
**Checkpoint**: One workspace-owned support-access truth exists and all affected surfaces can depend on the same grant state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Request and activate bounded workspace support access (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Authorized platform users can request or end workspace-scoped support access from the existing system workspace detail page.
|
||||||
|
|
||||||
|
**Independent Test**: Open `ViewWorkspace`, request `audit_view`, confirm the active summary and system audit trail update, then end the grant again.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T012 [P] [US1] Extend `apps/platform/tests/Feature/System/SupportAccessRequestFlowTest.php` to prove request validation, immediate activation for `audit_view`, early end, ownerless waiver activation, and wrong-plane or wrong-capability denials.
|
||||||
|
- [x] T013 [P] [US1] Extend `apps/platform/tests/Feature/System/Spec114/AccessLogsTest.php` to prove support-access events appear in the existing system access log family.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T014 [US1] Update `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` so the page renders current support-access posture and owns `Request support access` plus `End support access` actions.
|
||||||
|
- [x] T015 [US1] Update `apps/platform/app/Filament/System/Pages/Security/AccessLogs.php` so support-access lifecycle events are included in the existing system security history scope with the new stable audit action family.
|
||||||
|
- [x] T016 [US1] Add any bounded summary presenter or helper needed so the system detail page shows scope, requester, reason, approval state, and expiry without duplicating grant logic.
|
||||||
|
|
||||||
|
**Checkpoint**: The system workspace detail page becomes the one ordinary support-access entry point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Approve risky recovery and keep break-glass separate (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Workspace owners approve high-risk recovery requests, and the existing owner-repair utility requires both support access and break-glass.
|
||||||
|
|
||||||
|
**Independent Test**: Request `workspace_recovery`, approve it from workspace settings, activate break-glass, and confirm `RepairWorkspaceOwners` only succeeds when both prerequisites are active.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T017 [P] [US2] Extend `apps/platform/tests/Feature/Filament/Settings/WorkspaceSupportAccessApprovalTest.php` to prove pending recovery requests, owner approval, owner denial, and ownerless waiver messaging.
|
||||||
|
- [x] T018 [P] [US2] Extend `apps/platform/tests/Feature/System/SupportAccessRecoveryBoundaryTest.php` and `apps/platform/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php` to prove recovery requires the correct workspace-scoped grant plus active break-glass and that `audit_view` never satisfies the recovery path.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T019 [US2] Update `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` so workspace owners can approve or deny pending `workspace_recovery` requests and inspect the current support-access summary.
|
||||||
|
- [x] T020 [US2] Update `apps/platform/app/Filament/System/Pages/RepairWorkspaceOwners.php` so the header action and warning copy reflect the combined recovery boundary.
|
||||||
|
- [x] T021 [US2] Keep `BreakGlassSession` separate and ensure the recovery page consumes support-access state without turning break-glass into ordinary support access.
|
||||||
|
|
||||||
|
**Checkpoint**: Risky recovery support becomes explicitly approved and visibly distinct from ordinary support access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Inspect and export customer-visible support-access history (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Workspace actors can inspect and export support-access history from existing admin audit surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Open the admin audit log in a workspace with support-access events, filter to support access, and export the filtered history.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T022 [P] [US3] Extend `apps/platform/tests/Feature/Monitoring/AuditLogSupportAccessHistoryTest.php` to prove workspace-scoped filtering, selected-event inspection, and export behavior.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T023 [US3] Update `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php` so the page supports a support-access filter preset and workspace-scoped export.
|
||||||
|
- [x] T024 [US3] Confirm the new export path reuses current audit-history semantics instead of creating a second support-history surface.
|
||||||
|
|
||||||
|
**Checkpoint**: Customer-visible support-access history becomes inspectable and exportable from the current admin audit family.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Validation
|
||||||
|
|
||||||
|
**Purpose**: Validate the bounded slice and stop before it widens.
|
||||||
|
|
||||||
|
- [x] T025 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/System/SupportAccessGrantResolverTest.php`.
|
||||||
|
- [x] T026 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/SupportAccessRequestFlowTest.php tests/Feature/System/SupportAccessRecoveryBoundaryTest.php tests/Feature/Auth/BreakGlassModeTest.php tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php tests/Feature/System/Spec114/AccessLogsTest.php`.
|
||||||
|
- [x] T027 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceSupportAccessApprovalTest.php tests/Feature/Monitoring/AuditLogSupportAccessHistoryTest.php`.
|
||||||
|
- [x] T028 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
|
- [x] T029 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no globally searchable resource is added, no new asset strategy appears, and no impersonation bridge slipped in.
|
||||||
|
- [x] T030 [P] Record the final guardrail and test-governance outcome in the active feature close-out without reopening SCIM, delegated admin browsing, or full IAM scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||||
|
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
|
||||||
|
- **Phase 3 (US1)**: depends on Phase 2 and establishes the bounded support-access entry point.
|
||||||
|
- **Phase 4 (US2)**: depends on Phase 2 and should land with US1 so recovery enforcement consumes the finished grant truth.
|
||||||
|
- **Phase 5 (US3)**: depends on Phase 2 and should land after US1 so history export reflects the final grant lifecycle.
|
||||||
|
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: independently testable after Phase 2 and delivers the ordinary support-access lifecycle.
|
||||||
|
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so recovery uses the same grant truth.
|
||||||
|
- **US3 (P2)**: independently testable after Phase 2 and can follow once support-access events exist.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write the listed Pest coverage first and make it fail for the intended gap.
|
||||||
|
- Keep implementation inside the current model, resolver, system detail, settings, history, and recovery seams named above.
|
||||||
|
- Re-run the narrowest relevant validation command after each story checkpoint before moving on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
- MVP = **US1 + US2 together**. The feature is only valuable when workspace support access exists and the recovery path actually depends on it.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Phase 1 and Phase 2.
|
||||||
|
2. Deliver US1 so support access can be requested and ended safely.
|
||||||
|
3. Deliver US2 so risky recovery consumes the same support-access truth.
|
||||||
|
4. Add US3 for customer-visible history and export.
|
||||||
|
5. Finish with the focused validation and drift-review tasks in Phase 6.
|
||||||
|
|
||||||
|
### Team Strategy
|
||||||
|
|
||||||
|
1. Settle persistence and resolver shape first.
|
||||||
|
2. Parallelize failing tests within each story before runtime edits.
|
||||||
|
3. Serialize merges around `ViewWorkspace`, `WorkspaceSettings`, `RepairWorkspaceOwners`, and `AuditLog` so support vocabulary and history semantics stay coherent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred Follow-Ups / Non-Goals
|
||||||
|
|
||||||
|
- impersonation or delegated admin browsing
|
||||||
|
- SCIM or broader SSO follow-through
|
||||||
|
- support ticket or case integration
|
||||||
|
- broader support scope catalogs beyond `audit_view` and `workspace_recovery`
|
||||||
Loading…
Reference in New Issue
Block a user