merge: origin/dev into 109-review-pack-export

This commit is contained in:
Ahmed Darrazi 2026-02-23 20:39:14 +01:00
commit cd23adda1a
43 changed files with 2706 additions and 235 deletions

View File

@ -0,0 +1,23 @@
<?php
namespace App\Contracts\Hardening;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\Tenant;
interface WriteGateInterface
{
/**
* Evaluate whether a write operation is allowed for the given tenant.
*
* @throws ProviderAccessHardeningRequired when the operation is blocked
*/
public function evaluate(Tenant $tenant, string $operationType): void;
/**
* Check whether the gate would block a write operation for the given tenant.
*
* Non-throwing variant for UI disabled-state checks.
*/
public function wouldBlock(Tenant $tenant): bool;
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Exceptions\Hardening;
use RuntimeException;
class ProviderAccessHardeningRequired extends RuntimeException
{
public function __construct(
public readonly int $tenantId,
public readonly string $operationType,
public readonly string $reasonCode,
public readonly string $reasonMessage,
) {
parent::__construct($reasonMessage);
}
}

View File

@ -2,6 +2,8 @@
namespace App\Filament\Resources;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Filament\Resources\RestoreRunResource\Pages;
use App\Jobs\BulkRestoreRunDeleteJob;
use App\Jobs\BulkRestoreRunForceDeleteJob;
@ -51,6 +53,7 @@
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
@ -771,216 +774,7 @@ public static function table(Table $table): Table
->actions([
Actions\ViewAction::make(),
ActionGroup::make([
UiEnforcement::forTableAction(
Actions\Action::make('rerun')
->label('Rerun')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(function (RestoreRun $record): bool {
$backupSet = $record->backupSet;
return ! $record->trashed()
&& $record->isDeletable()
&& $backupSet !== null
&& ! $backupSet->trashed();
})
->action(function (
RestoreRun $record,
RestoreService $restoreService,
\App\Services\Intune\AuditLogger $auditLogger,
HasTable $livewire
) {
$tenant = $record->tenant;
$backupSet = $record->backupSet;
if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
Notification::make()
->title('Restore run cannot be rerun')
->body('Restore run or backup set is archived or unavailable.')
->warning()
->send();
return;
}
if (! (bool) $record->is_dry_run) {
$selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null;
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
$preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds);
$metadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => $highlanderLabel,
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
'rerun_of_restore_run_id' => $record->id,
];
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
->title('Restore already queued')
->body('Reusing the active restore run.')
->info()
->send();
return;
}
try {
$newRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
->title('Restore already queued')
->body('Reusing the active restore run.')
->info()
->send();
return;
}
throw $exception;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $newRun->id,
'backup_set_id' => $backupSet->id,
'rerun_of_restore_run_id' => $record->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
],
initiator: $initiator,
);
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$newRun->update(['operation_run_id' => $opRun->getKey()]);
}
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun);
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
try {
$newRun = $restoreService->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $record->requested_items ?? null,
dryRun: (bool) $record->is_dry_run,
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
groupMapping: $record->group_mapping ?? []
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
]
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
static::rerunActionWithGate(),
UiEnforcement::forTableAction(
Actions\Action::make('restore')
->label('Restore')
@ -1557,6 +1351,37 @@ public static function createRestoreRun(array $data): RestoreRun
abort(403);
}
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'restore_run',
context: [
'metadata' => [
'operation_type' => 'restore.execute',
'reason_code' => $e->reasonCode,
'backup_set_id' => $data['backup_set_id'] ?? null,
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
throw ValidationException::withMessages([
'backup_set_id' => $e->reasonMessage,
]);
}
/** @var BackupSet $backupSet */
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
@ -1976,4 +1801,315 @@ private static function normalizeGroupMapping(mixed $mapping): array
return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
}
/**
* Build the rerun table action with UiEnforcement + write gate disabled state.
*
* UiEnforcement::apply() overrides ->disabled() and ->tooltip(), so the gate
* check must compose on top of the enforcement action AFTER apply(). This method
* extracts the rerun action into its own builder to keep the table definition clean.
*/
private static function rerunActionWithGate(): Actions\Action|BulkAction
{
/** @var Actions\Action $action */
$action = UiEnforcement::forTableAction(
Actions\Action::make('rerun')
->label('Rerun')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(function (RestoreRun $record): bool {
$backupSet = $record->backupSet;
return ! $record->trashed()
&& $record->isDeletable()
&& $backupSet !== null
&& ! $backupSet->trashed();
})
->action(function (
RestoreRun $record,
RestoreService $restoreService,
\App\Services\Intune\AuditLogger $auditLogger,
HasTable $livewire
) {
$tenant = $record->tenant;
$backupSet = $record->backupSet;
if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
Notification::make()
->title('Restore run cannot be rerun')
->body('Restore run or backup set is archived or unavailable.')
->warning()
->send();
return;
}
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.rerun');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
resourceType: 'restore_run',
resourceId: (string) $record->getKey(),
context: [
'metadata' => [
'operation_type' => 'restore.rerun',
'reason_code' => $e->reasonCode,
'backup_set_id' => $backupSet?->getKey(),
'original_restore_run_id' => $record->getKey(),
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
return;
}
if (! (bool) $record->is_dry_run) {
$selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null;
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
$preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds);
$metadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => $highlanderLabel,
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
'rerun_of_restore_run_id' => $record->id,
];
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
->title('Restore already queued')
->body('Reusing the active restore run.')
->info()
->send();
return;
}
try {
$newRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
->title('Restore already queued')
->body('Reusing the active restore run.')
->info()
->send();
return;
}
throw $exception;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $newRun->id,
'backup_set_id' => $backupSet->id,
'rerun_of_restore_run_id' => $record->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
],
initiator: $initiator,
);
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$newRun->update(['operation_run_id' => $opRun->getKey()]);
}
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun);
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
try {
$newRun = $restoreService->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $record->requested_items ?? null,
dryRun: (bool) $record->is_dry_run,
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
groupMapping: $record->group_mapping ?? []
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
]
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply();
// Compose write gate disabled/tooltip on top of UiEnforcement's RBAC check.
// UiEnforcement::apply() sets its own ->disabled() / ->tooltip();
// we override here to merge both concerns.
$action->disabled(function (?Model $record = null): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
// Check RBAC capability first (mirrors UiEnforcement logic)
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return true;
}
// Then check write gate
return app(WriteGateInterface::class)->wouldBlock($tenant);
});
$action->tooltip(function (?Model $record = null): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return \App\Support\Auth\UiTooltips::insufficientPermission();
}
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
if (! $tenant instanceof Tenant) {
return 'Tenant unavailable';
}
// Check RBAC capability first
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return \App\Support\Auth\UiTooltips::insufficientPermission();
}
// Then check write gate
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.rerun');
} catch (ProviderAccessHardeningRequired $e) {
return $e->reasonMessage;
}
return null;
});
return $action;
}
}

View File

@ -849,7 +849,7 @@ public static function infolist(Schema $schema): Schema
->schema([
Infolists\Components\TextEntry::make('rbac_not_configured_hint')
->label('Status')
->state('Not configured')
->state('Not configured — Intune RBAC has not been set up for this tenant. Write operations will be blocked.')
->icon('heroicon-o-shield-exclamation')
->color('warning')
->columnSpanFull()
@ -862,38 +862,62 @@ public static function infolist(Schema $schema): Schema
->icon(BadgeRenderer::icon(BadgeDomain::TenantRbacStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantRbacStatus))
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_status_reason')
->label('RBAC reason')
Infolists\Components\TextEntry::make('rbac_explanation')
->label('Summary')
->state(function (Tenant $record): string {
$status = $record->rbac_status;
$lastChecked = $record->rbac_last_checked_at;
$threshold = (int) config('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
if (blank($status) || $status === 'not_configured') {
return 'RBAC is not configured. Write operations to this tenant are blocked until RBAC is set up.';
}
if (in_array($status, ['degraded', 'failed', 'error', 'missing', 'partial'], true)) {
return 'RBAC health check reported an unhealthy state. Write operations are blocked.';
}
if ($status === 'ok' && ($lastChecked === null || $lastChecked->diffInHours(now()) >= $threshold)) {
return "RBAC status is OK but the last health check is older than {$threshold} hours. Write operations are blocked until refreshed.";
}
return 'RBAC is healthy and up to date. Write operations are permitted.';
})
->columnSpanFull()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_last_checked_at')
->label('RBAC last checked')
->label('Last checked')
->since()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_role_display_name')
->label('RBAC role')
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_role_definition_id')
->label('Role definition ID')
->copyable()
Section::make('RBAC Details')
->schema([
Infolists\Components\TextEntry::make('rbac_status_reason')
->label('Reason'),
Infolists\Components\TextEntry::make('rbac_role_definition_id')
->label('Role definition ID')
->copyable(),
Infolists\Components\TextEntry::make('rbac_scope_mode')
->label('Scope'),
Infolists\Components\TextEntry::make('rbac_scope_id')
->label('Scope ID'),
Infolists\Components\TextEntry::make('rbac_group_id')
->label('Group ID')
->copyable(),
Infolists\Components\TextEntry::make('rbac_role_assignment_id')
->label('Role assignment ID')
->copyable(),
Infolists\Components\ViewEntry::make('rbac_summary')
->label('Last RBAC Setup')
->view('filament.infolists.entries.rbac-summary')
->visible(fn (Tenant $record) => filled($record->rbac_last_setup_at)),
])
->columns(2)
->collapsible()
->collapsed()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_scope_mode')
->label('RBAC scope')
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_scope_id')
->label('Scope ID')
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_group_id')
->label('RBAC group ID')
->copyable()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_role_assignment_id')
->label('Role assignment ID')
->copyable()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\ViewEntry::make('rbac_summary')
->label('Last RBAC Setup')
->view('filament.infolists.entries.rbac-summary')
->visible(fn (Tenant $record) => filled($record->rbac_last_setup_at)),
])
->columns(2)
->columnSpanFull()

View File

@ -8,12 +8,15 @@
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
use App\Filament\Widgets\Tenant\TenantVerificationReport;
use App\Jobs\RefreshTenantRbacHealthJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
@ -191,6 +194,74 @@ protected function getHeaderActions(): array
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
TenantResource::rbacAction(),
UiEnforcement::forAction(
Actions\Action::make('refresh_rbac')
->label('Refresh RBAC status')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->isActive())
->action(function (Tenant $record): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->ensureRun(
tenant: $record,
type: OperationRunType::RbacHealthCheck->value,
inputs: [
'tenant_id' => (int) $record->getKey(),
'surface' => 'tenant_view_header',
],
initiator: $user,
);
$runUrl = OperationRunLinks::tenantlessView($opRun);
if ($opRun->wasRecentlyCreated === false) {
Notification::make()
->title('RBAC health check already running')
->body('A check is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
return;
}
RefreshTenantRbacHealthJob::dispatch(
(int) $record->getKey(),
(int) $user->getKey(),
$opRun,
);
Notification::make()
->title('RBAC health check started')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Deactivate')

View File

@ -2,6 +2,8 @@
namespace App\Jobs;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Listeners\SyncRestoreRunToOperationRun;
use App\Models\OperationRun;
use App\Models\RestoreRun;
@ -97,6 +99,31 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
return;
}
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
$restoreRun->update([
'status' => RestoreRunStatus::Failed->value,
'failure_reason' => $e->reasonMessage,
'completed_at' => CarbonImmutable::now(),
]);
if ($this->operationRun) {
app(\App\Services\OperationRunService::class)->updateRun(
$this->operationRun,
status: \App\Support\OperationRunStatus::Completed->value,
outcome: \App\Support\OperationRunOutcome::Failed->value,
failures: [[
'code' => 'hardening.write_blocked',
'reason_code' => $e->reasonCode,
'message' => $e->reasonMessage,
]],
);
}
return;
}
$restoreRun->update([
'status' => RestoreRunStatus::Running->value,
'started_at' => CarbonImmutable::now(),

View File

@ -0,0 +1,86 @@
<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\RbacHealthService;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class RefreshTenantRbacHealthJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
RbacHealthService $rbacHealthService,
OperationRunService $runs,
): void {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new RuntimeException('User not found.');
}
$result = $rbacHealthService->check($tenant);
if (! $this->operationRun instanceof OperationRun) {
return;
}
$status = $result['status'] ?? 'error';
$isHealthy = in_array($status, ['ok', 'configured', 'manual_assignment_required'], true);
if ($isHealthy) {
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
return;
}
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'rbac.health_check.failed',
'reason_code' => $result['reason'] ?? 'unknown',
'message' => sprintf('RBAC health check completed with status: %s', $status),
]],
);
}
}

