TenantAtlas/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php
ahmido d90fb0f963 065-tenant-rbac-v1 (#79)
PR Body
Implements Spec 065 “Tenant RBAC v1” with capabilities-first RBAC, tenant membership scoping (Option 3), and consistent Filament action semantics.

Key decisions / rules

Tenancy Option 3: tenant switching is tenantless (ChooseTenant), tenant-scoped routes stay scoped, non-members get 404 (not 403).
RBAC model: canonical capability registry + role→capability map + Gates for each capability (no role-string checks in UI logic).
UX policy: for tenant members lacking permission → actions are visible but disabled + tooltip (avoid click→403).
Security still enforced server-side.
What’s included

Capabilities foundation:
Central capability registry (Capabilities::*)
Role→capability mapping (RoleCapabilityMap)
Gate registration + resolver/manager updates to support tenant-scoped authorization
Filament enforcement hardening across the app:
Tenant registration & tenant CRUD properly gated
Backup/restore/policy flows aligned to “visible-but-disabled” where applicable
Provider operations (health check / inventory sync / compliance snapshot) guarded and normalized
Directory groups + inventory sync start surfaces normalized
Policy version maintenance actions (archive/restore/prune/force delete) gated
SpecKit artifacts for 065:
spec.md, plan/tasks updates, checklists, enforcement hitlist
Security guarantees

Non-member → 404 via tenant scoping/membership guards.
Member without capability → 403 on execution, even if UI is disabled.
No destructive actions execute without proper authorization checks.
Tests

Adds/updates Pest coverage for:
Tenant scoping & membership denial behavior
Role matrix expectations (owner/manager/operator/readonly)
Filament surface checks (visible/disabled actions, no side effects)
Provider/Inventory/Groups run-start authorization
Verified locally with targeted vendor/bin/sail artisan test --compact …
Deployment / ops notes

No new services required.
Safe change: behavior is authorization + UI semantics; no breaking route changes intended.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #79
2026-01-28 21:09:47 +00:00

103 lines
4.6 KiB
PHP

<?php
namespace App\Filament\Resources\PolicyResource\RelationManagers;
use App\Filament\Resources\RestoreRunResource;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Intune\RestoreService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Gate;
class VersionsRelationManager extends RelationManager
{
protected static string $relationship = 'versions';
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
Tables\Columns\TextColumn::make('policy_type')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('version_number', 'desc')
->filters([])
->headerActions([])
->actions([
Actions\Action::make('restore_to_intune')
->label('Restore to Intune')
->icon('heroicon-o-arrow-path-rounded-square')
->color('danger')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
->visible(fn (): bool => ($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant))
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.')
->form([
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true),
])
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if ($record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
return;
}
try {
$run = $restoreService->executeFromPolicyVersion(
tenant: $tenant,
version: $record,
dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Restore run started')
->success()
->send();
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
}),
Actions\ViewAction::make()
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
])
->bulkActions([]);
}
}