View File

@ -2,6 +2,8 @@
namespace App\Jobs;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
@ -212,6 +214,33 @@ public function handle(
throw new RuntimeException('OperationRun is required for RestoreAssignmentsJob execution.');
}
try {
app(WriteGateInterface::class)->evaluate($tenant, 'assignments.restore');
} catch (ProviderAccessHardeningRequired $e) {
$operationRunService->updateRun(
$run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'hardening.write_blocked',
'reason_code' => $e->reasonCode,
'message' => $e->reasonMessage,
]],
summaryCounts: [
'total' => max(1, count($this->assignments)),
'processed' => 0,
'success' => 0,
'failed' => max(1, count($this->assignments)),
'skipped' => 0,
],
);
return [
'outcomes' => [],
'summary' => ['success' => 0, 'failed' => 0, 'skipped' => 0],
];
}
$executionIdentityKey = AssignmentJobFingerprint::executionIdentityKey(
jobType: self::OPERATION_TYPE,
tenantId: (int) $tenant->getKey(),

View File

@ -56,6 +56,11 @@ public function register(): void
{
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
$this->app->bind(
\App\Contracts\Hardening\WriteGateInterface::class,
\App\Services\Hardening\IntuneRbacWriteGate::class,
);
$this->app->singleton(GraphClientInterface::class, function ($app) {
$config = $app['config']->get('graph');

View File

@ -0,0 +1,84 @@
<?php
namespace App\Services\Hardening;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\Tenant;
use Illuminate\Support\Facades\Log;
class IntuneRbacWriteGate implements WriteGateInterface
{
public function evaluate(Tenant $tenant, string $operationType): void
{
if (! $this->isEnabled()) {
Log::warning('Intune write gate is disabled — write operation proceeding without RBAC verification.', [
'tenant_id' => $tenant->getKey(),
'operation_type' => $operationType,
]);
return;
}
$status = $tenant->rbac_status;
if ($status === null || $status === 'not_configured') {
throw new ProviderAccessHardeningRequired(
tenantId: (int) $tenant->getKey(),
operationType: $operationType,
reasonCode: 'intune_rbac.not_configured',
reasonMessage: 'Intune RBAC is not configured for this tenant. Configure RBAC before performing write operations.',
);
}
if (in_array($status, ['degraded', 'failed', 'error', 'missing', 'partial'], true)) {
throw new ProviderAccessHardeningRequired(
tenantId: (int) $tenant->getKey(),
operationType: $operationType,
reasonCode: 'intune_rbac.unhealthy',
reasonMessage: sprintf(
'Intune RBAC status is "%s". Resolve RBAC issues before performing write operations.',
$status,
),
);
}
if ($this->isStale($tenant)) {
throw new ProviderAccessHardeningRequired(
tenantId: (int) $tenant->getKey(),
operationType: $operationType,
reasonCode: 'intune_rbac.stale',
reasonMessage: 'Intune RBAC health check is outdated. Run a fresh health check before performing write operations.',
);
}
}
public function wouldBlock(Tenant $tenant): bool
{
try {
$this->evaluate($tenant, 'ui_check');
return false;
} catch (ProviderAccessHardeningRequired) {
return true;
}
}
private function isEnabled(): bool
{
return (bool) config('tenantpilot.hardening.intune_write_gate.enabled', true);
}
private function isStale(Tenant $tenant): bool
{
$lastCheckedAt = $tenant->rbac_last_checked_at;
if ($lastCheckedAt === null) {
return true;
}
$thresholdHours = (int) config('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
return $lastCheckedAt->diffInHours(now()) >= $thresholdHours;
}
}

View File

@ -17,6 +17,8 @@ public function spec(mixed $value): BadgeSpec
'configured' => new BadgeSpec('Configured', 'success', 'heroicon-m-check-circle'),
'manual_assignment_required' => new BadgeSpec('Manual assignment required', 'warning', 'heroicon-m-exclamation-triangle'),
'not_configured' => new BadgeSpec('Not configured', 'gray', 'heroicon-m-minus-circle'),
'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'),
'stale' => new BadgeSpec('Stale', 'warning', 'heroicon-m-clock'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),

View File

@ -52,6 +52,7 @@ public static function labels(): array
'permission_posture_check' => 'Permission posture check',
'entra.admin_roles.scan' => 'Entra admin roles scan',
'tenant.review_pack.generate' => 'Review pack generation',
'rbac.health_check' => 'RBAC health check',
];
}
@ -84,6 +85,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
'permission_posture_check' => 30,
'entra.admin_roles.scan' => 60,
'tenant.review_pack.generate' => 60,
'rbac.health_check' => 30,
default => null,
};
}

View File

@ -18,6 +18,7 @@ enum OperationRunType: string
case RestoreExecute = 'restore.execute';
case EntraAdminRolesScan = 'entra.admin_roles.scan';
case ReviewPackGenerate = 'tenant.review_pack.generate';
case RbacHealthCheck = 'rbac.health_check';
public static function values(): array
{

View File

@ -30,6 +30,12 @@ final class ProviderReasonCodes
public const string UnknownError = 'unknown_error';
public const string IntuneRbacNotConfigured = 'intune_rbac.not_configured';
public const string IntuneRbacUnhealthy = 'intune_rbac.unhealthy';
public const string IntuneRbacStale = 'intune_rbac.stale';
/**
* @return array<int, string>
*/
@ -49,6 +55,9 @@ public static function all(): array
self::NetworkUnreachable,
self::RateLimited,
self::UnknownError,
self::IntuneRbacNotConfigured,
self::IntuneRbacUnhealthy,
self::IntuneRbacStale,
];
}

View File

@ -365,4 +365,11 @@
'include_pii_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_PII_DEFAULT', true),
'include_operations_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_OPERATIONS_DEFAULT', true),
],
'hardening' => [
'intune_write_gate' => [
'enabled' => (bool) env('TENANTPILOT_INTUNE_WRITE_GATE_ENABLED', true),
'freshness_threshold_hours' => (int) env('TENANTPILOT_INTUNE_WRITE_GATE_FRESHNESS_HOURS', 24),
],
],
];

View File

@ -46,6 +46,8 @@ public function definition(): array
'environment' => 'other',
'is_current' => false,
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
];
}
}

View File

@ -0,0 +1,67 @@
# Specification Quality Checklist: Provider Access Hardening v1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-22
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Spec is ready for `/speckit.clarify` or `/speckit.plan`.
- No [NEEDS CLARIFICATION] markers — all decisions were informed by the detailed user input and existing codebase context.
- The spec references existing codebase concepts (OperationRun, ProviderOperationStartGate, rbac_status fields) as domain terms, not implementation details.
## Implementation Validation (2025-07-16)
### Runtime Behavior
- [x] Config toggle (`hardening.intune_write_gate.enabled`) allows disabling the gate
- [x] Gate bypass logs a warning for operational visibility
- [x] Gate evaluates RBAC status before any write operation
- [x] Stale health check threshold is configurable (`freshness_threshold_hours`)
- [x] Blocked operations produce audit log entries with sanitized metadata
- [x] UI disables write actions when gate would block
- [x] Badge component renders all RBAC status values (ok, degraded, stale, failed, error, not_configured)
### Security & Audit
- [x] No secrets/tokens stored in audit log metadata
- [x] AuditContextSanitizer applied to all logged metadata
- [x] Operation type and reason code recorded for blocked writes
- [x] Tenant-scoped audit entries with actor identification
### Testing Coverage
- [x] Gate blocks for not_configured status (T006)
- [x] Gate blocks for unhealthy statuses (T007)
- [x] Gate blocks for stale health check (T008)
- [x] Gate passes for ok + fresh (T009)
- [x] Gate bypass when disabled (T010)
- [x] Job-level enforcement — ExecuteRestoreRunJob (T013)
- [x] Job-level enforcement — RestoreAssignmentsJob (T014)
- [x] Zero HTTP leakage when gate blocks (T015b)
- [x] UI disabled state for blocked actions (T019)
- [x] RBAC card rendering in TenantResource (T020)
- [x] Audit log creation on blocked writes (T022)
- [x] Badge mapping for all status values (T024)

View File

@ -0,0 +1,133 @@
openapi: 3.0.3
info:
title: TenantPilot - Intune Write Gate (Provider Access Hardening v1)
version: 1.0.0
description: |
Conceptual contract for server-side gating of Intune write operations.
Note: In the current application these actions are initiated via Filament/Livewire
surfaces (not a public JSON API). This contract documents the expected request/response
semantics, stable reason codes, and outcome metadata for the gate.
servers:
- url: https://tenantpilot.local
paths:
/tenants/{tenantId}/operations/restore/execute:
post:
summary: Start restore execution (Intune write)
parameters:
- name: tenantId
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [restoreRunId]
properties:
restoreRunId:
type: integer
dryRun:
type: boolean
default: false
responses:
"202":
description: Accepted (OperationRun created/enqueued)
content:
application/json:
schema:
$ref: "#/components/schemas/OperationStarted"
"422":
description: Precondition failed (RBAC hardening gate blocked)
content:
application/json:
schema:
$ref: "#/components/schemas/GateBlocked"
/tenants/{tenantId}/operations/assignments/restore:
post:
summary: Start assignments restore (Intune write)
parameters:
- name: tenantId
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [restoreRunId, policyType, policyId]
properties:
restoreRunId:
type: integer
policyType:
type: string
policyId:
type: string
responses:
"202":
description: Accepted (OperationRun created/enqueued)
content:
application/json:
schema:
$ref: "#/components/schemas/OperationStarted"
"422":
description: Precondition failed (RBAC hardening gate blocked)
content:
application/json:
schema:
$ref: "#/components/schemas/GateBlocked"
/tenants/{tenantId}/operations/intune-rbac/refresh:
post:
summary: Start async RBAC health check refresh
parameters:
- name: tenantId
in: path
required: true
schema:
type: integer
responses:
"202":
description: Accepted (OperationRun created/enqueued)
content:
application/json:
schema:
$ref: "#/components/schemas/OperationStarted"
components:
schemas:
OperationStarted:
type: object
required: [operationRunId, type]
properties:
operationRunId:
type: integer
type:
type: string
status:
type: string
enum: [queued, running]
GateBlocked:
type: object
required: [reason_code, message]
properties:
reason_code:
type: string
enum:
- intune_rbac.not_configured
- intune_rbac.unhealthy
- intune_rbac.stale
message:
type: string
cta:
type: object
nullable: true
properties:
label:
type: string
url:
type: string

View File

@ -0,0 +1,84 @@
# Data Model — Provider Access Hardening v1 (Intune Write Gate)
## Entities (existing)
### Tenant
Used as the persisted source of truth for the gate.
- `tenants.rbac_status` (nullable string)
- Expected values used by this feature: `null`, `not_configured`, `ok`, `degraded`, `failed`
- `tenants.rbac_status_reason` (nullable string)
- Human-readable/safe explanation from last check / setup.
- `tenants.rbac_last_checked_at` (nullable timestamp)
- Used for freshness evaluation.
Relationships:
- `Tenant` → hasMany `AuditLog`
- `Tenant` → hasMany `OperationRun` (tenant-scoped runs)
### OperationRun
Used to record outcomes for queued/async work.
- `operation_runs.type`
- Existing types touched by this feature (no new types introduced):
- `restore.execute`
- `assignments.restore`
- health checks: reuse existing verification/provider connection check type(s)
- `operation_runs.failures` / failure metadata
- Must include stable reason codes when gate blocks a queued job.
### RestoreRun
Represents the restore workflow state and links to an `OperationRun`.
- `restore_runs.operation_run_id` (FK to `operation_runs`)
- `restore_runs.status` transitions drive UX and notifications.
### AuditLog
Used to record UI-level blocked write attempts (P3 in spec).
- Must store stable `action` (e.g., `intune_rbac.write_blocked`)
- Must store tenant scope and sanitized metadata (no tokens, no raw Graph payloads)
## Domain Objects (new / feature-scoped)
### IntuneRbacWriteGate (service)
- Inputs: `Tenant`, operation identifier (string), “write class” operation
- Output: allowed or throws a domain exception containing:
- `reason_code`: one of
- `intune_rbac.not_configured`
- `intune_rbac.unhealthy`
- `intune_rbac.stale`
- `reason_message`: sanitized, user-facing message
Freshness rule:
- Evaluate staleness by comparing `tenant.rbac_last_checked_at` to `now() - threshold`.
- Threshold is configurable (default 24 hours).
Config toggle:
- If disabled, gate always allows but logs a warning per evaluation (observability).
### ProviderAccessHardeningRequired (exception)
- Carries tenant + operation + reason code + safe message.
- Used by both start surfaces (to present UX) and jobs (to fail `OperationRun` safely).
## State transitions (gate perspective)
- **Allowed**: `rbac_status = ok` AND `rbac_last_checked_at` is within freshness threshold.
- **Blocked — Not configured**: `rbac_status is null` OR `rbac_status = not_configured`.
- **Blocked — Unhealthy**: `rbac_status in {degraded, failed}`.
- **Blocked — Stale**: `rbac_status = ok` but `rbac_last_checked_at` is older than threshold.
## Validation & safety rules
- Gate evaluation must be DB-only (no Graph calls).
- Blocked paths must ensure **zero Graph write calls**.
- Failure metadata must be sanitized and stable.

View File

@ -0,0 +1,162 @@
# Implementation Plan: Provider Access Hardening v1 — Intune Write Gate
**Branch**: `108-provider-access-hardening` | **Date**: 2026-02-22 | **Spec**: [specs/108-provider-access-hardening/spec.md](specs/108-provider-access-hardening/spec.md)
**Input**: Feature specification from [specs/108-provider-access-hardening/spec.md](specs/108-provider-access-hardening/spec.md)
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Add a defense-in-depth, server-side write gate that blocks all Intune write operations (restore execution + restore assignments) unless tenant RBAC hardening is configured, healthy, and fresh. The gate reads only persisted Tenant RBAC status fields (no synchronous Graph calls), returns stable reason codes, disables Filament write actions with explanations, and fails queued jobs safely before any Graph mutation.
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: PHP 8.4.x (Laravel 12)
**Primary Dependencies**: Filament v5 + Livewire v4, Laravel Sail, Microsoft Graph via `GraphClientInterface`
**Storage**: PostgreSQL (Sail)
**Testing**: Pest v4 (PHPUnit 12 underneath)
**Target Platform**: Web app (Laravel) running in Docker via Sail
**Project Type**: Web application (backend-rendered admin via Filament)
**Performance Goals**: Gate evaluation is O(1) DB reads; no Graph calls; negligible overhead per write attempt
**Constraints**: Must be DB-only at evaluation time; stable reason codes; no secrets in logs; no UI render-time HTTP
**Scale/Scope**: Tenant-scoped; affects existing write start surfaces + queued jobs only (no new Resources/Pages)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
Status: PASS (no violations expected).
- Inventory-first: clarify what is “last observed” vs snapshots/backups
- Read/write separation: any writes require preview + confirmation + audit + tests
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; tenant-context routes (/admin/t/{tenant}/...) are tenant-scoped; canonical workspace-context routes under /admin remain tenant-safe; non-member tenant/workspace access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
## Project Structure
### Documentation (this feature)
```text
specs/108-provider-access-hardening/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
app/
├── Filament/
│ ├── Resources/
│ │ ├── RestoreRunResource.php
│ │ └── TenantResource.php
│ └── Resources/TenantResource/Pages/
│ └── ViewTenant.php
├── Jobs/
│ ├── ExecuteRestoreRunJob.php
│ └── RestoreAssignmentsJob.php
├── Models/
│ ├── Tenant.php
│ ├── OperationRun.php
│ └── AuditLog.php
├── Services/
│ └── Providers/
│ └── ProviderOperationStartGate.php
└── Support/
└── (badge domains, reason codes, UI enforcement helpers)
tests/
├── Feature/
└── Unit/
```
**Structure Decision**: Web application (Laravel) with Filament/Livewire admin panel. Gate logic lives in application services and is invoked from Filament start surfaces and queued jobs.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
## Phase 0 — Outline & Research
Output: [specs/108-provider-access-hardening/research.md](specs/108-provider-access-hardening/research.md)
Research focus for this feature is mostly decision capture + aligning with existing patterns:
- Confirm start surfaces and jobs involved (RestoreRun execution + assignments restore)
- Confirm the existing RBAC setup surface (Tenant view / `TenantResource::rbacAction()`)
- Confirm existing observability primitives (`OperationRun`) and audit logging (`AuditLog`)
- Decide on behavior for in-flight health check and for gate toggle (bypass + logging)
## Phase 1 — Design & Contracts
Outputs:
- [specs/108-provider-access-hardening/data-model.md](specs/108-provider-access-hardening/data-model.md)
- [specs/108-provider-access-hardening/contracts/intune-write-gate.openapi.yaml](specs/108-provider-access-hardening/contracts/intune-write-gate.openapi.yaml)
- [specs/108-provider-access-hardening/quickstart.md](specs/108-provider-access-hardening/quickstart.md)
Design highlights:
- Introduce a provider-agnostic gate interface with an Intune implementation (`IntuneRbacWriteGate`).
- Gate reads only persisted Tenant RBAC fields (`rbac_status`, `rbac_last_checked_at`, `rbac_status_reason`).
- Gate returns stable reason codes: `intune_rbac.not_configured`, `intune_rbac.unhealthy`, `intune_rbac.stale`.
- Start surfaces:
- `RestoreRunResource::createRestoreRun()` blocks before creating `OperationRun` + before enqueuing `ExecuteRestoreRunJob`.
- Any “rerun/execute” action path in `RestoreRunResource` blocks similarly before dispatch.
- Job-level enforcement:
- `ExecuteRestoreRunJob::handle()` re-checks gate before calling restore service.
- `RestoreAssignmentsJob::handle()` re-checks gate before calling `AssignmentRestoreService`.
- UI affordance:
- Tenant view RBAC infolist section is collapsed into a compact status card with contextual actions.
- Write-trigger actions render visible but disabled with tooltips when gate would block.
### Post-design Constitution Re-check
Status: PASS (design remains DB-only for gate evaluation, does not add Graph calls/contracts, preserves RBAC semantics, and keeps destructive actions confirmed + audited).
## Phase 1 — Agent Context Update
Run: `.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Planning (for /speckit.tasks)
Implementation tasks will be generated in `tasks.md` via `/speckit.tasks`, but the intended breakdown is:
1) Add gate service + exception (reason codes + message mapping)
2) Start-surface enforcement (RestoreRun start + rerun + assignments start)
3) Job-level enforcement (ExecuteRestoreRunJob, RestoreAssignmentsJob)
4) Tenant view RBAC card + action disabling + CTAs
5) Audit logging for blocked attempts (use existing `AuditLog`)
6) Add/adjust badge semantics for `stale` in `TenantRbacStatus`
7) Pest tests: start surfaces + jobs + UI disabled state + reason codes

View File

@ -0,0 +1,32 @@
# Quickstart — Provider Access Hardening v1 (Intune Write Gate)
## Goal
Validate that Intune write operations (restore execution + restore assignments) are blocked unless tenant RBAC hardening is configured, healthy, and fresh.
## Local setup
- Start containers: `vendor/bin/sail up -d`
## Manual verification (once implemented)
1) Navigate to a tenant view page (`TenantResource` → View).
2) Set tenant RBAC status to a blocked state (e.g., `rbac_status = null` or `degraded`, or make `rbac_last_checked_at` stale).
3) Attempt to start a restore execution (Restore Runs → Execute).
- Expected: start surface blocks before enqueue; operator sees reason + CTA; no `OperationRun` is started for execution.
4) Attempt to trigger assignments restore (where available).
- Expected: blocked with the same reason codes.
5) For job-level defense-in-depth, directly enqueue the job (or trigger a code path that dispatches it) while tenant is blocked.
- Expected: `OperationRun` is marked failed with `reason_code` and no Graph mutation occurs.
## Test execution (once implemented)
Run the minimal related tests:
- `vendor/bin/sail artisan test --compact --filter=IntuneRbacWriteGate`
- or run file-scoped tests created for this feature under `tests/Feature`.
## Notes
- Gate evaluation is DB-only; no synchronous Graph calls are allowed during UI evaluation.
- When the gate is disabled via config, writes proceed but a warning is logged per evaluation that the gate is bypassed.

View File

@ -0,0 +1,67 @@
# Research — Provider Access Hardening v1 (Intune Write Gate)
## Repository Reality Check (existing implementation surfaces)
### Write start surfaces
- Restore execution start (wizard submit / create path): `App\Filament\Resources\RestoreRunResource::createRestoreRun()`
- Creates a queued `RestoreRun` and a corresponding `OperationRun` of type `restore.execute`.
- Dispatches `App\Jobs\ExecuteRestoreRunJob` with the `OperationRun` instance.
- Restore rerun path (action path inside `RestoreRunResource`): also enqueues `restore.execute` and dispatches `ExecuteRestoreRunJob`.
### Job execution layer (defense-in-depth insertion points)
- `App\Jobs\ExecuteRestoreRunJob::handle()`
- Calls `RestoreService::executeForRun(...)` (this is the Graph-mutation path).
- `App\Jobs\RestoreAssignmentsJob::handle()`
- Calls `AssignmentRestoreService::restore(...)` (Graph-mutation path for assignments).
### Tenant view RBAC UX surfaces
- Tenant view page: `App\Filament\Resources\TenantResource::infolist()` currently renders a full "RBAC" section with many raw fields.
- Tenant view header actions: `App\Filament\Resources\TenantResource\Pages\ViewTenant::getHeaderActions()` includes `TenantResource::rbacAction()`.
- RBAC setup surface already exists: `App\Filament\Resources\TenantResource::rbacAction()` creates the "Setup Intune RBAC" action (no new wizard page required).
### Existing gating and observability primitives
- Operation dedupe + provider connection blocking: `App\Services\Providers\ProviderOperationStartGate` (existing pattern for start surfaces).
- Long-running work visibility: `OperationRun` is the canonical monitoring primitive.
- Audit logging: `App\Models\AuditLog` exists and `Tenant` already relates to it.
## Decisions (resolved clarifications)
### Gate evaluation during health check
- Decision: Gate evaluates persisted Tenant state only; an in-progress health check has no special effect.
- Rationale: Keeps the gate DB-only and deterministic; avoids introducing locks/sentinels.
- Alternatives considered:
- Block all writes while health check is running (safer but adds operational friction and requires tracking “running” state).
### "Setup Intune RBAC" CTA destination
- Decision: CTA deep-links to the existing Tenant view RBAC section / surfaces (no new wizard page in this feature).
- Rationale: Minimizes scope and leverages `TenantResource::rbacAction()` which already exists.
- Alternatives considered:
- Build a dedicated setup wizard page for RBAC (adds navigation, testing, and more UX requirements).
### Audit logging for blocked attempts
- Decision: Use existing `AuditLog` model for UI-level blocked write attempts (P3), in addition to OperationRun failures.
- Rationale: Meets compliance intent without introducing new schema.
- Alternatives considered:
- Create a new audit log table (unnecessary; model already exists).
### Config toggle behavior
- Decision: `tenantpilot.hardening.intune_write_gate.enabled=false` causes writes to proceed, but logs a warning per evaluation that the gate is bypassed.
- Rationale: Rollback safety without silent loss of protection.
- Alternatives considered:
- Silent bypass (bad observability).
- Startup-only warning (can be missed; per-evaluation warning is explicit).
## Key constraints reaffirmed
- No synchronous Graph calls in the gate.
- Start surface must block before job enqueue.
- Job-level must block before any Graph mutation call.
- Reason codes are stable and sanitized.

View File

@ -0,0 +1,193 @@
# Feature Specification: Provider Access Hardening v1 — Write-Path RBAC Gate (Intune)
**Feature Branch**: `108-provider-access-hardening`
**Created**: 2026-02-22
**Status**: Draft
**Input**: Server-side write gate for Intune operations requiring RBAC hardening to be configured and healthy before any Graph mutations can execute.
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant
- **Primary Routes**: Tenant View page (RBAC card), Restore Run start actions, any Intune write-trigger actions
- **Data Ownership**: tenant-owned (`tenants.rbac_status`, `tenants.rbac_last_checked_at`, `operation_runs`)
- **RBAC**: workspace membership required + tenant-context access; write operations additionally gated by Intune RBAC hardening status (DB-persisted)
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Write Operations Blocked When RBAC Not Configured (Priority: P1)
An operator attempts to restore an Intune policy (or restore assignments) on a tenant where Intune RBAC hardening has not been configured. The system blocks the operation at the server level before any Graph write occurs. The operator receives a clear explanation and a call-to-action directing them to configure Intune RBAC.
**Why this priority**: This is the core safety gate. Without it, write operations can execute with full app-only permissions and no blast-radius control — the primary compliance and trust risk this feature addresses.
**Independent Test**: Can be fully tested by attempting a restore start on a tenant with `rbac_status = null` and verifying the operation is blocked with the correct reason code.
**Acceptance Scenarios**:
1. **Given** a tenant with `rbac_status` not set (null), **When** an operator triggers a restore action, **Then** the system blocks the operation, does not enqueue any job, and returns a reason code `intune_rbac.not_configured` with a human-readable message.
2. **Given** a tenant with `rbac_status = not_configured`, **When** an operator triggers a restore assignments action, **Then** the system blocks the operation with reason code `intune_rbac.not_configured` and provides a CTA to "Setup Intune RBAC".
3. **Given** a tenant with `rbac_status = ok` and a fresh `rbac_last_checked_at`, **When** an operator triggers a restore action, **Then** the operation proceeds normally without any gate interference.
---
### User Story 2 — Write Operations Blocked When RBAC Unhealthy or Stale (Priority: P1)
An operator attempts a write operation on a tenant where RBAC hardening was previously configured but is now in a degraded, failed, or stale state. The system blocks the operation and explains why, offering relevant recovery actions.
**Why this priority**: Equally critical as US1 — a configured-but-broken RBAC state is arguably more dangerous because operators may assume it is safe.
**Independent Test**: Can be tested by setting `rbac_status = degraded` or `rbac_last_checked_at` to a date older than the freshness threshold, then attempting a write operation.
**Acceptance Scenarios**:
1. **Given** a tenant with `rbac_status = degraded`, **When** a write operation is attempted, **Then** the system blocks it with reason code `intune_rbac.unhealthy` and a CTA to "Run health check".
2. **Given** a tenant with `rbac_status = failed`, **When** a write operation is attempted, **Then** the system blocks it with reason code `intune_rbac.unhealthy`.
3. **Given** a tenant with `rbac_status = ok` but `rbac_last_checked_at` older than the configured freshness threshold, **When** a write operation is attempted, **Then** the system blocks it with reason code `intune_rbac.stale` and a CTA to "Run health check".
---
### User Story 3 — Defense in Depth: Job-Level Gate (Priority: P1)
Even if a write operation is somehow enqueued (race condition, direct dispatch, future code path), the job itself must re-check the gate before executing any Graph write call. If blocked, the job marks its OperationRun as failed with a stable reason code and does not attempt any Graph mutation.
**Why this priority**: Defense-in-depth is a non-negotiable for enterprise SaaS. The job-level gate is the last line of defense before actual Graph writes.
**Independent Test**: Can be tested by directly instantiating a restore job with a tenant in blocked state and verifying the OperationRun is marked failed without any Graph calls.
**Acceptance Scenarios**:
1. **Given** a tenant with `rbac_status = not_configured`, **When** `ExecuteRestoreRunJob` runs, **Then** the job marks the OperationRun as failed with reason code `intune_rbac.not_configured` and performs zero Graph write calls.
2. **Given** a tenant with `rbac_status = ok` but stale `rbac_last_checked_at`, **When** `RestoreAssignmentsJob` runs, **Then** the job marks the OperationRun as failed with `intune_rbac.stale`.
3. **Given** a tenant with `rbac_status = ok` and fresh health check, **When** a restore job runs, **Then** the gate passes and the job proceeds to execute Graph writes.
---
### User Story 4 — UI: Disabled Actions with Reason and CTA (Priority: P2)
When the Intune write gate would block an operation, the UI should proactively disable write-trigger actions (e.g., "Execute restore", "Restore assignments") and show the operator why the action is unavailable, along with a relevant CTA.
**Why this priority**: Good UX prevents confusion and reduces support burden. However, server-side enforcement (US1US3) is the security boundary; UI is an affordance.
**Independent Test**: Can be tested by rendering a restore action on a tenant with blocked RBAC status and verifying the action is disabled with the correct tooltip/helper text.
**Acceptance Scenarios**:
1. **Given** a tenant with `rbac_status = null`, **When** the operator views restore actions, **Then** write-trigger actions are visible but disabled, with a helper explaining "Intune RBAC not configured" and a link to the Tenant View page RBAC section.
2. **Given** a tenant with `rbac_status = degraded`, **When** the operator views write actions, **Then** actions are disabled with a helper explaining the degraded state and a CTA to run a health check.
3. **Given** a tenant with `rbac_status = ok` and fresh health, **When** the operator views write actions, **Then** actions are enabled normally.
---
### User Story 5 — Tenant RBAC Status Card (Progressive Disclosure) (Priority: P2)
On the tenant view page, the RBAC hardening status is displayed as a compact card with a badge, short explanation, and contextual actions — replacing the current approach of showing many individual RBAC fields.
**Why this priority**: Improves operator understanding of RBAC posture at a glance. Supports the write gate UX by making status visible before operators attempt writes.
**Independent Test**: Can be tested by viewing a tenant page with various `rbac_status` values and verifying the card renders the correct badge, text, and actions.
**Acceptance Scenarios**:
1. **Given** a tenant with `rbac_status = ok`, **When** the operator views the tenant page, **Then** a card displays "Intune Access Hardening" with a "Healthy" badge and a "Run health check" action.
2. **Given** a tenant with `rbac_status = null`, **When** the operator views the tenant page, **Then** a card displays a "Not Configured" badge and a "Setup Intune RBAC" action.
3. **Given** a tenant with `rbac_status = degraded`, **When** the operator views the tenant page, **Then** a card displays a "Degraded" badge and both "Run health check" and "View details" actions.
---
### User Story 6 — Auditable Blocked Write Attempts (Priority: P3)
When a write operation is blocked by the gate, the event is recorded for audit and compliance purposes. At the job level this is captured via the OperationRun failure. At the UI level, an optional AuditLog entry records the blocked attempt.
**Why this priority**: Important for compliance and post-incident review but not a functional blocker for the gate itself.
**Independent Test**: Can be tested by triggering a blocked write and verifying the OperationRun or AuditLog contains the expected reason code and metadata.
**Acceptance Scenarios**:
1. **Given** a blocked write attempt in a job, **When** the gate blocks execution, **Then** the OperationRun is marked failed with `reason_code`, `reason_message`, and no sensitive data.
2. **Given** a blocked write attempt at the UI start surface, **When** the gate prevents operation start, **Then** an AuditLog entry is created with the action `intune_rbac.write_blocked`, the tenant ID, and the operation type.
---
### Edge Cases
- What happens when `rbac_status` is transitioning (health check running concurrently with a write attempt)? The gate evaluates persisted status only; a running health check OperationRun has no special effect on gate evaluation. If the last persisted status was `ok` and fresh, writes proceed. If stale or degraded, writes remain blocked. The operator must wait for the health check to complete and then retry. No "in-progress" sentinel or lock is applied to the gate.
- What happens when the freshness threshold configuration changes? The gate uses the threshold value at evaluation time. Lowering the threshold may immediately block previously-allowed operations if `rbac_last_checked_at` is now considered stale.
- What happens when a tenant has `rbac_status = ok` but the underlying RBAC artifacts were removed externally in Entra/Intune? The gate will allow the write (status is persisted). The next health check will detect the problem and update `rbac_status` to `degraded` or `failed`. This is by design — no live Graph calls in the gate path.
- How does the gate interact with `ProviderOperationStartGate`? The write hardening gate runs as an additional check within or alongside the existing start gate. If the provider connection is unresolved, that blocking reason takes precedence. If the connection is resolved but RBAC is unhealthy, the write hardening gate blocks.
## Clarifications
### Session 2026-02-22
- Q: How should the gate behave while a health check OperationRun is in-progress? → A: Gate evaluates persisted status only; running health check has no effect on gate evaluation (consistent with FR-004).
- Q: Where does the "Setup Intune RBAC" CTA link to? → A: Links to the existing Tenant View page RBAC section (no new wizard page created in this feature scope).
- Q: Does an AuditLog model exist or must it be created? → A: `AuditLog` model already exists (related to `Tenant` via `hasMany`). UI-level blocked write entries will use it directly — no new table needed.
- Q: When the write gate is disabled via config, should writes proceed and should it log? → A: Writes proceed and the gate logs a warning per evaluation that the gate is bypassed.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph calls or new OperationRun types. It adds a server-side gate that blocks existing write operations. Blocked writes at the job level are recorded in the existing OperationRun (failed status + reason code). Blocked writes at the UI level record an entry in the existing `AuditLog` model (already related to `Tenant`). No new contract registry entries are required; this feature gates operations that already have registered contracts.
**Constitution alignment (RBAC-UX):**
- Authorization plane: tenant-context `/admin/t/{tenant}/...`
- The write gate is not an RBAC capability check — it is a tenant-health prerequisite check that applies regardless of the operator's role.
- Existing RBAC capability checks (workspace membership, manage permission) remain enforced before the write gate is evaluated.
- 404 vs 403 semantics: The write gate returns a 422-family response (operation precondition not met), not 403 or 404, since the operator is authorized but the tenant's RBAC posture is insufficient.
- No new capability strings are introduced.
- Destructive actions (restore) already require confirmation; the gate adds a pre-check before the confirmation flow even applies.
**Constitution alignment (BADGE-001):** The tenant RBAC status card uses the existing `TenantRbacStatus` badge domain from `BadgeDomain`. New badge values (`stale`) must be registered in the centralized badge semantics. Tests will cover all badge states.
**Constitution alignment (Filament Action Surfaces):** This feature modifies existing Filament action surfaces (restore start actions on RestoreRunResource and tenant view page). No new Resources or Pages are created. The UI Action Matrix below covers the changes.
**Constitution alignment (UX-001):** The tenant RBAC card is placed within the existing tenant View page infolist, inside a Section. No naked inputs. Badge semantics use BADGE-001.
### Functional Requirements
- **FR-001**: System MUST evaluate Intune RBAC hardening status before allowing any Intune write operation to start or execute.
- **FR-002**: System MUST block write operations when the tenant's RBAC status is `null`, `not_configured`, `degraded`, or `failed`.
- **FR-003**: System MUST block write operations when `rbac_last_checked_at` is older than a configurable freshness threshold (default: 24 hours).
- **FR-004**: The write gate MUST use only persisted database state (no synchronous Graph calls during evaluation).
- **FR-005**: When a write operation is blocked at the job level, the system MUST mark the associated OperationRun as failed with a stable reason code (`intune_rbac.not_configured`, `intune_rbac.unhealthy`, or `intune_rbac.stale`) and a sanitized message.
- **FR-006**: When a write operation is blocked at the UI start surface, the system MUST prevent job enqueue and display the reason with a CTA to the operator.
- **FR-007**: Blocked write operations MUST NOT perform any Microsoft Graph mutations — zero write calls.
- **FR-008**: The write gate MUST be enforced at both the start surface (UI/command) and the job execution layer (defense in depth).
- **FR-009**: The tenant view page MUST display a compact RBAC hardening status card with badge, explanation, and contextual actions (link to Tenant View RBAC section, run health check).
- **FR-010**: Write-trigger Filament actions MUST be disabled with a reason tooltip when the write gate would block the operation.
- **FR-011**: The gate design MUST be provider-agnostic in its interface, even though v1 only implements the Intune check. Future providers can plug in without redesign.
- **FR-012**: A "Refresh Intune RBAC status" action on the tenant page MUST start an OperationRun that runs the health check asynchronously.
- **FR-013**: The gate MUST be toggleable via configuration (`tenantpilot.hardening.intune_write_gate.enabled`, default: `true`) for rollback safety. When disabled, the gate MUST allow writes to proceed and MUST log a warning per evaluation that the gate was bypassed.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance | Row Actions | Bulk Actions | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| TenantResource ViewTenant | Tenant View page | — | RBAC status card with badge | — | — | — | "Refresh RBAC status" (OperationRun), "Setup RBAC" (link to Tenant View RBAC section) | — | Yes (blocked writes) | Card replaces raw field list for RBAC section |
| RestoreRunResource | Restore Run actions | "Execute" action: disabled when gate blocks | — | — | — | — | "Execute": disabled + tooltip when blocked | — | Yes (OperationRun failure) | Gate check added before existing confirmation |
### Key Entities
- **IntuneRbacWriteGate**: Central service responsible for evaluating whether an Intune write operation is allowed. Reads tenant RBAC status fields, returns allowed or throws a domain exception with a stable reason code.
- **ProviderAccessHardeningRequired**: Domain exception carrying tenant ID, operation identifier, reason code, and a safe human-readable message. Used by both start surfaces and job-level enforcement.
- **Tenant (existing)**: Existing entity with `rbac_status`, `rbac_status_reason`, `rbac_last_checked_at` fields used as the gate's data source.
- **OperationRun (existing)**: Existing entity that captures job outcomes. Blocked write operations store the reason code in the run's failure metadata.
## Assumptions
- The existing `rbac_status`, `rbac_status_reason`, and `rbac_last_checked_at` fields on the Tenant model are sufficient for gate evaluation — no schema migrations are required.
- The existing periodic health check job (or `ProviderConnectionHealthCheckJob`) already updates RBAC status fields, or will be extended to do so as part of this feature.
- The freshness threshold defaults to 24 hours and is configured via `config('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours')`.
- `ProviderOperationStartGate` is the existing entry point for starting provider-backed operations and can be extended to invoke the write hardening gate for write-classified operations.
- The gate applies to all Intune write operations: restore, restore assignments, and any future write operation types.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of Intune write operations are blocked when the tenant's RBAC hardening status is not "ok" or is stale — verified by automated tests covering all three reason codes.
- **SC-002**: Zero Graph write calls occur when the gate blocks an operation — verified by mocking the Graph client and asserting zero invocations in gate-blocked scenarios.
- **SC-003**: Operators see a clear reason and CTA within the UI when a write action is blocked — verified by Livewire component tests asserting disabled state and helper text.
- **SC-004**: OperationRun failures from gate blocks contain stable, parseable reason codes — verified by asserting the `reason_code` field in job-level tests.
- **SC-005**: The gate adds no synchronous Graph calls to any UI render or action request — verified by architectural tests asserting no HTTP calls during gate evaluation.
- **SC-006**: Full test suite remains green with the gate enabled (default) and with the gate disabled (config toggle) — regression safety.

View File

@ -0,0 +1,138 @@
# Tasks — Provider Access Hardening v1: Intune Write Gate
**Feature**: `108-provider-access-hardening`
**Branch**: `108-provider-access-hardening`
**Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) | **Data Model**: [data-model.md](data-model.md)
**Generated**: 2026-02-22
---
## Implementation Strategy
MVP = US1 + US2 + US3 (the three P1 safety gates). These can be verified end-to-end before any UX or audit work begins.
Delivery order:
1. Config + exception (Phase 1)
2. Gate service — the shared core (Phase 2 — blocks all phases)
3. Start-surface enforcement (Phase 3 — US1+US2)
4. Job-level enforcement (Phase 4 — US3)
5. UI disabled actions + RBAC card (Phase 5 — US4+US5)
6. Audit logging (Phase 6 — US6)
7. Polish (Phase 7)
---
## Phase 1: Setup
> **Pre-condition (B2)**: `App\Services\Intune\RbacHealthService` exists and writes `rbac_status`, `rbac_status_reason`, and `rbac_last_checked_at` on Tenant, but has **zero callers**. T001b below wires it into a dispatchable job. Without it the gate's `ok + fresh` path is never reachable.
- [X] T001 Add `hardening.intune_write_gate` config block to `config/tenantpilot.php` with keys: `enabled` (bool, default `true`) and `freshness_threshold_hours` (int, default `24`)
- [X] T001b Create `App\Jobs\RefreshTenantRbacHealthJob` in `app/Jobs/RefreshTenantRbacHealthJob.php` — a queued job that accepts a `Tenant $tenant` and calls `App\Services\Intune\RbacHealthService` to run the RBAC health check and persist the result; follow the existing `ProviderConnectionHealthCheckJob` pattern for locking + OperationRun observability; this job is dispatched by the new header action in T018; **also** add `case RbacHealthCheck = 'rbac.health_check'` to `app/Support/OperationRunType.php` — this enum is the canonical type registry and all OperationRun types must be registered there
- [X] T002 Create `App\Exceptions\Hardening\ProviderAccessHardeningRequired` exception in `app/Exceptions/Hardening/ProviderAccessHardeningRequired.php` carrying: `tenantId` (int), `operationType` (string), `reasonCode` (string, one of `intune_rbac.not_configured` | `intune_rbac.unhealthy` | `intune_rbac.stale`), and `reasonMessage` (string)
---
## Phase 2: Foundational
> Must complete before any user story phase.
- [X] T002b Create `App\Contracts\Hardening\WriteGateInterface` in `app/Contracts/Hardening/WriteGateInterface.php` with two methods: `evaluate(Tenant $tenant, string $operationType): void` (throws `ProviderAccessHardeningRequired` when blocked) and `wouldBlock(Tenant $tenant): bool` (non-throwing, for UI disabled-state checks); `IntuneRbacWriteGate` (T003) implements both; satisfies FR-011 (provider-agnostic design) and makes future gate implementations pluggable without code changes in start surfaces or jobs
- [X] T003 Create `App\Services\Hardening\IntuneRbacWriteGate` in `app/Services/Hardening/IntuneRbacWriteGate.php` implementing `WriteGateInterface` with:
- `evaluate(Tenant $tenant, string $operationType): void`: returns when gate is disabled (logs warning); throws `ProviderAccessHardeningRequired` with `intune_rbac.not_configured` when `rbac_status` is `null` or `not_configured`; throws `intune_rbac.unhealthy` when `rbac_status` is `degraded` or `failed`; throws `intune_rbac.stale` when `rbac_status = ok` but `rbac_last_checked_at` is older than the freshness threshold; passes through only when `rbac_status = ok` AND timestamp is fresh
- `wouldBlock(Tenant $tenant): bool`: calls the same evaluation logic, catches `ProviderAccessHardeningRequired`, and returns `true`/`false` without throwing — used by UI disabled-state closures (T016)
---
## Phase 3: Start-Surface Enforcement [US1+US2]
**Story goal**: All Intune write start surfaces block before enqueuing any job or creating a write OperationRun.
**Independent test criteria**: Attempt a restore start on a tenant with `rbac_status = null`, `degraded`, or stale timestamp → operation is blocked, no jobs dispatched.
- [X] T004 [P] [US1+US2] Enforce gate in `RestoreRunResource::createRestoreRun()` in `app/Filament/Resources/RestoreRunResource.php` — call `IntuneRbacWriteGate::evaluate()` after authorization checks and before any `RestoreRun::create()` / `OperationRun` / `ExecuteRestoreRunJob::dispatch()` call; catch `ProviderAccessHardeningRequired` and throw a Filament notification + validation exception with the reason message
- [X] T005 [P] [US1+US2] Enforce gate in the rerun action path inside `RestoreRunResource.php` (around L860L980) — same pattern: call gate before `ExecuteRestoreRunJob::dispatch($newRun->id, ...)`; present reason to operator via `Notification::make()->danger()` and return early
- [X] T006 [US1+US2] Write Pest feature test `tests/Feature/Hardening/RestoreStartGateNotConfiguredTest.php`: assert that calling the restore start path with `rbac_status = null` and `rbac_status = not_configured` does NOT enqueue `ExecuteRestoreRunJob` and returns reason code `intune_rbac.not_configured`
- [X] T007 [P] [US1+US2] Write Pest feature test `tests/Feature/Hardening/RestoreStartGateUnhealthyTest.php`: assert that `rbac_status = degraded` and `rbac_status = failed` block with `intune_rbac.unhealthy`; mock `ExecuteRestoreRunJob` and assert zero invocations
- [X] T008 [P] [US1+US2] Write Pest feature test `tests/Feature/Hardening/RestoreStartGateStaleTest.php`: assert that `rbac_status = ok` with `rbac_last_checked_at` older than configured threshold blocks with `intune_rbac.stale`
- [X] T009 [US1+US2] Write Pest feature test `tests/Feature/Hardening/RestoreStartGatePassesTest.php`: assert that `rbac_status = ok` with fresh `rbac_last_checked_at` allows the operation to proceed (job is dispatched)
- [X] T010 [US1+US2] Write Pest test asserting gate bypass: when `tenantpilot.hardening.intune_write_gate.enabled = false`, restore start proceeds and a warning is logged (assert `Log::warning` called with bypass message)
---
## Phase 4: Job-Level Gate [US3]
**Story goal**: Even if a write job is enqueued by any path, the job re-checks the gate before touching the Graph and marks the OperationRun failed with a stable reason code.
**Independent test criteria**: Instantiate a restore job with a tenant in blocked state → OperationRun is marked failed with reason code; zero Graph calls made.
- [X] T011 [P] [US3] Enforce gate in `ExecuteRestoreRunJob::handle()` in `app/Jobs/ExecuteRestoreRunJob.php` — call `IntuneRbacWriteGate::evaluate()` after loading `$restoreRun` and `$tenant`, before calling `$restoreService->executeForRun(...)`; on `ProviderAccessHardeningRequired`: update `RestoreRun` status to `Failed` with sanitized `failure_reason`; update the associated `OperationRun` via `OperationRunService` with `outcome = failed` and `failures[reason_code]` set to the stable reason code; do NOT rethrow the exception
- [X] T012 [P] [US3] Enforce gate in `RestoreAssignmentsJob::handle()` in `app/Jobs/RestoreAssignmentsJob.php` — call gate after resolving `$tenant` and `$run`, before calling `$assignmentRestoreService->restore(...)`; on `ProviderAccessHardeningRequired`: call `OperationRunService::updateRun()` with `outcome = failed`, `failures[reason_code]` = the stable reason code, `summaryCounts total = max(1, count($this->assignments))`; return early without Graph calls
- [X] T013 [P] [US3] Write Pest feature test `tests/Feature/Hardening/ExecuteRestoreRunJobGateTest.php`: dispatch `ExecuteRestoreRunJob` with a tenant with `rbac_status = not_configured` → assert OperationRun is marked failed with `reason_code = intune_rbac.not_configured` in failures; mock `RestoreService` and assert `executeForRun` is never called
- [X] T014 [P] [US3] Write Pest feature test `tests/Feature/Hardening/RestoreAssignmentsJobGateTest.php`: dispatch `RestoreAssignmentsJob` via `dispatchTracked()` with a tenant with stale `rbac_last_checked_at` → assert OperationRun is marked failed with `reason_code = intune_rbac.stale`; mock `AssignmentRestoreService` and assert `restore` is never called
- [X] T015 [US3] Write Pest test asserting zero Graph write calls: for both jobs in blocked state, assert `GraphClientInterface` (mock) receives zero POST/PATCH/PUT invocations
- [X] T015b [US3 / SC-005] Write Pest unit test `tests/Unit/Hardening/IntuneRbacWriteGateNoHttpTest.php`: call `IntuneRbacWriteGate::evaluate()` with all three blocked states using a real in-process Tenant model; wrap with `Http::fake()` and assert `Http::assertNothingSent()` — verifies gate evaluation never triggers an outbound HTTP call (constitution DB-only requirement)
---
## Phase 5: UI Affordance [US4+US5]
**Story goal**: Write actions are visibly disabled with reason + CTA before operators attempt them; Tenant view RBAC section shows a compact status card.
**Independent test criteria**: Render restorerun view action with blocked tenant → assert action `isDisabled()` and helper text contains the reason; render tenant view with various `rbac_status` → assert correct badge and action labels.
- [X] T016 [P] [US4] Disable write-trigger Filament actions in `RestoreRunResource.php` — for the execute/rerun action(s), add `->disabled(fn ($record): bool => app(WriteGateInterface::class)->wouldBlock($record->tenant))` + `->tooltip(fn ($record): ?string => ...)` returning the human-readable reason or null; `RestoreRun` has a direct `tenant()` BelongsTo — use `$record->tenant`, **not** `$record->restoreBackup->tenant` or any other path; action must remain visible but clearly unavailable when gate would block; **do not** capture `$tenant` via `use ()` — use the `$record` closure argument injected by Filament
- [X] T017 [P] [US5] Replace the flat RBAC field list in `TenantResource::infolist()` (`app/Filament/Resources/TenantResource.php`, the `Section::make('RBAC')` block at ~L862) with a compact status card using a `ViewEntry` or structured `TextEntry` rows that shows: `TenantRbacStatus` badge, short explanation sentence, and contextual infolist actions ("Setup Intune RBAC" → `TenantResource::rbacAction()` modal; "Run health check" → existing verify action surf); retain collapsible raw detail fields behind a secondary "View details" affordance
- [X] T018 [P] [US5] Add "Refresh RBAC status" header action to `ViewTenant::getHeaderActions()` in `app/Filament/Resources/TenantResource/Pages/ViewTenant.php` — dispatch `RefreshTenantRbacHealthJob` (T001b) from the action closure; **do not** reuse `StartVerification` (that checks provider connections, not RBAC health); follow the existing `verify` action pattern for locking, deduplication, and `scope_busy` / `started` / `blocked` notification variants; use `UiEnforcement::forAction(...)` wrapper consistent with existing header actions
- [X] T019 [US4] Write Pest Livewire test `tests/Feature/Hardening/RestoreRunActionDisabledStateTest.php`: mount the RestoreRun page as a Livewire component, assert the execute action is disabled when `rbac_status = null` and the tooltip helper contains the expected reason text; assert action is enabled when `rbac_status = ok` with fresh health
- [X] T020 [US5] Write Pest Livewire test `tests/Feature/Hardening/TenantRbacCardRenderTest.php`: assert the RBAC card renders the correct `TenantRbacStatus` badge and CTA label for each status value: `null` → "Not Configured" + "Setup Intune RBAC"; `ok` → "Healthy" + "Run health check"; `degraded` → "Degraded" + "Run health check" + "View details" affordance is present and collapsible (covering US5 acceptance scenario 3)
---
## Phase 6: Audit Logging [US6]
**Story goal**: Blocked write attempts at the UI start surface are recorded in `AuditLog` for compliance and post-incident review (OperationRun failures already provide job-level audit).
**Independent test criteria**: Trigger a blocked write via the start surface → assert an `AuditLog` record exists with `action = intune_rbac.write_blocked`, the correct `tenant_id`, and operation type in metadata.
- [X] T021 [US6] Write `AuditLog` entry in the `ProviderAccessHardeningRequired` catch block in `RestoreRunResource::createRestoreRun()` (and rerun path) using `App\Services\Intune\AuditLogger::log()` with: `action = 'intune_rbac.write_blocked'`, `status = 'blocked'` (`audit_logs.status` is a plain unconstrained `string` column — no enum restriction), `context.metadata` containing `operation_type`, `reason_code`, and the restore run / backup set IDs (no secrets, no raw payloads)
- [X] T022 [US6] Write Pest feature test `tests/Feature/Hardening/BlockedWriteAuditLogTest.php`: trigger a blocked restore start → assert an `AuditLog` record exists with the expected `action`, `tenant_id`, and sanitized metadata; assert it does NOT contain any token or credential fields
---
## Phase 7: Polish & Cross-cutting
- [X] T023 Register missing status values in `app/Support/Badges/Domains/TenantRbacStatusBadge.php`: add `stale` (amber warning, `heroicon-m-clock`) and `degraded` (amber warning, `heroicon-m-exclamation-triangle`) — both values are currently absent from the `match` block; update `BadgeCatalog` if new values require catalog registration; BADGE-001 requires all status-like values to map to non-ad-hoc label + color + icon
- [X] T024 Write Pest unit test `tests/Unit/Badge/TenantRbacStatusBadgeTest.php`: assert all badge values (`null`, `not_configured`, `ok`, `degraded`, `failed`, `stale`) map to non-empty label + color; no ad-hoc status mapping
> **Note (T025)**: Toggle regression is covered by T010 (gate bypass Pest test with `Log::warning` assertion). No separate verification script needed.
- [X] T026 Run `vendor/bin/sail bin pint --dirty` and commit any formatting corrections before finalizing the feature branch
- [X] T027 Create `specs/108-provider-access-hardening/checklists/requirements.md` using the standard checklist template — constitution v1.9.0 Spec-First Workflow requires this file for features that change runtime behavior
---
## Dependencies (story completion order)
```
Phase 1 (T001, T001b, T002)
→ Phase 2 (T002b, T003) [T002b before T003; T003 blocks all writes]
→ Phase 3 (US1+US2) [T004, T005 after T003]
→ Phase 4 (US3) [T011, T012, T013T015b in parallel with Phase 3]
→ Phase 5 (US4+US5) [T016 needs T002b for WriteGateInterface;
T018 needs T001b for RefreshTenantRbacHealthJob]
→ Phase 6 (US6) [T021, T022 after Phase 3]
Phase 7 (T023, T024, T026, T027) [T023 any time after Phase 2; T027 any time]
```
MVP cutoff: **T001T015b** (Phases 14) delivers all three P1 safety gates; US4+US5+US6 (Phases 56) can follow.
---
## Parallel Execution (per story)
| Story | Parallelizable tasks |
|---|---|
| Phase 1 | T001 \u2016 T001b simultaneously |
| US1+US2 | T004 \u2016 T005 simultaneously; then T006 \u2016 T007 \u2016 T008 \u2016 T009 simultaneously |
| US3 | T011 \u2016 T012 simultaneously; then T013 \u2016 T014 \u2016 T015 \u2016 T015b simultaneously |
| US4+US5 | T016 \u2016 T017 \u2016 T018 simultaneously; then T019 \u2016 T020 simultaneously |
| US6 | T021 then T022 |
| Polish | T023 \u2016 T024 simultaneously; T026 last; T027 any time |

View File

@ -0,0 +1,134 @@
<?php
use App\Filament\Resources\RestoreRunResource;
use App\Models\AuditLog;
use App\Models\BackupSet;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('tenantpilot.hardening.intune_write_gate.enabled', true);
config()->set('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
});
test('blocked restore start creates audit log entry', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->update([
'rbac_status' => null,
'rbac_last_checked_at' => null,
]);
$backupSet = BackupSet::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
try {
RestoreRunResource::createRestoreRun([
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
'selected_items' => null,
]);
$this->fail('Expected ValidationException to be thrown');
} catch (ValidationException) {
// Expected
}
$auditLog = AuditLog::query()
->where('tenant_id', $tenant->id)
->where('action', 'intune_rbac.write_blocked')
->first();
expect($auditLog)->not->toBeNull()
->and($auditLog->status)->toBe('blocked')
->and($auditLog->actor_email)->toBe($user->email)
->and($auditLog->resource_type)->toBe('restore_run')
->and($auditLog->metadata)->toBeArray()
->and($auditLog->metadata['operation_type'])->toBe('restore.execute')
->and($auditLog->metadata['reason_code'])->toBe('intune_rbac.not_configured')
->and($auditLog->metadata['backup_set_id'])->toBe($backupSet->id);
});
test('audit log does not contain token or credential fields', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->update([
'rbac_status' => 'degraded',
'rbac_last_checked_at' => now(),
]);
$backupSet = BackupSet::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
try {
RestoreRunResource::createRestoreRun([
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
'selected_items' => null,
]);
$this->fail('Expected ValidationException to be thrown');
} catch (ValidationException) {
// Expected
}
$auditLog = AuditLog::query()
->where('tenant_id', $tenant->id)
->where('action', 'intune_rbac.write_blocked')
->first();
expect($auditLog)->not->toBeNull();
$metadataJson = json_encode($auditLog->metadata);
$sensitivePatterns = ['token', 'secret', 'password', 'credential', 'bearer', 'client_secret'];
foreach ($sensitivePatterns as $pattern) {
expect(str_contains(strtolower($metadataJson), $pattern))->toBeFalse(
"Audit log metadata should not contain '{$pattern}'"
);
}
});
test('audit log records correct reason code for unhealthy status', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->update([
'rbac_status' => 'failed',
'rbac_last_checked_at' => now(),
]);
$backupSet = BackupSet::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
try {
RestoreRunResource::createRestoreRun([
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
'selected_items' => null,
]);
$this->fail('Expected ValidationException to be thrown');
} catch (ValidationException) {
// Expected
}
$auditLog = AuditLog::query()
->where('tenant_id', $tenant->id)
->where('action', 'intune_rbac.write_blocked')
->first();
expect($auditLog)->not->toBeNull()
->and($auditLog->metadata['reason_code'])->toBe('intune_rbac.unhealthy');
});

View File

@ -0,0 +1,125 @@
<?php
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('tenantpilot.hardening.intune_write_gate.enabled', true);
config()->set('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
});
test('execute restore run job marks run failed when rbac_status is not_configured', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'not_configured',
'rbac_last_checked_at' => null,
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 0,
]);
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => 'actor@example.com',
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'requested_items' => null,
'preview' => [],
'results' => null,
'metadata' => [],
]);
$this->mock(RestoreService::class, function (MockInterface $mock) {
$mock->shouldNotReceive('executeForRun');
});
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
],
);
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun);
$job->handle(app(RestoreService::class), app(AuditLogger::class));
$restoreRun->refresh();
$operationRun->refresh();
expect($restoreRun->status)->toBe(RestoreRunStatus::Failed->value)
->and($restoreRun->failure_reason)->toContain('not configured')
->and($operationRun->outcome)->toBe(OperationRunOutcome::Failed->value);
$failures = is_array($operationRun->failure_summary) ? $operationRun->failure_summary : [];
$reasonCodes = array_column($failures, 'reason_code');
expect($reasonCodes)->toContain('intune_rbac.not_configured');
});
test('execute restore run job marks run failed when rbac_status is stale', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subHours(48),
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 0,
]);
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => 'actor@example.com',
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'requested_items' => null,
'preview' => [],
'results' => null,
'metadata' => [],
]);
$this->mock(RestoreService::class, function (MockInterface $mock) {
$mock->shouldNotReceive('executeForRun');
});
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
],
);
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun);
$job->handle(app(RestoreService::class), app(AuditLogger::class));
$operationRun->refresh();
$failures = is_array($operationRun->failure_summary) ? $operationRun->failure_summary : [];
$reasonCodes = array_column($failures, 'reason_code');
expect($reasonCodes)->toContain('intune_rbac.stale');
});

View File

@ -0,0 +1,161 @@
<?php
use App\Jobs\RestoreAssignmentsJob;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\AssignmentRestoreService;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('tenantpilot.hardening.intune_write_gate.enabled', true);
config()->set('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
});
test('restore assignments job marks run failed when rbac is stale', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subHours(48),
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 0,
]);
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => 'actor@example.com',
'is_dry_run' => false,
'status' => RestoreRunStatus::Running->value,
'requested_items' => null,
'preview' => [],
'results' => null,
'metadata' => [],
]);
$this->mock(AssignmentRestoreService::class, function (MockInterface $mock) {
$mock->shouldNotReceive('restore');
});
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'assignments.restore',
inputs: [
'restore_run_id' => $restoreRun->id,
'policy_type' => 'deviceConfiguration',
'policy_id' => 'test-policy-id',
],
);
$assignments = [
['target' => ['@odata.type' => '#microsoft.graph.allLicensedUsersAssignmentTarget']],
];
$job = new RestoreAssignmentsJob(
restoreRunId: $restoreRun->id,
tenantId: (int) $tenant->getKey(),
policyType: 'deviceConfiguration',
policyId: 'test-policy-id',
assignments: $assignments,
groupMapping: [],
foundationMapping: [],
actorEmail: 'actor@example.com',
actorName: 'Actor',
operationRun: $operationRun,
);
$job->handle(
app(AssignmentRestoreService::class),
app(OperationRunService::class),
);
$operationRun->refresh();
expect($operationRun->outcome)->toBe(OperationRunOutcome::Failed->value)
->and($operationRun->status)->toBe(OperationRunStatus::Completed->value);
$failures = is_array($operationRun->failure_summary) ? $operationRun->failure_summary : [];
$reasonCodes = array_column($failures, 'reason_code');
expect($reasonCodes)->toContain('intune_rbac.stale');
});
test('restore assignments job marks run failed when rbac is not configured', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => null,
'rbac_last_checked_at' => null,
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 0,
]);
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => 'actor@example.com',
'is_dry_run' => false,
'status' => RestoreRunStatus::Running->value,
'requested_items' => null,
'preview' => [],
'results' => null,
'metadata' => [],
]);
$this->mock(AssignmentRestoreService::class, function (MockInterface $mock) {
$mock->shouldNotReceive('restore');
});
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'assignments.restore',
inputs: [
'restore_run_id' => $restoreRun->id,
'policy_type' => 'deviceConfiguration',
'policy_id' => 'another-policy-id',
],
);
$assignments = [
['target' => ['@odata.type' => '#microsoft.graph.allLicensedUsersAssignmentTarget']],
];
$job = new RestoreAssignmentsJob(
restoreRunId: $restoreRun->id,
tenantId: (int) $tenant->getKey(),
policyType: 'deviceConfiguration',
policyId: 'another-policy-id',
assignments: $assignments,
groupMapping: [],
foundationMapping: [],
actorEmail: 'actor@example.com',
actorName: 'Actor',
operationRun: $operationRun,
);
$job->handle(
app(AssignmentRestoreService::class),
app(OperationRunService::class),
);
$operationRun->refresh();
$failures = is_array($operationRun->failure_summary) ? $operationRun->failure_summary : [];
$reasonCodes = array_column($failures, 'reason_code');
expect($reasonCodes)->toContain('intune_rbac.not_configured');
});

View File

@ -0,0 +1,86 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('rerun action is disabled when rbac_status is null', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->update([
'rbac_status' => null,
'rbac_last_checked_at' => null,
]);
$backupSet = BackupSet::factory()->create(['tenant_id' => $tenant->id]);
$restoreRun = RestoreRun::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'status' => 'completed',
]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListRestoreRuns::class)
->assertTableActionDisabled('rerun', $restoreRun);
});
test('rerun action is enabled when rbac_status is ok and fresh', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->update([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subMinutes(30),
]);
$backupSet = BackupSet::factory()->create(['tenant_id' => $tenant->id]);
$restoreRun = RestoreRun::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'status' => 'completed',
]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListRestoreRuns::class)
->assertTableActionEnabled('rerun', $restoreRun);
});
test('rerun action tooltip contains reason when blocked', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->update([
'rbac_status' => 'not_configured',
'rbac_last_checked_at' => null,
]);
$backupSet = BackupSet::factory()->create(['tenant_id' => $tenant->id]);
$restoreRun = RestoreRun::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'status' => 'completed',
]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListRestoreRuns::class)
->assertTableActionExists(
'rerun',
fn ($action): bool => str_contains((string) $action->getTooltip(), 'not configured'),
$restoreRun
);
});

View File

@ -0,0 +1,45 @@
<?php
use App\Contracts\Hardening\WriteGateInterface;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
test('gate bypasses when disabled and logs warning', function () {
config()->set('tenantpilot.hardening.intune_write_gate.enabled', false);
$tenant = Tenant::factory()->create([
'rbac_status' => null,
'rbac_last_checked_at' => null,
]);
Log::shouldReceive('warning')
->once()
->withArgs(function (string $message, array $context) use ($tenant): bool {
return str_contains($message, 'write gate is disabled')
&& ($context['tenant_id'] ?? null) === $tenant->getKey()
&& ($context['operation_type'] ?? null) === 'restore.execute';
});
$gate = app(WriteGateInterface::class);
// Should not throw even with null rbac_status
$gate->evaluate($tenant, 'restore.execute');
expect(true)->toBeTrue();
});
test('wouldBlock returns false when gate is disabled', function () {
config()->set('tenantpilot.hardening.intune_write_gate.enabled', false);
$tenant = Tenant::factory()->create([
'rbac_status' => null,
'rbac_last_checked_at' => null,
]);
Log::shouldReceive('warning')->once();
expect(app(WriteGateInterface::class)->wouldBlock($tenant))->toBeFalse();
});

View File

@ -0,0 +1,68 @@
<?php
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('tenantpilot.hardening.intune_write_gate.enabled', true);
config()->set('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
});
test('gate blocks when rbac_status is null', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => null,
'rbac_last_checked_at' => null,
]);
$gate = app(WriteGateInterface::class);
expect(fn () => $gate->evaluate($tenant, 'restore.execute'))
->toThrow(ProviderAccessHardeningRequired::class);
try {
$gate->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
expect($e->reasonCode)->toBe('intune_rbac.not_configured')
->and($e->tenantId)->toBe((int) $tenant->getKey())
->and($e->operationType)->toBe('restore.execute');
}
});
test('gate blocks when rbac_status is not_configured', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'not_configured',
'rbac_last_checked_at' => null,
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
$this->fail('Expected ProviderAccessHardeningRequired to be thrown');
} catch (ProviderAccessHardeningRequired $e) {
expect($e->reasonCode)->toBe('intune_rbac.not_configured')
->and($e->tenantId)->toBe((int) $tenant->getKey());
}
});
test('wouldBlock returns true when rbac_status is null', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => null,
'rbac_last_checked_at' => null,
]);
expect(app(WriteGateInterface::class)->wouldBlock($tenant))->toBeTrue();
});
test('wouldBlock returns true when rbac_status is not_configured', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'not_configured',
'rbac_last_checked_at' => null,
]);
expect(app(WriteGateInterface::class)->wouldBlock($tenant))->toBeTrue();
});

View File

@ -0,0 +1,62 @@
<?php
use App\Contracts\Hardening\WriteGateInterface;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('tenantpilot.hardening.intune_write_gate.enabled', true);
config()->set('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
});
test('gate passes when rbac_status is ok and timestamp is fresh', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subHours(1),
]);
$gate = app(WriteGateInterface::class);
// Should not throw
$gate->evaluate($tenant, 'restore.execute');
expect(true)->toBeTrue(); // Reached here without exception
});
test('wouldBlock returns false when rbac_status is ok and fresh', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subMinutes(30),
]);
expect(app(WriteGateInterface::class)->wouldBlock($tenant))->toBeFalse();
});
test('gate passes for configured status with fresh timestamp', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'configured',
'rbac_last_checked_at' => now()->subHours(1),
]);
$gate = app(WriteGateInterface::class);
// Should not throw — 'configured' is not in the blocked list
$gate->evaluate($tenant, 'restore.execute');
expect(true)->toBeTrue();
});
test('gate passes for manual_assignment_required with fresh timestamp', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'manual_assignment_required',
'rbac_last_checked_at' => now()->subHours(1),
]);
$gate = app(WriteGateInterface::class);
$gate->evaluate($tenant, 'restore.execute');
expect(true)->toBeTrue();
});

View File

@ -0,0 +1,80 @@
<?php
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('tenantpilot.hardening.intune_write_gate.enabled', true);
config()->set('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
});
test('gate blocks when rbac_status is ok but rbac_last_checked_at is stale', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subHours(25),
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
$this->fail('Expected ProviderAccessHardeningRequired to be thrown');
} catch (ProviderAccessHardeningRequired $e) {
expect($e->reasonCode)->toBe('intune_rbac.stale')
->and($e->tenantId)->toBe((int) $tenant->getKey());
}
});
test('gate blocks when rbac_status is ok but rbac_last_checked_at is null', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => null,
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
$this->fail('Expected ProviderAccessHardeningRequired to be thrown');
} catch (ProviderAccessHardeningRequired $e) {
expect($e->reasonCode)->toBe('intune_rbac.stale');
}
});
test('gate blocks at exact threshold boundary', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subHours(24),
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
$this->fail('Expected ProviderAccessHardeningRequired to be thrown');
} catch (ProviderAccessHardeningRequired $e) {
expect($e->reasonCode)->toBe('intune_rbac.stale');
}
});
test('gate respects custom freshness threshold', function () {
config()->set('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 1);
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subHours(2),
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
$this->fail('Expected ProviderAccessHardeningRequired to be thrown');
} catch (ProviderAccessHardeningRequired $e) {
expect($e->reasonCode)->toBe('intune_rbac.stale');
}
});

View File

@ -0,0 +1,71 @@
<?php
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('tenantpilot.hardening.intune_write_gate.enabled', true);
config()->set('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
});
test('gate blocks when rbac_status is degraded', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'degraded',
'rbac_last_checked_at' => now(),
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
$this->fail('Expected ProviderAccessHardeningRequired to be thrown');
} catch (ProviderAccessHardeningRequired $e) {
expect($e->reasonCode)->toBe('intune_rbac.unhealthy')
->and($e->tenantId)->toBe((int) $tenant->getKey());
}
});
test('gate blocks when rbac_status is failed', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'failed',
'rbac_last_checked_at' => now(),
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
$this->fail('Expected ProviderAccessHardeningRequired to be thrown');
} catch (ProviderAccessHardeningRequired $e) {
expect($e->reasonCode)->toBe('intune_rbac.unhealthy');
}
});
test('gate blocks when rbac_status is error', function () {
$tenant = Tenant::factory()->create([
'rbac_status' => 'error',
'rbac_last_checked_at' => now(),
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
$this->fail('Expected ProviderAccessHardeningRequired to be thrown');
} catch (ProviderAccessHardeningRequired $e) {
expect($e->reasonCode)->toBe('intune_rbac.unhealthy');
}
});
test('wouldBlock returns true for all unhealthy statuses', function (string $status) {
$tenant = Tenant::factory()->create([
'rbac_status' => $status,
'rbac_last_checked_at' => now(),
]);
expect(app(WriteGateInterface::class)->wouldBlock($tenant))->toBeTrue();
})->with(['degraded', 'failed', 'error', 'missing', 'partial']);

View File

@ -0,0 +1,67 @@
<?php
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('rbac card shows not configured hint when rbac_status is null', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->update([
'rbac_status' => null,
'rbac_last_checked_at' => null,
]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Not configured');
});
test('rbac card shows healthy summary when rbac_status is ok and fresh', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->update([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subMinutes(5),
]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('healthy and up to date');
});
test('rbac card shows unhealthy summary for degraded status', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->update([
'rbac_status' => 'degraded',
'rbac_last_checked_at' => now(),
]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('unhealthy state');
});
test('refresh rbac header action exists on ViewTenant', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionExists('refresh_rbac');
});

View File

@ -10,3 +10,8 @@
expect($label)->toBe('Unknown operation');
expect($label)->not->toContain('some.new_operation');
})->group('ops-ux');
it('renders a label for RBAC health check', function (): void {
expect(OperationCatalog::label('rbac.health_check'))
->toBe('RBAC health check');
})->group('ops-ux');

View File

@ -25,6 +25,8 @@
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
@ -107,6 +109,8 @@
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
@ -208,6 +212,8 @@
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();

View File

@ -24,6 +24,8 @@
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();

View File

@ -25,6 +25,8 @@
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
@ -146,6 +148,8 @@
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
@ -243,6 +247,8 @@
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();

View File

@ -26,6 +26,8 @@
'tenant_id' => 'tenant-idempotency',
'name' => 'Tenant Idempotency',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();

View File

@ -27,6 +27,8 @@
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
@ -101,6 +103,8 @@
'tenant_id' => 'tenant-2',
'name' => 'Tenant Two',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();

View File

@ -22,6 +22,8 @@
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();

View File

@ -0,0 +1,57 @@
<?php
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\Domains\TenantRbacStatusBadge;
test('all rbac status values map to valid badge specs', function (string $status) {
$badge = new TenantRbacStatusBadge;
$spec = $badge->spec($status);
expect($spec)->toBeInstanceOf(BadgeSpec::class)
->and($spec->label)->not->toBeEmpty()
->and($spec->color)->not->toBeEmpty();
})->with([
'null' => 'null',
'not_configured' => 'not_configured',
'ok' => 'ok',
'degraded' => 'degraded',
'failed' => 'failed',
'stale' => 'stale',
]);
test('null status maps to unknown badge', function () {
$badge = new TenantRbacStatusBadge;
$spec = $badge->spec(null);
expect($spec->label)->toBe('Unknown')
->and($spec->color)->toBe('gray');
});
test('stale status has warning color and clock icon', function () {
$badge = new TenantRbacStatusBadge;
$spec = $badge->spec('stale');
expect($spec->label)->toBe('Stale')
->and($spec->color)->toBe('warning')
->and($spec->icon)->toBe('heroicon-m-clock');
});
test('degraded status has warning color and exclamation icon', function () {
$badge = new TenantRbacStatusBadge;
$spec = $badge->spec('degraded');
expect($spec->label)->toBe('Degraded')
->and($spec->color)->toBe('warning')
->and($spec->icon)->toBe('heroicon-m-exclamation-triangle');
});
test('all configured statuses map to specific badges (not unknown)', function () {
$badge = new TenantRbacStatusBadge;
$configuredStatuses = ['not_configured', 'ok', 'configured', 'degraded', 'failed', 'error', 'stale', 'manual_assignment_required'];
foreach ($configuredStatuses as $status) {
$spec = $badge->spec($status);
expect($spec->label)->not->toBe('Unknown', "Status '{$status}' should not fall through to Unknown");
}
});

View File

@ -0,0 +1,87 @@
<?php
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('tenantpilot.hardening.intune_write_gate.enabled', true);
config()->set('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
});
test('gate evaluation with not_configured status makes zero HTTP calls', function () {
Http::fake();
$tenant = Tenant::factory()->create([
'rbac_status' => 'not_configured',
'rbac_last_checked_at' => null,
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired) {
// Expected
}
Http::assertNothingSent();
});
test('gate evaluation with unhealthy status makes zero HTTP calls', function () {
Http::fake();
$tenant = Tenant::factory()->create([
'rbac_status' => 'failed',
'rbac_last_checked_at' => now(),
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired) {
// Expected
}
Http::assertNothingSent();
});
test('gate evaluation with stale status makes zero HTTP calls', function () {
Http::fake();
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subHours(48),
]);
$gate = app(WriteGateInterface::class);
try {
$gate->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired) {
// Expected
}
Http::assertNothingSent();
});
test('gate evaluation with ok fresh status makes zero HTTP calls', function () {
Http::fake();
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now()->subMinutes(30),
]);
$gate = app(WriteGateInterface::class);
// Should pass without exception
$gate->evaluate($tenant, 'restore.execute');
Http::assertNothingSent();
});