feat: add Intune RBAC inventory and backup support (#155)
## Summary - add Intune RBAC role definitions and role assignments as foundation-backed inventory, backup, and versioned snapshot types - add RBAC-specific normalization, coverage, permission-warning handling, and preview-only restore safety behavior across existing Filament and service surfaces - add spec 127 artifacts, contracts, audits, and focused regression coverage for inventory, backup, versioning, verification, and authorization behavior ## Testing - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Filament/InventoryCoverageTableTest.php tests/Feature/FoundationBackupTest.php tests/Feature/Filament/RestoreExecutionTest.php tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php tests/Unit/GraphContractRegistryTest.php tests/Unit/FoundationSnapshotServiceTest.php tests/Feature/Verification/IntuneRbacPermissionCoverageTest.php tests/Unit/IntuneRoleDefinitionNormalizerTest.php tests/Unit/IntuneRoleAssignmentNormalizerTest.php` ## Notes - tasks in `specs/127-rbac-inventory-backup/tasks.md` are complete except `T041`, which is the documented manual QA validation step Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #155
This commit is contained in:
parent
a490261eca
commit
c6e7591d19
@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
vendor/
|
||||
coverage/
|
||||
.git/
|
||||
|
||||
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -54,6 +54,8 @@ ## Active Technologies
|
||||
- PostgreSQL remains unchanged; this feature is presentation-layer and behavior-layer only (125-table-ux-standardization)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables (126-filter-ux-standardization)
|
||||
- PostgreSQL remains unchanged; session persistence uses Filament-native session keys and existing workspace/tenant contex (126-filter-ux-standardization)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack (127-rbac-inventory-backup)
|
||||
- PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs (127-rbac-inventory-backup)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -73,8 +75,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 127-rbac-inventory-backup: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack
|
||||
- 126-filter-ux-standardization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables
|
||||
- 125-table-ux-standardization: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `BadgeCatalog` / `BadgeRenderer`, existing UI enforcement helpers, existing Filament resources, relation managers, widgets, and Livewire table components
|
||||
- 124-inventory-coverage-table: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog`
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\Tenant;
|
||||
@ -13,7 +14,9 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -21,6 +24,7 @@
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -233,9 +237,12 @@ public function table(Table $table): Table
|
||||
->apply();
|
||||
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with(['policy', 'policyVersion', 'policyVersion.policy']))
|
||||
->defaultSort('policy.display_name')
|
||||
->paginated(TablePaginationProfiles::relationManager())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Item')
|
||||
@ -318,7 +325,19 @@ public function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')->since()->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([])
|
||||
->filters([
|
||||
SelectFilter::make('policy_type')
|
||||
->label('Type')
|
||||
->options(FilterOptionCatalog::policyTypes())
|
||||
->searchable(),
|
||||
SelectFilter::make('restore_mode')
|
||||
->label('Restore')
|
||||
->options(static::restoreModeOptions())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applyRestoreModeFilter($query, $data['value'] ?? null)),
|
||||
SelectFilter::make('platform')
|
||||
->options(FilterOptionCatalog::platforms())
|
||||
->searchable(),
|
||||
])
|
||||
->headerActions([
|
||||
$refreshTable,
|
||||
$addPolicies,
|
||||
@ -326,17 +345,21 @@ public function table(Table $table): Table
|
||||
->actions([
|
||||
Actions\ActionGroup::make([
|
||||
Actions\ViewAction::make()
|
||||
->label('View policy')
|
||||
->label(fn (BackupItem $record): string => $record->policy_version_id ? 'View version' : 'View policy')
|
||||
->url(function (BackupItem $record): ?string {
|
||||
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
|
||||
|
||||
if ($record->policy_version_id) {
|
||||
return PolicyVersionResource::getUrl('view', ['record' => $record->policy_version_id], tenant: $tenant);
|
||||
}
|
||||
|
||||
if (! $record->policy_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
|
||||
|
||||
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
|
||||
})
|
||||
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
||||
->hidden(fn (BackupItem $record) => ! $record->policy_version_id && ! $record->policy_id)
|
||||
->openUrlInNewTab(true),
|
||||
$removeItem,
|
||||
])
|
||||
@ -373,4 +396,42 @@ private static function typeMeta(?string $type): array
|
||||
return collect($types)
|
||||
->firstWhere('type', $type) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function restoreModeOptions(): array
|
||||
{
|
||||
return collect(InventoryPolicyTypeMeta::all())
|
||||
->pluck('restore')
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->map(fn (string $value): string => trim($value))
|
||||
->unique()
|
||||
->sort()
|
||||
->mapWithKeys(fn (string $value): array => [
|
||||
$value => BadgeRenderer::spec(BadgeDomain::PolicyRestoreMode, $value)->label,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function applyRestoreModeFilter(Builder $query, mixed $value): Builder
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$types = collect(InventoryPolicyTypeMeta::all())
|
||||
->filter(fn (array $meta): bool => ($meta['restore'] ?? null) === $value)
|
||||
->pluck('type')
|
||||
->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
||||
->map(fn (string $type): string => trim($type))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($types === []) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereIn('policy_type', $types);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,10 +5,12 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource\Pages;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -23,6 +25,7 @@
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -134,6 +137,9 @@ public static function table(Table $table): Table
|
||||
return $table
|
||||
->defaultSort('captured_at', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('Snapshot')
|
||||
@ -142,6 +148,7 @@ public static function table(Table $table): Table
|
||||
TextColumn::make('baselineProfile.name')
|
||||
->label('Baseline')
|
||||
->wrap()
|
||||
->searchable()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('captured_at')
|
||||
->label('Captured')
|
||||
@ -157,6 +164,17 @@ public static function table(Table $table): Table
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('baseline_profile_id')
|
||||
->label('Baseline')
|
||||
->options(static::baselineProfileOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('snapshot_state')
|
||||
->label('State')
|
||||
->options(static::snapshotStateOptions())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
|
||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make()->label('View'),
|
||||
])
|
||||
@ -218,6 +236,35 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function baselineProfileOptions(): array
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return BaselineProfile::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function snapshotStateOptions(): array
|
||||
{
|
||||
return [
|
||||
'complete' => 'Complete',
|
||||
'with_gaps' => 'Captured with gaps',
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolveWorkspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
@ -276,4 +323,28 @@ private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';
|
||||
}
|
||||
|
||||
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$gapCountExpression = self::gapCountExpression($query);
|
||||
|
||||
return match ($value) {
|
||||
'complete' => $query->whereRaw("{$gapCountExpression} = 0"),
|
||||
'with_gaps' => $query->whereRaw("{$gapCountExpression} > 0"),
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
|
||||
private static function gapCountExpression(Builder $query): string
|
||||
{
|
||||
return match ($query->getConnection()->getDriverName()) {
|
||||
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0)",
|
||||
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0)",
|
||||
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS UNSIGNED), 0)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,7 +92,9 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'),
|
||||
Infolists\Components\TextEntry::make('policy.display_name')
|
||||
->label('Policy')
|
||||
->state(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||
Infolists\Components\TextEntry::make('version_number')->label('Version'),
|
||||
Infolists\Components\TextEntry::make('policy_type')
|
||||
->badge()
|
||||
@ -488,7 +490,11 @@ public static function table(Table $table): Table
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Policy')
|
||||
->sortable()
|
||||
->searchable()
|
||||
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->badge()
|
||||
@ -890,4 +896,19 @@ public static function getPages(): array
|
||||
'view' => Pages\ViewPolicyVersion::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolvedDisplayName(PolicyVersion $record): string
|
||||
{
|
||||
$snapshot = is_array($record->snapshot) ? $record->snapshot : [];
|
||||
$displayName = $snapshot['displayName']
|
||||
?? $snapshot['name']
|
||||
?? $record->policy?->display_name
|
||||
?? null;
|
||||
|
||||
if (is_string($displayName) && $displayName !== '') {
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
return sprintf('Version %d', (int) $record->version_number);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,11 +7,13 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\FoundationSnapshotService;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\Intune\SnapshotValidator;
|
||||
use App\Services\Intune\VersionService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@ -57,6 +59,7 @@ public function handle(
|
||||
PolicyCaptureOrchestrator $captureOrchestrator,
|
||||
FoundationSnapshotService $foundationSnapshots,
|
||||
SnapshotValidator $snapshotValidator,
|
||||
VersionService $versionService,
|
||||
): void {
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
return;
|
||||
@ -400,8 +403,12 @@ public function handle(
|
||||
if ($includeFoundations) {
|
||||
[$foundationOutcome, $foundationFailureEntries] = $this->captureFoundations(
|
||||
foundationSnapshots: $foundationSnapshots,
|
||||
snapshotValidator: $snapshotValidator,
|
||||
versionService: $versionService,
|
||||
operationRunService: $operationRunService,
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
createdBy: $initiator?->email ? Str::limit((string) $initiator->email, 255, '') : null,
|
||||
);
|
||||
|
||||
if (($foundationOutcome['created'] ?? 0) > 0 || ($foundationOutcome['restored'] ?? 0) > 0) {
|
||||
@ -527,8 +534,12 @@ private function mapGraphFailureReasonCode(?int $status): string
|
||||
*/
|
||||
private function captureFoundations(
|
||||
FoundationSnapshotService $foundationSnapshots,
|
||||
SnapshotValidator $snapshotValidator,
|
||||
VersionService $versionService,
|
||||
OperationRunService $operationRunService,
|
||||
Tenant $tenant,
|
||||
BackupSet $backupSet,
|
||||
?string $createdBy = null,
|
||||
): array {
|
||||
$types = config('tenantpilot.foundation_types', []);
|
||||
$created = 0;
|
||||
@ -559,6 +570,13 @@ private function captureFoundations(
|
||||
'status' => $status,
|
||||
'reason_code' => $reasonCode,
|
||||
];
|
||||
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'total' => 1,
|
||||
'items' => 1,
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach (($result['items'] ?? []) as $snapshot) {
|
||||
@ -582,23 +600,87 @@ private function captureFoundations(
|
||||
if ($existing->trashed()) {
|
||||
$existing->restore();
|
||||
$restored++;
|
||||
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'total' => 1,
|
||||
'items' => 1,
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'updated' => 1,
|
||||
]);
|
||||
} else {
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'total' => 1,
|
||||
'items' => 1,
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
BackupItem::create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'backup_set_id' => $backupSet->getKey(),
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => $sourceId,
|
||||
'policy_type' => $foundationType,
|
||||
'platform' => $typeConfig['platform'] ?? null,
|
||||
'payload' => $snapshot['payload'] ?? [],
|
||||
'metadata' => $snapshot['metadata'] ?? [],
|
||||
]);
|
||||
$payload = is_array($snapshot['payload'] ?? null) ? $snapshot['payload'] : [];
|
||||
$metadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : [];
|
||||
$platform = is_string($typeConfig['platform'] ?? null) ? $typeConfig['platform'] : null;
|
||||
|
||||
if ($this->supportsFoundationVersioning($foundationType)) {
|
||||
$policy = $this->resolveFoundationPolicy(
|
||||
tenant: $tenant,
|
||||
foundationType: $foundationType,
|
||||
sourceId: $sourceId,
|
||||
platform: $platform,
|
||||
payload: $payload,
|
||||
metadata: $metadata,
|
||||
);
|
||||
|
||||
$version = $versionService->captureFoundationVersion(
|
||||
policy: $policy,
|
||||
payload: $payload,
|
||||
createdBy: $createdBy,
|
||||
metadata: array_merge(
|
||||
$metadata,
|
||||
[
|
||||
'foundation_type' => $foundationType,
|
||||
'source' => 'backup_foundation',
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
$this->createFoundationBackupItem(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
snapshotValidator: $snapshotValidator,
|
||||
foundationType: $foundationType,
|
||||
platform: $platform,
|
||||
sourceId: $sourceId,
|
||||
payload: $payload,
|
||||
metadata: $metadata,
|
||||
policy: $policy,
|
||||
version: $version,
|
||||
);
|
||||
} else {
|
||||
$this->createFoundationBackupItem(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
snapshotValidator: $snapshotValidator,
|
||||
foundationType: $foundationType,
|
||||
platform: $platform,
|
||||
sourceId: $sourceId,
|
||||
payload: $payload,
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
|
||||
$created++;
|
||||
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'total' => 1,
|
||||
'items' => 1,
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'created' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -611,4 +693,95 @@ private function captureFoundations(
|
||||
$failures,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
private function createFoundationBackupItem(
|
||||
Tenant $tenant,
|
||||
BackupSet $backupSet,
|
||||
SnapshotValidator $snapshotValidator,
|
||||
string $foundationType,
|
||||
?string $platform,
|
||||
string $sourceId,
|
||||
array $payload,
|
||||
array $metadata,
|
||||
?Policy $policy = null,
|
||||
?PolicyVersion $version = null,
|
||||
): BackupItem {
|
||||
$warnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : [];
|
||||
$validation = $snapshotValidator->validate($payload);
|
||||
$warnings = array_merge($warnings, $validation['warnings']);
|
||||
|
||||
$odataWarning = BackupItem::odataTypeWarning($payload, $foundationType, $platform);
|
||||
if ($odataWarning !== null) {
|
||||
$warnings[] = $odataWarning;
|
||||
}
|
||||
|
||||
if ($warnings !== []) {
|
||||
$metadata['warnings'] = array_values(array_unique($warnings));
|
||||
}
|
||||
|
||||
return BackupItem::create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'backup_set_id' => $backupSet->getKey(),
|
||||
'policy_id' => $policy?->getKey(),
|
||||
'policy_version_id' => $version?->getKey(),
|
||||
'policy_identifier' => $sourceId,
|
||||
'policy_type' => $foundationType,
|
||||
'platform' => $platform,
|
||||
'payload' => $payload,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
private function supportsFoundationVersioning(string $foundationType): bool
|
||||
{
|
||||
return in_array($foundationType, ['intuneRoleDefinition', 'intuneRoleAssignment'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
private function resolveFoundationPolicy(
|
||||
Tenant $tenant,
|
||||
string $foundationType,
|
||||
string $sourceId,
|
||||
?string $platform,
|
||||
array $payload,
|
||||
array $metadata,
|
||||
): Policy {
|
||||
$displayName = $metadata['displayName']
|
||||
?? $metadata['display_name']
|
||||
?? $payload['displayName']
|
||||
?? $payload['name']
|
||||
?? $sourceId;
|
||||
|
||||
$policy = Policy::query()->firstOrNew([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => $sourceId,
|
||||
'policy_type' => $foundationType,
|
||||
]);
|
||||
|
||||
$existingMetadata = is_array($policy->metadata) ? $policy->metadata : [];
|
||||
|
||||
$policy->fill([
|
||||
'platform' => $platform,
|
||||
'display_name' => is_string($displayName) && $displayName !== '' ? $displayName : $sourceId,
|
||||
'last_synced_at' => null,
|
||||
'metadata' => array_merge(
|
||||
$existingMetadata,
|
||||
[
|
||||
'foundation_anchor' => true,
|
||||
'foundation_type' => $foundationType,
|
||||
'capture_mode' => 'immutable_backup',
|
||||
],
|
||||
),
|
||||
]);
|
||||
$policy->save();
|
||||
|
||||
return $policy;
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,16 +95,16 @@ public function isFoundation(): bool
|
||||
|
||||
public function resolvedDisplayName(): string
|
||||
{
|
||||
if ($this->policy) {
|
||||
return $this->policy->display_name;
|
||||
}
|
||||
|
||||
$metadata = $this->metadata ?? [];
|
||||
$payload = is_array($this->payload) ? $this->payload : [];
|
||||
$versionSnapshot = is_array($this->policyVersion?->snapshot) ? $this->policyVersion->snapshot : [];
|
||||
$name = $metadata['displayName']
|
||||
?? $metadata['display_name']
|
||||
?? $payload['displayName']
|
||||
?? $payload['name']
|
||||
?? $versionSnapshot['displayName']
|
||||
?? $versionSnapshot['name']
|
||||
?? $this->policy?->display_name
|
||||
?? null;
|
||||
|
||||
if (is_string($name) && $name !== '') {
|
||||
|
||||
@ -25,6 +25,8 @@
|
||||
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
||||
use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer;
|
||||
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
|
||||
use App\Services\Intune\IntuneRoleAssignmentNormalizer;
|
||||
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
||||
use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer;
|
||||
use App\Services\Intune\ScriptsPolicyNormalizer;
|
||||
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
||||
@ -85,6 +87,8 @@ public function register(): void
|
||||
DeviceConfigurationPolicyNormalizer::class,
|
||||
EnrollmentAutopilotPolicyNormalizer::class,
|
||||
GroupPolicyConfigurationNormalizer::class,
|
||||
IntuneRoleAssignmentNormalizer::class,
|
||||
IntuneRoleDefinitionNormalizer::class,
|
||||
ManagedDeviceAppConfigurationNormalizer::class,
|
||||
ScriptsPolicyNormalizer::class,
|
||||
SettingsCatalogPolicyNormalizer::class,
|
||||
|
||||
@ -54,6 +54,23 @@ public function directoryRoleDefinitionsListPath(): string
|
||||
return '/'.ltrim($resource, '/');
|
||||
}
|
||||
|
||||
public function intuneRoleDefinitionPolicyType(): string
|
||||
{
|
||||
return 'intuneRoleDefinition';
|
||||
}
|
||||
|
||||
public function intuneRoleDefinitionListPath(): string
|
||||
{
|
||||
$resource = $this->resourcePath($this->intuneRoleDefinitionPolicyType()) ?? 'deviceManagement/roleDefinitions';
|
||||
|
||||
return '/'.ltrim($resource, '/');
|
||||
}
|
||||
|
||||
public function intuneRoleDefinitionItemPath(string $definitionId): string
|
||||
{
|
||||
return sprintf('%s/%s', $this->intuneRoleDefinitionListPath(), urlencode($definitionId));
|
||||
}
|
||||
|
||||
public function configurationPolicyTemplatePolicyType(): string
|
||||
{
|
||||
return 'configurationPolicyTemplate';
|
||||
@ -137,6 +154,23 @@ public function rbacRoleAssignmentItemPath(string $assignmentId): string
|
||||
return sprintf('%s/%s', $this->rbacRoleAssignmentListPath(), urlencode($assignmentId));
|
||||
}
|
||||
|
||||
public function intuneRoleAssignmentPolicyType(): string
|
||||
{
|
||||
return 'intuneRoleAssignment';
|
||||
}
|
||||
|
||||
public function intuneRoleAssignmentListPath(): string
|
||||
{
|
||||
$resource = $this->resourcePath($this->intuneRoleAssignmentPolicyType()) ?? 'deviceManagement/roleAssignments';
|
||||
|
||||
return '/'.ltrim($resource, '/');
|
||||
}
|
||||
|
||||
public function intuneRoleAssignmentItemPath(string $assignmentId): string
|
||||
{
|
||||
return sprintf('%s/%s', $this->intuneRoleAssignmentListPath(), urlencode($assignmentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@ -364,6 +364,47 @@ private function createBackupItemFromVersion(
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
private function createFoundationBackupItem(
|
||||
Tenant $tenant,
|
||||
BackupSet $backupSet,
|
||||
string $foundationType,
|
||||
?string $platform,
|
||||
string $sourceId,
|
||||
array $payload,
|
||||
array $metadata,
|
||||
?Policy $policy = null,
|
||||
?PolicyVersion $version = null,
|
||||
): BackupItem {
|
||||
$warnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : [];
|
||||
$validation = $this->snapshotValidator->validate($payload);
|
||||
$warnings = array_merge($warnings, $validation['warnings']);
|
||||
|
||||
$odataWarning = BackupItem::odataTypeWarning($payload, $foundationType, $platform);
|
||||
if ($odataWarning !== null) {
|
||||
$warnings[] = $odataWarning;
|
||||
}
|
||||
|
||||
if ($warnings !== []) {
|
||||
$metadata['warnings'] = array_values(array_unique($warnings));
|
||||
}
|
||||
|
||||
return BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy?->getKey(),
|
||||
'policy_version_id' => $version?->getKey(),
|
||||
'policy_identifier' => $sourceId,
|
||||
'policy_type' => $foundationType,
|
||||
'platform' => $platform,
|
||||
'payload' => $payload,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{created:int,restored:int,failures:array<int,array{foundation_type:string,reason:string,status:int|string|null}>}
|
||||
*/
|
||||
@ -406,16 +447,54 @@ private function captureFoundations(Tenant $tenant, BackupSet $backupSet): array
|
||||
continue;
|
||||
}
|
||||
|
||||
BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => $sourceId,
|
||||
'policy_type' => $foundationType,
|
||||
'platform' => $typeConfig['platform'] ?? null,
|
||||
'payload' => $snapshot['payload'],
|
||||
'metadata' => $snapshot['metadata'] ?? [],
|
||||
]);
|
||||
$payload = is_array($snapshot['payload'] ?? null) ? $snapshot['payload'] : [];
|
||||
$metadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : [];
|
||||
$platform = is_string($typeConfig['platform'] ?? null) ? $typeConfig['platform'] : null;
|
||||
|
||||
if ($this->supportsFoundationVersioning($foundationType)) {
|
||||
$policy = $this->resolveFoundationPolicy(
|
||||
tenant: $tenant,
|
||||
foundationType: $foundationType,
|
||||
sourceId: $sourceId,
|
||||
platform: $platform,
|
||||
payload: $payload,
|
||||
metadata: $metadata,
|
||||
);
|
||||
|
||||
$version = $this->versionService->captureFoundationVersion(
|
||||
policy: $policy,
|
||||
payload: $payload,
|
||||
metadata: array_merge(
|
||||
$metadata,
|
||||
[
|
||||
'foundation_type' => $foundationType,
|
||||
'source' => 'backup_foundation',
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
$this->createFoundationBackupItem(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
foundationType: $foundationType,
|
||||
platform: $platform,
|
||||
sourceId: $sourceId,
|
||||
payload: $payload,
|
||||
metadata: $metadata,
|
||||
policy: $policy,
|
||||
version: $version,
|
||||
);
|
||||
} else {
|
||||
$this->createFoundationBackupItem(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
foundationType: $foundationType,
|
||||
platform: $platform,
|
||||
sourceId: $sourceId,
|
||||
payload: $payload,
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
|
||||
$created++;
|
||||
}
|
||||
@ -428,6 +507,55 @@ private function captureFoundations(Tenant $tenant, BackupSet $backupSet): array
|
||||
];
|
||||
}
|
||||
|
||||
private function supportsFoundationVersioning(string $foundationType): bool
|
||||
{
|
||||
return in_array($foundationType, ['intuneRoleDefinition', 'intuneRoleAssignment'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
private function resolveFoundationPolicy(
|
||||
Tenant $tenant,
|
||||
string $foundationType,
|
||||
string $sourceId,
|
||||
?string $platform,
|
||||
array $payload,
|
||||
array $metadata,
|
||||
): Policy {
|
||||
$displayName = $metadata['displayName']
|
||||
?? $metadata['display_name']
|
||||
?? $payload['displayName']
|
||||
?? $payload['name']
|
||||
?? $sourceId;
|
||||
|
||||
$policy = Policy::query()->firstOrNew([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => $sourceId,
|
||||
'policy_type' => $foundationType,
|
||||
]);
|
||||
|
||||
$existingMetadata = is_array($policy->metadata) ? $policy->metadata : [];
|
||||
|
||||
$policy->fill([
|
||||
'platform' => $platform,
|
||||
'display_name' => is_string($displayName) && $displayName !== '' ? $displayName : $sourceId,
|
||||
'last_synced_at' => null,
|
||||
'metadata' => array_merge(
|
||||
$existingMetadata,
|
||||
[
|
||||
'foundation_anchor' => true,
|
||||
'foundation_type' => $foundationType,
|
||||
'capture_mode' => 'immutable_backup',
|
||||
],
|
||||
),
|
||||
]);
|
||||
$policy->save();
|
||||
|
||||
return $policy;
|
||||
}
|
||||
|
||||
private function assertActiveTenant(Tenant $tenant): void
|
||||
{
|
||||
if (! $tenant->isActive()) {
|
||||
|
||||
@ -40,6 +40,10 @@ public function fetchAll(Tenant $tenant, string $foundationType): array
|
||||
$query['$select'] = $contract['allowed_select'];
|
||||
}
|
||||
|
||||
if (! empty($contract['allowed_expand']) && is_array($contract['allowed_expand'])) {
|
||||
$query['$expand'] = $contract['allowed_expand'];
|
||||
}
|
||||
|
||||
$sanitized = $this->contracts->sanitizeQuery($foundationType, $query);
|
||||
$options = $this->graphOptionsResolver->resolveForTenant($tenant);
|
||||
$items = [];
|
||||
|
||||
248
app/Services/Intune/IntuneRoleAssignmentNormalizer.php
Normal file
248
app/Services/Intune/IntuneRoleAssignmentNormalizer.php
Normal file
@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class IntuneRoleAssignmentNormalizer implements PolicyTypeNormalizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
||||
) {}
|
||||
|
||||
public function supports(string $policyType): bool
|
||||
{
|
||||
return $policyType === 'intuneRoleAssignment';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, settings: array<int, array<string, mixed>>, warnings: array<int, string>}
|
||||
*/
|
||||
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$snapshot = is_array($snapshot) ? $snapshot : [];
|
||||
|
||||
if ($snapshot === []) {
|
||||
return [
|
||||
'status' => 'warning',
|
||||
'settings' => [],
|
||||
'warnings' => ['No snapshot available.'],
|
||||
];
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
$settings = [];
|
||||
$roleDefinition = Arr::get($snapshot, 'roleDefinition');
|
||||
$roleDefinition = is_array($roleDefinition) ? $roleDefinition : [];
|
||||
|
||||
$roleDefinitionDisplay = $this->formatRoleDefinition($roleDefinition);
|
||||
if ($roleDefinitionDisplay === null) {
|
||||
$warnings[] = 'Role definition details were not expanded; using identifier fallback where possible.';
|
||||
} elseif (($roleDefinition['displayName'] ?? null) === null && ($roleDefinition['id'] ?? null) !== null) {
|
||||
$warnings[] = 'Role definition display name unavailable; using identifier fallback.';
|
||||
}
|
||||
|
||||
$members = $this->normalizeSubjects(Arr::get($snapshot, 'members', []));
|
||||
$scopeMembers = $this->normalizeSubjects(Arr::get($snapshot, 'scopeMembers', []));
|
||||
$resourceScopes = $this->normalizedStringList(Arr::get($snapshot, 'resourceScopes', []));
|
||||
|
||||
$summaryEntries = [];
|
||||
$this->pushEntry($summaryEntries, 'Assignment name', Arr::get($snapshot, 'displayName'));
|
||||
$this->pushEntry($summaryEntries, 'Description', Arr::get($snapshot, 'description'));
|
||||
$this->pushEntry($summaryEntries, 'Role definition', $roleDefinitionDisplay);
|
||||
$this->pushEntry($summaryEntries, 'Scope type', Arr::get($snapshot, 'scopeType'));
|
||||
$this->pushEntry($summaryEntries, 'Members count', count($members));
|
||||
$this->pushEntry($summaryEntries, 'Scope members count', count($scopeMembers));
|
||||
$this->pushEntry($summaryEntries, 'Resource scopes count', count($resourceScopes));
|
||||
|
||||
if ($summaryEntries !== []) {
|
||||
$settings[] = [
|
||||
'type' => 'keyValue',
|
||||
'title' => 'Role assignment',
|
||||
'entries' => $summaryEntries,
|
||||
];
|
||||
}
|
||||
|
||||
if ($members !== []) {
|
||||
$settings[] = [
|
||||
'type' => 'keyValue',
|
||||
'title' => 'Members',
|
||||
'entries' => [
|
||||
[
|
||||
'key' => 'Resolved members',
|
||||
'value' => $members,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($scopeMembers !== []) {
|
||||
$settings[] = [
|
||||
'type' => 'keyValue',
|
||||
'title' => 'Scope members',
|
||||
'entries' => [
|
||||
[
|
||||
'key' => 'Resolved scope members',
|
||||
'value' => $scopeMembers,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($resourceScopes !== []) {
|
||||
$settings[] = [
|
||||
'type' => 'keyValue',
|
||||
'title' => 'Resource scopes',
|
||||
'entries' => [
|
||||
[
|
||||
'key' => 'Scopes',
|
||||
'value' => $resourceScopes,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($settings === []) {
|
||||
return [
|
||||
'status' => 'warning',
|
||||
'settings' => [],
|
||||
'warnings' => array_values(array_unique(array_merge($warnings, ['Role assignment snapshot contains no readable fields.']))),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $warnings === [] ? 'ok' : 'warning',
|
||||
'settings' => $settings,
|
||||
'warnings' => array_values(array_unique($warnings)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
return $this->defaultNormalizer->flattenNormalizedForDiff(
|
||||
$this->normalize($snapshot, $policyType, $platform),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{key: string, value: mixed}> $entries
|
||||
*/
|
||||
private function pushEntry(array &$entries, string $key, mixed $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value) && $value === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatRoleDefinition(array $roleDefinition): ?string
|
||||
{
|
||||
$displayName = Arr::get($roleDefinition, 'displayName');
|
||||
$id = Arr::get($roleDefinition, 'id');
|
||||
|
||||
if (is_string($displayName) && $displayName !== '' && is_string($id) && $id !== '') {
|
||||
return sprintf('%s (%s)', $displayName, $id);
|
||||
}
|
||||
|
||||
if (is_string($displayName) && $displayName !== '') {
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
if (is_string($id) && $id !== '') {
|
||||
return $id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeSubjects(mixed $subjects): array
|
||||
{
|
||||
if (! is_array($subjects)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($subjects as $subject) {
|
||||
if (is_string($subject) && $subject !== '') {
|
||||
$normalized[] = $subject;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_array($subject)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$display = Arr::get($subject, 'displayName')
|
||||
?? Arr::get($subject, 'userPrincipalName')
|
||||
?? Arr::get($subject, 'mail')
|
||||
?? Arr::get($subject, 'name');
|
||||
$identifier = Arr::get($subject, 'id')
|
||||
?? Arr::get($subject, 'principalId')
|
||||
?? Arr::get($subject, 'groupId');
|
||||
|
||||
if (is_string($display) && $display !== '' && is_string($identifier) && $identifier !== '' && $display !== $identifier) {
|
||||
$normalized[] = sprintf('%s (%s)', $display, $identifier);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($display) && $display !== '') {
|
||||
$normalized[] = $display;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($identifier) && $identifier !== '') {
|
||||
$normalized[] = $identifier;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizedStringList(mixed $values): array
|
||||
{
|
||||
if (! is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = array_values(array_filter(
|
||||
array_map(
|
||||
static fn (mixed $value): ?string => is_string($value) && $value !== '' ? $value : null,
|
||||
$values,
|
||||
),
|
||||
static fn (?string $value): bool => $value !== null,
|
||||
));
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
206
app/Services/Intune/IntuneRoleDefinitionNormalizer.php
Normal file
206
app/Services/Intune/IntuneRoleDefinitionNormalizer.php
Normal file
@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class IntuneRoleDefinitionNormalizer implements PolicyTypeNormalizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
||||
) {}
|
||||
|
||||
public function supports(string $policyType): bool
|
||||
{
|
||||
return $policyType === 'intuneRoleDefinition';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, settings: array<int, array<string, mixed>>, warnings: array<int, string>}
|
||||
*/
|
||||
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$snapshot = is_array($snapshot) ? $snapshot : [];
|
||||
|
||||
if ($snapshot === []) {
|
||||
return [
|
||||
'status' => 'warning',
|
||||
'settings' => [],
|
||||
'warnings' => ['No snapshot available.'],
|
||||
];
|
||||
}
|
||||
|
||||
$settings = [];
|
||||
$warnings = [];
|
||||
$summaryEntries = [];
|
||||
|
||||
$this->pushEntry($summaryEntries, 'Display name', Arr::get($snapshot, 'displayName'));
|
||||
$this->pushEntry($summaryEntries, 'Description', Arr::get($snapshot, 'description'));
|
||||
|
||||
$isBuiltIn = Arr::get($snapshot, 'isBuiltIn');
|
||||
if (is_bool($isBuiltIn)) {
|
||||
$this->pushEntry($summaryEntries, 'Role source', $isBuiltIn ? 'Built-in' : 'Custom');
|
||||
}
|
||||
|
||||
$permissionBlocks = $this->normalizePermissionBlocks(Arr::get($snapshot, 'rolePermissions', []));
|
||||
$this->pushEntry($summaryEntries, 'Permission blocks', count($permissionBlocks));
|
||||
|
||||
$scopeTagIds = $this->normalizedStringList(Arr::get($snapshot, 'roleScopeTagIds', []));
|
||||
$this->pushEntry($summaryEntries, 'Scope tag IDs', $scopeTagIds);
|
||||
|
||||
if ($summaryEntries !== []) {
|
||||
$settings[] = [
|
||||
'type' => 'keyValue',
|
||||
'title' => 'Role definition',
|
||||
'entries' => $summaryEntries,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($permissionBlocks as $index => $block) {
|
||||
$entries = [];
|
||||
$this->pushEntry($entries, 'Allowed actions', $block['allowed']);
|
||||
$this->pushEntry($entries, 'Denied actions', $block['denied']);
|
||||
$this->pushEntry($entries, 'Conditions', $block['conditions']);
|
||||
|
||||
if ($entries === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$settings[] = [
|
||||
'type' => 'keyValue',
|
||||
'title' => sprintf('Permission block %d', $index + 1),
|
||||
'entries' => $entries,
|
||||
];
|
||||
}
|
||||
|
||||
if ($permissionBlocks === []) {
|
||||
$warnings[] = 'Role definition contains no expanded permission blocks.';
|
||||
}
|
||||
|
||||
if ($settings === []) {
|
||||
return [
|
||||
'status' => 'warning',
|
||||
'settings' => [],
|
||||
'warnings' => array_values(array_unique(array_merge($warnings, ['Role definition snapshot contains no readable fields.']))),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $warnings === [] ? 'ok' : 'warning',
|
||||
'settings' => $settings,
|
||||
'warnings' => array_values(array_unique($warnings)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
return $this->defaultNormalizer->flattenNormalizedForDiff(
|
||||
$this->normalize($snapshot, $policyType, $platform),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{allowed: array<int, string>, denied: array<int, string>, conditions: array<int, string>, fingerprint: string}>
|
||||
*/
|
||||
private function normalizePermissionBlocks(mixed $rolePermissions): array
|
||||
{
|
||||
if (! is_array($rolePermissions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($rolePermissions as $permissionBlock) {
|
||||
if (! is_array($permissionBlock)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resourceActions = $permissionBlock['resourceActions'] ?? null;
|
||||
$resourceActions = is_array($resourceActions) ? $resourceActions : [];
|
||||
|
||||
$allowed = [];
|
||||
$denied = [];
|
||||
$conditions = [];
|
||||
|
||||
foreach ($resourceActions as $resourceAction) {
|
||||
if (! is_array($resourceAction)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$allowed = array_merge($allowed, $this->normalizedStringList($resourceAction['allowedResourceActions'] ?? []));
|
||||
$denied = array_merge($denied, $this->normalizedStringList($resourceAction['notAllowedResourceActions'] ?? []));
|
||||
$conditions = array_merge($conditions, $this->normalizedStringList([$resourceAction['condition'] ?? null]));
|
||||
}
|
||||
|
||||
$allowed = array_values(array_unique($allowed));
|
||||
sort($allowed);
|
||||
|
||||
$denied = array_values(array_unique($denied));
|
||||
sort($denied);
|
||||
|
||||
$conditions = array_values(array_unique($conditions));
|
||||
sort($conditions);
|
||||
|
||||
$block = [
|
||||
'allowed' => $allowed,
|
||||
'denied' => $denied,
|
||||
'conditions' => $conditions,
|
||||
];
|
||||
|
||||
$block['fingerprint'] = hash(
|
||||
'sha256',
|
||||
json_encode($block, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
|
||||
$normalized[] = $block;
|
||||
}
|
||||
|
||||
usort($normalized, fn (array $left, array $right): int => strcmp($left['fingerprint'], $right['fingerprint']));
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{key: string, value: mixed}> $entries
|
||||
*/
|
||||
private function pushEntry(array &$entries, string $key, mixed $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value) && $value === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizedStringList(mixed $values): array
|
||||
{
|
||||
if (! is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(
|
||||
static fn (mixed $value): ?string => is_string($value) && $value !== '' ? $value : null,
|
||||
$values,
|
||||
),
|
||||
static fn (?string $value): bool => $value !== null,
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -42,7 +42,7 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem
|
||||
|
||||
$results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
|
||||
$results[] = $this->checkMetadataOnlySnapshots($policyItems);
|
||||
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
|
||||
$results[] = $this->checkPreviewOnlyPolicies($items);
|
||||
$results[] = $this->checkEndpointSecurityTemplates($tenant, $policyItems);
|
||||
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
|
||||
$results[] = $this->checkStalePolicies($tenant, $policyItems);
|
||||
@ -190,14 +190,14 @@ private function checkOrphanedGroups(Tenant $tenant, Collection $policyItems, ar
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BackupItem> $policyItems
|
||||
* @param Collection<int, BackupItem> $items
|
||||
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||
*/
|
||||
private function checkPreviewOnlyPolicies(Collection $policyItems): ?array
|
||||
private function checkPreviewOnlyPolicies(Collection $items): ?array
|
||||
{
|
||||
$byType = [];
|
||||
|
||||
foreach ($policyItems as $item) {
|
||||
foreach ($items as $item) {
|
||||
$restoreMode = $this->resolveRestoreMode($item->policy_type);
|
||||
|
||||
if ($restoreMode !== 'preview-only') {
|
||||
|
||||
@ -60,6 +60,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||
|
||||
[$foundationItems, $policyItems] = $this->splitItems($items);
|
||||
[$previewOnlyFoundationItems, $restorableFoundationItems] = $this->splitPreviewOnlyFoundationItems($foundationItems);
|
||||
|
||||
$notificationTemplateIds = $foundationItems
|
||||
->where('policy_type', 'notificationMessageTemplate')
|
||||
@ -68,7 +69,10 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$foundationPreview = $this->foundationMappingService->map($tenant, $foundationItems, false)['entries'] ?? [];
|
||||
$foundationPreview = array_merge(
|
||||
$this->previewOnlyFoundationEntries($previewOnlyFoundationItems, true),
|
||||
$this->foundationMappingService->map($tenant, $restorableFoundationItems, false)['entries'] ?? [],
|
||||
);
|
||||
|
||||
$policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant, $notificationTemplateIds) {
|
||||
$existing = Policy::query()
|
||||
@ -300,6 +304,7 @@ public function execute(
|
||||
|
||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||
[$foundationItems, $policyItems] = $this->splitItems($items);
|
||||
[$previewOnlyFoundationItems, $restorableFoundationItems] = $this->splitPreviewOnlyFoundationItems($foundationItems);
|
||||
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
|
||||
|
||||
$wizardMetadata = [
|
||||
@ -368,10 +373,12 @@ public function execute(
|
||||
);
|
||||
}
|
||||
|
||||
$foundationOutcome = $this->foundationMappingService->map($tenant, $foundationItems, ! $dryRun);
|
||||
$foundationOutcome = $this->foundationMappingService->map($tenant, $restorableFoundationItems, ! $dryRun);
|
||||
$foundationEntries = $foundationOutcome['entries'] ?? [];
|
||||
$foundationFailures = (int) ($foundationOutcome['failed'] ?? 0);
|
||||
$foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0);
|
||||
$previewOnlyFoundationEntries = $this->previewOnlyFoundationEntries($previewOnlyFoundationItems, $dryRun);
|
||||
$foundationSkipped += count($previewOnlyFoundationEntries);
|
||||
|
||||
$foundationBackupItemIdsBySourceId = $foundationItems
|
||||
->pluck('id', 'policy_identifier')
|
||||
@ -392,6 +399,7 @@ public function execute(
|
||||
|
||||
return $entry;
|
||||
}, $foundationEntries));
|
||||
$foundationEntries = array_values(array_merge($previewOnlyFoundationEntries, $foundationEntries));
|
||||
$foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries);
|
||||
$scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? [];
|
||||
$scopeTagNamesById = $this->buildScopeTagNameLookup($foundationEntries);
|
||||
@ -1134,6 +1142,45 @@ private function splitItems(Collection $items): array
|
||||
return [$foundationItems, $policyItems];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BackupItem> $foundationItems
|
||||
* @return array{0: Collection<int, BackupItem>, 1: Collection<int, BackupItem>}
|
||||
*/
|
||||
private function splitPreviewOnlyFoundationItems(Collection $foundationItems): array
|
||||
{
|
||||
$previewOnly = $foundationItems
|
||||
->filter(fn (BackupItem $item): bool => $this->resolveRestoreMode($item->policy_type) === 'preview-only')
|
||||
->values();
|
||||
|
||||
$restorable = $foundationItems
|
||||
->reject(fn (BackupItem $item): bool => $this->resolveRestoreMode($item->policy_type) === 'preview-only')
|
||||
->values();
|
||||
|
||||
return [$previewOnly, $restorable];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BackupItem> $items
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function previewOnlyFoundationEntries(Collection $items, bool $dryRun): array
|
||||
{
|
||||
return $items
|
||||
->map(function (BackupItem $item) use ($dryRun): array {
|
||||
return [
|
||||
'type' => $item->policy_type,
|
||||
'sourceId' => $item->policy_identifier,
|
||||
'sourceName' => $item->resolvedDisplayName(),
|
||||
'decision' => $dryRun ? 'dry_run' : 'skipped',
|
||||
'reason' => 'preview_only',
|
||||
'restore_mode' => 'preview-only',
|
||||
'backup_item_id' => (int) $item->getKey(),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@ -120,6 +120,70 @@ public function captureVersion(
|
||||
return $version;
|
||||
}
|
||||
|
||||
public function captureFoundationVersion(
|
||||
Policy $policy,
|
||||
array $payload,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): PolicyVersion {
|
||||
$policy->loadMissing('tenant');
|
||||
|
||||
$workspaceId = is_numeric($policy->tenant?->workspace_id ?? null)
|
||||
? (int) $policy->tenant?->workspace_id
|
||||
: 0;
|
||||
|
||||
$protectedSnapshot = $this->snapshotRedactor->protect(
|
||||
workspaceId: $workspaceId,
|
||||
payload: $payload,
|
||||
assignments: null,
|
||||
scopeTags: null,
|
||||
);
|
||||
|
||||
$snapshotHash = $this->snapshotContractHash(
|
||||
snapshot: $protectedSnapshot->snapshot,
|
||||
snapshotFingerprints: $protectedSnapshot->secretFingerprints['snapshot'],
|
||||
redactionVersion: $protectedSnapshot->redactionVersion,
|
||||
);
|
||||
|
||||
$existingVersion = PolicyVersion::query()
|
||||
->where('policy_id', $policy->getKey())
|
||||
->where('capture_purpose', $capturePurpose->value)
|
||||
->when(
|
||||
$capturePurpose !== PolicyVersionCapturePurpose::Backup && $baselineProfileId !== null,
|
||||
fn ($query) => $query->where('baseline_profile_id', $baselineProfileId),
|
||||
)
|
||||
->get()
|
||||
->first(function (PolicyVersion $version) use ($snapshotHash): bool {
|
||||
return $this->snapshotContractHash(
|
||||
snapshot: is_array($version->snapshot) ? $version->snapshot : [],
|
||||
snapshotFingerprints: $this->fingerprintBucket($version, 'snapshot'),
|
||||
redactionVersion: is_numeric($version->redaction_version) ? (int) $version->redaction_version : null,
|
||||
) === $snapshotHash;
|
||||
});
|
||||
|
||||
if ($existingVersion instanceof PolicyVersion) {
|
||||
return $existingVersion;
|
||||
}
|
||||
|
||||
return $this->captureVersion(
|
||||
policy: $policy,
|
||||
payload: $payload,
|
||||
createdBy: $createdBy,
|
||||
metadata: array_merge(
|
||||
['capture_source' => 'foundation_capture'],
|
||||
$metadata,
|
||||
),
|
||||
assignments: null,
|
||||
scopeTags: null,
|
||||
capturePurpose: $capturePurpose,
|
||||
operationRunId: $operationRunId,
|
||||
baselineProfileId: $baselineProfileId,
|
||||
);
|
||||
}
|
||||
|
||||
private function isUniqueViolation(QueryException $exception): bool
|
||||
{
|
||||
if ($exception instanceof UniqueConstraintViolationException) {
|
||||
@ -329,6 +393,55 @@ private function resolveScopeTags(Tenant $tenant, array $scopeTagIds): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @param array<string, string> $snapshotFingerprints
|
||||
*/
|
||||
private function snapshotContractHash(array $snapshot, array $snapshotFingerprints, ?int $redactionVersion): string
|
||||
{
|
||||
return hash(
|
||||
'sha256',
|
||||
json_encode(
|
||||
$this->normalizeHashValue([
|
||||
'snapshot' => $snapshot,
|
||||
'secret_fingerprints' => $snapshotFingerprints,
|
||||
'redaction_version' => $redactionVersion,
|
||||
]),
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
|
||||
{
|
||||
$fingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
|
||||
$bucketFingerprints = $fingerprints[$bucket] ?? [];
|
||||
|
||||
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
|
||||
}
|
||||
|
||||
private function normalizeHashValue(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (array_is_list($value)) {
|
||||
return array_map(fn (mixed $item): mixed => $this->normalizeHashValue($item), $value);
|
||||
}
|
||||
|
||||
ksort($value);
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = $this->normalizeHashValue($item);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function nextVersionNumber(Policy $policy): int
|
||||
{
|
||||
$current = PolicyVersion::query()
|
||||
|
||||
@ -6,7 +6,20 @@ class InventoryMetaSanitizer
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @return array{odata_type?: string, etag?: string|null, scope_tag_ids?: list<string>, assignment_target_count?: int|null, warnings?: list<string>}
|
||||
* @return array{
|
||||
* odata_type?: string,
|
||||
* etag?: string|null,
|
||||
* scope_tag_ids?: list<string>,
|
||||
* assignment_target_count?: int|null,
|
||||
* is_built_in?: bool,
|
||||
* role_permission_count?: int,
|
||||
* role_definition_id?: string,
|
||||
* role_definition_name?: string,
|
||||
* member_count?: int,
|
||||
* scope_member_count?: int,
|
||||
* resource_scope_count?: int,
|
||||
* warnings?: list<string>
|
||||
* }
|
||||
*/
|
||||
public function sanitize(array $meta): array
|
||||
{
|
||||
@ -36,6 +49,45 @@ public function sanitize(array $meta): array
|
||||
$sanitized['assignment_target_count'] = null;
|
||||
}
|
||||
|
||||
$isBuiltIn = $meta['is_built_in'] ?? null;
|
||||
if (is_bool($isBuiltIn)) {
|
||||
$sanitized['is_built_in'] = $isBuiltIn;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'role_permission_count',
|
||||
'member_count',
|
||||
'scope_member_count',
|
||||
'resource_scope_count',
|
||||
] as $countKey) {
|
||||
$count = $meta[$countKey] ?? null;
|
||||
|
||||
if (is_int($count)) {
|
||||
$sanitized[$countKey] = $count;
|
||||
} elseif (is_numeric($count)) {
|
||||
$sanitized[$countKey] = (int) $count;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'role_definition_id',
|
||||
'role_definition_name',
|
||||
] as $stringKey) {
|
||||
$value = $meta[$stringKey] ?? null;
|
||||
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[$stringKey] = $value;
|
||||
}
|
||||
|
||||
$warnings = $meta['warnings'] ?? null;
|
||||
if (is_array($warnings)) {
|
||||
$sanitized['warnings'] = $this->boundedStringList($warnings, 25, 200);
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
@ -32,6 +33,7 @@ public function __construct(
|
||||
private readonly InventoryConcurrencyLimiter $concurrencyLimiter,
|
||||
private readonly ProviderConnectionResolver $providerConnections,
|
||||
private readonly ProviderGateway $providerGateway,
|
||||
private readonly GraphContractRegistry $graphContracts,
|
||||
private readonly OperationRunService $operationRuns,
|
||||
) {}
|
||||
|
||||
@ -281,10 +283,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
||||
|
||||
$response = $this->listPoliciesWithRetry(
|
||||
$policyType,
|
||||
[
|
||||
'platform' => $typeConfig['platform'] ?? null,
|
||||
'filter' => $typeConfig['filter'] ?? null,
|
||||
],
|
||||
$this->listOptionsForPolicyType($policyType, $typeConfig),
|
||||
$connection
|
||||
);
|
||||
|
||||
@ -341,6 +340,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
||||
'etag' => $policyData['@odata.etag'] ?? null,
|
||||
'scope_tag_ids' => is_array($scopeTagIds) ? $scopeTagIds : null,
|
||||
'assignment_target_count' => $assignmentTargetCount,
|
||||
...$this->rbacMeta($policyType, $policyData),
|
||||
'warnings' => [],
|
||||
]);
|
||||
|
||||
@ -567,6 +567,67 @@ private function foundationTypes(): array
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $typeConfig
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function listOptionsForPolicyType(string $policyType, array $typeConfig): array
|
||||
{
|
||||
$options = [
|
||||
'platform' => $typeConfig['platform'] ?? null,
|
||||
'filter' => $typeConfig['filter'] ?? null,
|
||||
];
|
||||
|
||||
if (! in_array($policyType, $this->foundationTypes(), true)) {
|
||||
return $options;
|
||||
}
|
||||
|
||||
$expand = $this->graphContracts->get($policyType)['allowed_expand'] ?? [];
|
||||
|
||||
if (is_array($expand) && $expand !== []) {
|
||||
$options['expand'] = $expand;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $policyData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rbacMeta(string $policyType, array $policyData): array
|
||||
{
|
||||
if ($policyType === 'intuneRoleDefinition') {
|
||||
$rolePermissions = $policyData['rolePermissions'] ?? null;
|
||||
|
||||
return [
|
||||
'is_built_in' => is_bool($policyData['isBuiltIn'] ?? null) ? $policyData['isBuiltIn'] : null,
|
||||
'role_permission_count' => is_array($rolePermissions) ? count($rolePermissions) : null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($policyType !== 'intuneRoleAssignment') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$roleDefinition = $policyData['roleDefinition'] ?? null;
|
||||
$members = $policyData['members'] ?? null;
|
||||
$scopeMembers = $policyData['scopeMembers'] ?? null;
|
||||
$resourceScopes = $policyData['resourceScopes'] ?? null;
|
||||
|
||||
return [
|
||||
'role_definition_id' => is_array($roleDefinition) && is_string($roleDefinition['id'] ?? null)
|
||||
? $roleDefinition['id']
|
||||
: (is_string($policyData['roleDefinitionId'] ?? null) ? $policyData['roleDefinitionId'] : null),
|
||||
'role_definition_name' => is_array($roleDefinition) && is_string($roleDefinition['displayName'] ?? null)
|
||||
? $roleDefinition['displayName']
|
||||
: null,
|
||||
'member_count' => is_array($members) ? count($members) : null,
|
||||
'scope_member_count' => is_array($scopeMembers) ? count($scopeMembers) : null,
|
||||
'resource_scope_count' => is_array($resourceScopes) ? count($resourceScopes) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function selectionLockKey(Tenant $tenant, string $selectionHash): string
|
||||
{
|
||||
return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash);
|
||||
|
||||
@ -66,6 +66,31 @@ public static function metaFor(?string $type): array
|
||||
return static::byType()[(string) $type] ?? [];
|
||||
}
|
||||
|
||||
public static function label(?string $type): ?string
|
||||
{
|
||||
$label = static::metaFor($type)['label'] ?? null;
|
||||
|
||||
return is_string($label) ? $label : null;
|
||||
}
|
||||
|
||||
public static function category(?string $type): ?string
|
||||
{
|
||||
$category = static::metaFor($type)['category'] ?? null;
|
||||
|
||||
return is_string($category) ? $category : null;
|
||||
}
|
||||
|
||||
public static function isFoundation(?string $type): bool
|
||||
{
|
||||
if (! filled($type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return collect(static::foundations())
|
||||
->pluck('type')
|
||||
->contains((string) $type);
|
||||
}
|
||||
|
||||
public static function restoreMode(?string $type): ?string
|
||||
{
|
||||
$restore = static::metaFor($type)['restore'] ?? null;
|
||||
|
||||
@ -40,7 +40,8 @@ public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnectio
|
||||
],
|
||||
ProviderReasonCodes::ProviderPermissionMissing,
|
||||
ProviderReasonCodes::ProviderPermissionDenied,
|
||||
ProviderReasonCodes::ProviderPermissionRefreshFailed => [
|
||||
ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
||||
ProviderReasonCodes::IntuneRbacPermissionMissing => [
|
||||
[
|
||||
'label' => 'Open Required Permissions',
|
||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
|
||||
@ -22,6 +22,8 @@ final class ProviderReasonCodes
|
||||
|
||||
public const string ProviderPermissionRefreshFailed = 'provider_permission_refresh_failed';
|
||||
|
||||
public const string IntuneRbacPermissionMissing = 'intune_rbac.permission_missing';
|
||||
|
||||
public const string TenantTargetMismatch = 'tenant_target_mismatch';
|
||||
|
||||
public const string NetworkUnreachable = 'network_unreachable';
|
||||
@ -51,6 +53,7 @@ public static function all(): array
|
||||
self::ProviderPermissionMissing,
|
||||
self::ProviderPermissionDenied,
|
||||
self::ProviderPermissionRefreshFailed,
|
||||
self::IntuneRbacPermissionMissing,
|
||||
self::TenantTargetMismatch,
|
||||
self::NetworkUnreachable,
|
||||
self::RateLimited,
|
||||
|
||||
@ -239,9 +239,8 @@ private static function buildCheck(
|
||||
array_map(static fn (array $row): string => $row['key'], $errored),
|
||||
)));
|
||||
|
||||
$message = $missingKeys !== []
|
||||
? sprintf('Missing required application permission(s): %s', implode(', ', array_slice($missingKeys, 0, 6)))
|
||||
: 'Missing required permissions.';
|
||||
$reasonCode = self::permissionMissingReasonCode($key);
|
||||
$message = self::missingApplicationMessage($key, $missingKeys);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
@ -249,10 +248,10 @@ private static function buildCheck(
|
||||
'status' => VerificationCheckStatus::Fail->value,
|
||||
'severity' => VerificationCheckSeverity::Critical->value,
|
||||
'blocking' => true,
|
||||
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message,
|
||||
'evidence' => $evidence,
|
||||
'next_steps' => self::nextSteps($tenant, ProviderReasonCodes::ProviderPermissionMissing),
|
||||
'next_steps' => self::nextSteps($tenant, $reasonCode),
|
||||
];
|
||||
}
|
||||
|
||||
@ -286,6 +285,33 @@ private static function buildCheck(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $missingKeys
|
||||
*/
|
||||
private static function missingApplicationMessage(string $key, array $missingKeys): string
|
||||
{
|
||||
if ($key === 'permissions.intune_rbac_assignments') {
|
||||
$listed = implode(', ', array_slice($missingKeys, 0, 6));
|
||||
|
||||
return $listed !== ''
|
||||
? sprintf('Missing required Intune RBAC read permission(s): %s. RBAC inventory and backup history will remain read-only.', $listed)
|
||||
: 'Missing required Intune RBAC read permissions. RBAC inventory and backup history will remain read-only.';
|
||||
}
|
||||
|
||||
return $missingKeys !== []
|
||||
? sprintf('Missing required application permission(s): %s', implode(', ', array_slice($missingKeys, 0, 6)))
|
||||
: 'Missing required permissions.';
|
||||
}
|
||||
|
||||
private static function permissionMissingReasonCode(string $key): string
|
||||
{
|
||||
if ($key === 'permissions.intune_rbac_assignments') {
|
||||
return ProviderReasonCodes::IntuneRbacPermissionMissing;
|
||||
}
|
||||
|
||||
return ProviderReasonCodes::ProviderPermissionMissing;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $missingApplication
|
||||
* @param array<int, TenantPermissionRow> $missingDelegated
|
||||
|
||||
@ -366,6 +366,9 @@ private static function sanitizeMessage(mixed $message): string
|
||||
|
||||
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||
$message = self::classifier()->sanitizeAuditString($message);
|
||||
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
|
||||
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password|authorization)\b\s*[:=]\s*\[REDACTED\]/i', '[REDACTED]', $message) ?? $message;
|
||||
$message = preg_replace('/"(access_token|refresh_token|client_secret|password|authorization)"\s*:\s*"\[REDACTED\]"/i', '"[REDACTED]"', $message) ?? $message;
|
||||
|
||||
return $message === '' ? '—' : substr($message, 0, 240);
|
||||
}
|
||||
|
||||
@ -33,6 +33,15 @@
|
||||
'allowed_select' => ['id', 'displayName', 'isBuiltIn'],
|
||||
'allowed_expand' => [],
|
||||
],
|
||||
'intuneRoleDefinition' => [
|
||||
'resource' => 'deviceManagement/roleDefinitions',
|
||||
'allowed_select' => ['id', 'displayName', 'description', 'isBuiltIn', 'rolePermissions', 'roleScopeTagIds'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.roleDefinition',
|
||||
'#microsoft.graph.deviceAndAppManagementRoleDefinition',
|
||||
],
|
||||
],
|
||||
'managedDevices' => [
|
||||
'resource' => 'deviceManagement/managedDevices',
|
||||
'allowed_select' => ['id', 'complianceState'],
|
||||
@ -67,6 +76,15 @@
|
||||
'allowed_select' => ['id', 'displayName', 'resourceScopes', 'members'],
|
||||
'allowed_expand' => ['roleDefinition($select=id,displayName)'],
|
||||
],
|
||||
'intuneRoleAssignment' => [
|
||||
'resource' => 'deviceManagement/roleAssignments',
|
||||
'allowed_select' => ['id', 'displayName', 'description', 'members', 'scopeMembers', 'resourceScopes', 'scopeType'],
|
||||
'allowed_expand' => ['roleDefinition($select=id,displayName,description,isBuiltIn)'],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.roleAssignment',
|
||||
'#microsoft.graph.deviceAndAppManagementRoleAssignment',
|
||||
],
|
||||
],
|
||||
'entraRoleDefinitions' => [
|
||||
'resource' => 'roleManagement/directory/roleDefinitions',
|
||||
'allowed_select' => ['id', 'displayName', 'templateId', 'isBuiltIn'],
|
||||
|
||||
@ -313,6 +313,26 @@
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'low',
|
||||
],
|
||||
[
|
||||
'type' => 'intuneRoleDefinition',
|
||||
'label' => 'Intune Role Definition',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/roleDefinitions',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
'type' => 'intuneRoleAssignment',
|
||||
'label' => 'Intune Role Assignment',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/roleAssignments',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
'type' => 'notificationMessageTemplate',
|
||||
'label' => 'Notification Message Template',
|
||||
|
||||
385
docs/audits/2026-03-09-enterprise-rbac-scope-audit.md
Normal file
385
docs/audits/2026-03-09-enterprise-rbac-scope-audit.md
Normal file
@ -0,0 +1,385 @@
|
||||
# Enterprise RBAC, Scope Enforcement & Navigation Governance Audit
|
||||
|
||||
**Datum:** 9. März 2026
|
||||
**Anwendung:** TenantPilot / TenantAtlas
|
||||
**Stack:** Laravel 12, Filament v5, Livewire v4, PostgreSQL, Tailwind v4
|
||||
**Auditor:** Enterprise SaaS Security & Architecture Audit
|
||||
**Scope:** RBAC, Multi-Tenancy, Scope Enforcement, Navigation Governance, Filament Authorization
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Security / Structure Assessment
|
||||
|
||||
### Gesamturteil: **Teilweise robust — mit gezieltem Hardening-Bedarf**
|
||||
|
||||
Die Anwendung verfügt über ein **architektonisch durchdachtes, capability-first RBAC-Modell** mit klarer Trennung von Platform/Workspace/Tenant-Scopes. Die Kernarchitektur (CapabilityResolver, UiEnforcement, Membership-Manager, Policies) ist solide und enterprise-geeignet.
|
||||
|
||||
Allerdings gibt es **strukturelle Lücken** in folgenden Bereichen:
|
||||
- System-Panel-Widgets aggregieren cross-workspace/cross-tenant Daten ohne Kompartimentierung
|
||||
- Einige Monitoring-Pages fehlen explizite `canAccess()` Checks und verlassen sich ausschließlich auf Middleware
|
||||
- AuditLog-Modell hat keine `workspace_id` Spalte und damit kein strukturelles Workspace-Scoping
|
||||
- Einige Modelle (OperationRun, BaselineProfile, AlertRule) haben keine Immutabilitäts-Traits am Model-Level
|
||||
- URL-basierte Enumeration von sequenziellen IDs ist möglich (Finding, OperationRun)
|
||||
|
||||
### Die 5 kritischsten RBAC-/Scope-Probleme
|
||||
|
||||
| # | Problem | Severity |
|
||||
|---|---------|----------|
|
||||
| 1 | **System Panel leakt Cross-Workspace/Cross-Tenant Operational Data** — ControlTower-Widgets zeigen Workspace-Namen, Tenant-Namen und Fehlerstatistiken aller Mandanten an jeden PlatformUser | CRITICAL |
|
||||
| 2 | **Directory-Pages exponieren alle Tenants/Workspaces** — System/Directory-Seiten erlauben Aufzählung aller registrierten Tenants/Workspaces mit Health-Status und Permission-Gaps | HIGH |
|
||||
| 3 | **AuditLog hat kein strukturelles Workspace-Scoping** — Nur `tenant_id`, kein `workspace_id`; Scoping ist rein auf Query-Level und kann bei neuen Zugangspfaden umgangen werden | HIGH |
|
||||
| 4 | **Monitoring-Pages (Operations, AuditLog) fehlen `canAccess()` Checks** — Verlassen sich nur auf Middleware + Query-Scoping, nicht auf explizite Page-Level Authorization | MEDIUM |
|
||||
| 5 | **Sequenzielle IDs in URLs erlauben Enumeration** — Finding-IDs, OperationRun-IDs sind sequential integers in Deep Links und Notifications | MEDIUM |
|
||||
|
||||
### Die 5 wichtigsten Korrekturen
|
||||
|
||||
| # | Maßnahme | Priorität |
|
||||
|---|----------|-----------|
|
||||
| 1 | System-Panel Kompartimentierung: Capability-Granularisierung für cross-workspace Sicht | P0 |
|
||||
| 2 | `canAccess()` auf allen Monitoring-Pages implementieren | P1 |
|
||||
| 3 | AuditLog-Modell um `workspace_id` ergänzen mit Migration + Scoping-Trait | P1 |
|
||||
| 4 | OperationRun + BaselineProfile + AuditLog mit DerivesWorkspaceId-Trait absichern | P2 |
|
||||
| 5 | UUID-basierte Route-Keys für sensitive Ressourcen (Finding, OperationRun) evaluieren | P2 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Access Model Map
|
||||
|
||||
### Subjekte
|
||||
|
||||
| Subjekt | Guard | Scope | Modell |
|
||||
|---------|-------|-------|--------|
|
||||
| **Platform User** | `platform` | System-weit | `PlatformUser` |
|
||||
| **Workspace Owner** | `web` | Workspace | `User` + `WorkspaceMembership(role=owner)` |
|
||||
| **Workspace Manager** | `web` | Workspace | `User` + `WorkspaceMembership(role=manager)` |
|
||||
| **Workspace Operator** | `web` | Workspace | `User` + `WorkspaceMembership(role=operator)` |
|
||||
| **Workspace Readonly** | `web` | Workspace | `User` + `WorkspaceMembership(role=readonly)` |
|
||||
| **Tenant Owner** | `web` | Tenant | `User` + `TenantMembership(role=owner)` |
|
||||
| **Tenant Manager** | `web` | Tenant | `User` + `TenantMembership(role=manager)` |
|
||||
| **Tenant Operator** | `web` | Tenant | `User` + `TenantMembership(role=operator)` |
|
||||
| **Tenant Readonly** | `web` | Tenant | `User` + `TenantMembership(role=readonly)` |
|
||||
|
||||
### Scopes
|
||||
|
||||
| Scope | Kontext | Enforcement |
|
||||
|-------|---------|-------------|
|
||||
| **Platform** | System-Administration (break-glass, directory, ops) | `platform` Guard + PlatformCapabilities |
|
||||
| **Workspace** | Organisation/Account-Isolation | WorkspaceMembership + WorkspaceCapabilityResolver + WorkspaceContext (Session) |
|
||||
| **Tenant** | Mandant innerhalb eines Workspace | TenantMembership + CapabilityResolver + Filament::getTenant() |
|
||||
|
||||
### Kernobjekte pro Scope
|
||||
|
||||
| Scope | Objekte |
|
||||
|-------|---------|
|
||||
| **Platform** | PlatformUser, Access Logs, Break-Glass-Sessions |
|
||||
| **Workspace** | Workspace, WorkspaceMembership, WorkspaceSetting, AlertRule, AlertDestination, BaselineProfile, OperationRun (multi-tenant) |
|
||||
| **Tenant** | Tenant, TenantMembership, Policy, PolicyVersion, BackupSet, BackupItem, BackupSchedule, RestoreRun, Finding, ReviewPack, InventoryItem, EntraGroup, ProviderConnection, TenantPermission, TenantRoleMapping |
|
||||
| **Ambivalent** | AuditLog (nur tenant_id, kein workspace_id), OperationRun (hat workspace_id, aber kein Immutabilitäts-Trait), AlertDelivery (workspace_id + optional tenant_id) |
|
||||
|
||||
### Capability-Muster
|
||||
|
||||
**Klar und konsistent:**
|
||||
- Capability-first Design über `Capabilities::*` und `PlatformCapabilities::*`
|
||||
- Static Registry (`Capabilities::all()`, `Capabilities::isKnown()`)
|
||||
- Rolle→Capability Mapping über `RoleCapabilityMap` und `WorkspaceRoleCapabilityMap`
|
||||
- Gates werden dynamisch aus Capabilities registriert (AuthServiceProvider)
|
||||
- UiEnforcement erzwingt dreilagige Autorisierung (Visibility → Disabled → Server Guard)
|
||||
|
||||
**Inkonsistent:**
|
||||
- Einige Policies nutzen `Gate::allows()`, andere nutzen `CapabilityResolver::can()` direkt
|
||||
- System-Panel nutzt separate `PlatformCapabilities` ohne Kompartimentierung innerhalb des System-Scopes
|
||||
- AuditLog hat keinen eigenen Capability-Check — Sichtbarkeit wird durch Page-Navigation gesteuert
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings Table
|
||||
|
||||
| ID | Severity | Kategorie | Datei / Klasse | Scope | UI-Symptom | Technische Ursache | Risiko | Empfohlene Korrektur |
|
||||
|----|----------|-----------|----------------|-------|------------|---------------------|--------|---------------------|
|
||||
| F-01 | **CRITICAL** | Cross-Scope Data Leak | `App\Filament\System\Widgets\ControlTowerTopOffenders` | System→All | Widget zeigt Top-10-Fehler mit Workspace+Tenant-Namen | Query ohne jegliche Scope-Filterung: `OperationRun::query()->selectRaw('workspace_id, tenant_id...')` | Jeder PlatformUser sieht Betriebsdaten aller Mandanten | Capability-basierte Kompartimentierung oder Anonymisierung im System-Panel |
|
||||
| F-02 | **CRITICAL** | Cross-Scope Data Leak | `App\Filament\System\Widgets\ControlTowerKpis` | System→All | KPI-Dashboard zeigt Gesamtstatistiken über alle Workspaces | Globale `OperationRun::query()` ohne WHERE-Klausel | Informations-Leaking über Betriebsumfang und Fehlerquoten | Scope-Filter oder Capability-Granularisierung |
|
||||
| F-03 | **CRITICAL** | Cross-Scope Data Leak | `App\Filament\System\Widgets\ControlTowerRecentFailures` | System→All | Zeigt letzte fehlgeschlagene Runs mit Workspace-Namen | Ungefilterter Query | Sensitive Betriebs-Metadaten sichtbar | Scope-aware oder anonymisiert |
|
||||
| F-04 | **HIGH** | Tenant Enumeration | `App\Filament\System\Pages\Directory\Tenants` | System | Directory listet alle Tenants mit Health, Status, Permission Gaps | `Tenant::query()->with('workspace')` ohne Scope | Reconnaissance: Angreifer sieht alle Mandanten, deren Konfigurationsprobleme und Provider-Status | Capability-basierter Zugang oder Scope-Einschränkung |
|
||||
| F-05 | **HIGH** | Workspace Enumeration | `App\Filament\System\Pages\Directory\Workspaces` | System | Directory listet alle Workspaces mit Tenant-Count und Fehlerquoten | `Workspace::query()` ohne Scope | Reconnaissance: vollständige Sicht auf Struktur und Probleme aller Organisationen | Wie F-04 |
|
||||
| F-06 | **HIGH** | Missing Structural Scope | `App\Models\AuditLog` | Tenant/Workspace | AuditLog-Einträge haben kein `workspace_id` | Model hat nur `tenant_id`, kein Workspace-Feld | Bei neuen Zugangspfaden oder Queries ohne Tenant-Kontext könnten Audit-Daten cross-workspace leaken | Migration: `workspace_id` Spalte + NOT NULL + DerivesWorkspaceIdFromTenant Trait |
|
||||
| F-07 | **MEDIUM** | Missing `canAccess()` | `App\Filament\Pages\Monitoring\Operations` | Admin | Seite ist über `/admin/operations` direkt erreichbar | Kein expliziter `canAccess()` Check, nur Middleware+Query-Scoping | Falls Middleware fehlschlägt oder umgangen wird, keine zweite Schutzschicht | `canAccess()` mit WorkspaceCapabilityResolver implementieren |
|
||||
| F-08 | **MEDIUM** | Missing `canAccess()` | `App\Filament\Pages\Monitoring\AuditLog` | Admin | Seite über `/admin/audit-log` erreichbar, `shouldRegisterNavigation=false` | Kein `canAccess()`, nur `shouldRegisterNavigation=false` und OperateHubShell | Hidden page ohne Page-Level Auth ist per URL erreichbar | `canAccess()` implementieren |
|
||||
| F-09 | **MEDIUM** | URL Enumeration | Notification Deep Links + Route `{run}` | Tenant | OperationRun-IDs in Notification-URLs sind sequential integers | `OperationRunLinks::tenantlessView($run)` nutzt Integer-ID | ID-Enumeration erlaubt Erkennung valider Run-IDs (Auth verhindert Lesezugriff) | UUID-basierte Route-Keys oder Rate-Limiting |
|
||||
| F-10 | **MEDIUM** | URL Enumeration | Finding-URLs in Tenant Panel | Tenant | Finding-IDs in Links: `/findings/140`, `/findings/139` | Sequential integer keys in URLs | Ähnlich wie F-09 | UUID-basierte Anzeige |
|
||||
| F-11 | **MEDIUM** | No Model Immutability | `App\Models\OperationRun` | Workspace/Tenant | N/A | Kein `DerivesWorkspaceIdFromTenant` Trait; workspace_id wird per boot() auto-filled, aber nicht immutabel erzwungen | Theoretische Reassignment-Möglichkeit (kein aktueller Exploit-Pfad) | DerivesWorkspaceIdFromTenant Trait hinzufügen |
|
||||
| F-12 | **MEDIUM** | No Model Immutability | `App\Models\BaselineProfile` | Workspace | N/A | Kein Scoping-Trait; workspace_id muss bei Creation gesetzt werden | Developer-Fehler bei zukünftigen Code-Änderungen möglich | Trait oder DB-Constraint |
|
||||
| F-13 | **LOW** | Break-Glass Scope | `App\Filament\System\Pages\RepairWorkspaceOwners` | System | PlatformUser kann Owner für beliebigen Workspace zuweisen | Nur `USE_BREAK_GLASS` Capability geprüft, keine Workspace-Zugehörigkeitsvalidierung | Intentional (Emergency-Feature), aber unkompartimentiert | Dokumentation und Audit-Alerting sicherstellen; optional: Require 4-eyes |
|
||||
| F-14 | **LOW** | Access Log Exposure | `App\Filament\System\Pages\Security\AccessLogs` | System | Zeigt alle Platform-Logins und Break-Glass-Aktivitäten | Nur `CONSOLE_VIEW` Capability erforderlich | Offenlegen, wer System-Zugang hat und wann | Separate Capability `SECURITY_AUDIT_VIEW` |
|
||||
| F-15 | **LOW** | Livewire Auth Gap | `App\Livewire\SettingsCatalogSettingsTable` | Tenant | Liest Policy-Einstellungen ohne eigene Auth | Kein eigener Authorization-Check; angenommen: nur auf autorisierten Seiten gemounted | Falls auf anderer Seite gemounted, Daten-Leak möglich | Defensive Auth prüfen oder Assert bei mount() |
|
||||
|
||||
---
|
||||
|
||||
## 4. Mismatch Matrix
|
||||
|
||||
| Dimension | Status | Details |
|
||||
|-----------|--------|---------|
|
||||
| **Navigation Visibility ↔ Policy Enforcement** | ✅ Konsistent für Tenant-Panel | Navigation items nutzen `canViewAny()` + Policy-Checks; UiEnforcement erzwingt Dreischicht-Modell |
|
||||
| **Navigation Visibility ↔ Policy Enforcement** | ⚠️ Inkonsistent für Monitoring-Pages | Operations und AuditLog sind `shouldRegisterNavigation=false` aber per URL erreichbar ohne `canAccess()` |
|
||||
| **Navigation Visibility ↔ Policy Enforcement** | ⚠️ Inkonsistent für System-Panel | System-Pages haben nur `DIRECTORY_VIEW`/`OPERATIONS_VIEW` Capability, aber zeigen Daten aller Mandanten |
|
||||
| **Query/Data Scope ↔ Navigation Scope** | ✅ Konsistent für Tenant-scoped Resources | `getEloquentQuery()` filtert konsequent nach `Tenant::current()` |
|
||||
| **Query/Data Scope ↔ Navigation Scope** | ✅ Konsistent für Workspace-scoped Resources | `getEloquentQuery()` filtert nach `WorkspaceContext::currentWorkspaceId()` mit `whereRaw('1 = 0')` als Fallback |
|
||||
| **Query/Data Scope ↔ Navigation Scope** | ⚠️ System-Widgets: Kein Scope, Navigation erlaubt | ControlTower-Widgets haben weder Query-Scope noch Navigation-Gate |
|
||||
| **Route Accessibility ↔ Authorization** | ✅ Tenant Panel Seiten | Middleware-Stack (`ensure-workspace-selected`, `ensure-filament-tenant-selected`, `DenyNonMemberTenantAccess`) |
|
||||
| **Route Accessibility ↔ Authorization** | ✅ Tenantless Run Viewer | `mount()` → `$this->authorize('view', $run)` → OperationRunPolicy prüft Workspace+Tenant+Capability |
|
||||
| **Route Accessibility ↔ Authorization** | ⚠️ AuditLog Page | `shouldRegisterNavigation=false`, aber kein `canAccess()` — per URL erreichbar |
|
||||
| **Action Auth ↔ Bulk Action Auth** | ✅ Konsistent | UiEnforcement erzwingt all-or-nothing Semantik für Bulk Actions mit gleicher Capability wie Single Actions |
|
||||
| **Widget Data ↔ User Scope** | ✅ Dashboard-Widgets | `DashboardKpis`, `NeedsAttention`, `RecentOperations` nutzen `Filament::getTenant()` korrekt |
|
||||
| **Widget Data ↔ User Scope** | ✅ AlertsKpiHeader | Filtert nach workspace_id + user.tenantMemberships + optional activeTenant |
|
||||
| **Widget Data ↔ User Scope** | ⚠️ ControlTower-Widgets | Keine Scope-Filterung — zeigen Platform-weite Aggregate |
|
||||
| **Global Search ↔ Scope** | ✅ ScopesGlobalSearchToTenant Trait | Prüft `canAccessTenant()` und filtert per `whereBelongsTo()` |
|
||||
| **Global Search ↔ Scope** | ⚠️ Trait-Nutzung verifizieren | Unklar ob alle globalsuchbaren Resources diesen Trait nutzen |
|
||||
| **Relation Managers ↔ Parent Scope** | ✅ Konsistent | TenantMembershipsRelationManager, BackupItemsRelationManager etc. erben Parent-Scope des owning Resource |
|
||||
| **Deep Links ↔ Auth** | ✅ Notification-URLs | OperationRunPolicy prüft Workspace+Tenant bei mount() |
|
||||
| **Deep Links ↔ Auth** | ⚠️ IDs in URLs | Sequential integers ermöglichen Enumeration |
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Enterprise RBAC Model
|
||||
|
||||
### Rollen-/Capability-Modell (Zielzustand)
|
||||
|
||||
```
|
||||
Platform Layer
|
||||
├── Platform Admin: Full system access
|
||||
├── Platform Ops: Operations monitoring (scoped)
|
||||
├── Platform Audit: Security audit view only
|
||||
└── Platform Support: Break-glass + Directory (audited, 4-eyes for critical)
|
||||
|
||||
Workspace Layer
|
||||
├── Owner: Full workspace control + all tenant capabilities
|
||||
├── Manager: Workspace admin (no archive/delete) + most tenant capabilities
|
||||
├── Operator: Execute operations + view
|
||||
└── Readonly: View-only
|
||||
|
||||
Tenant Layer
|
||||
├── Owner: Full tenant control
|
||||
├── Manager: Admin (no delete/membership manage)
|
||||
├── Operator: Execute sync/backup/run + view
|
||||
└── Readonly: View-only
|
||||
```
|
||||
|
||||
### Regeln für Navigation Visibility
|
||||
|
||||
1. **Navigation zeigt NUR Items, für die der Nutzer authorization hat** — sowohl `shouldRegisterNavigation()` als auch `canAccess()` müssen konsistent sein
|
||||
2. **Hidden Pages müssen `canAccess()` implementieren** — `shouldRegisterNavigation=false` bedeutet nicht "keine Autorisierung nötig"
|
||||
3. **System-Panel Navigation muss capability-granular sein** — nicht eine einzige `CONSOLE_VIEW` Capability für alles
|
||||
4. **Navigation darf niemals die einzige Schutzschicht sein**
|
||||
|
||||
### Regeln für Query Scope
|
||||
|
||||
1. **Jede `getEloquentQuery()` muss scope-aware sein** — mit `whereRaw('1 = 0')` als Fallback bei fehlendem Kontext
|
||||
2. **Tenant-scoped Modelle nutzen `DerivesWorkspaceIdFromTenant` oder `$tenantOwnershipRelationshipName`**
|
||||
3. **Workspace-scoped Modelle filtern nach `WorkspaceContext::currentWorkspaceId()`**
|
||||
4. **System-Queries (ControlTower) müssen entweder scope-kompartimentiert oder anonymisiert sein**
|
||||
5. **AuditLog muss `workspace_id` als strukturelles Scope-Feld haben**
|
||||
|
||||
### Regeln für Direct URL Access
|
||||
|
||||
1. **Jede Page muss `canAccess()` implementieren** — keine Ausnahme für "hidden" Pages
|
||||
2. **Route Model Binding muss Scope-Ownership erzwingen** — OperationRunPolicy-Muster als Standard
|
||||
3. **UUID-basierte Route-Keys für sensitive Ressourcen** — Findings, OperationRuns
|
||||
4. **Rate-Limiting auf Detail-Endpoints** — gegen Enumeration
|
||||
|
||||
### Regeln für Actions / Bulk Actions
|
||||
|
||||
1. **UiEnforcement ist der Standard für alle Actions** — kein Bypass
|
||||
2. **Destructive Actions: `->requiresConfirmation()` + `->action(...)` + Capability-Check**
|
||||
3. **Bulk Actions: all-or-nothing Semantik (bereits implementiert)**
|
||||
4. **Keine Action darf schwächer autorisiert sein als die entsprechende Single-Record-Action**
|
||||
|
||||
### Regeln für Widgets, Search, Exports und Deep Links
|
||||
|
||||
1. **Widgets dürfen nur scope-konforme Daten anzeigen** — kein Count über unerlaubte Scopes
|
||||
2. **Global Search muss `ScopesGlobalSearchToTenant` nutzen** — für alle tenant-scoped Resources
|
||||
3. **Deep Links in Notifications müssen Policy-geschützt sein** — bereits implementiert, UUID-Keys evaluieren
|
||||
4. **Export-Funktionen müssen dieselbe Scope-Filterung wie die UI anwenden**
|
||||
|
||||
---
|
||||
|
||||
## 6. Hardening Recommendations
|
||||
|
||||
### Quick Wins (P0-P1, sofort umsetzbar)
|
||||
|
||||
| # | Maßnahme | Nutzen | Risiko | Aufwand | Priorität |
|
||||
|---|----------|--------|--------|---------|-----------|
|
||||
| QW-1 | `canAccess()` auf `Operations.php` implementieren — WorkspaceCapabilityResolver + Membership Check | Schließt Page-Level Auth Lücke | Minimal: bestehende Pattern kopieren | 1h | P1 |
|
||||
| QW-2 | `canAccess()` auf `AuditLog.php` implementieren | Wie QW-1 | Minimal | 1h | P1 |
|
||||
| QW-3 | System-Panel Capability-Granularisierung: `OPERATIONS_VIEW` in `OPS_VIEW_RUNS`, `OPS_VIEW_FAILURES`, `OPS_VIEW_STUCK` aufteilen | Feinere Zugriffskontrolle | Migration + PlatformUser Capability-Update | 4h | P1 |
|
||||
| QW-4 | ControlTower-Widgets: Option für anonymisierte/aggregierte Ansicht | Verhindert Tenant-Namen-Leak | Widget-Refactor | 4h | P1 |
|
||||
| QW-5 | `SettingsCatalogSettingsTable` Livewire: Defensive Auth in `mount()` | Verhindert Context-Leak bei Wiederverwendung | Minimal | 30min | P2 |
|
||||
|
||||
### Mittlere Hardening-Maßnahmen (P2, geplant)
|
||||
|
||||
| # | Maßnahme | Nutzen | Risiko | Aufwand | Priorität |
|
||||
|---|----------|--------|--------|---------|-----------|
|
||||
| MH-1 | AuditLog Migration: `workspace_id` Spalte + NOT NULL + Index | Strukturelles Workspace-Scoping | Backfill-Migration nötig | 8h | P2 |
|
||||
| MH-2 | `DerivesWorkspaceIdFromTenant` auf OperationRun-Model | Immutabilitäts-Garantie | Sehr gering | 2h | P2 |
|
||||
| MH-3 | `DerivesWorkspaceIdFromTenant` auf BaselineProfile-Model | Wie MH-2 | Gering | 2h | P2 |
|
||||
| MH-4 | UUID-basierte Route-Keys für Finding, OperationRun | Verhindert ID-Enumeration | Model+Migration+Route-Refactor | 12h | P2 |
|
||||
| MH-5 | Rate-Limiting auf `/admin/operations/{run}` und ähnliche Detail-Endpoints | Anti-Enumeration | Middleware-Config | 2h | P2 |
|
||||
| MH-6 | System-Panel Directory: Capability `DIRECTORY_MANAGE` vs `DIRECTORY_VIEW` differenzieren | Least-Privilege im System-Panel | Capability-Registry Update | 4h | P2 |
|
||||
| MH-7 | Break-Glass: 4-eyes-Prinzip oder temporal scoping verschärfen | Privileged-Access-Governance | UX-Design nötig | 8h | P2 |
|
||||
|
||||
### Strukturelle Refactors (P3, Spec-gesteuert)
|
||||
|
||||
| # | Maßnahme | Nutzen | Risiko | Aufwand | Priorität | Spec? |
|
||||
|---|----------|--------|--------|---------|-----------|-------|
|
||||
| SR-1 | Global Scope auf OperationRun für automatisches Workspace-Scoping | Präventiv gegen zukünftige Query-Fehler | Model-Behavior-Änderung; Tests nötig | 16h | P3 | Ja |
|
||||
| SR-2 | System-Panel Kompartimentierung: Scope-basierte Platform Views | Enterprise isolation für managed-service Betrieb | Architektur-Refactor | 40h | P3 | Ja |
|
||||
| SR-3 | Einheitliche `canAccess()` Base-Mixin für alle Pages | DRY + Konsistenz | Base-Class Refactor | 16h | P3 | Ja |
|
||||
| SR-4 | Monitoring-Pages: Unified OperateHub Authorization Layer | Konsolidierte Auth statt Page-for-Page | Architektur | 24h | P3 | Ja |
|
||||
|
||||
---
|
||||
|
||||
## 7. Project Rules
|
||||
|
||||
### Verbindliche Enterprise RBAC Rules
|
||||
|
||||
1. **Navigation ist nie die Sicherheitsgrenze.** Jede Filament-Page muss eine `canAccess()` Methode implementieren, die unabhängig von Navigation Visibility autorisiert. `shouldRegisterNavigation=false` ersetzt keinen Authorization-Check.
|
||||
|
||||
2. **Jede sichtbare Aktion braucht deckungsgleiche Server-Autorisierung.** UiEnforcement (Tenant) oder WorkspaceUiEnforcement (Workspace) ist der Standard-Wrapper für alle Actions. Kein Action-Handler darf ohne Server-Side Guard ausgeführt werden.
|
||||
|
||||
3. **Hidden Pages dürfen nicht unautorisiert per URL erreichbar sein.** Jede Page mit `shouldRegisterNavigation=false` oder `isDiscovered=false` MUSS explizit `canAccess()` implementieren.
|
||||
|
||||
4. **Workspace- und Tenant-Scope müssen in Query, Route Binding und Policy konsistent erzwungen werden.** Scope-Enforcement findet auf mindestens zwei Ebenen statt: Query-Level (`getEloquentQuery()`) UND Policy/Gate-Level.
|
||||
|
||||
5. **Widgets, Counts und Search dürfen keine scope-fremden Metadaten leaken.** Jedes Dashboard-Widget muss denselben Scope-Filter anwenden wie die zugehörige Tabelle/Resource. System-Widgets müssen scope-kompartimentiert oder anonymisiert sein.
|
||||
|
||||
6. **Bulk Actions dürfen nie schwächer autorisiert sein als Single Actions.** UiEnforcement erzwingt all-or-nothing Semantik: Wenn ein Record in der Selektion die Capability verletzt, wird die gesamte Bulk-Action blockiert.
|
||||
|
||||
7. **Deep Links und Notifications müssen kanonisch und permission-sicher sein.** Jede URL, die per Notification, E-Mail oder API-Response geteilt wird, muss bei Zugriff denselben Auth-Check durchlaufen wie der UI-Pfad.
|
||||
|
||||
8. **Route Model Binding muss Scope-Ownership erzwingen.** Policies müssen bei `view()` immer Workspace-Membership UND Tenant-Membership (falls tenant-scoped) prüfen. Non-Members erhalten 404 (deny-as-not-found), nicht 403.
|
||||
|
||||
9. **Capability-first vor rollen-/UI-getriebener Logik.** Autorisierung leitet sich aus Capabilities ab, nicht aus Rollen-Strings oder UI-Zustand. `RoleCapabilityMap` ist die Single Source of Truth.
|
||||
|
||||
10. **Kein Zugriff über "zufällig funktionierende" indirekte Pfade.** Jeder neue Pfad (Relation Manager, Tab, Widget, Global Search, Export) muss explizit scope-geprüft werden. Filament Auto-Discovery allein ist keine Autorisierung.
|
||||
|
||||
11. **Modelle mit Tenant- oder Workspace-Zugehörigkeit MÜSSEN Immutabilitäts-Traits nutzen.** `DerivesWorkspaceIdFromTenant` oder vergleichbare Traits stellen sicher, dass Scope-Zuweisungen nach Creation nicht verändert werden können.
|
||||
|
||||
12. **System-Panel Capabilities müssen granular sein.** Eine einzige `CONSOLE_VIEW` Capability für cross-workspace Sichtbarkeit reicht für Enterprise-Betrieb nicht aus. Differenzierung in Operations, Directory, Security, Break-Glass.
|
||||
|
||||
13. **Destructive Actions erfordern immer `->requiresConfirmation()`.** Keine Ausnahme, auch nicht bei "schnellen" Aktionen wie Archive oder Toggle.
|
||||
|
||||
14. **AuditLog muss workspace-scoped sein.** Jeder Audit-Eintrag muss `workspace_id` tragen, um cross-workspace Reporting und Compliance zu ermöglichen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Spec Proposal Backlog
|
||||
|
||||
### Spec A: System Panel Kompartimentierung
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **Titel** | System Panel Capability Granularization & Data Compartmentalization |
|
||||
| **Problem** | Alle PlatformUser mit `CONSOLE_VIEW` sehen Betriebsdaten aller Workspaces/Tenants. ControlTower-Widgets, Directory-Pages und Access-Logs bieten keine Scope-Isolation. |
|
||||
| **Ziel** | PlatformCapabilities aufteilen in `OPS_VIEW_RUNS`, `OPS_VIEW_FAILURES`, `DIRECTORY_VIEW_TENANTS`, `DIRECTORY_VIEW_WORKSPACES`, `SECURITY_AUDIT_VIEW`. ControlTower-Widgets anonymisieren oder scope-kompartimentieren. |
|
||||
| **Scope** | System Panel Widgets, Directory Pages, Access Logs, PlatformCapabilities Registry |
|
||||
| **Non-goals** | Nicht: Umbau der web-Guard-Architektur; nicht: Multi-Panel Redesign |
|
||||
| **Priorität** | P1 — Enterprise-Compliance-Risiko |
|
||||
| **Abhängigkeiten** | Keine |
|
||||
|
||||
### Spec B: Page-Level Authorization Enforcement
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **Titel** | Enforce canAccess() on all Filament Pages |
|
||||
| **Problem** | Mehrere Pages (Operations, AuditLog, potentiell weitere) haben keinen expliziten `canAccess()` Check und verlassen sich nur auf Middleware |
|
||||
| **Ziel** | Alle Pages im Admin/Tenant/System Panel implementieren `canAccess()` mit CapabilityResolver/WorkspaceCapabilityResolver |
|
||||
| **Scope** | Alle Filament Pages; PHPStan oder Pest-Regel zur Enforcement-Prüfung |
|
||||
| **Non-goals** | Nicht: Middleware-Umbau; nicht: UiEnforcement-Refactor |
|
||||
| **Priorität** | P1 — Defense-in-Depth |
|
||||
| **Abhängigkeiten** | Keine |
|
||||
|
||||
### Spec C: AuditLog Workspace Scoping
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **Titel** | Add workspace_id to AuditLog for Structural Scope Isolation |
|
||||
| **Problem** | AuditLog hat nur `tenant_id`, kein `workspace_id`. Query-Level-Scoping ist fragil und kann bei neuen Zugangspfaden umgangen werden. |
|
||||
| **Ziel** | Migration: `workspace_id` Spalte (nullable → later NOT NULL); Backfill-Job; DerivesWorkspaceIdFromTenant-Trait; Index |
|
||||
| **Scope** | AuditLog Model, Migration, Backfill, Audit-Service Updates |
|
||||
| **Non-goals** | Nicht: AuditLog Schema-Redesign; nicht: Retention-Policy |
|
||||
| **Priorität** | P2 |
|
||||
| **Abhängigkeiten** | Keine |
|
||||
|
||||
### Spec D: Model Immutability Enforcement
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **Titel** | Apply DerivesWorkspaceId Trait to OperationRun, BaselineProfile, AlertRule |
|
||||
| **Problem** | OperationRun, BaselineProfile, AlertRule haben keine Model-Level Immutabilitäts-Garantie für workspace_id. Scope-Zuweisungen könnten theoretisch nach Creation verändert werden. |
|
||||
| **Ziel** | DerivesWorkspaceIdFromTenant oder DerivesWorkspaceIdFromTenantWhenPresent Trait auf alle scope-relevanten Modelle anwenden; WorkspaceIsolationViolation als Guard |
|
||||
| **Scope** | OperationRun, BaselineProfile, AlertRule, AlertDestination Models |
|
||||
| **Non-goals** | Nicht: Global Scope Enforcement (separate Spec) |
|
||||
| **Priorität** | P2 |
|
||||
| **Abhängigkeiten** | Keine |
|
||||
|
||||
### Spec E: UUID Route Keys für Sensitive Resources
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **Titel** | UUID-based Route Keys for Findings, OperationRuns, and Sensitive Records |
|
||||
| **Problem** | Sequential integer IDs in URLs ermöglichen Enumeration. Notification-Deep-Links und Tabellen-Links enthalten vorhersagbare IDs. |
|
||||
| **Ziel** | getRouteKeyName() auf `external_id` (UUID) umstellen für Finding, OperationRun; Migration für UUID-Spalte; URL-Refactor |
|
||||
| **Scope** | Finding, OperationRun, ReviewPack Models; Routes; Notification Links |
|
||||
| **Non-goals** | Nicht: Alle Models umstellen; nicht: bestehende DB-PKs ändern |
|
||||
| **Priorität** | P2 |
|
||||
| **Abhängigkeiten** | Keine |
|
||||
|
||||
### Spec F: OperateHub Unified Authorization Layer
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **Titel** | Unified Authorization Layer for Monitoring/OperateHub Pages |
|
||||
| **Problem** | Monitoring-Pages (Operations, Alerts, AuditLog) haben unterschiedliche Auth-Patterns. Operations nutzt kein `canAccess()`, Alerts nutzt WorkspaceCapabilityResolver, AuditLog nutzt keines. |
|
||||
| **Ziel** | Shared Trait oder Base-Page-Klasse für OperateHub-Pages mit einheitlicher `canAccess()` + Capability-Mapping |
|
||||
| **Scope** | OperateHub Pages, Monitoring Cluster, Shared Trait |
|
||||
| **Non-goals** | Nicht: Redesign der OperateHubShell Navigation-Logik |
|
||||
| **Priorität** | P2 |
|
||||
| **Abhängigkeiten** | Spec B (Page-Level Auth Enforcement) |
|
||||
|
||||
### Spec G: Global Search Scope Verification & Trait Coverage
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **Titel** | Verify and Enforce ScopesGlobalSearchToTenant Usage on All Searchable Resources |
|
||||
| **Problem** | `ScopesGlobalSearchToTenant` Trait existiert und ist gut implementiert, aber es ist unklar, ob alle global-suchbaren Resources ihn nutzen. |
|
||||
| **Ziel** | Audit: Welche Resources sind global-suchbar? Nutzen alle den Trait? Pest-Test für Enforcement. |
|
||||
| **Scope** | Alle Filament Resources mit $recordTitleAttribute oder globallySearchable |
|
||||
| **Non-goals** | Nicht: Redesign der Global-Search-Architektur |
|
||||
| **Priorität** | P2 |
|
||||
| **Abhängigkeiten** | Keine |
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Positive Architecture Highlights
|
||||
|
||||
### Was gut funktioniert
|
||||
|
||||
1. **Capability-first Design** mit `Capabilities::all()` als Single Source of Truth und dynamischer Gate-Registrierung
|
||||
2. **UiEnforcement** als dreilagiger RBAC-Wrapper (Visibility → Disabled → Server Guard) — Enterprise-Grade
|
||||
3. **Deny-as-not-found Semantik** — Non-Members bekommen 404, nicht 403 (verhindert Existence-Leaking)
|
||||
4. **DerivesWorkspaceIdFromTenant Trait** — Model-Level Immutabilität mit `WorkspaceIsolationViolation` Exception
|
||||
5. **Last-Owner Guard** — Verhindert Removal/Demotion des letzten Workspace/Tenant Owners
|
||||
6. **Workspace 7-Step Selection Algorithm** — Robust mit Stale-Session-Handling und Audit-Events
|
||||
7. **Tenant Middleware Stack** — 4 Middleware-Layer (correct guard → workspace selected → tenant selected → deny non-member)
|
||||
8. **Break-Glass Auditing** — Vollständig auditiert mit TTL, explicit start/exit, IP-Logging
|
||||
9. **OperateHubShell** — Konsistente Cross-Tenant-Sicht mit Membership-Validation und Entitlement-Prüfung
|
||||
10. **WriteGateInterface** — Hardening-Layer gegen unsichere RBAC-Zustände bei Intune-Writes
|
||||
|
||||
### Zusammenfassung
|
||||
|
||||
Die Architektur zeigt ein **reifes, mehrschichtiges Autorisierungsmodell**, das für die meisten Enterprise-Szenarien robust ist. Die identifizierten Lücken betreffen primär:
|
||||
|
||||
1. **System-Panel**: Unzureichende Kompartimentierung (alle PlatformUser sehen alles)
|
||||
2. **Monitoring-Pages**: Fehlende explizite Page-Level Auth (Defense-in-Depth Lücke)
|
||||
3. **AuditLog**: Fehlendes strukturelles Workspace-Scoping
|
||||
4. **ID-Enumeration**: Sequenzielle IDs in URLs (kein Datenleak, aber Reconnaissance-Vektor)
|
||||
|
||||
Keines dieser Findings ermöglicht aktuell einen direkten Daten-Leak oder Privilege-Escalation über die bestehenden Middleware- und Policy-Checks hinaus. Die Maßnahmen dienen der **Enterprise-Härtung und Defense-in-Depth**, nicht der Schließung aktiver Sicherheitslücken.
|
||||
578
docs/audits/enterprise-architecture-audit-2026-03-09.md
Normal file
578
docs/audits/enterprise-architecture-audit-2026-03-09.md
Normal file
@ -0,0 +1,578 @@
|
||||
# Enterprise Architecture Audit — TenantPilot / TenantAtlas
|
||||
|
||||
**Date:** 2026-03-09
|
||||
**Auditor role:** Senior Enterprise SaaS Architect, UX/IA Auditor, Security/RBAC Reviewer
|
||||
**Stack:** Laravel 12, Filament v5, Livewire v4, PostgreSQL, Tailwind v4
|
||||
**Scope:** Panel Architecture, Navigation, Scope Enforcement, RBAC, Routing, IA
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Assessment
|
||||
|
||||
### Gesamturteil: **Teilweise enterprise-tauglich — strukturell problematisch in Scope-Modell und Panelarchitektur**
|
||||
|
||||
Die Anwendung hat ein starkes RBAC-Fundament (Capability-first, role-to-capability maps, resolver services, policies) und ein funktionierendes Multi-Tenant-Modell. Die Kernfunktionen (Inventory, Backups, Governance, Monitoring) sind fachlich sinnvoll aufgebaut.
|
||||
|
||||
**Jedoch:** Die Panel-Architektur, Navigation und Scope-Trennung weisen strukturelle Defizite auf, die in einem Enterprise-Kontext zu Verwirrung, Scope-Leaks und Sicherheitslücken führen können.
|
||||
|
||||
### Die 5 schwersten strukturellen Probleme
|
||||
|
||||
| # | Problem | Severity |
|
||||
|---|---------|----------|
|
||||
| **S1** | **Kein Workspace-Home/Overview-Einstieg.** `/admin` redirected sofort zu `/admin/t/{uuid}` (Tenant-Dashboard). Es gibt keinen eigenständigen Workspace-Kontext mit eigener Landing, eigenem Dashboard, eigener Sidebar. Der Workspace ist nur eine Session-Variable, kein eigenständiger UI-Kontext. | P0 |
|
||||
| **S2** | **Cross-Scope-Navigation im Tenant-Panel.** Die Monitoring-Gruppe (Runs → `/admin/operations`, Alerts → `/admin/alerts`, Audit Log → `/admin/audit-log`) verlinkt im Tenant-Panel auf Admin-Panel-Routen. User verlassen den Tenant-Kontext ohne expliziten Hinweis. Dashboard-KPIs verlinken ebenfalls nach `/admin/operations`. | P0 |
|
||||
| **S3** | **Doppelte Resource-Discovery und panelübergreifende Registrierung.** Beide Panels (Admin + Tenant) rufen `discoverResources(in: app_path('Filament/Resources'))` auf. Die Admin-Panel-Provider registriert zusätzlich explizit Ressourcen wie `PolicyResource`, `InventoryItemResource` — die tenant-scoped sind und im Admin-Panel nichts zu suchen haben. AlertsCluster steuert Registrierung über `shouldRegisterNavigation()` nur per Panel-ID-Check, nicht per Scope-Check. | P1 |
|
||||
| **S4** | **Fehlende `canAccess()`-Guards auf kritischen Seiten.** `AuditLog`, `Operations` und andere Seiten haben keine `canAccess()`-Methode. Sie sind über URL direkt erreichbar, auch wenn `shouldRegisterNavigation = false` die Navigation versteckt. Navigation ist nicht die Sicherheitsgrenze — aber hier wird es so behandelt. | P1 |
|
||||
| **S5** | **Middleware kompensiert fehlende Panelarchitektur.** `EnsureFilamentTenantSelected` (270+ LOC) baut dynamisch Navigation, löst Scope auf, und erstellt eine "Workspace-Level"-Navigation inline per `NavigationBuilder`. Das ist Sonderlogik, die eine fehlende saubere Workspace-Panel-Architektur kompensiert. | P1 |
|
||||
|
||||
### Die 5 wichtigsten Korrekturen
|
||||
|
||||
| # | Korrektur | Priorität | Aufwand |
|
||||
|---|-----------|-----------|---------|
|
||||
| **K1** | Workspace-Panel als eigenständiges Panel mit eigenem Home/Dashboard einführen | P0 | Groß |
|
||||
| **K2** | Cross-Scope-Links im Tenant-Panel eliminieren oder als Scope-Wechsel kenntlich machen | P0 | Mittel |
|
||||
| **K3** | Resource-Discovery per Panel isolieren (Tenant-Resources nur im Tenant-Panel, Workspace-Resources nur im Workspace-Panel) | P1 | Mittel |
|
||||
| **K4** | `canAccess()` auf allen Seiten implementieren (Zero-Trust-Regel) | P1 | Klein |
|
||||
| **K5** | `EnsureFilamentTenantSelected`-Middleware refactoren: Navigation-Build-Logik in Panel-Konfiguration verlagern | P1 | Mittel |
|
||||
|
||||
---
|
||||
|
||||
## 2. Current State Map
|
||||
|
||||
### 2.1 Panel Architecture (3 Panels)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ System Panel (path: /system) │
|
||||
│ Guard: platform | Auth: PlatformCapabilities │
|
||||
│ Home: System Dashboard (Control Tower) │
|
||||
│ Scope: Platform-wide operations, break-glass, directory │
|
||||
│ Isolation: ✅ Separate session cookie, separate guard │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Admin Panel (path: /admin) — DEFAULT PANEL │
|
||||
│ Guard: web | Tenancy: NONE │
|
||||
│ Home: Redirect to Tenant Dashboard or ChooseWorkspace │
|
||||
│ Scope: Hybrid (workspace + monitoring + settings + some │
|
||||
│ tenant resources registered here too) │
|
||||
│ Resources: 10 explicit + auto-discovered = ~17 total │
|
||||
│ Problem: No stable workspace-level home or sidebar │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Tenant Panel (path: /admin/t) │
|
||||
│ Guard: web | Tenancy: Tenant::class (slugAttribute: │
|
||||
│ external_id) │
|
||||
│ Home: TenantDashboard │
|
||||
│ Scope: Tenant-scoped operations (inventory, backups, │
|
||||
│ compliance, drift, restore, review packs) │
|
||||
│ Problem: Monitoring nav links to /admin/* routes │
|
||||
│ Discovery: discoverResources + discoverPages + widgets │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 Navigation Map
|
||||
|
||||
#### Tenant Panel Sidebar (current state in browser)
|
||||
|
||||
```
|
||||
Dashboard → /admin/t/{uuid} ✅ Tenant-scoped
|
||||
─────────────────
|
||||
Inventory
|
||||
Items → /admin/t/{uuid}/inventory ✅ Tenant-scoped
|
||||
Policies → /admin/t/{uuid}/policies ✅ Tenant-scoped
|
||||
Policy Versions → /admin/t/{uuid}/policy-versions ✅
|
||||
─────────────────
|
||||
Backups & Restore
|
||||
Backup Schedules → /admin/t/{uuid}/backup-schedules ✅
|
||||
Backup Sets → /admin/t/{uuid}/backup-sets ✅
|
||||
Restore Runs → /admin/t/{uuid}/restore-runs ✅
|
||||
─────────────────
|
||||
Directory
|
||||
Groups → /admin/t/{uuid}/entra-groups ✅
|
||||
─────────────────
|
||||
Governance
|
||||
Findings → /admin/t/{uuid}/findings ✅
|
||||
Baseline Compare → /admin/t/{uuid}/baseline-compare-landing ✅
|
||||
─────────────────
|
||||
Monitoring ⚠️ CROSS-SCOPE GROUP
|
||||
Runs → /admin/operations ❌ Admin-Panel-Route!
|
||||
Alerts → /admin/alerts ❌ Admin-Panel-Route!
|
||||
Audit Log → /admin/audit-log ❌ Admin-Panel-Route!
|
||||
─────────────────
|
||||
Reporting
|
||||
Review Packs → /admin/t/{uuid}/review-packs ✅
|
||||
```
|
||||
|
||||
#### Admin Panel Navigation (when no tenant selected — built by middleware)
|
||||
|
||||
```
|
||||
Settings
|
||||
Manage workspaces → /admin/workspaces
|
||||
Monitoring
|
||||
Operations → /admin/operations
|
||||
Alert targets → /admin/alert-destinations
|
||||
Alert rules → /admin/alert-rules
|
||||
Alert deliveries → /admin/alert-deliveries
|
||||
Alerts → /admin/alerts
|
||||
Audit Log → /admin/audit-log
|
||||
```
|
||||
|
||||
#### Admin Panel Navigation (when tenant IS selected — full navigation includes both)
|
||||
|
||||
```
|
||||
(Full tenant resources + workspace resources mixed together)
|
||||
Settings
|
||||
Manage workspaces → /admin/workspaces
|
||||
Integrations → /admin/provider-connections
|
||||
Settings → /admin/settings/workspace
|
||||
(+ TenantResource, etc.)
|
||||
Monitoring
|
||||
Operations → /admin/operations
|
||||
Audit Log → /admin/audit-log
|
||||
Tenant-scoped resources (Policies, Inventory, etc.) also visible
|
||||
```
|
||||
|
||||
### 2.3 Scope-Wechsel und Rückwege
|
||||
|
||||
```
|
||||
Login → EnsureWorkspaceSelected middleware:
|
||||
├─ 1 Workspace → auto-select → WorkspaceRedirectResolver:
|
||||
│ ├─ 0 Tenants → ManagedTenantsLanding (/admin/w/{ws}/managed-tenants)
|
||||
│ ├─ 1 Tenant → TenantDashboard (/admin/t/{uuid})
|
||||
│ └─ N Tenants → ChooseTenant (/admin/choose-tenant)
|
||||
└─ N Workspaces → ChooseWorkspace (/admin/choose-workspace)
|
||||
|
||||
Workspace wechseln:
|
||||
Context-Bar → Workspace dropdown → "Switch workspace" → ChooseWorkspace?choose=1
|
||||
✅ Expliziter Wechsel
|
||||
|
||||
Tenant wechseln:
|
||||
Context-Bar → Tenant dropdown → Select tenant (POST)
|
||||
Context-Bar → "Clear tenant context"
|
||||
Filament tenant menu (in Tenant Panel) → Searchable tenant menu
|
||||
✅ Expliziter Wechsel
|
||||
|
||||
Rückweg Tenant → Workspace:
|
||||
❌ Kein direkter "Back to Workspace" Button
|
||||
⚠️ Nur indirekt: Context-Bar → Switch workspace
|
||||
⚠️ Oder: Monitoring-Links → navigieren implizit zum Admin-Panel
|
||||
⚠️ Brand-Logo auf Tenant-Panel führt zum Tenant-Dashboard, nicht zum Workspace-Home
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings Table
|
||||
|
||||
| ID | Kategorie | Severity | Ort / Screen | Datei / Klasse / Route | Symptom | Technische Ursache | Risiko | Empfohlene Korrektur |
|
||||
|----|-----------|----------|--------------|------------------------|---------|---------------------|--------|---------------------|
|
||||
| F01 | Scope-Modell | Critical | `/admin` (Home) | `routes/web.php` L43-66, `WorkspaceRedirectResolver` | `/admin` hat keine eigene Seite, redirected immer auf Tenant-Dashboard oder ChooseWorkspace | Admin-Panel hat kein Dashboard/Home. Route-Handler ist ein Redirect-Closure. | Kein Workspace-Level-Einstieg. Admin-Nutzer sehen nie einen Workspace-Überblick. | Workspace-Home-Page mit KPIs (Tenants, Operations, Health) einführen |
|
||||
| F02 | IA/Navigation | Critical | Tenant-Panel Sidebar → Monitoring | `TenantPanelProvider` L51-63 | "Runs", "Alerts", "Audit Log" verlinken auf `/admin/operations`, `/admin/alerts`, `/admin/audit-log` | `NavigationItem::make()` im TenantPanelProvider nutzt Admin-Panel-Routen + hardcoded URLs | Stiller Scope-Wechsel. User verlässt Tenant-Kontext ohne Warnung. URL ändert sich von `/admin/t/{uuid}/...` zu `/admin/...`. | Monitoring-Links entweder tenant-scoped machen oder als Cross-Scope-Links kennzeichnen + auf Workspace-Panel verlagern |
|
||||
| F03 | Scope-Modell | Critical | Tenant Dashboard KPIs | Dashboard Widgets | "Active operations" + "Inventory active" verlinken auf `/admin/operations` (Admin-Panel) | Dashboard-Widget-Links nutzen Admin-Routen | Scope-Falle: User klickt im Tenant-Dashboard und landet auf workspace-weiter Operations-Seite ohne Kontext | KPI-Links auf tenant-gefilterte Views lenken |
|
||||
| F04 | Security | High | `/admin/audit-log` | `AuditLog.php` | Seite hat kein `canAccess()`, nur `shouldRegisterNavigation = false` | Kein Authorization-Guard auf Page-Ebene | Jeder authentifizierte User kann Audit-Log über URL aufrufen — Verletzung des Least-Privilege-Prinzips | `canAccess()` mit `AUDIT_VIEW` Capability implementieren |
|
||||
| F05 | Security | High | `/admin/operations` | `Operations.php` | Seite hat kein `canAccess()`, nur `isDiscovered = false` | Kein Authorization-Guard auf Page-Ebene | Jeder authentifizierte Workspace-Member kann Operations-Seite aufrufen | `canAccess()` implementieren (Workspace-Membership reicht als Minimum) |
|
||||
| F06 | Registrierung | High | Admin + Tenant Panel | `AdminPanelProvider` L143-154, `TenantPanelProvider` L46-48 | Beide Panels entdecken Resources aus demselben Verzeichnis (`app/Filament/Resources`) | `discoverResources(in: app_path('Filament/Resources'))` in beiden Panels | Tenant-scoped Resources (Policy, Inventory, BackupSchedule, etc.) erscheinen potenziell in beiden Panels. `$isScopedToTenant` verhindert teilweise Daten-Leaks, aber Navigation ist dennoch inkonsistent | Resource-Discovery per Panel isolieren: Tenant-Resources in `app/Filament/Tenant/Resources`, Workspace-Resources in `app/Filament/Admin/Resources` |
|
||||
| F07 | IA | High | Admin Panel Settings-Gruppe | `AdminPanelProvider` L59-110 | Settings-Gruppe enthält: "Manage workspaces" + "Integrations" + "Settings" — wirkt wie der primäre Navigationsbereich, obwohl es sekundär sein sollte | NavigationItem-Registrierung im PanelProvider mit statischer Sortierung | Settings/Admin sind prominent, operative Bereiche (nur Monitoring) wirken nachrangig | Settings ans Ende der Navigation verschieben, operative Bereiche nach oben |
|
||||
| F08 | Scope-Modell | High | Workspace-Level-Navigation | `EnsureFilamentTenantSelected` L172-258 | Wenn kein Tenant gewählt ist, wird eine "Workspace-Level"-Navigation per `NavigationBuilder` inline in der Middleware gebaut | Middleware kompensiert fehlendes Workspace-Panel | Fragile Sonderlogik, schwer wartbar, Navigation-Änderungen erfordern Middleware-Änderung statt Panel-Konfiguration | Workspace-Panel als eigenständiges Panel konfigurieren |
|
||||
| F09 | IA/UX | Medium | Brand-Logo (beide Panels) | `AdminPanelProvider` L56 + `TenantPanelProvider` L29 | Brand-Logo auf Tenant-Panel führt zum aktuellen Tenant-Dashboard (`/admin/t/{uuid}`). Im Admin-Panel führt es zu `/admin` (→ Redirect auf Tenant-Dashboard). Es gibt keinen Weg per Logo zum Workspace-Home. | Filament-Default: `brandLogo` verlinkt auf Panel-Home. Admin-Panel hat kein Home → Redirect. | Nutzer können nie per Logo "nach oben" zum Workspace navigieren | Brand-Logo im Workspace-Panel auf Workspace-Home, im Tenant-Panel auf Tenant-Dashboard verlinken. Cross-Level-Navigation über Context-Bar. |
|
||||
| F10 | Routing | Medium | `/admin/operations/{run}` | `routes/web.php` L149-163 | Operations-Detail-Seite ist workspace-scoped, aber über Admin-Panel geroutet mit eigener Middleware-Kette | Custom Route statt Filament-Resource-Page | Deep-Links zu Operations-Runs (z.B. aus Notifications) funktionieren, aber verlassen den Tenant-Kontext | OK als Workspace-Level-Seite, aber muss klar als solche erkennbar sein |
|
||||
| F11 | RBAC | Medium | `AlertsCluster` | `AlertsCluster.php` | `shouldRegisterNavigation()` prüft nur Panel-ID (`=== 'admin'`), nicht Capabilities | Visibility-Check basiert auf Panel-Name, nicht auf Authorization | Cluster erscheint für alle Admin-Panel-Nutzer, egal ob sie `ALERTS_VIEW` haben | `shouldRegisterNavigation()` soll zusätzlich `ALERTS_VIEW` Capability prüfen |
|
||||
| F12 | Routing | Medium | `url(fn (): string => url('/admin/alerts'))` | `TenantPanelProvider` L55 | Hardcoded URL statt Named Route | `url()` statt `route()` | Brüchig bei URL-Änderungen, kein Laravel-Standard | Named Route verwenden |
|
||||
| F13 | IA | Medium | Tenant Panel Sidebar | TenantPanelProvider + Resource discovery | "Directory > Groups" ist einziger Eintrag unter "Directory" | Natürliches Wachstumslimit | Einelementige Navigationsgruppe wirkt unformful | Entweder "Directory" wachsen lassen oder "Groups" in eine bestehende Gruppe integrieren |
|
||||
| F14 | Registration | Low | `InventoryCluster` | `InventoryCluster.php` | Kein `shouldRegisterNavigation()` Override — erscheint potenziell in beiden Panels | Fehlende Panel-Gate-Logik im Cluster | Cluster könnte in falschen Panels sichtbar werden | `shouldRegisterNavigation()` mit Panel-ID-Check hinzufügen (wie AlertsCluster) |
|
||||
| F15 | UX | Low | Context-Bar | `context-bar.blade.php` | Workspace-Dropdown hat nur eine Option: "Switch workspace". Kein Shortcut zu Workspace-Overview/Dashboard. | Dropdown-Inhalt minimal | Nutzer müssen den Workspace erst wechseln und dann navigieren statt direkt zum Workspace-Home zu gelangen | "Workspace overview" Link im Dropdown ergänzen |
|
||||
|
||||
---
|
||||
|
||||
## 4. Structural Mismatches
|
||||
|
||||
### 4.1 UI-Modell vs Code-Modell
|
||||
|
||||
| Aspekt | UI suggeriert | Code implementiert | Mismatch |
|
||||
|--------|---------------|-------------------|----------|
|
||||
| Monitoring (Runs/Alerts/AuditLog) | Tenant-scoped Monitoring (erscheint in Tenant-Sidebar) | Workspace-scoped Seiten auf Admin-Panel-Routen | **Kritisch**: Scope-Illusion |
|
||||
| Dashboard KPIs | Tenant-spezifische Metriken | "Active operations" und "Inventory active" verlinken auf workspace-weite `/admin/operations` | Scope-Mismatch |
|
||||
| "Settings" Navigationsgruppe | Nebenbereich für Konfiguration | Enthält Workspace-Management + Integrations (=Kernfunktionalität für Admins) | IA-Inversion |
|
||||
| Brand-Logo | "Navigiere zum Home" | Tenant-Panel: OK (→ Tenant-Dashboard). Admin-Panel: Redirect-Loop zu Tenant. | Fehlender Workspace-Home |
|
||||
|
||||
### 4.2 Scope-Modell vs Routing
|
||||
|
||||
| Aspekt | Scope-Modell sagt | Routing implementiert | Mismatch |
|
||||
|--------|-------------------|----------------------|----------|
|
||||
| Workspace-Ebene | Eigener Kontext mit eigenem Home | Kein eigenes Home, kein eigenes Dashboard, nur Redirect | **Kritisch** |
|
||||
| Monitoring-Seiten | Workspace-scoped | Routen unter `/admin/*` mit `panel:admin` Middleware, obwohl Admin-Panel kein Tenancy hat | Middleware-Sonderlogik statt Panel-Architektur |
|
||||
| Operations-View (`/admin/operations/{run}`) | Workspace-scoped Detail | Custom Route mit eigener Middleware-Kette, nicht als Filament Page/Resource registriert | Parallel-Routing neben Filament |
|
||||
|
||||
### 4.3 Navigation vs RBAC
|
||||
|
||||
| Aspekt | Navigation zeigt | RBAC prüft | Mismatch |
|
||||
|--------|------------------|------------|----------|
|
||||
| Audit Log | In Sidebar sichtbar (via NavigationItem in Middleware) | Kein `canAccess()` auf der Page | Visible-and-accessible-without-authorization |
|
||||
| Operations | In Sidebar sichtbar (via NavigationItem) | Kein `canAccess()` auf der Page | Hidden-from-nav-but-accessible-by-url |
|
||||
| AlertsCluster | Prüft Panel-ID, nicht Capability | AlertRulePolicy prüft ALERTS_VIEW | Visibility ≠ Authorization |
|
||||
| Operations im Tenant-Panel | Kein Visibility-Check | OperationRunPolicy prüft Membership | Immer sichtbar, auch für Readonly-User |
|
||||
|
||||
### 4.4 Filament-Defaults vs Produktmodell
|
||||
|
||||
| Filament-Default | Produktmodell erfordert | Aktueller Zustand |
|
||||
|------------------|------------------------|-------------------|
|
||||
| `discoverResources()` ohne Panel-Scoping | Resources per Panel isoliert | Beide Panels discovern dieselben Resources |
|
||||
| Default Panel Home = Dashboard | Workspace-Home ≠ Tenant-Dashboard | Admin-Panel hat kein Home → Redirect auf Tenant-Panel |
|
||||
| Panel `brandLogo` verlinkt auf Panel-Home | Brand-Logo → kontextbezogenes Home | Admin-Panel Home redirected sofort weg |
|
||||
| NavigationItem-Sichtbarkeit = Filament Discovery | RBAC-gesteuerte Sichtbarkeit | Middleware baut Navigation dynamisch |
|
||||
| Cluster shouldRegisterNavigation = true | Per-Panel und per-Capability | Nur Panel-ID Check in AlertsCluster |
|
||||
|
||||
### 4.5 Middleware als Architektur-Kompensation
|
||||
|
||||
| Middleware | Kompensiert | Eigentlich nötig |
|
||||
|-----------|-------------|------------------|
|
||||
| `EnsureFilamentTenantSelected` (270+ LOC) | Fehlende Workspace-Panel-Architektur + fehlende Navigation-Trennung | Eigenständiges Workspace-Panel mit eigener Navigation |
|
||||
| `EnsureWorkspaceSelected` (180+ LOC) | Fehlender Workspace-Home-Einstieg | Workspace-Home als Default-Landing |
|
||||
| `isWorkspaceOptionalPath()` mit Regex | Fehlende saubere Route-Trennung | Workspace-Panel-Routen, die per Middleware-Kette klar sind |
|
||||
| `configureNavigationForRequest()` | Fehlende stabile Workspace-Navigation | Workspace-Panel mit eigener Navigation-Konfiguration |
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Enterprise Architecture
|
||||
|
||||
### 5.1 Drei-Panel-Modell (Zielzustand)
|
||||
|
||||
```
|
||||
┌═══════════════════════════════════════════════════════════┐
|
||||
║ SYSTEM PANEL (path: /system) ║
|
||||
║ Guard: platform | Session: Separate ║
|
||||
║ Purpose: Break-glass, platform ops, directory, access ║
|
||||
║ Home: System Dashboard (Control Tower) ║
|
||||
║ Audience: Platform operators ║
|
||||
║ Status: ✅ Gut isoliert, gut geschützt ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
|
||||
┌═══════════════════════════════════════════════════════════┐
|
||||
║ WORKSPACE PANEL (path: /admin) — DEFAULT PANEL ║
|
||||
║ Guard: web | Tenancy: NONE ║
|
||||
║ Purpose: Workspace-level administration, cross-tenant ║
|
||||
║ monitoring, alerts, integrations, onboarding, settings ║
|
||||
║ Home: WorkspaceDashboard (Tenant-Health, Operations KPIs,║
|
||||
║ Recent Activity, Alerts Summary) ║
|
||||
║ Brand-Logo → /admin (Workspace-Home) ║
|
||||
╠───────────────────────────────────────────────────────────╣
|
||||
║ NAVIGATION GROUPS: ║
|
||||
║ ║
|
||||
║ [Dashboard] Workspace-Home ║
|
||||
║ ║
|
||||
║ [Tenants] Tenant-Übersicht ║
|
||||
║ (Onboarding, Tenant-Status, ║
|
||||
║ → deep link to Tenant-Panel) ║
|
||||
║ ║
|
||||
║ [Monitoring] Operations ║
|
||||
║ Alerts (Overview, Rules, Targets, ║
|
||||
║ Deliveries) ║
|
||||
║ Audit Log ║
|
||||
║ ║
|
||||
║ [Governance] Baseline Profiles ║
|
||||
║ Baseline Snapshots ║
|
||||
║ ║
|
||||
║ [Settings] Workspace Settings ║
|
||||
║ Integrations ║
|
||||
║ Members ║
|
||||
║ Manage Workspaces ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
|
||||
┌═══════════════════════════════════════════════════════════┐
|
||||
║ TENANT PANEL (path: /admin/t) ║
|
||||
║ Guard: web | Tenancy: Tenant::class ║
|
||||
║ Purpose: Tenant-specific operations ║
|
||||
║ Home: TenantDashboard ║
|
||||
║ Brand-Logo → /admin/t/{uuid} (Tenant-Dashboard) ║
|
||||
╠───────────────────────────────────────────────────────────╣
|
||||
║ NAVIGATION GROUPS: ║
|
||||
║ ║
|
||||
║ [Dashboard] Tenant-Dashboard ║
|
||||
║ ║
|
||||
║ [Inventory] Items / Coverage ║
|
||||
║ Policies ║
|
||||
║ Policy Versions ║
|
||||
║ ║
|
||||
║ [Backups & Restore] Backup Schedules ║
|
||||
║ Backup Sets ║
|
||||
║ Restore Runs ║
|
||||
║ ║
|
||||
║ [Governance] Findings ║
|
||||
║ Baseline Compare ║
|
||||
║ ║
|
||||
║ [Directory] Groups ║
|
||||
║ ║
|
||||
║ [Reporting] Review Packs ║
|
||||
║ ║
|
||||
║ ──────────────────────────────── ║
|
||||
║ ⬆ Back to Workspace (Cross-Scope-Link, deutlich ║
|
||||
║ gekennzeichnet → /admin) ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
### 5.2 Workspace-Home/Dashboard — Empfehlung
|
||||
|
||||
Das Workspace-Dashboard soll zeigen:
|
||||
|
||||
1. **Tenant-Health-Overview**: Anzahl aktive Tenants, Status (OK/Warning/Critical), letzte Sync-Zeiten
|
||||
2. **Operations-KPIs**: Active/Queued/Failed operations (workspace-weit)
|
||||
3. **Alert-Summary**: Aktive Alerts, undelivered, rules count
|
||||
4. **Recent Activity**: Letzte 5 Audit-Log-Einträge (workspace-scoped)
|
||||
5. **Needs Attention**: Failed operations, unacknowledged findings, overdue SLAs
|
||||
6. **Quick Actions**: "Onboard tenant", "View operations", "Open tenant" (per Tenant-Link)
|
||||
|
||||
### 5.3 Brand-Logo-Verhalten
|
||||
|
||||
| Panel | Logo-Target | Begründung |
|
||||
|-------|-------------|------------|
|
||||
| System | `/system` | Bleibt im System-Kontext |
|
||||
| Workspace | `/admin` | Workspace-Home (neues Dashboard) |
|
||||
| Tenant | `/admin/t/{uuid}` | Tenant-Dashboard (aktuell korrekt) |
|
||||
|
||||
### 5.4 Cross-Scope-Navigation
|
||||
|
||||
| Richtung | Mechanismus | UI-Hinweis |
|
||||
|----------|-------------|------------|
|
||||
| Workspace → Tenant | Tenant-Liste im Workspace-Dashboard oder Sidebar → "Open in Tenant Panel" | Deutliches Icon (z.B. external-link) + Farb/Style-Unterscheidung |
|
||||
| Tenant → Workspace | Expliziter "Back to Workspace" Link in Tenant-Sidebar (unten) + Context-Bar | Pfeil-Icon + Label "⬆ Workspace" |
|
||||
| Workspace → System | Nur für Platform-Admins über User-Menü oder separate URL | Nicht in normaler Navigation |
|
||||
| Tenant → Tenant | Filament Tenant-Menü (searchable, bereits vorhanden) | ✅ Bereits gut gelöst |
|
||||
|
||||
### 5.5 Kanonische Routen
|
||||
|
||||
| Route | Scope | Zweck |
|
||||
|-------|-------|-------|
|
||||
| `/admin` | Workspace | Workspace-Home/Dashboard |
|
||||
| `/admin/tenants` | Workspace | Tenant-Übersicht (aktuell TenantResource) |
|
||||
| `/admin/operations` | Workspace | Operations-Monitoring |
|
||||
| `/admin/alerts` | Workspace | Alert-Overview |
|
||||
| `/admin/audit-log` | Workspace | Audit-Log |
|
||||
| `/admin/settings/workspace` | Workspace | Workspace-Settings |
|
||||
| `/admin/workspaces` | Workspace | Workspace-Management (cross-workspace) |
|
||||
| `/admin/choose-workspace` | Tenantless | Workspace-Selektor |
|
||||
| `/admin/choose-tenant` | Workspace | Tenant-Selektor |
|
||||
| `/admin/t/{uuid}` | Tenant | Tenant-Dashboard |
|
||||
| `/admin/t/{uuid}/inventory` | Tenant | Inventar |
|
||||
| `/admin/t/{uuid}/policies` | Tenant | Policies |
|
||||
| `/admin/t/{uuid}/findings` | Tenant | Findings |
|
||||
| `/admin/t/{uuid}/backup-sets` | Tenant | Backup Sets |
|
||||
| `/system` | Platform | System Control Tower |
|
||||
|
||||
---
|
||||
|
||||
## 6. Navigation & Scope Principles
|
||||
|
||||
### Verbindliche Architekturregeln
|
||||
|
||||
1. **Workspace ist primärer Kontext.** Jeder authentifizierte User befindet sich immer in genau einem Workspace. Der Workspace wird über Session persistiert und in der Context-Bar angezeigt.
|
||||
|
||||
2. **Tenant ist operativer Kontext.** Tenant-scoped Seiten erfordern einen aktiven Tenant. Der Tenant-Kontext ist optional auf Workspace-Ebene und verpflichtend auf Tenant-Ebene.
|
||||
|
||||
3. **Jede Ebene hat genau einen klaren Home-Einstieg.** System → System Dashboard. Workspace → Workspace Dashboard. Tenant → Tenant Dashboard.
|
||||
|
||||
4. **Tenant-scoped Navigation erscheint NIE im Workspace-Panel.** Resources wie Policy, InventoryItem, BackupSchedule, Finding, ReviewPack gehören ausschließlich ins Tenant-Panel.
|
||||
|
||||
5. **Workspace-scoped Navigation erscheint NIE im Tenant-Panel.** Operations, Alerts, Audit Log, Workspace Settings, Workspace Management gehören ausschließlich ins Workspace-Panel.
|
||||
|
||||
6. **Settings/Admin sind immer untergeordnet zum Kernworkflow.** Settings-Navigationsgruppe steht am Ende der Sidebar, nie am Anfang.
|
||||
|
||||
7. **Brand-Logo führt zum kanonischen Home der aktuellen Ebene.** Workspace-Panel → `/admin`. Tenant-Panel → `/admin/t/{uuid}`.
|
||||
|
||||
8. **Switch workspace und Manage workspaces sind getrennte Konzepte.** "Switch workspace" wechselt den Kontext (via ChooseWorkspace). "Manage workspaces" ist eine Admin-Funktion (CRUD).
|
||||
|
||||
9. **Navigation ist NIE die Sicherheitsgrenze.** Jede Seite muss `canAccess()` implementieren, unabhängig von `shouldRegisterNavigation`. `shouldRegisterNavigation = false` ist eine UX-Entscheidung, keine Security-Maßnahme.
|
||||
|
||||
10. **Cross-Scope-Wechsel sind explizit.** Links, die den Scope wechseln (z.B. Tenant → Workspace), müssen visuell als solche erkennbar sein (Icon, Label, Farbe).
|
||||
|
||||
11. **Middleware erzeugt keine fachliche Navigation.** Die Navigation wird in der Panel-Konfiguration definiert, nicht dynamisch in Middleware gebaut.
|
||||
|
||||
12. **Resource-Discovery ist per Panel isoliert.** Jedes Panel discover Resources nur aus seinem eigenen Verzeichnis. Kein `discoverResources()` über Panel-Grenzen hinweg.
|
||||
|
||||
13. **Alle Filament-Defaults müssen explizit bestätigt oder überschrieben werden.** Default-Home, Default-Discovery, Default-Logo-Verhalten dürfen nicht stillschweigend das Produktmodell dominieren.
|
||||
|
||||
14. **URL-Semantik spiegelt Scope-Hierarchy wider.** `/admin/...` = Workspace-Level. `/admin/t/{uuid}/...` = Tenant-Level. `/system/...` = Platform-Level.
|
||||
|
||||
15. **Polling und Heavy Assets werden capability-gated und interval-kontrolliert.** Workspace-Widgets die workspace-weite Queries machen, dürfen nicht unkontrolliert pollen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommendations
|
||||
|
||||
### 7.1 Quick Wins (sofort umsetzbar, geringes Risiko)
|
||||
|
||||
| # | Empfehlung | Nutzen | Risiko | Aufwand | Abhängigkeiten |
|
||||
|---|------------|--------|--------|---------|----------------|
|
||||
| QW1 | `canAccess()` auf `AuditLog.php` und `Operations.php` implementieren | Security-Lücke schließen | Keins | 30min | Keine |
|
||||
| QW2 | `shouldRegisterNavigation()` in `InventoryCluster` auf Panel-ID + Capability gaten | Verhindert Cross-Panel-Sichtbarkeit | Keins | 15min | Keine |
|
||||
| QW3 | Hardcoded URL `url('/admin/alerts')` durch Named Route ersetzen | Routing-Stabilität | Keins | 5min | Keine |
|
||||
| QW4 | Workspace-Overview-Link in Context-Bar-Dropdown ergänzen | Orientierung verbessern | Keins | 30min | Keine |
|
||||
| QW5 | Dashboard KPI-Links (Active operations, Inventory active) tenant-filtern | Scope-Konsistenz | Keins | 1h | Keine |
|
||||
|
||||
**Aktion:** Sofort implementieren.
|
||||
|
||||
### 7.2 Mittlere Refactors
|
||||
|
||||
| # | Empfehlung | Nutzen | Risiko | Aufwand | Abhängigkeiten |
|
||||
|---|------------|--------|--------|---------|----------------|
|
||||
| MR1 | Monitoring-NavigationItems aus TenantPanelProvider entfernen | Eliminiert Cross-Scope-Navigation | Mittel (ändert Tenant-Panel UX) | 2h | Workspace-Panel muss Monitoring übernehmen |
|
||||
| MR2 | "Back to Workspace"-Link in Tenant-Panel-Sidebar einführen | Bidirektionale Navigation | Gering | 1h | Workspace-Panel muss existieren mit Home |
|
||||
| MR3 | Resource-Discovery per Panel isolieren (Verzeichnistrennung) | Eliminiert Cross-Panel-Registrierung | Mittel (Dateiumzug) | 4h | Tests müssen angepasst werden |
|
||||
| MR4 | `EnsureFilamentTenantSelected::configureNavigationForRequest()` in Panel-Konfiguration verlagern | Architectural Debt tilgen | Mittel | 4h | Workspace-Panel existiert |
|
||||
| MR5 | AlertsCluster `shouldRegisterNavigation()` um Capability-Check erweitern | RBAC-Konsistenz | Gering | 30min | Keine |
|
||||
|
||||
**Aktion:** Als Spec schreiben, nach Quick Wins umsetzen.
|
||||
|
||||
### 7.3 Größere Strukturänderungen
|
||||
|
||||
| # | Empfehlung | Nutzen | Risiko | Aufwand | Abhängigkeiten |
|
||||
|---|------------|--------|--------|---------|----------------|
|
||||
| GA1 | Workspace-Home/Dashboard als eigenständige Page im Admin-Panel einführen | Löst F01, F09, ermöglicht eigenständigen Workspace-Kontext | Mittel (Redirect-Logik muss angepasst werden) | 8-16h | Widget-Entwicklung, UX-Design |
|
||||
| GA2 | Monitoring-Navigation komplett in Workspace-Panel verlagern | Löst F02, F03, eliminiert Cross-Scope-Links | Hoch (ändert gesamte Monitoring-UX) | 16-24h | GA1, MR1 |
|
||||
| GA3 | Resource-Verzeichnisstruktur nach Panel aufteilen (`Filament/Admin/Resources`, `Filament/Tenant/Resources`) | Löst F06, verhindert künftige Cross-Panel-Registrierung | Mittel (viele Dateien bewegen) | 4-8h | Tests anpassen |
|
||||
| GA4 | `EnsureFilamentTenantSelected` Middleware refactoren: Pure scope-validation, keine Navigation-Logik | Löst F08, S5, reduziert Middleware-Komplexität | Mittel | 8-16h | GA1, GA2 |
|
||||
|
||||
**Aktion:** In Constitution/Product-Principles verankern, als Specs planen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Spec Backlog Proposal
|
||||
|
||||
### Spec 120: Workspace Home & Dashboard
|
||||
|
||||
**Titel:** Workspace-Level Dashboard als eigenständiger Einstieg
|
||||
**Ziel:** Einen stabilen, informativen Workspace-Home-Einstieg schaffen, der Nutzern cross-tenant Überblick, Operations-Status und Quick Actions bietet.
|
||||
**Problem:** `/admin` hat kein Dashboard/Home, redirected sofort auf Tenant-Dashboard. Kein Workspace-Level-Überblick existiert. Die Brand-Logo-Navigation ist sinnlos.
|
||||
**Scope:** WorkspaceDashboard Page, Widgets (Tenant Health, Operations KPIs, Alerts Summary, Recent Activity, Needs Attention), Redirect-Logik anpassen, Brand-Logo-Target korrigieren.
|
||||
**Non-goals:** Keine Änderung der Tenant-Panel-Navigation. Kein neues Panel.
|
||||
**Priorität:** P0
|
||||
**Risiken:** Widget-Queries müssen workspace-scoped und performant sein.
|
||||
**Abhängigkeiten:** Keine.
|
||||
**Reihenfolge:** 1 (Enabler für alle weiteren Specs)
|
||||
|
||||
---
|
||||
|
||||
### Spec 121: Monitoring Scope Separation & Cross-Scope Navigation Cleanup
|
||||
|
||||
**Titel:** Monitoring-Navigation aus Tenant-Panel entfernen, Workspace-Monitoring stärken
|
||||
**Ziel:** Monitoring (Operations, Alerts, Audit Log) gehört eindeutig zum Workspace-Panel. Im Tenant-Panel sollen keine Cross-Scope-Links zu Admin-Routen existieren.
|
||||
**Problem:** Monitoring-Gruppe im Tenant-Panel verlinkt auf `/admin/*`-Routen. User verlassen den Tenant-Kontext ohne Warnung. Dashboard-KPIs verlinken ebenfalls cross-scope.
|
||||
**Scope:** NavigationItems aus TenantPanelProvider entfernen. Monitoring-Navigation im Admin-Panel stärken. Dashboard-KPIs auf tenant-scoped Views lenken. "Back to Workspace"-Link in Tenant-Panel einführen.
|
||||
**Non-goals:** Kein tenant-scoped Monitoring (das kommt ggf. später).
|
||||
**Priorität:** P0
|
||||
**Risiken:** Nutzer verlieren temporär Monitoring-Zugang im Tenant-Panel. Mitigation: "Back to Workspace → Monitoring" als expliziter Weg.
|
||||
**Abhängigkeiten:** Spec 120 (Workspace-Home existiert als Rückweg-Ziel).
|
||||
**Reihenfolge:** 2
|
||||
|
||||
---
|
||||
|
||||
### Spec 122: Resource Discovery Panel Isolation
|
||||
|
||||
**Titel:** Filament Resource-Discovery per Panel isolieren
|
||||
**Ziel:** Jedes Panel entdeckt nur Resources, die zu seinem Scope gehören. Keine Cross-Panel-Registrierung.
|
||||
**Problem:** Beide Panels rufen `discoverResources(in: app_path('Filament/Resources'))` auf. Tenant-scoped Resources erscheinen im Admin-Panel. AdminPanelProvider registriert explizit tenant-scoped Resources (Policy, Inventory).
|
||||
**Scope:** Verzeichnisstruktur umbauen: `app/Filament/Admin/Resources/` + `app/Filament/Tenant/Resources/`. Discovery-Paths in Providern anpassen. Tests aktualisieren.
|
||||
**Non-goals:** Keine Funktionsänderung an Resources selbst. Kein neues Panel.
|
||||
**Priorität:** P1
|
||||
**Risiken:** Viele Dateien bewegen → Import-Pfade und Namespaces anpassen. Tests müssen grün bleiben.
|
||||
**Abhängigkeiten:** Keine (kann parallel zu 120/121 laufen).
|
||||
**Reihenfolge:** 3 (parallel möglich)
|
||||
|
||||
---
|
||||
|
||||
### Spec 123: Page-Level Authorization Zero-Trust Enforcement
|
||||
|
||||
**Titel:** `canAccess()` auf allen Filament-Pages als Pflicht-Guard
|
||||
**Ziel:** Jede Filament-Page muss eine `canAccess()`-Methode haben, die authorization durchführt. `shouldRegisterNavigation = false` ist keine Security-Maßnahme.
|
||||
**Problem:** `AuditLog`, `Operations` und weitere Pages haben kein `canAccess()`. Sie sind über URL direkt erreichbar.
|
||||
**Scope:** Alle Pages auditieren. Fehlende `canAccess()` ergänzen. Archunit-ähnliche Regel als Test (jede Page-Klasse muss `canAccess()` definieren).
|
||||
**Non-goals:** Keine Policy-Änderungen. Keine neuen Capabilities.
|
||||
**Priorität:** P1
|
||||
**Risiken:** Gering. Falsche `canAccess()`-Logik könnte Zugang sperren → Tests.
|
||||
**Abhängigkeiten:** Keine.
|
||||
**Reihenfolge:** 2 (parallel zu Spec 121)
|
||||
|
||||
---
|
||||
|
||||
### Spec 124: Middleware Navigation Extraction
|
||||
|
||||
**Titel:** Navigation-Logik aus `EnsureFilamentTenantSelected` in Panel-Konfiguration verlagern
|
||||
**Ziel:** Die 270-LOC-Middleware soll reine Scope-Validation machen. Navigation-Build-Logik gehört in Panel-Konfiguration.
|
||||
**Problem:** `configureNavigationForRequest()` baut inline eine "Workspace-Level"-Navigation per `NavigationBuilder`. Das ist fragile Sonderlogik.
|
||||
**Scope:** Navigation-Konfiguration in Admin-Panel-Provider verlagern. Middleware auf Scope-Validation reduzieren. Tests für beide Aspekte separat.
|
||||
**Non-goals:** Kein neues Panel. Keine Funktionsänderung.
|
||||
**Priorität:** P1
|
||||
**Risiken:** Mittel. Navigation-Verhalten muss identisch bleiben.
|
||||
**Abhängigkeiten:** Spec 120 (Workspace-Home als Landing-Ziel), Spec 121 (Monitoring-Navigation bereinigt).
|
||||
**Reihenfolge:** 4
|
||||
|
||||
---
|
||||
|
||||
### Spec 125: Context-Bar & Scope-Indicator Enhancement
|
||||
|
||||
**Titel:** Context-Bar um Workspace-Home-Link, Scope-Indicator und Back-to-Workspace erweitern
|
||||
**Ziel:** Nutzer sollen jederzeit wissen, auf welcher Ebene sie sich befinden, und explizit zwischen Ebenen wechseln können.
|
||||
**Problem:** Kein Workspace-Home-Link in Context-Bar. Kein visueller Scope-Indicator (Workspace vs Tenant). "Back to Workspace" nur indirekt möglich.
|
||||
**Scope:** Context-Bar-Dropdown erweitern: "Workspace overview" Link. Scope-Badge in Topbar (Workspace | Tenant). "Back to Workspace ↑" im Tenant-Panel.
|
||||
**Non-goals:** Keine Breadcrumbs-Überarbeitung.
|
||||
**Priorität:** P2
|
||||
**Risiken:** Gering. UX-Änderung, kein Security-Impact.
|
||||
**Abhängigkeiten:** Spec 120 (Workspace-Home existiert).
|
||||
**Reihenfolge:** 5
|
||||
|
||||
---
|
||||
|
||||
### Spec 126: Navigation & RBAC Alignment Enforcement
|
||||
|
||||
**Titel:** Navigation-Sichtbarkeit an Capabilities koppeln (Cluster, NavigationItems, Resources)
|
||||
**Ziel:** Jeder NavigationItem und Cluster soll seine Sichtbarkeit an Capabilities koppeln, nicht nur an Panel-ID.
|
||||
**Problem:** AlertsCluster prüft nur Panel-ID. NavigationItems im AdminPanelProvider sind nicht alle capability-gated. InventoryCluster hat keinen Visibility-Check.
|
||||
**Scope:** Alle Clusters und NavigationItems mit Capability-Checks ausstatten. Architectural Test: "Jeder NavigationItem muss eine `visible()`-Closure haben, die Capabilities prüft."
|
||||
**Non-goals:** Keine neuen Capabilities definieren.
|
||||
**Priorität:** P2
|
||||
**Risiken:** Gering. Könnte Navigation für Nutzer mit eingeschränkten Rechten ändern → gezieltes Testing.
|
||||
**Abhängigkeiten:** Keine.
|
||||
**Reihenfolge:** 5 (parallel zu Spec 125)
|
||||
|
||||
---
|
||||
|
||||
### Empfohlene Reihenfolge:
|
||||
|
||||
```
|
||||
1. Spec 120 (Workspace Home) ← Enabler [P0]
|
||||
2. Spec 121 (Monitoring Scope) + Spec 123 (canAccess Zero Trust) [P0/P1, parallel]
|
||||
3. Spec 122 (Resource Discovery Isolation) [P1, parallel]
|
||||
4. Spec 124 (Middleware Extraction) [P1, after 120+121]
|
||||
5. Spec 125 (Context-Bar) + Spec 126 (RBAC Alignment) [P2, parallel]
|
||||
```
|
||||
|
||||
Quick Wins (QW1–QW5) werden sofort vor allen Specs implementiert.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Discovery-Overlap-Analyse
|
||||
|
||||
Beide Panels discovern aus `app/Filament/Resources`:
|
||||
|
||||
| Resource | `$tenantOwnershipRelationshipName` | Admin-Panel (should be) | Tenant-Panel (should be) | Aktuell in |
|
||||
|----------|------------------------------------|-----------------------|------------------------|----|
|
||||
| PolicyResource | `tenant` | ❌ Nein | ✅ Ja | Beide |
|
||||
| InventoryItemResource | `tenant` | ❌ Nein | ✅ Ja | Beide |
|
||||
| BackupScheduleResource | `tenant` | ❌ Nein | ✅ Ja | Beide |
|
||||
| BackupSetResource | `tenant` | ❌ Nein | ✅ Ja | Beide |
|
||||
| RestoreRunResource | `tenant` | ❌ Nein | ✅ Ja | Beide |
|
||||
| FindingResource | `tenant` | ❌ Nein | ✅ Ja | Beide |
|
||||
| ReviewPackResource | `tenant` | ❌ Nein | ✅ Ja | Beide |
|
||||
| PolicyVersionResource | `tenant` | ❌ Nein | ✅ Ja | Beide |
|
||||
| EntraGroupResource | none | ❌ Nein | ✅ Ja | Beide |
|
||||
| TenantResource | none | ✅ Ja | ❌ Nein | Beide (explizit) |
|
||||
| OperationRunResource | none | ✅ Ja | ❌ Nein | Beide |
|
||||
| AlertRuleResource | none | ✅ Ja | ❌ Nein | Beide |
|
||||
| AlertDestinationResource | none | ✅ Ja | ❌ Nein | Beide |
|
||||
| AlertDeliveryResource | none | ✅ Ja | ❌ Nein | Beide |
|
||||
| ProviderConnectionResource | none | ✅ Ja | ❌ Nein | Admin (explicit) |
|
||||
| WorkspaceResource | none | ✅ Ja | ❌ Nein | Admin (explicit, not discovered) |
|
||||
| BaselineProfileResource | none | ✅ Ja | ❌ Nein | Admin (explicit, not discovered) |
|
||||
| BaselineSnapshotResource | none | ✅ Ja | ❌ Nein | Admin (explicit, not discovered) |
|
||||
|
||||
**Ergebnis:** 9 tenant-scoped Resources werden aktuell in beiden Panels discovert. Filament's Tenancy-System filtert die Daten korrekt per `$tenantOwnershipRelationshipName`, aber die Resources erscheinen trotzdem in der Navigation des Admin-Panels (wenn auch teilweise ohne Daten).
|
||||
@ -7,7 +7,7 @@ # Implementation Plan: Filter UX Standardization
|
||||
|
||||
## Summary
|
||||
|
||||
Standardize filter UX across TenantPilot’s important Filament list surfaces using native Filament filters, native session persistence, and light regression guards. The implementation will add the persistence trio to all Tier 1 and Tier 2 filtered resource lists, expand consistent `TrashedFilter` usage, add missing date-range and essential domain filters on the highest-value surfaces, align prioritized status filters to centralized vocabularies, and extend existing Pest guard and Livewire coverage without introducing a custom filter framework. A thin shared helper layer is intentionally selected for repeated option-source, archived-filter, and date-range mechanics because those repetitions are already proven across the target surfaces.
|
||||
Standardize filter UX across TenantPilot’s important Filament list surfaces using native Filament filters, native session persistence, and light regression guards. The implementation will add the persistence trio to all Tier 1 and Tier 2 filtered resource lists, expand consistent `TrashedFilter` usage, add missing date-range and essential domain filters on the highest-value surfaces, align prioritized status filters to centralized vocabularies, and extend existing Pest guard and Livewire coverage without introducing a custom filter framework. A thin shared helper layer is intentionally selected for repeated option-source, archived-filter, and date-range mechanics because those repetitions are already proven across the target surfaces. After the main rollout, the plan also allows two low-risk follow-ups where operator review exposed the same gap: `BaselineSnapshotResource` and `BackupItemsRelationManager`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
@ -19,7 +19,7 @@ ## Technical Context
|
||||
**Project Type**: Laravel monolith / Filament web application
|
||||
**Performance Goals**: No material query regression on existing list pages; new date-range and status filters must compose with existing scopes without introducing obvious N+1 or high-cost relation queries; list-state persistence remains native and server-driven
|
||||
**Constraints**: Filament-native first, no plugin dependency, no heavy helper framework, no grouped custom filter UI, no authorization behavior changes, no schema changes, no new asset pipeline work, and any extracted helper must remain thin and mechanically scoped
|
||||
**Scale/Scope**: 12 Tier 1–2 resource lists define the core standard; 5 immediate persistence-gap resources, 6 immediate essential-filter targets, 2 prioritized status-source alignments, 1 existing table guard suite to expand, and optional low-effort Tier 3 cleanup only after core consistency is stable
|
||||
**Scale/Scope**: 12 Tier 1–2 resource lists define the core standard; 5 immediate persistence-gap resources, 6 immediate essential-filter targets, 2 prioritized status-source alignments, 1 existing table guard suite to expand, and 2 approved low-effort follow-up surfaces only after core consistency is stable
|
||||
|
||||
## Constitution Check
|
||||
|
||||
@ -151,7 +151,7 @@ ## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quic
|
||||
Design focus:
|
||||
- Model filter behavior as a contract over existing resource tables rather than as a new runtime subsystem.
|
||||
- Keep the standard resource-local and Filament-native, with only thin preset extraction for repeated mechanical patterns.
|
||||
- Make persistence mandatory on Tier 1 and Tier 2 resource lists while keeping relation managers, widgets, and pickers out of scope unless already justified.
|
||||
- Make persistence mandatory on Tier 1 and Tier 2 resource lists while keeping relation managers, widgets, and pickers out of scope unless already justified. `BackupItemsRelationManager` is the explicit justified exception because the gap surfaced during real backup review, and `BaselineSnapshotResource` is the matching workspace-scoped follow-up.
|
||||
- Treat centralized status vocabularies as existing domain assets that may need thin option helpers rather than broad enum migrations everywhere.
|
||||
- Preserve query safety and tenancy/workspace boundaries while adding date-range and essential domain filters.
|
||||
|
||||
@ -178,6 +178,7 @@ ### Verification
|
||||
- Run focused Pest suites for guards and changed resource table behavior.
|
||||
- Run Pint on dirty files through Sail.
|
||||
- Perform manual QA on refresh/navigation persistence, date-range indicators, and archive semantics on representative tenant and workspace lists.
|
||||
- Perform targeted follow-up QA on `BaselineSnapshotResource` and `BackupItemsRelationManager` once the core Tier 1–2 rollout is stable.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
|
||||
@ -22,6 +22,12 @@ ## First-Wave Implementation Steps
|
||||
6. Replace prioritized local status arrays with centralized option sources backed by the thin shared helper layer where needed.
|
||||
7. Keep domain-specific filter intent resource-local while using the thin shared helper layer only for repeated mechanical presets.
|
||||
|
||||
## Approved Follow-up Surfaces
|
||||
|
||||
1. Add the same persistence and filter treatment to `BaselineSnapshotResource` when governance review shows the missing filter gap in daily use.
|
||||
2. Add type/restore/platform filtering to `BackupItemsRelationManager` when backup reviewers need to isolate specific foundations or RBAC entries inside a backup set.
|
||||
3. Keep these follow-ups Filament-native and low-risk; they are extensions of the same standard, not a second filter framework.
|
||||
|
||||
## Verification
|
||||
|
||||
### Automated
|
||||
@ -30,6 +36,8 @@ ### Automated
|
||||
vendor/bin/sail up -d
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Guards/FilamentTableStandardsGuardTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStatePersistenceTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotListFiltersTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupItemsRelationManagerFiltersTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/Findings
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/Alerts
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
@ -42,9 +50,11 @@ ### Manual
|
||||
3. Apply each new date-range filter and confirm the indicator summary shows the active window clearly.
|
||||
4. Verify Archived, Active, and All semantics on each changed soft-deletable surface.
|
||||
5. Verify workspace-scoped monitoring lists and tenant-scoped inventory/governance lists do not reveal records outside the current entitlement boundary.
|
||||
6. On `Baseline Snapshots`, filter by baseline, state, and capture window, then clear the filters and confirm the full workspace-scoped list returns.
|
||||
7. Inside a backup set, filter `Items` by type, restore mode, and platform to isolate RBAC or foundation entries without losing the backup-set scope.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Revert the affected resource `table()` filter definitions and any guard updates that enforce the new standard.
|
||||
- Keep the thin shared helper layer mechanically scoped and remove only pieces that stop serving repeated archived, date-range, or centralized-option use cases.
|
||||
- No database rollback is required because the feature introduces no schema changes.
|
||||
- No database rollback is required because the feature introduces no schema changes.
|
||||
|
||||
@ -8,13 +8,14 @@ ## Decision 1: Use resource-local native Filament filters as the default impleme
|
||||
- Build a generic filter DSL or plugin-backed filter framework: rejected because the spec explicitly forbids heavy abstraction and the audit concluded the work is fully achievable with Filament-native APIs.
|
||||
- Centralize all filter definitions into traits or macros first: rejected because the behavior varies by domain and the review burden would increase immediately.
|
||||
|
||||
## Decision 2: Treat Tier 1 and Tier 2 persistence as mandatory and enforce it with the existing guard suite
|
||||
## Decision 2: Treat Tier 1 and Tier 2 persistence as mandatory and enforce it with the existing guard suite, then add only justified follow-up surfaces
|
||||
|
||||
- Decision: Expand the persistence expectation from the current seven-resource enforcement set to all Tier 1 and Tier 2 filtered resource lists.
|
||||
- Rationale: The audit identified the main consistency gap as missing state persistence on `InventoryItemResource`, `PolicyVersionResource`, `RestoreRunResource`, `AlertDeliveryResource`, and `EntraGroupResource`. The repo already has both native persistence methods and a guard test structure in `tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, so the smallest reliable move is to extend that existing enforcement.
|
||||
- Rationale: The audit identified the main consistency gap as missing state persistence on `InventoryItemResource`, `PolicyVersionResource`, `RestoreRunResource`, `AlertDeliveryResource`, and `EntraGroupResource`. The repo already has both native persistence methods and a guard test structure in `tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, so the smallest reliable move is to extend that existing enforcement. After rollout, operator review exposed the same obvious gap on `BaselineSnapshotResource` and `BackupItemsRelationManager`, which are both low-risk enough to include as explicit follow-ups.
|
||||
- Alternatives considered:
|
||||
- Leave persistence as a review convention only: rejected because the current drift happened under partial convention and weak enforcement.
|
||||
- Add persistence to relation managers, pickers, widgets, and system pages in the same pass: rejected because the strongest operator pain is on resource lists and immediate broadening would expand scope unnecessarily.
|
||||
- Add persistence to every relation manager, picker, widget, and system page in the same pass: rejected because the strongest operator pain was on resource lists and immediate broadening would expand scope unnecessarily.
|
||||
- Keep `BaselineSnapshotResource` and `BackupItemsRelationManager` out of scope even after operator feedback: rejected because both surfaces surfaced during real workflows, the filter gaps were obvious, and the implementation remained low-risk and Filament-native.
|
||||
|
||||
## Decision 3: Keep the soft-delete pattern exactly as the existing archive standard
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ # Feature Specification: Filter UX Standardization
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**: Tier 1 and Tier 2 Filament resource list pages under `/admin`, `/admin/t/{tenant}/...`, and selected low-effort Tier 3 list pages where the current filter gap is obvious and low risk
|
||||
- **Primary Routes**: Tier 1 and Tier 2 Filament resource list pages under `/admin`, `/admin/t/{tenant}/...`, selected low-effort Tier 3 list pages where the current filter gap is obvious and low risk, and the high-value backup-set item relation table when operator review exposes the same gap during backup inspection
|
||||
- **Data Ownership**: Both workspace-owned and tenant-owned records are affected at the list-behavior layer only; this feature does not create new business entities or change underlying ownership
|
||||
- **RBAC**: Existing workspace membership, tenant membership, plane separation, and capability gates remain authoritative; this feature standardizes list filtering behavior only within already-authorized surfaces
|
||||
|
||||
@ -89,7 +89,7 @@ ### Functional Requirements
|
||||
- **FR-002**: The standard MUST apply mandatory list-state persistence to all Tier 1 and Tier 2 resource lists that expose filters.
|
||||
- **FR-003**: Tier 1 resources for this standard are `PolicyResource`, `FindingResource`, `OperationRunResource`, `TenantResource`, and `InventoryItemResource`.
|
||||
- **FR-004**: Tier 2 resources for this standard are `BackupScheduleResource`, `BackupSetResource`, `RestoreRunResource`, `PolicyVersionResource`, `ProviderConnectionResource`, `AlertDeliveryResource`, and `EntraGroupResource`.
|
||||
- **FR-005**: Tier 3 resources may receive low-effort, high-value improvements, but Tier 3 work MUST NOT delay Tier 1 or Tier 2 rollout.
|
||||
- **FR-005**: Tier 3 resources and low-effort relation-manager tables may receive high-value improvements when the UX gap is obvious, but that follow-up work MUST NOT delay Tier 1 or Tier 2 rollout.
|
||||
- **FR-006**: Every in-scope soft-deletable resource list MUST expose a consistent archive visibility filter using the shared Active, All, and Archived semantics.
|
||||
- **FR-007**: Archived visibility wording MUST use “Archived” rather than resource-local alternatives such as “Trashed” or “Deleted.”
|
||||
- **FR-008**: Status and outcome filters on prioritized resources MUST source their options from a centralized enum or domain catalog when such a source exists.
|
||||
@ -129,7 +129,8 @@ ### Phase 3 - Consistency and Polish
|
||||
|
||||
### Phase 4 - Optional Low-Priority Follow-ups
|
||||
|
||||
- Consider low-effort Tier 3 additions only after Tier 1 and Tier 2 consistency is stable.
|
||||
- Consider low-effort Tier 3 additions and relation-manager follow-ups only after Tier 1 and Tier 2 consistency is stable.
|
||||
- `BaselineSnapshotResource` and `BackupItemsRelationManager` are approved examples of these follow-ups because operator review exposed immediate filtering friction on active governance and backup-inspection workflows.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
@ -147,6 +148,8 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
| Alert delivery list | `app/Filament/Resources/AlertDeliveryResource.php` + `app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php` | Existing monitoring header actions retained | Existing record inspection affordance retained | Existing row actions retained | Existing grouped bulk actions retained where supported | Existing empty-state CTA structure retained | Existing `ViewAlertDelivery` header actions retained | Not changed by this spec | Unchanged | Filter-only change; workspace-context entitlement behavior remains unchanged |
|
||||
| Entra group list | `app/Filament/Resources/EntraGroupResource.php` + `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` | Existing resource header actions retained | Existing record inspection affordance retained | Existing row actions retained | Existing grouped bulk actions retained where supported | Existing empty-state CTA structure retained | Existing `ViewEntraGroup` header actions retained | Not changed by this spec | Unchanged | Filter-only change |
|
||||
| Baseline profile list | `app/Filament/Resources/BaselineProfileResource.php` + `app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php` | Existing baseline-profile header actions retained | Existing record inspection affordance retained | Existing row actions retained | Existing grouped bulk actions retained | Existing empty-state CTA structure retained | Existing `ViewBaselineProfile` header actions retained | Existing create/edit flows retained | Unchanged | Filter-only change |
|
||||
| Baseline snapshot list | `app/Filament/Resources/BaselineSnapshotResource.php` + `app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php` | Existing list header remains empty because snapshots are capture outputs | Existing record inspection affordance retained | Existing `View` row action retained | No bulk actions by design | Existing empty-state CTA structure retained | Existing `ViewBaselineSnapshot` header remains informational | Not changed by this spec | Unchanged | Low-effort follow-up filter-only change on a workspace-scoped governance list |
|
||||
| Backup set items relation table | `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` | Existing `Refresh` and `Add Policies` header actions retained | Existing relation-table inspection affordance retained | Existing grouped row actions retained, including destructive remove with confirmation | Existing grouped bulk remove retained | Existing empty-state CTA retained | Uses parent backup-set view header; no new detail-header action | Not changed by this spec | Unchanged | Low-effort follow-up filter-only change on the backup inspection table; existing authorization and confirmation behavior remain unchanged |
|
||||
| Operation run canonical table | `app/Filament/Resources/OperationRunResource.php` and workspace monitoring pages that reuse it | Existing canonical page actions retained | Existing record inspection affordance retained in monitoring surfaces | Existing row actions retained | Existing grouped bulk actions retained where supported | Existing empty-state CTA structure retained | No new view/create/edit change in this spec | Not applicable | Unchanged | Filter option-label alignment only; canonical workspace view remains DB-only and entitlement-safe |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
@ -79,6 +79,9 @@ ### Implementation for User Story 2
|
||||
- [X] T020 [US2] Add policy type, platform, `captured_at` date-range, and standard archived filtering in `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- [X] T021 [US2] Add platform and sync-freshness filtering in `app/Filament/Resources/InventoryItemResource.php` and a status filter in `app/Filament/Resources/BaselineProfileResource.php`
|
||||
- [X] T022 [US2] Update the operation-type filter options in `app/Filament/Resources/OperationRunResource.php` to use `OperationCatalog` labels consistently with the filter standard
|
||||
- [X] T030 [P] [US2] Add follow-up filter behavior coverage in `tests/Feature/Filament/BackupItemsRelationManagerFiltersTest.php` and `tests/Feature/Filament/BaselineSnapshotListFiltersTest.php`
|
||||
- [X] T031 [US2] Add type, restore-mode, platform filters and session persistence to `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`
|
||||
- [X] T032 [US2] Add baseline, state, `captured_at` filters and session persistence to `app/Filament/Resources/BaselineSnapshotResource.php`, and extend persistence enforcement in `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` and `tests/Feature/Filament/TableStatePersistenceTest.php`
|
||||
|
||||
**Checkpoint**: The highest-value Tier 1–2 lists share predictable archived semantics, date-range behavior, and centralized status labels.
|
||||
|
||||
|
||||
36
specs/127-rbac-inventory-backup/checklists/requirements.md
Normal file
36
specs/127-rbac-inventory-backup/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Intune RBAC Inventory & Backup v1
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-09
|
||||
**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
|
||||
|
||||
- Validation completed on 2026-03-09.
|
||||
- No clarification markers remain.
|
||||
- The spec stays intentionally read-oriented for v1 and explicitly excludes restore execution, compare, and drift expansion.
|
||||
@ -0,0 +1,70 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://tenantpilot.local/contracts/foundation-rbac-snapshot.schema.json",
|
||||
"title": "Foundation RBAC Snapshot",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "sourceId", "payload", "metadata"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["intuneRoleDefinition", "intuneRoleAssignment"]
|
||||
},
|
||||
"policyId": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Synthetic tenant-scoped policy anchor used when immutable RBAC history is linked into the shared policy-version surfaces."
|
||||
},
|
||||
"policyVersionId": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Immutable policy version identifier for RBAC foundation snapshots when version linkage is present."
|
||||
},
|
||||
"sourceId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"displayName": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"description": "Full immutable Graph payload captured for backup or version display."
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": ["kind", "graph"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["intuneRoleDefinition", "intuneRoleAssignment"]
|
||||
},
|
||||
"displayName": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"graph": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["resource", "apiVersion"],
|
||||
"properties": {
|
||||
"resource": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"deviceManagement/roleDefinitions",
|
||||
"deviceManagement/roleAssignments"
|
||||
]
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://tenantpilot.local/contracts/inventory-coverage-rbac.schema.json",
|
||||
"title": "Inventory Coverage RBAC Foundation Extension",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["foundation_types"],
|
||||
"properties": {
|
||||
"foundation_types": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["intuneRoleDefinition", "intuneRoleAssignment"],
|
||||
"properties": {
|
||||
"intuneRoleDefinition": {
|
||||
"$ref": "#/$defs/coverageRow"
|
||||
},
|
||||
"intuneRoleAssignment": {
|
||||
"$ref": "#/$defs/coverageRow"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"coverageRow": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status"],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "skipped"]
|
||||
},
|
||||
"item_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"error_code": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,259 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Intune RBAC Foundation Inventory and Backup Contract (127)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Conceptual contract for how existing tenant inventory, coverage, backup, and
|
||||
verification flows are extended by Intune RBAC foundation types.
|
||||
|
||||
NOTE: This feature reuses existing Filament and service-driven flows. The
|
||||
contract documents required domain behavior, scoping, and payload shape rather
|
||||
than promising new public routes.
|
||||
|
||||
servers:
|
||||
- url: https://example.invalid
|
||||
|
||||
paths:
|
||||
/tenants/{tenantId}/inventory-sync:
|
||||
post:
|
||||
summary: Run tenant inventory sync including RBAC foundations
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InventorySelection'
|
||||
responses:
|
||||
'202':
|
||||
description: Inventory sync accepted into the existing OperationRun flow
|
||||
'404':
|
||||
description: Not found for non-members or wrong tenant scope
|
||||
'403':
|
||||
description: Forbidden for tenant members without inventory-sync capability
|
||||
|
||||
/tenants/{tenantId}/inventory/coverage:
|
||||
get:
|
||||
summary: Read tenant inventory coverage including RBAC foundation types
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: Coverage payload
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InventoryCoverage'
|
||||
'404':
|
||||
description: Not found for non-members or wrong tenant scope
|
||||
'403':
|
||||
description: Forbidden for tenant members without view capability
|
||||
|
||||
/tenants/{tenantId}/backup-sets:
|
||||
post:
|
||||
summary: Create backup set including RBAC foundations
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupCreateRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Backup capture accepted into the existing OperationRun flow
|
||||
'404':
|
||||
description: Not found for non-members or wrong tenant scope
|
||||
'403':
|
||||
description: Forbidden for tenant members without backup capability
|
||||
|
||||
/tenants/{tenantId}/backup-sets/{backupSetId}/items/{backupItemId}:
|
||||
get:
|
||||
summary: Read immutable RBAC foundation backup item detail
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/BackupSetId'
|
||||
- $ref: '#/components/parameters/BackupItemId'
|
||||
responses:
|
||||
'200':
|
||||
description: Backup item detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FoundationBackupItem'
|
||||
'404':
|
||||
description: Not found for non-members or wrong tenant scope
|
||||
'403':
|
||||
description: Forbidden for tenant members without backup or version visibility
|
||||
|
||||
/tenants/{tenantId}/verification/provider-permissions/rbac:
|
||||
get:
|
||||
summary: Read RBAC permission readiness for inventory and backup coverage
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: Verification check row for RBAC read coverage
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerificationCheck'
|
||||
'404':
|
||||
description: Not found for non-members or wrong tenant scope
|
||||
'403':
|
||||
description: Forbidden for tenant members without verification visibility
|
||||
|
||||
components:
|
||||
parameters:
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
BackupSetId:
|
||||
name: backupSetId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
BackupItemId:
|
||||
name: backupItemId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
|
||||
schemas:
|
||||
InventorySelection:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [policy_types, categories, include_foundations, include_dependencies]
|
||||
properties:
|
||||
policy_types:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Existing selected policy types; RBAC foundations are included when include_foundations is true.
|
||||
categories:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
include_foundations:
|
||||
type: boolean
|
||||
default: true
|
||||
include_dependencies:
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
InventoryCoverage:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [foundation_types]
|
||||
properties:
|
||||
foundation_types:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [intuneRoleDefinition, intuneRoleAssignment]
|
||||
properties:
|
||||
intuneRoleDefinition:
|
||||
$ref: '#/components/schemas/CoverageRow'
|
||||
intuneRoleAssignment:
|
||||
$ref: '#/components/schemas/CoverageRow'
|
||||
|
||||
CoverageRow:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [status]
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [succeeded, failed, skipped]
|
||||
item_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
error_code:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
BackupCreateRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [include_foundations]
|
||||
properties:
|
||||
policy_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
include_assignments:
|
||||
type: boolean
|
||||
default: false
|
||||
include_scope_tags:
|
||||
type: boolean
|
||||
default: false
|
||||
include_foundations:
|
||||
type: boolean
|
||||
description: Must be true for RBAC foundation capture.
|
||||
|
||||
FoundationBackupItem:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [policy_type, policy_identifier, payload, metadata, restore_mode]
|
||||
properties:
|
||||
policy_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Synthetic tenant-scoped policy anchor used for immutable RBAC history when version linkage is available.
|
||||
policy_version_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Immutable version row for RBAC foundation snapshots; present for role definitions and role assignments.
|
||||
policy_type:
|
||||
type: string
|
||||
enum: [intuneRoleDefinition, intuneRoleAssignment]
|
||||
policy_identifier:
|
||||
type: string
|
||||
display_name:
|
||||
type: string
|
||||
nullable: true
|
||||
payload:
|
||||
type: object
|
||||
description: Immutable captured Graph payload.
|
||||
metadata:
|
||||
type: object
|
||||
description: Snapshot metadata carried on the backup item; readable display attributes remain immutable even if the synthetic anchor changes later.
|
||||
restore_mode:
|
||||
type: string
|
||||
enum: [preview-only]
|
||||
|
||||
VerificationCheck:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [key, status, severity, blocking, reason_code, message]
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
example: intune_rbac_permissions
|
||||
status:
|
||||
type: string
|
||||
enum: [pass, warn, fail, skip, running]
|
||||
severity:
|
||||
type: string
|
||||
enum: [info, low, medium, high, critical]
|
||||
blocking:
|
||||
type: boolean
|
||||
reason_code:
|
||||
type: string
|
||||
example: provider_permission_missing
|
||||
message:
|
||||
type: string
|
||||
evidence:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
next_steps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
199
specs/127-rbac-inventory-backup/data-model.md
Normal file
199
specs/127-rbac-inventory-backup/data-model.md
Normal file
@ -0,0 +1,199 @@
|
||||
# Data Model — Intune RBAC Inventory & Backup v1 (127)
|
||||
|
||||
## Entities
|
||||
|
||||
### Foundation Type Metadata
|
||||
Config-defined support metadata for RBAC foundations.
|
||||
|
||||
- Source: `config/tenantpilot.php`
|
||||
- New keys:
|
||||
- `intuneRoleDefinition`
|
||||
- `intuneRoleAssignment`
|
||||
- Required attributes:
|
||||
- `type`
|
||||
- `label`
|
||||
- `category = RBAC`
|
||||
- `platform = all`
|
||||
- `endpoint`
|
||||
- `backup`
|
||||
- `restore = preview-only`
|
||||
- `risk`
|
||||
|
||||
### Graph Contract Definition
|
||||
Config-defined Graph fetch contract for inventory-grade RBAC capture.
|
||||
|
||||
- Source: `config/graph_contracts.php`
|
||||
- New contract keys:
|
||||
- `intuneRoleDefinition`
|
||||
- `intuneRoleAssignment`
|
||||
- Required fields:
|
||||
- `resource`
|
||||
- `allowed_select`
|
||||
- `allowed_expand`
|
||||
- `type_family` when available
|
||||
- optional `hydration` or follow-up fetch hints if full assignment fidelity requires them
|
||||
- Compatibility rule:
|
||||
- Existing `directoryRoleDefinitions` and `rbacRoleAssignment` keys remain unchanged for current health/onboarding flows.
|
||||
|
||||
### InventoryItem (tenant-owned observed state)
|
||||
Represents the latest observed RBAC inventory row per tenant and external object.
|
||||
|
||||
- Existing model/table: `InventoryItem`
|
||||
- Ownership:
|
||||
- `workspace_id` NOT NULL
|
||||
- `tenant_id` NOT NULL
|
||||
- Identity:
|
||||
- unique by `tenant_id + policy_type + external_id`
|
||||
- Relevant fields for RBAC:
|
||||
- `policy_type` = `intuneRoleDefinition` or `intuneRoleAssignment`
|
||||
- `external_id`
|
||||
- `display_name`
|
||||
- `category = RBAC`
|
||||
- `platform = all`
|
||||
- `meta_jsonb` sanitized, metadata-only
|
||||
- `last_seen_at`
|
||||
- `last_seen_operation_run_id`
|
||||
- `meta_jsonb` for Role Definitions should include only safe observed metadata such as:
|
||||
- OData type
|
||||
- built-in versus custom state
|
||||
- summary counts or identifiers needed for inventory surfaces
|
||||
- `meta_jsonb` for Role Assignments should include only safe observed metadata such as:
|
||||
- linked role definition identifier or display name if available
|
||||
- assignment target counts or scope counts
|
||||
- warnings and sanitized support-state metadata
|
||||
|
||||
### Inventory Coverage Payload
|
||||
Canonical coverage status written into `OperationRun.context.inventory.coverage.foundation_types`.
|
||||
|
||||
- Existing source: `App\Support\Inventory\InventoryCoverage`
|
||||
- New keys:
|
||||
- `foundation_types.intuneRoleDefinition`
|
||||
- `foundation_types.intuneRoleAssignment`
|
||||
- Per-type fields:
|
||||
- `status` in `succeeded | failed | skipped`
|
||||
- optional `item_count`
|
||||
- optional `error_code`
|
||||
|
||||
### BackupItem (tenant-owned immutable RBAC payload snapshot)
|
||||
Represents a captured RBAC foundation snapshot inside a backup set.
|
||||
|
||||
- Existing model/table: `BackupItem`
|
||||
- Ownership:
|
||||
- `tenant_id` NOT NULL
|
||||
- workspace derived via tenant relationship
|
||||
- RBAC foundation shape:
|
||||
- `policy_id` = synthetic tenant-scoped policy anchor for `intuneRoleDefinition` and `intuneRoleAssignment`
|
||||
- `policy_version_id` = immutable snapshot row created or reused for the captured RBAC payload
|
||||
- `policy_type` = `intuneRoleDefinition` or `intuneRoleAssignment`
|
||||
- `policy_identifier` = source Graph object id
|
||||
- `payload` = full immutable RBAC payload
|
||||
- `metadata.displayName`
|
||||
- `metadata.kind`
|
||||
- `metadata.graph.resource`
|
||||
- additional metadata warnings or capture details
|
||||
|
||||
### Policy / PolicyVersion linkage for RBAC foundations
|
||||
RBAC foundations reuse the existing policy-version review surfaces by creating synthetic policy anchors.
|
||||
|
||||
- Existing models/tables: `Policy`, `PolicyVersion`
|
||||
- RBAC anchor shape:
|
||||
- `Policy.external_id` = RBAC source Graph object id
|
||||
- `Policy.policy_type` = `intuneRoleDefinition` or `intuneRoleAssignment`
|
||||
- `Policy.metadata.foundation_anchor = true`
|
||||
- `Policy.metadata.capture_mode = immutable_backup`
|
||||
- RBAC version shape:
|
||||
- `PolicyVersion.snapshot` = immutable RBAC payload
|
||||
- `PolicyVersion.capture_purpose = backup`
|
||||
- identical protected snapshots are reused for the same synthetic policy instead of creating duplicate versions
|
||||
|
||||
### Normalized RBAC Snapshot View Model
|
||||
Human-readable representation produced for backup/version display and future diff-safe reuse.
|
||||
|
||||
#### Role Definition normalized fields
|
||||
- `name`
|
||||
- `description`
|
||||
- `is_built_in`
|
||||
- `role_permissions`
|
||||
- normalized permission blocks
|
||||
- ordered for diff stability
|
||||
- optional warnings when payload is incomplete or partially expanded
|
||||
|
||||
#### Role Assignment normalized fields
|
||||
- `assignment_name`
|
||||
- `role_definition`
|
||||
- preferred: readable name + stable id
|
||||
- `members`
|
||||
- readable names when known, ids as fallback
|
||||
- `scope_members`
|
||||
- readable names when known, ids as fallback
|
||||
- `resource_scopes`
|
||||
- stable ordered values
|
||||
- optional warnings when referenced objects are unresolved
|
||||
|
||||
### Verification Check Row
|
||||
Provider or tenant verification result describing RBAC permission readiness.
|
||||
|
||||
- Existing source pattern: `TenantPermissionCheckClusters`
|
||||
- Relevant fields:
|
||||
- `key`
|
||||
- `title`
|
||||
- `status = pass | warn | fail`
|
||||
- `severity`
|
||||
- `blocking`
|
||||
- `reason_code`
|
||||
- `message`
|
||||
- `evidence[]`
|
||||
- `next_steps[]`
|
||||
- RBAC v1 target behavior:
|
||||
- Missing `DeviceManagementRBAC.Read.All` produces a clear warning or failure reason with actionable next steps.
|
||||
- The rest of the inventory pipeline remains able to complete partially.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A `Tenant` has many `InventoryItem` rows, including RBAC foundation rows.
|
||||
- A `Tenant` has many `BackupSet` rows.
|
||||
- A `BackupSet` has many `BackupItem` rows, including RBAC foundation payload snapshots.
|
||||
- A synthetic RBAC `Policy` has many immutable `PolicyVersion` rows and can be referenced by RBAC `BackupItem` rows.
|
||||
- An `OperationRun` for inventory sync carries coverage status for RBAC foundation types in `context.inventory.coverage.foundation_types`.
|
||||
- Verification rows and provider reason codes describe permission posture for the same tenant and workspace scope.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `intuneRoleDefinition` and `intuneRoleAssignment` are separate foundation types everywhere: config, contracts, inventory, backup, coverage, and normalization.
|
||||
- Inventory rows remain metadata-only and tenant-scoped.
|
||||
- Full RBAC payloads are stored only in immutable backup/version artifacts.
|
||||
- Restore mode for both RBAC foundation types is always `preview-only` in v1.
|
||||
- Non-members must not be able to infer RBAC object existence across workspace or tenant boundaries.
|
||||
- Missing `DeviceManagementRBAC.Read.All` must produce a stable reason path rather than an opaque exception.
|
||||
- Role Definitions and Role Assignments must normalize in a stable, ordered, diff-safe manner.
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Inventory coverage state
|
||||
- `skipped`
|
||||
- when foundations are excluded from a run or a type is not attempted
|
||||
- `failed`
|
||||
- when the RBAC fetch for that type fails or returns a permission/problem reason
|
||||
- `succeeded`
|
||||
- when the RBAC type is fetched and inventory rows are updated successfully
|
||||
|
||||
### Backup capture state for RBAC foundations
|
||||
- not present in backup set
|
||||
- created as immutable `BackupItem` plus synthetic `Policy` / `PolicyVersion` linkage for version-detail review
|
||||
- restored from soft-delete if already present in the same backup set and previously archived
|
||||
|
||||
### Verification state for RBAC permission readiness
|
||||
- `pass`
|
||||
- all required RBAC read permissions are present
|
||||
- `warn`
|
||||
- delegated refresh or adjacent non-blocking permission posture issue exists
|
||||
- `fail`
|
||||
- required application permission is missing or permission fetch errored in a blocking way
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- Foundation type config rows must include deterministic `restore` and `risk` metadata.
|
||||
- RBAC Graph contracts must only request fields allowed by the contract registry.
|
||||
- Inventory updates require non-empty Graph `id` values.
|
||||
- Backup foundation snapshots require non-empty `source_id` values.
|
||||
- Normalizers must preserve identifiers when readable expansions are absent.
|
||||
172
specs/127-rbac-inventory-backup/plan.md
Normal file
172
specs/127-rbac-inventory-backup/plan.md
Normal file
@ -0,0 +1,172 @@
|
||||
# Implementation Plan: Intune RBAC Inventory & Backup v1
|
||||
|
||||
**Branch**: `127-rbac-inventory-backup` | **Date**: 2026-03-09 | **Spec**: `/specs/127-rbac-inventory-backup/spec.md`
|
||||
**Input**: Feature specification from `/specs/127-rbac-inventory-backup/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Add Intune Role Definitions and Intune Role Assignments as first-class foundation types in the existing tenant inventory and backup/version architecture.
|
||||
|
||||
The implementation reuses the current config-driven foundation flow in `config/tenantpilot.php`, inventory sync persistence through `App\Services\Inventory\InventorySyncService`, foundation backup capture through `App\Services\Intune\FoundationSnapshotService` + `App\Services\Intune\BackupService`, version history capture through `App\Services\Intune\VersionService`, and normalized rendering through `App\Services\Intune\PolicyNormalizer` with new type-specific normalizers. Existing backup set detail and policy version history/detail surfaces will render the new RBAC history without adding a dedicated RBAC resource. To avoid regressions with existing RBAC health and onboarding paths, the plan uses new inventory-grade Graph contract keys for Intune RBAC objects while preserving the existing `directoryRoleDefinitions` and `rbacRoleAssignment` contract semantics already used elsewhere.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack
|
||||
**Storage**: PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs
|
||||
**Testing**: Pest v4 on PHPUnit 12
|
||||
**Target Platform**: Web app (tenant/admin Filament panel and tenant-scoped operational flows)
|
||||
**Project Type**: Laravel monolith with config-driven inventory and backup services
|
||||
**Performance Goals**: preserve current inventory-sync and backup runtime characteristics; no additional render-time Graph calls; RBAC normalization remains deterministic and diff-safe for audit use
|
||||
**Constraints**: Graph access only via `GraphClientInterface` + contract registry; no write or restore-execution path; strict workspace and tenant isolation; preview-only restore semantics; graceful warning path for missing `DeviceManagementRBAC.Read.All`; metadata-only inventory storage with full payloads only in immutable snapshots/backups
|
||||
**Scale/Scope**: two new foundation types spanning config, Graph contracts, inventory sync, backup items, version history entries, normalized snapshot display, coverage, verification messaging, and focused regression tests
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS. RBAC inventory remains the “last observed” layer via `InventoryItem`; immutable payload history remains explicit in backup/version capture.
|
||||
- Read/write separation: PASS. This feature is read-only; both RBAC types remain `preview-only` and no write path is introduced.
|
||||
- Graph contract path: PASS. All RBAC reads will use `GraphClientInterface` and new config-backed contract entries in `config/graph_contracts.php`.
|
||||
- Deterministic capabilities: PASS. Restore/risk/support metadata stays config-driven via `config('tenantpilot.foundation_types')` and `InventoryPolicyTypeMeta`.
|
||||
- RBAC-UX plane separation: PASS. This stays in the Tenant/Admin plane and reuses existing tenant-scoped capability checks; no `/system` exposure is added.
|
||||
- Workspace isolation: PASS. Inventory, backup items, versions, and verification results remain workspace-bound through existing tenant-owned models.
|
||||
- RBAC-UX destructive confirmation: PASS by exemption. No new destructive action is introduced.
|
||||
- RBAC-UX global search safety: PASS by non-expansion. No dedicated searchable RBAC resource is added.
|
||||
- Tenant isolation: PASS. Existing tenant-scoped inventory, backup, version, and verification storage/queries are reused.
|
||||
- Run observability: PASS. Existing inventory sync and backup/version flows already create `OperationRun` records and remain enqueue-only at start surfaces.
|
||||
- Ops-UX 3-surface feedback: PASS. The feature reuses existing run types and must not add ad-hoc notifications.
|
||||
- Ops-UX lifecycle: PASS. No direct `OperationRun` transition logic is added.
|
||||
- Ops-UX summary counts: PASS. The feature adds coverage and counts through existing inventory and backup flows without changing the summary contract.
|
||||
- Ops-UX guards: PASS. Existing regression guards remain in force; focused tests will confirm the reused flows still comply after RBAC types are added.
|
||||
- Ops-UX system runs: PASS. Scheduled and initiator-null behavior is unchanged.
|
||||
- Automation: PASS. Existing queue, dedupe, and retry behavior for inventory/backup flows is reused.
|
||||
- Data minimization: PASS. `InventoryItem.meta_jsonb` stays sanitized and payload-heavy RBAC data is stored only in backup/version snapshots.
|
||||
- Badge semantics (BADGE-001): PASS. Any new support/risk/restore badges must reuse existing badge domains or extend them centrally with tests.
|
||||
- Filament UI Action Surface Contract / UX-001: PASS by extension-only scope. Existing coverage, backup set detail, and policy version history/detail surfaces are extended with new rows and labels only; no new Filament resource/page is introduced, and the spec references `docs/product/standards/list-surface-review-checklist.md` for the touched list surfaces.
|
||||
|
||||
**Result (pre-Phase 0)**: PASS.
|
||||
|
||||
- The main design constraint is compatibility: existing `directoryRoleDefinitions` and `rbacRoleAssignment` contracts already serve RBAC health/onboarding flows, so inventory-grade RBAC capture must use separate contract keys instead of silently repurposing those existing keys.
|
||||
- The feature reuses existing tenant inventory and backup operations; no additional `OperationRun` types, UI workflows, or write gates are needed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/127-rbac-inventory-backup/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ ├── Resources/
|
||||
│ └── Widgets/
|
||||
├── Models/
|
||||
├── Providers/
|
||||
├── Services/
|
||||
│ ├── Graph/
|
||||
│ ├── Intune/
|
||||
│ ├── Inventory/
|
||||
│ └── Providers/
|
||||
├── Support/
|
||||
│ ├── Badges/
|
||||
│ ├── Inventory/
|
||||
│ ├── Providers/
|
||||
│ └── Verification/
|
||||
└── Jobs/
|
||||
|
||||
config/
|
||||
tests/
|
||||
specs/
|
||||
```
|
||||
|
||||
Expected additions or edits during implementation:
|
||||
|
||||
```text
|
||||
config/tenantpilot.php
|
||||
config/graph_contracts.php
|
||||
config/intune_permissions.php
|
||||
app/Services/Intune/IntuneRoleDefinitionNormalizer.php
|
||||
app/Services/Intune/IntuneRoleAssignmentNormalizer.php
|
||||
app/Services/Intune/FoundationSnapshotService.php
|
||||
app/Services/Intune/VersionService.php
|
||||
app/Services/Inventory/InventorySyncService.php
|
||||
app/Support/Inventory/InventoryPolicyTypeMeta.php
|
||||
app/Support/Verification/TenantPermissionCheckClusters.php
|
||||
app/Providers/AppServiceProvider.php
|
||||
app/Filament/Resources/PolicyVersionResource.php
|
||||
tests/Feature/Inventory/*
|
||||
tests/Feature/Filament/*
|
||||
tests/Feature/FoundationBackupTest.php
|
||||
tests/Feature/BackupServiceVersionReuseTest.php
|
||||
tests/Feature/Notifications/OperationRunNotificationTest.php
|
||||
tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php
|
||||
tests/Unit/*GraphContract*Test.php
|
||||
tests/Unit/FoundationSnapshotServiceTest.php
|
||||
tests/Unit/Badges/*
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith. The feature extends existing config, service, and Filament presentation layers without introducing new top-level directories or a dedicated RBAC resource.
|
||||
|
||||
## Phase 0 — Outline & Research
|
||||
|
||||
Deliverable: `research.md` with implementation decisions resolved.
|
||||
|
||||
Key questions answered in research:
|
||||
|
||||
- How to extend foundation registration and coverage classification while keeping deterministic metadata resolution.
|
||||
- How to add full-fidelity RBAC Graph contracts without breaking current RBAC onboarding and health-check helpers.
|
||||
- Which storage path should carry immutable RBAC payloads in v1 and how that differs from the sanitized `InventoryItem.meta_jsonb` record.
|
||||
- Which existing `VersionService` capture and `PolicyVersionResource` surfaces should carry foundation-backed RBAC history alongside backup set detail.
|
||||
- Which existing verification and provider-reason patterns should handle missing `DeviceManagementRBAC.Read.All` as a soft warning rather than an opaque failure.
|
||||
- Which existing tests and patterns for foundations, inventory coverage, preview-only restore, and Graph contract sanitization should be reused.
|
||||
|
||||
## Phase 1 — Design & Contracts
|
||||
|
||||
Deliverables: `data-model.md`, `contracts/*`, `quickstart.md`.
|
||||
|
||||
- Data model defines the tenant-owned inventory, backup, version, and verification artifacts affected by `intuneRoleDefinition` and `intuneRoleAssignment`.
|
||||
- Contracts define the conceptual tenant inventory, coverage, verification, and backup/view behaviors extended by the new RBAC foundation types.
|
||||
- Quickstart defines the smallest local verification loop using Sail, Pint, and focused Pest tests.
|
||||
|
||||
## Phase 2 — Planning
|
||||
|
||||
Implementation sequence (high-level):
|
||||
|
||||
1. Extend `config/tenantpilot.php` foundation metadata with `intuneRoleDefinition` and `intuneRoleAssignment`, category `RBAC`, and `restore: preview-only`.
|
||||
2. Add inventory-grade RBAC Graph contracts in `config/graph_contracts.php`, preserving existing health/onboarding contract keys.
|
||||
3. Wire the new contract keys into foundation inventory and snapshot capture paths, including any Graph contract registry helpers needed for full RBAC reads.
|
||||
4. Add `IntuneRoleDefinitionNormalizer` and `IntuneRoleAssignmentNormalizer`, then register them in `AppServiceProvider`.
|
||||
5. Extend coverage and presentation metadata so the RBAC category and both foundation types appear consistently in inventory coverage, backup set detail, and policy version history/detail surfaces.
|
||||
6. Wire RBAC foundation snapshots into the existing `VersionService` capture and reuse paths so backup capture and version history stay aligned.
|
||||
7. Add graceful permission-warning behavior for missing `DeviceManagementRBAC.Read.All` in verification/provider messaging and capture failure metadata.
|
||||
8. Add focused Pest coverage for config registration, Graph contract sanitization, inventory sync, foundation backup capture, version capture/detail, normalized output, coverage visibility, preview-only restore behavior, permission-warning handling, and reused `OperationRun` notification and summary-count invariants.
|
||||
|
||||
**Constitution re-check (post-design)**: PASS.
|
||||
|
||||
- Inventory remains the observed-state layer; payload-heavy RBAC data remains in immutable backup/version artifacts.
|
||||
- No write or restore-execution path is added.
|
||||
- All Graph access remains contract-driven.
|
||||
- Tenant/workspace isolation and 404/403 rules remain unchanged.
|
||||
- Existing `OperationRun` flows are reused without adding non-canonical feedback surfaces.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| None | N/A | N/A |
|
||||
75
specs/127-rbac-inventory-backup/quickstart.md
Normal file
75
specs/127-rbac-inventory-backup/quickstart.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Quickstart — Intune RBAC Inventory & Backup v1 (127)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker running
|
||||
- Laravel Sail available at `vendor/bin/sail`
|
||||
- Existing test database available through Sail
|
||||
|
||||
## Local setup
|
||||
|
||||
- Start containers:
|
||||
- `vendor/bin/sail up -d`
|
||||
|
||||
- Install dependencies if needed:
|
||||
- `vendor/bin/sail composer install`
|
||||
- `vendor/bin/sail npm install`
|
||||
|
||||
## Implementation checkpoints
|
||||
|
||||
1. Extend foundation metadata and Graph contracts for:
|
||||
- `intuneRoleDefinition`
|
||||
- `intuneRoleAssignment`
|
||||
2. Add RBAC normalizers and register them in `AppServiceProvider`.
|
||||
3. Wire permission-warning handling for missing `DeviceManagementRBAC.Read.All`.
|
||||
4. Verify inventory, backup, and restore preview surfaces treat both types as foundations and `preview-only`.
|
||||
|
||||
## Format
|
||||
|
||||
- Run formatting before final review:
|
||||
- `vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Focused tests
|
||||
|
||||
Run the smallest relevant sets first:
|
||||
|
||||
- Inventory sync and coverage:
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncServiceTest.php`
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageTableTest.php`
|
||||
|
||||
- Foundation backup capture and preview-only behavior:
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/FoundationBackupTest.php`
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreExecutionTest.php`
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php`
|
||||
|
||||
- Graph contract and snapshot helpers:
|
||||
- `vendor/bin/sail artisan test --compact tests/Unit/GraphContractRegistryTest.php`
|
||||
- `vendor/bin/sail artisan test --compact tests/Unit/FoundationSnapshotServiceTest.php`
|
||||
|
||||
- RBAC permission and verification surfaces:
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Verification`
|
||||
- `vendor/bin/sail artisan test --compact tests/Unit/RbacOnboardingServiceTest.php`
|
||||
|
||||
If dedicated spec-127 tests are added, run those file paths directly.
|
||||
|
||||
## Manual verification checklist
|
||||
|
||||
- As a tenant member with inventory capability:
|
||||
- run inventory sync with foundations enabled
|
||||
- confirm `intuneRoleDefinition` and `intuneRoleAssignment` appear in coverage under RBAC
|
||||
|
||||
- As a tenant member with backup visibility:
|
||||
- create a backup set with foundations enabled
|
||||
- confirm RBAC foundation items appear as backup items with readable display names
|
||||
|
||||
- In backup or restore preview detail:
|
||||
- confirm both RBAC foundation types show `preview-only`
|
||||
- confirm no execute-restore action is available for them
|
||||
|
||||
- With missing `DeviceManagementRBAC.Read.All`:
|
||||
- run verification or inventory sync
|
||||
- confirm the result surfaces a clear RBAC permission warning or reason instead of an opaque failure
|
||||
|
||||
- As a non-member:
|
||||
- attempt direct access to tenant-scoped RBAC coverage or backup/version views
|
||||
- confirm the response is 404
|
||||
82
specs/127-rbac-inventory-backup/research.md
Normal file
82
specs/127-rbac-inventory-backup/research.md
Normal file
@ -0,0 +1,82 @@
|
||||
# Research — Intune RBAC Inventory & Backup v1 (127)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1 — Register RBAC as two new foundation types
|
||||
- **Chosen**: Add `intuneRoleDefinition` and `intuneRoleAssignment` to `config/tenantpilot.php` under `foundation_types`, with category `RBAC`, deterministic risk metadata, and `restore: preview-only`.
|
||||
- **Rationale**: Existing foundation support is config-driven. `InventorySyncService`, `BackupService`, `InventoryPolicyTypeMeta`, `BackupItem::isFoundation()`, and Filament coverage/backup surfaces already derive classification and restore/risk semantics from `config('tenantpilot.foundation_types')`.
|
||||
- **Alternatives considered**:
|
||||
- Model RBAC as supported policy types rather than foundations. Rejected because the product and existing architecture already distinguish governance foundations from policy families.
|
||||
- Merge definitions and assignments into a single RBAC type. Rejected because payloads, risks, and future compare/restore semantics differ materially.
|
||||
|
||||
### Decision 2 — Use new inventory-grade Graph contract keys for RBAC
|
||||
- **Chosen**: Introduce inventory-grade contract keys named `intuneRoleDefinition` and `intuneRoleAssignment` in `config/graph_contracts.php`, while leaving `directoryRoleDefinitions` and `rbacRoleAssignment` intact for current health/onboarding usage.
|
||||
- **Rationale**: The repo already contains narrowly scoped RBAC keys used by onboarding and health services. `GraphContractRegistry::directoryRoleDefinitionsListPath()` and `GraphContractRegistry::rbacRoleAssignmentListPath()` are referenced by `RbacOnboardingService` and `RbacHealthService`. Reusing those keys for full-fidelity snapshot capture would silently change behavior in existing flows.
|
||||
- **Alternatives considered**:
|
||||
- Expand the existing keys in place. Rejected because it couples inventory fidelity requirements to onboarding and health-check behavior, increasing regression risk.
|
||||
- Hardcode RBAC endpoints in feature services. Rejected because it violates the constitution’s single contract path to Graph.
|
||||
|
||||
### Decision 3 — Reuse existing inventory sync persistence through `InventoryItem`
|
||||
- **Chosen**: Keep RBAC inventory in `InventoryItem` rows created by `App\Services\Inventory\InventorySyncService`, using sanitized `meta_jsonb` only.
|
||||
- **Rationale**: Existing foundation inventory already works this way. `InventorySyncService` merges `supported_policy_types` and `foundation_types`, fetches by configured type, and stores a tenant-scoped `InventoryItem` keyed by `tenant_id + policy_type + external_id`. Existing tests already verify this behavior for `roleScopeTag`.
|
||||
- **Alternatives considered**:
|
||||
- Add dedicated RBAC inventory tables. Rejected because the repo already has a generic inventory store and the feature does not require custom query semantics.
|
||||
- Persist full RBAC payloads in inventory. Rejected because the constitution requires metadata-only inventory and immutable payload storage elsewhere.
|
||||
|
||||
### Decision 4 — Reuse foundation backup capture for immutable RBAC payloads
|
||||
- **Chosen**: Reuse `App\Services\Intune\FoundationSnapshotService` and `App\Services\Intune\BackupService::captureFoundations()` for RBAC payload capture, but create synthetic tenant-scoped `Policy` anchors plus immutable `PolicyVersion` rows for `intuneRoleDefinition` and `intuneRoleAssignment`.
|
||||
- **Rationale**: RBAC still enters the system through the existing foundation capture path and `include_foundations`, but linked `PolicyVersion` rows let backup-set detail and policy-version detail reuse the same immutable review surface. This preserves foundation semantics without introducing a separate RBAC snapshot model.
|
||||
- **Alternatives considered**:
|
||||
- Keep RBAC backup items permanently detached from `PolicyVersion`. Rejected after implementation because it would leave backup-set detail and version detail on different immutable paths and make readable RBAC history inconsistent with the product goal.
|
||||
- Create a separate RBAC snapshot model. Rejected because it duplicates current foundation backup behavior and would fragment restore/preview surfaces.
|
||||
|
||||
### Decision 5 — Add dedicated RBAC normalizers through the existing policy normalizer registry
|
||||
- **Chosen**: Implement `IntuneRoleDefinitionNormalizer` and `IntuneRoleAssignmentNormalizer` as `PolicyTypeNormalizer` implementations and register them through the `policy-type-normalizers` tag in `AppServiceProvider`.
|
||||
- **Rationale**: `PolicyNormalizer` already resolves type-specific normalizers this way. This keeps RBAC readable in version and backup surfaces while preserving the repo’s normalization strategy for future diffing.
|
||||
- **Alternatives considered**:
|
||||
- Use the default normalizer only. Rejected because raw RBAC payloads are not sufficiently readable for governance and audit use.
|
||||
- Add ad-hoc formatting inside Filament screens. Rejected because it would bypass the centralized normalization path and make future diffs inconsistent.
|
||||
|
||||
### Decision 6 — Handle missing `DeviceManagementRBAC.Read.All` as a verification warning and capture failure reason
|
||||
- **Chosen**: Reuse existing provider reason and verification cluster patterns so missing RBAC read permission results in a clear warning or non-blocking failure reason rather than a pipeline crash.
|
||||
- **Rationale**: The repo already has soft-permission patterns in `TenantPermissionCheckClusters`, `ProviderReasonCodes`, `RunFailureSanitizer`, and fail-soft Graph helper code such as `ScopeTagResolver`. Existing RBAC health and onboarding services also route reason codes through `ProviderReasonCodes`.
|
||||
- **Alternatives considered**:
|
||||
- Let Graph 403 bubble as a generic exception. Rejected because it produces opaque failures and weak operator guidance.
|
||||
- Mark missing delegated or application permission as hard-blocking for all inventory. Rejected because the spec explicitly requires graceful handling and partial continuity for unrelated inventory types.
|
||||
|
||||
### Decision 7 — Preserve preview-only restore by configuration, not special-case UI logic
|
||||
- **Chosen**: Rely on existing restore-mode evaluation through config metadata and current restore-risk checks rather than adding RBAC-only branches.
|
||||
- **Rationale**: `InventoryPolicyTypeMeta`, `RestoreRiskChecker`, `RestoreService`, and badge tests already treat `preview-only` as a first-class mode. Adding the new foundation types with `restore: preview-only` lets existing restore guards and UI labels work consistently.
|
||||
- **Alternatives considered**:
|
||||
- Add RBAC-specific UI suppression logic. Rejected because it is redundant and risks diverging from the canonical restore-mode rules.
|
||||
|
||||
### Decision 8 — Treat Entra admin role reporting as a normalization reference, not a storage model
|
||||
- **Chosen**: Reuse payload-shaping ideas from `EntraAdminRolesReportService` for readable role-definition/assignment summaries, but keep Intune RBAC data inside the inventory/foundation architecture.
|
||||
- **Rationale**: The Entra reporting code already demonstrates stable role/assignment mapping and fingerprint-friendly sorting, which is useful for readable RBAC normalization. However, it is a separate feature and not a replacement for tenant inventory or backup storage.
|
||||
- **Alternatives considered**:
|
||||
- Reuse Entra role reporting models directly. Rejected because the domain is different and the feature scope is Intune RBAC foundations, not Entra directory roles.
|
||||
|
||||
## Resolved Unknowns
|
||||
|
||||
- **Unknown**: Whether existing RBAC contract keys could be reused safely.
|
||||
- **Resolved**: No. Existing keys are already consumed by RBAC onboarding and health flows and should stay backward compatible.
|
||||
|
||||
- **Unknown**: Whether foundations already had an immutable full-payload path.
|
||||
- **Resolved**: Yes. `FoundationSnapshotService` + `BackupService::captureFoundations()` already persists full foundation payloads as `BackupItem` rows.
|
||||
|
||||
- **Unknown**: Whether inventory stores full payloads or sanitized metadata.
|
||||
- **Resolved**: Inventory stores sanitized metadata only in `InventoryItem.meta_jsonb`; full payloads belong in backup/version artifacts.
|
||||
|
||||
- **Unknown**: Whether preview-only restore needs a new specialized guard.
|
||||
- **Resolved**: No. Existing config-driven restore-mode evaluation already supports `preview-only` across coverage and restore paths.
|
||||
|
||||
## Repo Evidence
|
||||
|
||||
- Foundation registration baseline: `config/tenantpilot.php`
|
||||
- Existing RBAC contract keys: `config/graph_contracts.php`
|
||||
- Contract helper usage in onboarding and health: `app/Services/Graph/GraphContractRegistry.php`, `app/Services/Intune/RbacOnboardingService.php`, `app/Services/Intune/RbacHealthService.php`
|
||||
- Inventory persistence path: `app/Services/Inventory/InventorySyncService.php`
|
||||
- Coverage payload model: `app/Support/Inventory/InventoryCoverage.php`
|
||||
- Foundation payload backup path: `app/Services/Intune/FoundationSnapshotService.php`, `app/Services/Intune/BackupService.php`
|
||||
- Normalizer registry: `app/Services/Intune/PolicyNormalizer.php`, `app/Providers/AppServiceProvider.php`
|
||||
- Permission-warning patterns: `app/Support/Verification/TenantPermissionCheckClusters.php`, `app/Support/Providers/ProviderReasonCodes.php`, `app/Services/Graph/ScopeTagResolver.php`
|
||||
159
specs/127-rbac-inventory-backup/spec.md
Normal file
159
specs/127-rbac-inventory-backup/spec.md
Normal file
@ -0,0 +1,159 @@
|
||||
# Feature Specification: Intune RBAC Inventory & Backup v1
|
||||
|
||||
**Feature Branch**: `127-rbac-inventory-backup`
|
||||
**Created**: 2026-03-09
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Add Intune RBAC Role Definitions and Role Assignments as Foundation Types with inventory sync and immutable backup/versioning"
|
||||
|
||||
## Spec Scope Fields
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**: Existing tenant inventory sync flow, tenant inventory coverage views, tenant backup set detail views, tenant policy version history/detail views for foundation-backed snapshots, restore preview/detail surfaces for foundation-backed items, and provider verification surfaces that explain missing Graph permissions.
|
||||
- **Data Ownership**: Tenant-owned inventory records, tenant-owned coverage rows, tenant-owned backup items, immutable tenant-owned version snapshots, and tenant/provider verification outcomes bound to a workspace and tenant.
|
||||
- **RBAC**: Tenant/Admin plane only. Viewing tenant-scoped RBAC inventory, coverage, backups, and versions requires established workspace and tenant membership. Existing canonical capability registry continues to govern inventory sync start, provider verification visibility, backup visibility, and version inspection. Non-members must receive 404 deny-as-not-found behavior; members lacking the required capability must receive 403.
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 - Capture Intune RBAC Inventory (Priority: P1)
|
||||
|
||||
As an MSP or enterprise admin, I can run the existing tenant inventory flow and have Intune Role Definitions and Intune Role Assignments captured as first-class Foundation Types so I can see the tenant's RBAC model.
|
||||
|
||||
**Why this priority**: Inventory visibility is the core product value for v1 and the prerequisite for every later RBAC governance workflow.
|
||||
|
||||
**Independent Test**: Can be fully tested by running a tenant inventory sync with mocked RBAC responses and verifying that both RBAC object families are stored, categorized, and surfaced without needing backup or restore behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant with readable Intune RBAC data, **When** an inventory sync runs, **Then** Role Definitions are captured as `intuneRoleDefinition` foundation items with readable summaries and tenant-scoped metadata.
|
||||
2. **Given** a tenant with readable Intune RBAC data, **When** an inventory sync runs, **Then** Role Assignments are captured as `intuneRoleAssignment` foundation items with readable summaries and tenant-scoped metadata.
|
||||
3. **Given** an inventory coverage view for a tenant, **When** the sync completes, **Then** both RBAC foundation types appear under the RBAC category alongside their support and capture state.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Review Immutable RBAC History (Priority: P1)
|
||||
|
||||
As an auditor or security-conscious admin, I can inspect backup and version history for Intune RBAC objects so I can reconstruct what the tenant's role model looked like at a given point in time.
|
||||
|
||||
**Why this priority**: Immutable history closes the governance gap even before compare or restore is introduced.
|
||||
|
||||
**Independent Test**: Can be fully tested by capturing a backup/version run after inventory and confirming that RBAC objects appear in backup/version surfaces with immutable, readable snapshot content.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant with RBAC inventory data, **When** a backup/version capture runs, **Then** Role Definitions and Role Assignments are stored as immutable foundation-backed snapshots.
|
||||
2. **Given** a stored Role Definition snapshot, **When** an admin opens its normalized content, **Then** the snapshot clearly shows name, description, built-in versus custom state, and readable permissions structure.
|
||||
3. **Given** a stored Role Assignment snapshot, **When** an admin opens its normalized content, **Then** the snapshot clearly shows the assigned role, principals or members, scope members, and resource scopes.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve Safe Read-Only Governance Posture (Priority: P2)
|
||||
|
||||
As an operator, I can see that RBAC is covered and historically preserved without being offered an unsafe write or restore path before the product has dedicated RBAC safety gates.
|
||||
|
||||
**Why this priority**: Safe product positioning matters for a governance feature touching high-risk access structures.
|
||||
|
||||
**Independent Test**: Can be fully tested by validating restore-adjacent surfaces and provider verification behavior without requiring any new RBAC write capability.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an RBAC backup item, **When** an admin reviews restore options, **Then** both RBAC foundation types are shown as preview-only and no executable restore action is presented.
|
||||
2. **Given** a tenant whose provider lacks `DeviceManagementRBAC.Read.All`, **When** sync or verification runs, **Then** the system records a clear warning or reason for RBAC coverage without crashing the run pipeline.
|
||||
3. **Given** a user outside the current workspace or tenant scope, **When** they attempt to access RBAC inventory or version data, **Then** the system responds as not found and does not leak RBAC object existence.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Built-in role definitions with minimal custom fields must still normalize into a readable, diff-safe summary.
|
||||
- Role assignments that reference deleted or unresolved principals, scope tags, or resource scopes must preserve stable fallback identifiers rather than dropping the reference.
|
||||
- Missing `DeviceManagementRBAC.Read.All` must mark RBAC coverage as unavailable or warning-state without aborting unrelated inventory families.
|
||||
- If role definitions are readable but role assignments fail for a recoverable reason, the run must preserve partial RBAC visibility with a clear reason instead of silently reporting full success.
|
||||
- Existing coverage, backup, and version surfaces must remain tenant-scoped so RBAC metadata cannot appear in workspace-only or cross-tenant contexts.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Constitution alignment (required):** This feature extends Microsoft Graph-backed tenant inventory and snapshot flows. Contract registry updates are required for both RBAC object families, restore safety remains preview-only, tenant isolation remains mandatory, and the existing inventory and backup `OperationRun` flows continue to provide run observability and auditability. No new RBAC write path is introduced.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature reuses existing inventory sync and backup/version `OperationRun` flows. The implementation must preserve the three-surface feedback contract: queued-only toasts, progress only in the active-ops widget and run detail surfaces, and initiator-only terminal DB notifications. `OperationRun.status` and `OperationRun.outcome` remain service-owned through `OperationRunService`, summary counts remain numeric and limited to canonical keys, and scheduled or system-run behavior remains unchanged. Regression coverage must confirm these reused flows remain compliant after RBAC types are added.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature affects the Tenant/Admin plane only. Tenant-context access under `/admin/t/{tenant}/...` and tenant-aware canonical monitoring or coverage views must preserve deny-as-not-found semantics for non-members and 403 semantics for members missing the required capability. All visibility and operation-start checks remain server-side via existing gates or policies and the canonical capability registry. No raw capability strings or role-string checks may be introduced. Existing global search safety rules remain unchanged because no dedicated RBAC resource is added in v1.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Any new or reused risk, restore-mode, support-state, or availability badges for RBAC types must use centralized badge semantics. If RBAC introduces new badge values, the badge mapper and regression tests must be updated in the same change.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces / UX-001):** No new Filament resource, relation manager, or standalone RBAC screen is introduced. Existing inventory coverage and backup/version detail surfaces are only extended with additional RBAC rows, summaries, and preview-only labeling. Existing action surfaces, layouts, empty states, and inspection affordances must remain intact; the UI Action Matrix below documents the affected surfaces and confirms that no new destructive action is introduced.
|
||||
|
||||
**Constitution alignment (UI-STD-001):** This feature modifies existing list and detail surfaces, so review must follow `docs/product/standards/list-surface-review-checklist.md` for the inventory coverage, backup set detail, and policy version history/detail surfaces touched by this change.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system must register `intuneRoleDefinition` and `intuneRoleAssignment` as separate Foundation Types with labels, platform metadata, risk level, restore mode, and inventory/backup participation metadata consistent with existing Foundation Types.
|
||||
- **FR-002**: The system must expose RBAC as a dedicated semantic category so both RBAC Foundation Types appear under RBAC in coverage and related presentation rather than being folded into a generic foundations label.
|
||||
- **FR-003**: The system must define inventory-grade Graph contract entries for Intune Role Definitions and Intune Role Assignments that are suitable for full snapshot fidelity rather than minimal health checks.
|
||||
- **FR-004**: The Role Definition contract must support retrieving the stable fields needed for immutable history and readable display, including identifier, display name, description, built-in versus custom state, and permissions data.
|
||||
- **FR-005**: The Role Assignment contract must support retrieving the stable fields needed for immutable history and readable display, including identifier, display name, description when present, linked role definition reference, principals or members, scope members, resource scopes, and stable type metadata for referenced principals and scopes.
|
||||
- **FR-006**: Existing legacy or health-check-oriented RBAC contract keys must remain backward compatible. If separate inventory-grade keys are required, they must coexist without silently changing the meaning of existing health-check flows.
|
||||
- **FR-007**: All RBAC Graph reads introduced by this feature must continue to flow through `GraphClientInterface` and the contract registry; feature code must not hardcode RBAC endpoints outside the registry.
|
||||
- **FR-008**: The existing tenant inventory sync flow must capture both RBAC Foundation Types without creating a new standalone RBAC workflow.
|
||||
- **FR-009**: Inventory sync must persist tenant-scoped inventory results for both RBAC Foundation Types using the existing provider, contract, and normalization model.
|
||||
- **FR-010**: Backup and version capture must create immutable RBAC snapshots for both Foundation Types using the existing foundation-backed snapshot model, including backup items and tenant-scoped version history entries.
|
||||
- **FR-011**: Backup sets must include both RBAC Foundation Types as foundation-backed items using the same backup item semantics applied to other supported foundation objects.
|
||||
- **FR-012**: Coverage must track `coverage.foundation_types.intuneRoleDefinition` and `coverage.foundation_types.intuneRoleAssignment` and present them under the RBAC category.
|
||||
- **FR-013**: Role Definition normalization must produce human-readable output that includes the role name, description, built-in versus custom indicator, and a stable normalized permissions structure suitable for audit review and future diffs.
|
||||
- **FR-014**: Role Assignment normalization must produce human-readable output that includes assignment name, assigned role, principals or members, scope members, and resource scopes.
|
||||
- **FR-015**: Reference-aware RBAC normalization must prefer readable related-object names when practical, but must fall back to stable identifiers when Microsoft Graph does not provide a readable expansion so snapshots stay understandable and diff-safe.
|
||||
- **FR-016**: Both RBAC Foundation Types must be visible through existing inventory, coverage, backup set detail, and policy version history/detail surfaces without introducing a dedicated RBAC CRUD resource in v1.
|
||||
- **FR-017**: Both RBAC Foundation Types must be configured as `preview-only` for restore and must not expose any executable restore path in v1.
|
||||
- **FR-018**: If `DeviceManagementRBAC.Read.All` is missing, provider verification and sync outcomes must record a clear warning or reason for RBAC coverage rather than failing with an opaque system error.
|
||||
- **FR-019**: RBAC inventory, backup, coverage, and version data must remain strictly bound to existing workspace and tenant isolation rules, with no cross-tenant leakage in storage or presentation.
|
||||
- **FR-020**: Adding RBAC Foundation Types must not degrade existing inventory, coverage, backup, or version behavior for currently supported types.
|
||||
- **FR-021**: The feature must not introduce any Intune RBAC write, restore-execution, drift, compare, or cross-tenant comparison path in v1.
|
||||
- **FR-022**: Any authorization checks touched by RBAC visibility or operation-start surfaces must continue to enforce canonical capability registry usage, 404 semantics for non-members, and 403 semantics for in-scope members missing required capability.
|
||||
- **FR-023**: Reused inventory sync and backup/version operations must remain observable through existing `OperationRun` behavior, including canonical summary-count keys, service-owned lifecycle transitions, and initiator-only terminal notifications.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- No restore execution for Role Definitions or Role Assignments.
|
||||
- No RBAC baseline compare, drift detection, findings, or alerts.
|
||||
- No dedicated Filament CRUD resource or separate RBAC operational workflow.
|
||||
- No Entra RBAC or Entra role backup scope.
|
||||
- No expansion of Scope Tag functionality beyond referencing existing data already supported elsewhere.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- Existing tenant inventory sync and backup/version workflows already provide the operational entry points needed to add new Foundation Types.
|
||||
- Existing canonical capabilities already cover tenant inventory sync, provider verification, backup visibility, and version inspection for the affected surfaces.
|
||||
- Microsoft Graph returns enough stable RBAC read data to preserve immutable snapshots even when some related objects can only be represented by identifiers.
|
||||
- Scope Tags remain managed by existing functionality and are only referenced as related governance context by RBAC assignments in this v1.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing provider and contract registry architecture.
|
||||
- Existing tenant inventory sync pipeline.
|
||||
- Existing immutable backup and version capture model.
|
||||
- Existing coverage aggregation for foundation types.
|
||||
- Existing preview-only restore semantics and restore-surface gating.
|
||||
|
||||
## UI Action Matrix
|
||||
|
||||
This spec modifies existing list surfaces and therefore uses `docs/product/standards/list-surface-review-checklist.md` as the review checklist for inventory coverage, backup set detail, and policy version history/detail changes.
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Inventory Coverage | Existing tenant inventory coverage screen | No new actions introduced | Existing coverage inspection pattern remains unchanged | No new row actions introduced | None introduced | Existing sync/run CTA remains the single empty-state CTA | N/A | N/A | Existing inventory sync `OperationRun` and audit trail remain in force | Change is limited to RBAC category rows, labels, and support state presentation; Action Surface Contract remains satisfied by the existing surface. |
|
||||
| Backup Set Detail and Policy Version Detail | Existing tenant backup set detail and policy version detail screens | No new actions introduced | Existing item preview/detail affordance remains unchanged | Existing preview or view behavior only; no execute restore action for RBAC items | None introduced | Existing backup or capture CTA remains unchanged | Existing preview/view actions only | N/A | Existing backup/version `OperationRun` and audit trail remain in force | Change is limited to additional RBAC items, readable normalized content, and visible preview-only restore labeling. |
|
||||
| Provider Verification / Support Detail | Existing provider or verification detail surfaces | No new actions introduced | Existing provider detail inspection remains unchanged | No new row actions introduced | None introduced | Existing verification CTA remains unchanged | Existing detail affordances unchanged | N/A | Existing verification logging remains in force | Surface adds a readable warning or reason for missing `DeviceManagementRBAC.Read.All` without adding new action patterns. |
|
||||
|
||||
## Key Entities
|
||||
|
||||
- **Intune Role Definition Inventory Item**: The tenant-scoped representation of a built-in or custom Intune role definition, including stable identity, descriptive metadata, built-in versus custom state, and readable permission structure.
|
||||
- **Intune Role Assignment Inventory Item**: The tenant-scoped representation of an Intune role assignment, including stable identity, linked role definition, principals or members, scope members, resource scopes, and stable fallback references.
|
||||
- **RBAC Snapshot Version**: An immutable historical record of a Role Definition or Role Assignment captured through the existing foundation-backed snapshot model for audit and version history.
|
||||
- **RBAC Coverage Entry**: The tenant-scoped support and capture status for each RBAC Foundation Type within `coverage.foundation_types`.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In a tenant with RBAC read access, one normal inventory sync cycle is sufficient for admins to see both Role Definitions and Role Assignments represented in coverage and tenant-scoped inventory views without using a separate RBAC workflow.
|
||||
- **SC-002**: 100% of stored Role Definition snapshots produced by the feature show a readable built-in versus custom indicator and a readable permissions summary.
|
||||
- **SC-003**: 100% of stored Role Assignment snapshots produced by the feature show the assigned role and assignment scope details, using stable fallback identifiers whenever readable expansions are unavailable.
|
||||
- **SC-004**: For tenants lacking `DeviceManagementRBAC.Read.All`, verification or sync surfaces show an explicit RBAC warning or reason and complete without an unhandled failure or cross-tenant data leak.
|
||||
- **SC-005**: RBAC backup items never present an executable restore action in v1; they remain clearly labeled as preview-only across affected restore-adjacent surfaces.
|
||||
221
specs/127-rbac-inventory-backup/tasks.md
Normal file
221
specs/127-rbac-inventory-backup/tasks.md
Normal file
@ -0,0 +1,221 @@
|
||||
# Tasks: Intune RBAC Inventory & Backup v1 (127)
|
||||
|
||||
**Input**: Design documents from `specs/127-rbac-inventory-backup/` (spec.md, plan.md, research.md, data-model.md, contracts/)
|
||||
**Prerequisites**: `specs/127-rbac-inventory-backup/plan.md` (required), `specs/127-rbac-inventory-backup/spec.md` (required for user stories)
|
||||
|
||||
**Tests**: REQUIRED (Pest) for all runtime behavior changes in this repo.
|
||||
**Operations**: Reuse existing `OperationRun` flows for inventory sync and backup capture; do not add ad-hoc operation feedback or notification patterns.
|
||||
**RBAC**: Preserve tenant/admin plane enforcement, deny-as-not-found 404 for non-members, 403 for in-scope members missing capability, and canonical capability registry usage only.
|
||||
**Filament UI**: No new Filament resource/page is added; existing coverage, backup/version, and provider verification surfaces are extended only.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Reconfirm the relevant existing architecture and validation surfaces before changing config-driven foundation behavior.
|
||||
|
||||
- [X] T001 Review existing foundation type registration in `config/tenantpilot.php`
|
||||
- [X] T002 Review existing RBAC-related Graph contracts in `config/graph_contracts.php`
|
||||
- [X] T003 [P] Review foundation inventory sync flow in `app/Services/Inventory/InventorySyncService.php`
|
||||
- [X] T004 [P] Review foundation backup and version capture flow in `app/Services/Intune/FoundationSnapshotService.php`, `app/Services/Intune/BackupService.php`, and `app/Services/Intune/VersionService.php`
|
||||
- [X] T005 [P] Review RBAC permission warning patterns in `app/Support/Verification/TenantPermissionCheckClusters.php`, `app/Support/Providers/ProviderReasonCodes.php`, and `app/Services/Graph/ScopeTagResolver.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared config and registry work that must exist before inventory, backup, or safety behavior can be implemented.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T006 Add `intuneRoleDefinition` and `intuneRoleAssignment` foundation metadata in `config/tenantpilot.php`
|
||||
- [X] T007 Add inventory-grade RBAC Graph contract entries in `config/graph_contracts.php`
|
||||
- [X] T008 Update RBAC contract helper coverage for the new inventory-grade keys in `app/Services/Graph/GraphContractRegistry.php`
|
||||
- [X] T009 [P] Extend foundation metadata resolution for RBAC category and preview-only semantics in `app/Support/Inventory/InventoryPolicyTypeMeta.php`
|
||||
- [X] T010 [P] Add or extend Graph contract sanitization coverage for RBAC foundation keys in `tests/Unit/GraphContractRegistryTest.php`
|
||||
- [X] T011 [P] Add config registration coverage for RBAC foundations in `tests/Feature/Filament/InventoryCoverageTableTest.php` and `tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php`
|
||||
|
||||
**Checkpoint**: Foundation type config, contract registry, and metadata resolution are ready; user stories can now proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Capture Intune RBAC Inventory (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Tenant inventory sync captures RBAC Role Definitions and Role Assignments as first-class foundation inventory items and reports them in coverage under RBAC.
|
||||
|
||||
**Independent Test**: Run tenant inventory sync with mocked RBAC responses and verify `InventoryItem` rows plus `coverage.foundation_types` entries for both RBAC types without needing backup capture.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T012 [P] [US1] Add inventory sync coverage for RBAC foundation upserts in `tests/Feature/Inventory/InventorySyncServiceTest.php`
|
||||
- [X] T013 [P] [US1] Add coverage page assertions for RBAC foundation rows and category labeling in `tests/Feature/Filament/InventoryCoverageTableTest.php`
|
||||
- [X] T014 [P] [US1] Add foundation snapshot paging and payload-shape coverage for RBAC types in `tests/Unit/FoundationSnapshotServiceTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T015 [US1] Extend foundation inventory sync persistence for `intuneRoleDefinition` and `intuneRoleAssignment` in `app/Services/Inventory/InventorySyncService.php`
|
||||
- [X] T016 [US1] Add RBAC-specific sanitized metadata shaping for observed inventory rows in `app/Services/Inventory/InventorySyncService.php`
|
||||
- [X] T017 [US1] Extend foundation snapshot fetch behavior for full-fidelity RBAC capture in `app/Services/Intune/FoundationSnapshotService.php`
|
||||
- [X] T018 [US1] Surface RBAC foundation metadata in inventory coverage presentation using `app/Support/Inventory/InventoryPolicyTypeMeta.php` and `app/Filament/Pages/InventoryCoverage.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when sync creates RBAC inventory rows and coverage shows both RBAC foundation types under the RBAC category.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Review Immutable RBAC History (Priority: P1)
|
||||
|
||||
**Goal**: Backup set detail and policy version history/detail surfaces show immutable RBAC snapshots with readable normalized content for both role definitions and assignments.
|
||||
|
||||
**Independent Test**: Create a backup set with foundations enabled and verify RBAC backup items and policy version entries exist with full payloads and readable normalized content in backup set detail and policy version detail surfaces.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T019 [P] [US2] Add backup foundation capture coverage for RBAC role definitions and assignments in `tests/Feature/FoundationBackupTest.php`
|
||||
- [X] T020 [P] [US2] Add Role Definition normalizer coverage for built-in and custom roles in `tests/Unit/IntuneRoleDefinitionNormalizerTest.php`
|
||||
- [X] T021 [P] [US2] Add Role Assignment normalizer coverage for role, principals, scopes, and fallback identifiers in `tests/Unit/IntuneRoleAssignmentNormalizerTest.php`
|
||||
- [X] T022 [P] [US2] Add version capture and reuse coverage for RBAC foundations in `tests/Feature/BackupServiceVersionReuseTest.php` and `tests/Feature/VersionCaptureMetadataOnlyTest.php`
|
||||
- [X] T023 [P] [US2] Add backup set and policy version surface assertions for readable RBAC history in `tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php` and `tests/Feature/Filament/PolicyVersionTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T024 [US2] Capture RBAC foundation backup items and aligned version history entries through `app/Services/Intune/BackupService.php` and `app/Services/Intune/VersionService.php`
|
||||
- [X] T025 [P] [US2] Implement `IntuneRoleDefinitionNormalizer` in `app/Services/Intune/IntuneRoleDefinitionNormalizer.php`
|
||||
- [X] T026 [P] [US2] Implement `IntuneRoleAssignmentNormalizer` in `app/Services/Intune/IntuneRoleAssignmentNormalizer.php`
|
||||
- [X] T027 [US2] Register RBAC normalizers in `app/Providers/AppServiceProvider.php`
|
||||
- [X] T028 [US2] Extend normalized backup set and policy version rendering for RBAC foundation items in `app/Services/Intune/PolicyNormalizer.php`, `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`, and `app/Filament/Resources/PolicyVersionResource.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when RBAC backup items and policy version history entries are created and render readable, immutable snapshot content for audit review.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Preserve Safe Read-Only Governance Posture (Priority: P2)
|
||||
|
||||
**Goal**: RBAC remains clearly preview-only for restore, permission gaps are surfaced gracefully, and tenant/workspace isolation remains enforced for RBAC coverage and history surfaces.
|
||||
|
||||
**Independent Test**: Verify restore-adjacent surfaces mark both RBAC types as preview-only, missing `DeviceManagementRBAC.Read.All` yields a clear warning or reason, and unauthorized access remains 404 or 403 as appropriate.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T029 [P] [US3] Add preview-only restore guard coverage for RBAC foundation types in `tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php` and `tests/Feature/Filament/RestoreExecutionTest.php`
|
||||
- [X] T030 [P] [US3] Add verification or permission-warning coverage for missing `DeviceManagementRBAC.Read.All` in `tests/Feature/Verification/IntuneRbacPermissionCoverageTest.php`
|
||||
- [X] T031 [P] [US3] Add tenant/workspace isolation coverage for RBAC backup or coverage visibility in `tests/Feature/WorkspaceIsolation/TenantOwnedWorkspaceInvariantTest.php`, `tests/Feature/Filament/InventoryCoverageTableTest.php`, and `tests/Feature/Filament/PolicyVersionTest.php`
|
||||
- [X] T032 [P] [US3] Add badge or restore-mode mapping coverage for preview-only RBAC foundations in `tests/Unit/Badges/PolicyBadgesTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T033 [US3] Preserve preview-only RBAC restore semantics in `app/Services/Intune/RestoreRiskChecker.php` and `app/Services/Intune/RestoreService.php`
|
||||
- [X] T034 [US3] Add graceful RBAC permission-warning clustering and next-step messaging in `app/Support/Verification/TenantPermissionCheckClusters.php`
|
||||
- [X] T035 [US3] Extend provider reason mapping for RBAC read gaps where needed in `app/Support/Providers/ProviderReasonCodes.php` and related verification helpers under `app/Support/Verification/`
|
||||
- [X] T036 [US3] Ensure backup set, policy version, coverage, and verification surfaces present RBAC support state without bypassing existing tenant/admin authorization in `app/Filament/Pages/InventoryCoverage.php`, `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/PolicyVersionResource.php`, and related policies or helpers already backing those surfaces
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when RBAC is clearly read-only and safe, permission gaps are operator-readable, and no scope leakage is introduced.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final verification, formatting, and cross-story regression protection.
|
||||
|
||||
- [X] T037 [P] Add or update targeted contract documentation examples in `specs/127-rbac-inventory-backup/contracts/rbac-foundation.openapi.yaml` and `specs/127-rbac-inventory-backup/contracts/foundation-rbac-snapshot.schema.json` if implementation shape changed
|
||||
- [X] T038 [P] Add reused `OperationRun` notification and summary-count regression coverage for RBAC inventory and backup/version flows in `tests/Feature/Notifications/OperationRunNotificationTest.php` and `tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php`
|
||||
- [X] T039 Run formatting for changed files via `specs/127-rbac-inventory-backup/quickstart.md`
|
||||
- [X] T040 Run focused Pest verification from `specs/127-rbac-inventory-backup/quickstart.md`
|
||||
- [ ] T041 Validate manual QA checklist from `specs/127-rbac-inventory-backup/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion and benefits from US1 because the same RBAC type metadata and contracts are already in place.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion and should run after US1/US2 touch the relevant coverage and backup surfaces.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: First deliverable and MVP. No dependency on other user stories.
|
||||
- **US2 (P1)**: Depends on the same shared config/contract work as US1, but can proceed once foundational work is done.
|
||||
- **US3 (P2)**: Builds on the RBAC types being visible in coverage and backup flows, so it should follow US1 and US2.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests should be added before or alongside implementation and must fail before the feature code is considered complete.
|
||||
- Config/registry changes precede service changes.
|
||||
- Service changes precede Filament presentation adjustments.
|
||||
- Story-specific implementation is complete only when its focused tests pass.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- Setup review tasks marked `[P]` can be done in parallel.
|
||||
- In Phase 2, metadata and test tasks `T009` to `T011` can run in parallel after the core config/contract additions begin.
|
||||
- In US1, `T012` to `T014` can run in parallel, and `T017` can proceed in parallel with metadata work once contracts are defined.
|
||||
- In US2, the two normalizer implementations `T025` and `T026` can run in parallel with their test files, and version capture coverage `T022` can proceed in parallel with surface assertions `T023`.
|
||||
- In US3, warning, isolation, and badge tests `T029` to `T032` can run in parallel.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch US1 test work in parallel:
|
||||
T012 tests/Feature/Inventory/InventorySyncServiceTest.php
|
||||
T013 tests/Feature/Filament/InventoryCoverageTableTest.php
|
||||
T014 tests/Unit/FoundationSnapshotServiceTest.php
|
||||
|
||||
# Launch US1 implementation work in parallel after config/contracts are ready:
|
||||
T016 app/Services/Inventory/InventorySyncService.php
|
||||
T017 app/Services/Intune/FoundationSnapshotService.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch RBAC normalizer work in parallel:
|
||||
T020 tests/Unit/IntuneRoleDefinitionNormalizerTest.php + T025 app/Services/Intune/IntuneRoleDefinitionNormalizer.php
|
||||
T021 tests/Unit/IntuneRoleAssignmentNormalizerTest.php + T026 app/Services/Intune/IntuneRoleAssignmentNormalizer.php
|
||||
|
||||
# Launch version-history work in parallel:
|
||||
T022 tests/Feature/BackupServiceVersionReuseTest.php
|
||||
T023 tests/Feature/Filament/PolicyVersionTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Launch safety and permission tasks in parallel:
|
||||
T030 tests/Feature/Verification/IntuneRbacPermissionCoverageTest.php
|
||||
T031 tests/Feature/WorkspaceIsolation/TenantOwnedWorkspaceInvariantTest.php
|
||||
T032 tests/Unit/Badges/PolicyBadgesTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate focused inventory sync and coverage behavior.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship US1 to establish RBAC inventory visibility.
|
||||
2. Add US2 to make immutable RBAC history readable and auditable.
|
||||
3. Add US3 to harden preview-only messaging, permission warnings, and scope safety.
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = Phases 1 through 3, then run `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncServiceTest.php` and `vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageTableTest.php`.
|
||||
|
||||
---
|
||||
|
||||
## Format Validation
|
||||
|
||||
- Every task follows the checklist format `- [ ] T### [P?] [US?] Description with file path`.
|
||||
- Setup, Foundational, and Polish phases intentionally omit story labels.
|
||||
- User story phases use `[US1]`, `[US2]`, and `[US3]` labels.
|
||||
- Parallel markers are applied only where tasks can proceed independently on different files or clearly separable work.
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\FoundationSnapshotService;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
@ -142,3 +143,86 @@
|
||||
expect($item)->not->toBeNull();
|
||||
expect($item->policy_version_id)->not->toBe($staleVersion->id);
|
||||
});
|
||||
|
||||
it('reuses an existing RBAC foundation version across backup sets when the snapshot is unchanged', function () {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
config()->set('tenantpilot.foundation_types', [
|
||||
[
|
||||
'type' => 'intuneRoleDefinition',
|
||||
'label' => 'Intune Role Definition',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/roleDefinitions',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'id' => 'role-def-1',
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
'description' => 'Built-in RBAC role',
|
||||
'isBuiltIn' => true,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) use ($payload) {
|
||||
$mock->shouldReceive('fetchAll')
|
||||
->twice()
|
||||
->withArgs(fn (Tenant $tenant, string $foundationType): bool => $foundationType === 'intuneRoleDefinition')
|
||||
->andReturn([
|
||||
'items' => [[
|
||||
'source_id' => 'role-def-1',
|
||||
'display_name' => 'Policy and Profile Manager',
|
||||
'payload' => $payload,
|
||||
'metadata' => [
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
'kind' => 'intuneRoleDefinition',
|
||||
'graph' => [
|
||||
'resource' => 'deviceManagement/roleDefinitions',
|
||||
'apiVersion' => 'beta',
|
||||
],
|
||||
],
|
||||
]],
|
||||
'failures' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
$service = app(BackupService::class);
|
||||
|
||||
$firstBackupSet = $service->createBackupSet(
|
||||
tenant: $tenant,
|
||||
policyIds: [],
|
||||
name: 'RBAC Backup 1',
|
||||
includeFoundations: true,
|
||||
);
|
||||
|
||||
$secondBackupSet = $service->createBackupSet(
|
||||
tenant: $tenant,
|
||||
policyIds: [],
|
||||
name: 'RBAC Backup 2',
|
||||
includeFoundations: true,
|
||||
);
|
||||
|
||||
$firstItem = $firstBackupSet->items()->first();
|
||||
$secondItem = $secondBackupSet->items()->first();
|
||||
|
||||
expect($firstItem)->not->toBeNull();
|
||||
expect($secondItem)->not->toBeNull();
|
||||
expect($firstItem->policy_id)->toBe($secondItem->policy_id);
|
||||
expect($firstItem->policy_version_id)->toBe($secondItem->policy_version_id);
|
||||
expect(PolicyVersion::query()->where('tenant_id', $tenant->id)->where('policy_type', 'intuneRoleDefinition')->count())->toBe(1);
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Services\Intune\FoundationSnapshotService;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\Intune\SnapshotValidator;
|
||||
use App\Services\Intune\VersionService;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
@ -121,6 +122,7 @@
|
||||
captureOrchestrator: app(PolicyCaptureOrchestrator::class),
|
||||
foundationSnapshots: $this->mock(FoundationSnapshotService::class),
|
||||
snapshotValidator: app(SnapshotValidator::class),
|
||||
versionService: app(VersionService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
@ -146,3 +148,202 @@
|
||||
|
||||
expect($backupSet->status)->toBe('partial');
|
||||
});
|
||||
|
||||
it('captures RBAC foundation items with linked policy versions when include_foundations is enabled', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
config()->set('tenantpilot.foundation_types', [
|
||||
[
|
||||
'type' => 'intuneRoleDefinition',
|
||||
'label' => 'Intune Role Definition',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/roleDefinitions',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
'type' => 'intuneRoleAssignment',
|
||||
'label' => 'Intune Role Assignment',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/roleAssignments',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
],
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'RBAC foundations',
|
||||
'status' => 'completed',
|
||||
'metadata' => ['failures' => []],
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows',
|
||||
'ignored_at' => null,
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'snapshot' => ['id' => $policy->external_id],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_ids' => [(int) $policy->getKey()],
|
||||
],
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
]);
|
||||
|
||||
$this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($policy, $tenant, $version) {
|
||||
$mock->shouldReceive('capture')
|
||||
->once()
|
||||
->andReturnUsing(function (
|
||||
Policy $capturedPolicy,
|
||||
\App\Models\Tenant $tenantArg,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = []
|
||||
) use ($policy, $tenant, $version) {
|
||||
expect($capturedPolicy->is($policy))->toBeTrue();
|
||||
expect($tenantArg->is($tenant))->toBeTrue();
|
||||
expect($metadata['backup_set_id'] ?? null)->toBe((int) $metadata['backup_set_id']);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'captured' => [
|
||||
'payload' => [
|
||||
'id' => $policy->external_id,
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
],
|
||||
'assignments' => [],
|
||||
'scope_tags' => null,
|
||||
'metadata' => [],
|
||||
],
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetchAll')
|
||||
->twice()
|
||||
->andReturnUsing(function (\App\Models\Tenant $tenant, string $foundationType): array {
|
||||
return match ($foundationType) {
|
||||
'intuneRoleDefinition' => [
|
||||
'items' => [[
|
||||
'source_id' => 'role-def-1',
|
||||
'display_name' => 'Policy and Profile Manager',
|
||||
'payload' => [
|
||||
'id' => 'role-def-1',
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
'description' => 'Built-in RBAC role',
|
||||
'isBuiltIn' => true,
|
||||
],
|
||||
'metadata' => [
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
'kind' => 'intuneRoleDefinition',
|
||||
],
|
||||
]],
|
||||
'failures' => [],
|
||||
],
|
||||
'intuneRoleAssignment' => [
|
||||
'items' => [[
|
||||
'source_id' => 'role-assign-1',
|
||||
'display_name' => 'Helpdesk Assignment',
|
||||
'payload' => [
|
||||
'id' => 'role-assign-1',
|
||||
'displayName' => 'Helpdesk Assignment',
|
||||
'members' => ['group-1'],
|
||||
'resourceScopes' => ['/'],
|
||||
'roleDefinition' => [
|
||||
'id' => 'role-def-1',
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'displayName' => 'Helpdesk Assignment',
|
||||
'kind' => 'intuneRoleAssignment',
|
||||
],
|
||||
]],
|
||||
'failures' => [],
|
||||
],
|
||||
default => [
|
||||
'items' => [],
|
||||
'failures' => [],
|
||||
],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
$job = new AddPoliciesToBackupSetJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
policyIds: [(int) $policy->getKey()],
|
||||
options: [
|
||||
'include_assignments' => false,
|
||||
'include_scope_tags' => false,
|
||||
'include_foundations' => true,
|
||||
],
|
||||
idempotencyKey: 'rbac-foundation-additions',
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
operationRunService: app(OperationRunService::class),
|
||||
captureOrchestrator: app(PolicyCaptureOrchestrator::class),
|
||||
foundationSnapshots: app(FoundationSnapshotService::class),
|
||||
snapshotValidator: app(SnapshotValidator::class),
|
||||
versionService: app(VersionService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
$backupSet->refresh();
|
||||
|
||||
$definitionItem = BackupItem::query()
|
||||
->where('backup_set_id', $backupSet->id)
|
||||
->where('policy_type', 'intuneRoleDefinition')
|
||||
->first();
|
||||
$assignmentItem = BackupItem::query()
|
||||
->where('backup_set_id', $backupSet->id)
|
||||
->where('policy_type', 'intuneRoleAssignment')
|
||||
->first();
|
||||
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
expect($run->summary_counts)->toMatchArray([
|
||||
'total' => 3,
|
||||
'processed' => 3,
|
||||
'succeeded' => 3,
|
||||
'created' => 3,
|
||||
'items' => 3,
|
||||
]);
|
||||
expect($backupSet->status)->toBe('completed');
|
||||
expect($backupSet->item_count)->toBe(3);
|
||||
expect($definitionItem)->not->toBeNull();
|
||||
expect($definitionItem?->policy_id)->not->toBeNull();
|
||||
expect($definitionItem?->policy_version_id)->not->toBeNull();
|
||||
expect($definitionItem?->resolvedDisplayName())->toBe('Policy and Profile Manager');
|
||||
expect($assignmentItem)->not->toBeNull();
|
||||
expect($assignmentItem?->policy_id)->not->toBeNull();
|
||||
expect($assignmentItem?->policy_version_id)->not->toBeNull();
|
||||
expect($assignmentItem?->resolvedDisplayName())->toBe('Helpdesk Assignment');
|
||||
});
|
||||
|
||||
192
tests/Feature/Filament/BackupItemsRelationManagerFiltersTest.php
Normal file
192
tests/Feature/Filament/BackupItemsRelationManagerFiltersTest.php
Normal file
@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function backupItemsRelationManagerComponent(BackupSet $backupSet)
|
||||
{
|
||||
return Livewire::test(BackupItemsRelationManager::class, [
|
||||
'ownerRecord' => $backupSet,
|
||||
'pageClass' => EditBackupSet::class,
|
||||
]);
|
||||
}
|
||||
|
||||
it('filters backup items by type, restore mode, and platform inside the current backup set', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$otherBackupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$roleDefinition = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'role-definition',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => 'Application Manager',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
$roleAssignment = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'role-assignment',
|
||||
'policy_type' => 'intuneRoleAssignment',
|
||||
'display_name' => 'TenantPilot Assignment',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
$settingsCatalog = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'settings-policy',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'display_name' => 'Windows Hardening',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$matching = BackupItem::factory()->for($backupSet)->for($tenant)->create([
|
||||
'policy_id' => (int) $roleDefinition->getKey(),
|
||||
'policy_identifier' => 'role-definition',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'metadata' => ['displayName' => 'Application Manager'],
|
||||
]);
|
||||
|
||||
$wrongType = BackupItem::factory()->for($backupSet)->for($tenant)->create([
|
||||
'policy_id' => (int) $roleAssignment->getKey(),
|
||||
'policy_identifier' => 'role-assignment',
|
||||
'policy_type' => 'intuneRoleAssignment',
|
||||
'platform' => 'all',
|
||||
'metadata' => ['displayName' => 'TenantPilot Assignment'],
|
||||
]);
|
||||
|
||||
$wrongRestoreMode = BackupItem::factory()->for($backupSet)->for($tenant)->create([
|
||||
'policy_id' => (int) $settingsCatalog->getKey(),
|
||||
'policy_identifier' => 'settings-policy',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows',
|
||||
'metadata' => ['displayName' => 'Windows Hardening'],
|
||||
]);
|
||||
|
||||
$otherBackupSetMatch = BackupItem::factory()->for($otherBackupSet)->for($tenant)->create([
|
||||
'policy_id' => (int) $roleDefinition->getKey(),
|
||||
'policy_identifier' => 'role-definition-other',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'metadata' => ['displayName' => 'Application Manager Copy'],
|
||||
]);
|
||||
|
||||
backupItemsRelationManagerComponent($backupSet)
|
||||
->filterTable('policy_type', 'intuneRoleDefinition')
|
||||
->filterTable('restore_mode', 'preview-only')
|
||||
->filterTable('platform', 'all')
|
||||
->assertCanSeeTableRecords([$matching])
|
||||
->assertCanNotSeeTableRecords([$wrongType, $wrongRestoreMode, $otherBackupSetMatch]);
|
||||
});
|
||||
|
||||
it('clears backup item filters back to the backup-set scoped list', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$roleDefinition = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'role-definition-clear',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => 'Help Desk Operator',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
$roleAssignment = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'role-assignment-clear',
|
||||
'policy_type' => 'intuneRoleAssignment',
|
||||
'display_name' => 'TenantPilot Assignment',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
$definitionItem = BackupItem::factory()->for($backupSet)->for($tenant)->create([
|
||||
'policy_id' => (int) $roleDefinition->getKey(),
|
||||
'policy_identifier' => 'role-definition-clear',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'metadata' => ['displayName' => 'Help Desk Operator'],
|
||||
]);
|
||||
|
||||
$assignmentItem = BackupItem::factory()->for($backupSet)->for($tenant)->create([
|
||||
'policy_id' => (int) $roleAssignment->getKey(),
|
||||
'policy_identifier' => 'role-assignment-clear',
|
||||
'policy_type' => 'intuneRoleAssignment',
|
||||
'platform' => 'all',
|
||||
'metadata' => ['displayName' => 'TenantPilot Assignment'],
|
||||
]);
|
||||
|
||||
$component = backupItemsRelationManagerComponent($backupSet)
|
||||
->filterTable('policy_type', 'intuneRoleDefinition')
|
||||
->filterTable('platform', 'all')
|
||||
->assertCanSeeTableRecords([$definitionItem])
|
||||
->assertCanNotSeeTableRecords([$assignmentItem]);
|
||||
|
||||
$component
|
||||
->set('tableFilters.policy_type.value', null)
|
||||
->set('tableFilters.platform.value', null)
|
||||
->assertCanSeeTableRecords([$definitionItem, $assignmentItem]);
|
||||
});
|
||||
|
||||
it('persists backup item search, sort, and filter state across remounts', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'role-definition-persist',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => 'Role Persistence',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
$component = backupItemsRelationManagerComponent($backupSet)
|
||||
->searchTable('Role')
|
||||
->call('sortTable', 'policy.display_name', 'desc')
|
||||
->set('tableFilters.policy_type.value', 'intuneRoleDefinition');
|
||||
|
||||
$instance = $component->instance();
|
||||
|
||||
expect(session()->get($instance->getTableSearchSessionKey()))->toBe('Role');
|
||||
expect(session()->get($instance->getTableSortSessionKey()))->toBe('policy.display_name:desc');
|
||||
expect(data_get(session()->get($instance->getTableFiltersSessionKey()), 'policy_type.value'))->toBe('intuneRoleDefinition');
|
||||
|
||||
backupItemsRelationManagerComponent($backupSet)
|
||||
->assertSet('tableSearch', 'Role')
|
||||
->assertSet('tableSort', 'policy.display_name:desc')
|
||||
->assertSet('tableFilters.policy_type.value', 'intuneRoleDefinition');
|
||||
});
|
||||
144
tests/Feature/Filament/BaselineSnapshotListFiltersTest.php
Normal file
144
tests/Feature/Filament/BaselineSnapshotListFiltersTest.php
Normal file
@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource\Pages\ListBaselineSnapshots;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function baselineSnapshotSummary(int $content, int $meta, int $gaps): array
|
||||
{
|
||||
return [
|
||||
'total_items' => $content + $meta,
|
||||
'fidelity_counts' => [
|
||||
'content' => $content,
|
||||
'meta' => $meta,
|
||||
],
|
||||
'gaps' => [
|
||||
'count' => $gaps,
|
||||
'by_reason' => $gaps > 0 ? ['meta_fallback' => $gaps] : [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function baselineSnapshotFilterIndicatorLabels($component): array
|
||||
{
|
||||
return collect($component->instance()->getTable()->getFilterIndicators())
|
||||
->map(fn ($indicator): string => (string) $indicator->getLabel())
|
||||
->all();
|
||||
}
|
||||
|
||||
it('filters baseline snapshots by baseline, state, and captured date inside the current workspace', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
$baselineA = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Device Compliance',
|
||||
]);
|
||||
|
||||
$baselineB = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Device Configuration',
|
||||
]);
|
||||
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
|
||||
$otherWorkspaceBaseline = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $otherWorkspace->getKey(),
|
||||
'name' => 'Other Workspace Baseline',
|
||||
]);
|
||||
|
||||
$matching = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $baselineA->getKey(),
|
||||
'captured_at' => now()->subDay(),
|
||||
'summary_jsonb' => baselineSnapshotSummary(3, 0, 2),
|
||||
]);
|
||||
|
||||
$wrongState = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $baselineA->getKey(),
|
||||
'captured_at' => now()->subDay(),
|
||||
'summary_jsonb' => baselineSnapshotSummary(3, 0, 0),
|
||||
]);
|
||||
|
||||
$wrongBaseline = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $baselineB->getKey(),
|
||||
'captured_at' => now()->subDay(),
|
||||
'summary_jsonb' => baselineSnapshotSummary(2, 1, 1),
|
||||
]);
|
||||
|
||||
$outsideWindow = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $baselineA->getKey(),
|
||||
'captured_at' => now()->subDays(10),
|
||||
'summary_jsonb' => baselineSnapshotSummary(2, 0, 2),
|
||||
]);
|
||||
|
||||
$otherWorkspaceSnapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $otherWorkspace->getKey(),
|
||||
'baseline_profile_id' => (int) $otherWorkspaceBaseline->getKey(),
|
||||
'captured_at' => now()->subDay(),
|
||||
'summary_jsonb' => baselineSnapshotSummary(4, 0, 3),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ListBaselineSnapshots::class)
|
||||
->filterTable('baseline_profile_id', (string) $baselineA->getKey())
|
||||
->filterTable('snapshot_state', 'with_gaps')
|
||||
->set('tableFilters.captured_at.from', now()->subDays(2)->toDateString())
|
||||
->set('tableFilters.captured_at.until', now()->toDateString())
|
||||
->assertCanSeeTableRecords([$matching])
|
||||
->assertCanNotSeeTableRecords([$wrongState, $wrongBaseline, $outsideWindow, $otherWorkspaceSnapshot]);
|
||||
|
||||
expect(baselineSnapshotFilterIndicatorLabels($component))
|
||||
->toContain('Captured from '.now()->subDays(2)->toFormattedDateString())
|
||||
->toContain('Captured until '.now()->toFormattedDateString());
|
||||
});
|
||||
|
||||
it('clears baseline snapshot filters back to the workspace-owned list', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
$baseline = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$withGaps = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $baseline->getKey(),
|
||||
'captured_at' => now()->subHour(),
|
||||
'summary_jsonb' => baselineSnapshotSummary(2, 0, 1),
|
||||
]);
|
||||
|
||||
$complete = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $baseline->getKey(),
|
||||
'captured_at' => now()->subDays(5),
|
||||
'summary_jsonb' => baselineSnapshotSummary(2, 0, 0),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ListBaselineSnapshots::class)
|
||||
->filterTable('snapshot_state', 'with_gaps')
|
||||
->set('tableFilters.captured_at.from', now()->subDays(2)->toDateString())
|
||||
->set('tableFilters.captured_at.until', now()->toDateString())
|
||||
->assertCanSeeTableRecords([$withGaps])
|
||||
->assertCanNotSeeTableRecords([$complete]);
|
||||
|
||||
$component
|
||||
->set('tableFilters.snapshot_state.value', null)
|
||||
->set('tableFilters.captured_at.from', null)
|
||||
->set('tableFilters.captured_at.until', null)
|
||||
->assertCanSeeTableRecords([$withGaps, $complete]);
|
||||
});
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\TagBadgeCatalog;
|
||||
@ -104,6 +105,8 @@ function removeInventoryCoverageRestoreMetadata(): void
|
||||
|
||||
$assignmentFilterKey = inventoryCoverageRecordKey('foundation', 'assignmentFilter');
|
||||
$scopeTagKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
|
||||
$roleDefinitionKey = inventoryCoverageRecordKey('foundation', 'intuneRoleDefinition');
|
||||
$roleAssignmentKey = inventoryCoverageRecordKey('foundation', 'intuneRoleAssignment');
|
||||
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
|
||||
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
|
||||
$securityBaselineKey = inventoryCoverageRecordKey('policy', 'securityBaselinePolicy');
|
||||
@ -113,10 +116,14 @@ function removeInventoryCoverageRestoreMetadata(): void
|
||||
->assertTableFilterExists('restore')
|
||||
->filterTable('category', 'Foundations')
|
||||
->assertCanSeeTableRecords([$assignmentFilterKey, $scopeTagKey])
|
||||
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $conditionalAccessKey])
|
||||
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $conditionalAccessKey, $roleDefinitionKey, $roleAssignmentKey])
|
||||
->removeTableFilters()
|
||||
->filterTable('category', 'RBAC')
|
||||
->assertCanSeeTableRecords([$roleDefinitionKey, $roleAssignmentKey])
|
||||
->assertCanNotSeeTableRecords([$assignmentFilterKey, $scopeTagKey, $deviceConfigurationKey])
|
||||
->removeTableFilters()
|
||||
->filterTable('restore', 'preview-only')
|
||||
->assertCanSeeTableRecords([$conditionalAccessKey, $securityBaselineKey])
|
||||
->assertCanSeeTableRecords([$conditionalAccessKey, $securityBaselineKey, $roleDefinitionKey, $roleAssignmentKey])
|
||||
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $assignmentFilterKey]);
|
||||
});
|
||||
|
||||
@ -213,3 +220,25 @@ function removeInventoryCoverageRestoreMetadata(): void
|
||||
&& (string) $column->getIcon($state) === 'heroicon-m-minus-circle';
|
||||
}, $assignmentFilterKey);
|
||||
});
|
||||
|
||||
it('returns 404 for non-members on the inventory coverage page even when RBAC foundations exist', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($owner);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$outsider = User::factory()->create();
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $outsider->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($outsider);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Intune\VersionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@ -33,3 +34,93 @@
|
||||
->assertSee('Policy A')
|
||||
->assertSee((string) PolicyVersion::max('version_number'));
|
||||
});
|
||||
|
||||
test('policy version detail renders readable normalized RBAC assignment content', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'rbac-assign-1',
|
||||
'policy_type' => 'intuneRoleAssignment',
|
||||
'display_name' => 'Current assignment name',
|
||||
'platform' => 'all',
|
||||
'last_synced_at' => null,
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_type' => 'intuneRoleAssignment',
|
||||
'platform' => 'all',
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceAndAppManagementRoleAssignment',
|
||||
'displayName' => 'Helpdesk Assignment',
|
||||
'description' => 'Delegated access for helpdesk operators',
|
||||
'scopeType' => 'allDevicesAssignment',
|
||||
'members' => [
|
||||
['displayName' => 'Helpdesk Group', 'id' => 'group-1'],
|
||||
'group-2',
|
||||
],
|
||||
'scopeMembers' => ['scope-group-1'],
|
||||
'resourceScopes' => ['/', '/deviceManagement/managedDevices'],
|
||||
'roleDefinition' => [
|
||||
'id' => 'role-1',
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings&tenant='.(string) $tenant->external_id);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Helpdesk Assignment');
|
||||
$response->assertSee('Role assignment');
|
||||
$response->assertSee('Policy and Profile Manager (role-1)');
|
||||
$response->assertSee('Helpdesk Group (group-1)');
|
||||
$response->assertSee('group-2');
|
||||
$response->assertSee('scope-group-1');
|
||||
$response->assertSee('/deviceManagement/managedDevices');
|
||||
});
|
||||
|
||||
test('policy version detail returns 404 for non-members on RBAC versions', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'rbac-assign-404',
|
||||
'policy_type' => 'intuneRoleAssignment',
|
||||
'display_name' => 'Hidden assignment',
|
||||
'platform' => 'all',
|
||||
'last_synced_at' => null,
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_type' => 'intuneRoleAssignment',
|
||||
'platform' => 'all',
|
||||
'snapshot' => [
|
||||
'displayName' => 'Hidden assignment',
|
||||
],
|
||||
]);
|
||||
|
||||
$outsider = User::factory()->create();
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $outsider->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$this->actingAs($outsider)
|
||||
->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tenant='.(string) $tenant->external_id)
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -214,6 +214,91 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
]);
|
||||
});
|
||||
|
||||
test('restore execution keeps RBAC foundation items preview-only and never applies them', function () {
|
||||
app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
|
||||
{
|
||||
public array $applyCalls = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, ['payload' => []]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
$this->applyCalls[] = compact('policyType', 'policyId', 'payload', 'options');
|
||||
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
});
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->state([
|
||||
'policy_id' => null,
|
||||
'policy_version_id' => null,
|
||||
'policy_identifier' => 'role-def-1',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'payload' => [
|
||||
'id' => 'role-def-1',
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
],
|
||||
'metadata' => [
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
],
|
||||
])
|
||||
->create();
|
||||
|
||||
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
);
|
||||
|
||||
expect($run->status)->toBe('partial');
|
||||
expect($run->results['foundations'] ?? [])->toHaveCount(1);
|
||||
expect($run->results['foundations'][0]['decision'] ?? null)->toBe('skipped');
|
||||
expect($run->results['foundations'][0]['reason'] ?? null)->toBe('preview_only');
|
||||
expect($run->results['foundations'][0]['restore_mode'] ?? null)->toBe('preview-only');
|
||||
});
|
||||
|
||||
test('restore execution records compliance notification mapping outcomes', function () {
|
||||
app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
|
||||
{
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
||||
use App\Filament\Resources\BaselineSnapshotResource\Pages\ListBaselineSnapshots;
|
||||
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||
@ -14,6 +15,7 @@
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -245,6 +247,23 @@ function spec125AssertPersistedTableState(
|
||||
);
|
||||
});
|
||||
|
||||
it('persists baseline snapshot list search, sort, and filter state across remounts', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
spec125AssertPersistedTableState(
|
||||
ListBaselineSnapshots::class,
|
||||
[],
|
||||
'Baseline',
|
||||
'captured_at',
|
||||
'asc',
|
||||
'tableFilters.snapshot_state.value',
|
||||
'with_gaps',
|
||||
);
|
||||
});
|
||||
|
||||
it('persists monitoring operations search, sort, and filter state across remounts', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\FoundationSnapshotService;
|
||||
@ -48,7 +49,7 @@
|
||||
|
||||
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetchAll')
|
||||
->once()
|
||||
->zeroOrMoreTimes()
|
||||
->andReturn([
|
||||
'items' => [
|
||||
[
|
||||
@ -95,3 +96,129 @@
|
||||
expect($foundationItem->policy_identifier)->toBe('filter-1');
|
||||
expect($foundationItem->metadata['displayName'])->toBe('Filter One');
|
||||
});
|
||||
|
||||
it('captures RBAC foundations with linked immutable policy versions', function () {
|
||||
config()->set('tenantpilot.foundation_types', [
|
||||
[
|
||||
'type' => 'intuneRoleDefinition',
|
||||
'label' => 'Intune Role Definition',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/roleDefinitions',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
'type' => 'intuneRoleAssignment',
|
||||
'label' => 'Intune Role Assignment',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/roleAssignments',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetchAll')
|
||||
->twice()
|
||||
->andReturnUsing(function (Tenant $tenant, string $foundationType): array {
|
||||
return match ($foundationType) {
|
||||
'intuneRoleDefinition' => [
|
||||
'items' => [[
|
||||
'source_id' => 'role-def-1',
|
||||
'display_name' => 'Policy and Profile Manager',
|
||||
'payload' => [
|
||||
'id' => 'role-def-1',
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
'description' => 'Built-in RBAC role',
|
||||
'isBuiltIn' => true,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
'kind' => 'intuneRoleDefinition',
|
||||
'graph' => [
|
||||
'resource' => 'deviceManagement/roleDefinitions',
|
||||
'apiVersion' => 'beta',
|
||||
],
|
||||
],
|
||||
]],
|
||||
'failures' => [],
|
||||
],
|
||||
'intuneRoleAssignment' => [
|
||||
'items' => [[
|
||||
'source_id' => 'role-assign-1',
|
||||
'display_name' => 'Helpdesk Assignment',
|
||||
'payload' => [
|
||||
'id' => 'role-assign-1',
|
||||
'displayName' => 'Helpdesk Assignment',
|
||||
'members' => ['group-1'],
|
||||
'scopeMembers' => ['scope-group-1'],
|
||||
'resourceScopes' => ['/'],
|
||||
'roleDefinition' => [
|
||||
'id' => 'role-def-1',
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'displayName' => 'Helpdesk Assignment',
|
||||
'kind' => 'intuneRoleAssignment',
|
||||
'graph' => [
|
||||
'resource' => 'deviceManagement/roleAssignments',
|
||||
'apiVersion' => 'beta',
|
||||
],
|
||||
],
|
||||
]],
|
||||
'failures' => [],
|
||||
],
|
||||
default => [
|
||||
'items' => [],
|
||||
'failures' => [],
|
||||
],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
$service = app(BackupService::class);
|
||||
|
||||
$backupSet = $service->createBackupSet(
|
||||
tenant: $this->tenant,
|
||||
policyIds: [],
|
||||
name: 'RBAC Foundation Backup',
|
||||
includeFoundations: true,
|
||||
);
|
||||
|
||||
$definitionItem = BackupItem::query()
|
||||
->where('backup_set_id', $backupSet->id)
|
||||
->where('policy_type', 'intuneRoleDefinition')
|
||||
->first();
|
||||
$assignmentItem = BackupItem::query()
|
||||
->where('backup_set_id', $backupSet->id)
|
||||
->where('policy_type', 'intuneRoleAssignment')
|
||||
->first();
|
||||
|
||||
expect($backupSet->item_count)->toBe(2);
|
||||
expect($definitionItem)->not->toBeNull();
|
||||
expect($assignmentItem)->not->toBeNull();
|
||||
expect($definitionItem->policy_id)->not->toBeNull();
|
||||
expect($definitionItem->policy_version_id)->not->toBeNull();
|
||||
expect($definitionItem->resolvedDisplayName())->toBe('Policy and Profile Manager');
|
||||
expect($assignmentItem->policy_id)->not->toBeNull();
|
||||
expect($assignmentItem->policy_version_id)->not->toBeNull();
|
||||
expect($assignmentItem->resolvedDisplayName())->toBe('Helpdesk Assignment');
|
||||
expect(Policy::query()->where('tenant_id', $this->tenant->id)->where('policy_type', 'intuneRoleDefinition')->count())->toBe(1);
|
||||
expect(PolicyVersion::query()->where('tenant_id', $this->tenant->id)->where('policy_type', 'intuneRoleAssignment')->count())->toBe(1);
|
||||
});
|
||||
|
||||
@ -119,7 +119,7 @@
|
||||
expect($missing)->toBeEmpty('Missing standardized empty-state declarations: '.implode(', ', $missing));
|
||||
});
|
||||
|
||||
it('keeps persistence declarations explicit on the designated critical resource lists', function (): void {
|
||||
it('keeps persistence declarations explicit on the designated critical and follow-up filter surfaces', function (): void {
|
||||
$paths = [
|
||||
'app/Filament/Resources/TenantResource.php',
|
||||
'app/Filament/Resources/PolicyResource.php',
|
||||
@ -133,6 +133,8 @@
|
||||
'app/Filament/Resources/AlertDeliveryResource.php',
|
||||
'app/Filament/Resources/EntraGroupResource.php',
|
||||
'app/Filament/Resources/OperationRunResource.php',
|
||||
'app/Filament/Resources/BaselineSnapshotResource.php',
|
||||
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
@ -228,6 +229,83 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('inventory sync captures RBAC foundation rows with sanitized metadata and coverage status', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||
|
||||
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
||||
'deviceConfiguration' => [],
|
||||
'intuneRoleDefinition' => [
|
||||
[
|
||||
'id' => 'role-def-1',
|
||||
'displayName' => 'Help Desk Operator',
|
||||
'description' => 'Built-in support role',
|
||||
'isBuiltIn' => true,
|
||||
'rolePermissions' => [
|
||||
['resourceActions' => ['managedDevices/read']],
|
||||
['resourceActions' => ['deviceConfigurations/read']],
|
||||
],
|
||||
],
|
||||
],
|
||||
'intuneRoleAssignment' => [
|
||||
[
|
||||
'id' => 'role-assignment-1',
|
||||
'displayName' => 'Help Desk Assignment',
|
||||
'description' => 'Tenant-wide assignment',
|
||||
'members' => ['group-1', 'group-2'],
|
||||
'scopeMembers' => ['scope-member-1'],
|
||||
'resourceScopes' => ['scope-1', 'scope-2'],
|
||||
'roleDefinition' => [
|
||||
'id' => 'role-def-1',
|
||||
'displayName' => 'Help Desk Operator',
|
||||
],
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$run = app(InventorySyncService::class)->syncNow($tenant, [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'categories' => [],
|
||||
'include_foundations' => true,
|
||||
'include_dependencies' => false,
|
||||
]);
|
||||
|
||||
$definition = InventoryItem::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('policy_type', 'intuneRoleDefinition')
|
||||
->where('external_id', 'role-def-1')
|
||||
->first();
|
||||
|
||||
expect($definition)->not->toBeNull();
|
||||
expect($definition->category)->toBe('RBAC');
|
||||
expect($definition->meta_jsonb)->toMatchArray([
|
||||
'is_built_in' => true,
|
||||
'role_permission_count' => 2,
|
||||
]);
|
||||
|
||||
$assignment = InventoryItem::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('policy_type', 'intuneRoleAssignment')
|
||||
->where('external_id', 'role-assignment-1')
|
||||
->first();
|
||||
|
||||
expect($assignment)->not->toBeNull();
|
||||
expect($assignment->category)->toBe('RBAC');
|
||||
expect($assignment->meta_jsonb)->toMatchArray([
|
||||
'role_definition_id' => 'role-def-1',
|
||||
'role_definition_name' => 'Help Desk Operator',
|
||||
'member_count' => 2,
|
||||
'scope_member_count' => 1,
|
||||
'resource_scope_count' => 2,
|
||||
]);
|
||||
|
||||
$coverage = $run->context['inventory']['coverage']['foundation_types'] ?? [];
|
||||
|
||||
expect($coverage['intuneRoleDefinition']['status'] ?? null)->toBe('succeeded');
|
||||
expect($coverage['intuneRoleAssignment']['status'] ?? null)->toBe('succeeded');
|
||||
});
|
||||
|
||||
test('inventory sync does not sync foundation types when include_foundations is false', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
|
||||
@ -131,6 +131,57 @@
|
||||
->toBe(OperationRunLinks::view($run, $tenant));
|
||||
});
|
||||
|
||||
it('renders partial backup-set update notifications with RBAC foundation summary counts', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'backup_set_id' => 42,
|
||||
'options' => ['include_foundations' => true],
|
||||
],
|
||||
]);
|
||||
|
||||
$service = app(OperationRunService::class);
|
||||
|
||||
$service->updateRun(
|
||||
$run,
|
||||
status: 'completed',
|
||||
outcome: 'partially_succeeded',
|
||||
summaryCounts: [
|
||||
'total' => 3,
|
||||
'processed' => 3,
|
||||
'succeeded' => 2,
|
||||
'failed' => 1,
|
||||
'items' => 3,
|
||||
'created' => 2,
|
||||
],
|
||||
failures: [[
|
||||
'code' => 'foundation.capture_failed',
|
||||
'message' => 'DeviceManagementRBAC.Read.All is missing.',
|
||||
]],
|
||||
);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['title'] ?? null)->toBe('Backup set update completed with warnings');
|
||||
expect($notification->data['body'] ?? null)->toContain('Completed with warnings.');
|
||||
expect($notification->data['body'] ?? null)->toContain('Total: 3');
|
||||
expect($notification->data['body'] ?? null)->toContain('Affected items: 3');
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($run, $tenant));
|
||||
});
|
||||
|
||||
it('marks a run failed if dispatch throws synchronously', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -43,3 +43,52 @@
|
||||
expect($run->summary_counts['failed'] ?? null)->toBe(1);
|
||||
expect($run->summary_counts)->not->toHaveKey('secrets');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('increments canonical backup-flow summary keys used by RBAC foundation capture', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => ['options' => ['include_foundations' => true]],
|
||||
'summary_counts' => [
|
||||
'total' => 1,
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'created' => 1,
|
||||
'items' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $service */
|
||||
$service = app(OperationRunService::class);
|
||||
|
||||
$service->incrementSummaryCounts($run, [
|
||||
'total' => 2,
|
||||
'items' => 2,
|
||||
'processed' => 2,
|
||||
'succeeded' => 2,
|
||||
'created' => 1,
|
||||
'updated' => 1,
|
||||
'secrets' => 123,
|
||||
]);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->summary_counts)->toMatchArray([
|
||||
'total' => 3,
|
||||
'processed' => 3,
|
||||
'succeeded' => 3,
|
||||
'created' => 2,
|
||||
'updated' => 1,
|
||||
'items' => 3,
|
||||
]);
|
||||
expect($run->summary_counts)->not->toHaveKey('secrets');
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -5,10 +5,14 @@
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -58,3 +62,59 @@
|
||||
->assertTableActionVisible('remove', $backupItem)
|
||||
->assertTableActionDisabled('remove', $backupItem);
|
||||
});
|
||||
|
||||
it('routes versioned RBAC foundation items to immutable policy version detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'role-def-1',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'display_name' => 'Current role label',
|
||||
'last_synced_at' => null,
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'snapshot' => [
|
||||
'displayName' => 'Captured RBAC role',
|
||||
],
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create([
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_version_id' => (int) $version->getKey(),
|
||||
'policy_identifier' => 'role-def-1',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'payload' => [
|
||||
'displayName' => 'Captured RBAC role',
|
||||
],
|
||||
'metadata' => [
|
||||
'displayName' => 'Captured RBAC role',
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(BackupItemsRelationManager::class, [
|
||||
'ownerRecord' => $backupSet,
|
||||
'pageClass' => EditBackupSet::class,
|
||||
])
|
||||
->assertTableColumnFormattedStateSet('policy.display_name', 'Captured RBAC role', $backupItem)
|
||||
->assertTableActionVisible('view', $backupItem)
|
||||
->assertTableActionExists('view', function (Action $action) use ($tenant, $version): bool {
|
||||
return $action->getLabel() === 'View version'
|
||||
&& $action->getUrl() === PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant);
|
||||
}, $backupItem);
|
||||
});
|
||||
|
||||
@ -116,3 +116,12 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($result['status'] ?? null)->toBe('skipped');
|
||||
expect($result['restore_mode'] ?? null)->toBe('preview-only');
|
||||
});
|
||||
|
||||
test('restore keeps RBAC foundation types preview-only and never executes them', function () {
|
||||
$service = app(RestoreService::class);
|
||||
$method = new ReflectionMethod($service, 'resolveRestoreMode');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($service, 'intuneRoleDefinition'))->toBe('preview-only');
|
||||
expect($method->invoke($service, 'intuneRoleAssignment'))->toBe('preview-only');
|
||||
});
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Verification\TenantPermissionCheckClusters;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('surfaces a dedicated RBAC permission reason when DeviceManagementRBAC.Read.All is missing', function (): void {
|
||||
$tenant = Tenant::factory()->create(['external_id' => 'tenant-rbac-check']);
|
||||
|
||||
$checks = TenantPermissionCheckClusters::buildChecks($tenant, [
|
||||
[
|
||||
'key' => 'DeviceManagementRBAC.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => 'Read Intune RBAC roles and assignments',
|
||||
'features' => ['rbac_inventory', 'rbac_backup_history'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$rbacCheck = collect($checks)->firstWhere('key', 'permissions.intune_rbac_assignments');
|
||||
|
||||
expect($rbacCheck)->toBeArray();
|
||||
expect($rbacCheck['status'] ?? null)->toBe(VerificationCheckStatus::Fail->value);
|
||||
expect($rbacCheck['blocking'] ?? null)->toBeTrue();
|
||||
expect($rbacCheck['reason_code'] ?? null)->toBe(ProviderReasonCodes::IntuneRbacPermissionMissing);
|
||||
expect((string) ($rbacCheck['message'] ?? ''))->toContain('DeviceManagementRBAC.Read.All');
|
||||
expect((string) ($rbacCheck['message'] ?? ''))->toContain('RBAC inventory and backup history');
|
||||
expect($rbacCheck['next_steps'][0]['url'] ?? null)->toBe(RequiredPermissionsLinks::requiredPermissions($tenant));
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
@ -65,3 +66,74 @@
|
||||
expect($version->metadata['warnings'])->toBeArray();
|
||||
expect($version->metadata['warnings'][0])->toContain('metadata only');
|
||||
});
|
||||
|
||||
it('captures and reuses immutable RBAC foundation versions from payload snapshots', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'external_id' => 'role-def-1',
|
||||
'display_name' => 'Policy and Profile Manager',
|
||||
'last_synced_at' => null,
|
||||
]);
|
||||
|
||||
$service = app(VersionService::class);
|
||||
|
||||
$basePayload = [
|
||||
'id' => 'role-def-1',
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
'isBuiltIn' => true,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$firstVersion = $service->captureFoundationVersion(
|
||||
policy: $policy,
|
||||
payload: $basePayload,
|
||||
createdBy: 'tester@example.test',
|
||||
metadata: [
|
||||
'kind' => 'intuneRoleDefinition',
|
||||
'graph' => [
|
||||
'resource' => 'deviceManagement/roleDefinitions',
|
||||
'apiVersion' => 'beta',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$reusedVersion = $service->captureFoundationVersion(
|
||||
policy: $policy,
|
||||
payload: $basePayload,
|
||||
createdBy: 'tester@example.test',
|
||||
metadata: [
|
||||
'kind' => 'intuneRoleDefinition',
|
||||
],
|
||||
);
|
||||
|
||||
$changedVersion = $service->captureFoundationVersion(
|
||||
policy: $policy,
|
||||
payload: array_merge($basePayload, [
|
||||
'description' => 'Updated role description',
|
||||
]),
|
||||
createdBy: 'tester@example.test',
|
||||
metadata: [
|
||||
'kind' => 'intuneRoleDefinition',
|
||||
],
|
||||
);
|
||||
|
||||
expect($firstVersion->id)->toBe($reusedVersion->id);
|
||||
expect($changedVersion->id)->not->toBe($firstVersion->id);
|
||||
expect($changedVersion->version_number)->toBe(2);
|
||||
expect($firstVersion->metadata['capture_source'] ?? null)->toBe('foundation_capture');
|
||||
expect($firstVersion->metadata['kind'] ?? null)->toBe('intuneRoleDefinition');
|
||||
expect(PolicyVersion::query()->where('policy_id', $policy->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
@ -121,3 +121,61 @@
|
||||
->toThrow(WorkspaceIsolationViolation::class);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps RBAC foundation policy versions and backup items pinned to the tenant workspace', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$policy = Policy::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'external_id' => 'role-def-1',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'display_name' => 'Policy and Profile Manager',
|
||||
'last_synced_at' => null,
|
||||
'metadata' => ['foundation_anchor' => true],
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'captured_at' => now(),
|
||||
'snapshot' => ['displayName' => 'Policy and Profile Manager'],
|
||||
'metadata' => ['capture_source' => 'foundation_capture'],
|
||||
'secret_fingerprints' => [
|
||||
'snapshot' => [],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
],
|
||||
'redaction_version' => 1,
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'backup_set_id' => $backupSet->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'policy_version_id' => $version->getKey(),
|
||||
'policy_identifier' => 'role-def-1',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'payload' => ['displayName' => 'Policy and Profile Manager'],
|
||||
'metadata' => ['displayName' => 'Policy and Profile Manager'],
|
||||
]);
|
||||
|
||||
expect((int) $version->workspace_id)->toBe((int) $workspace->getKey());
|
||||
expect((int) $backupItem->workspace_id)->toBe((int) $workspace->getKey());
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
it('maps policy snapshot mode values to canonical badge semantics', function (): void {
|
||||
$full = BadgeCatalog::spec(BadgeDomain::PolicySnapshotMode, 'full');
|
||||
@ -44,3 +45,8 @@
|
||||
expect($ignored->label)->toBe('Yes');
|
||||
expect($ignored->color)->toBe('warning');
|
||||
});
|
||||
|
||||
it('maps RBAC foundation restore modes to preview-only metadata', function (): void {
|
||||
expect(InventoryPolicyTypeMeta::restoreMode('intuneRoleDefinition'))->toBe('preview-only');
|
||||
expect(InventoryPolicyTypeMeta::restoreMode('intuneRoleAssignment'))->toBe('preview-only');
|
||||
});
|
||||
|
||||
@ -121,3 +121,73 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($client->requests[1]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc');
|
||||
expect($client->requests[1]['options']['query'])->toBe([]);
|
||||
});
|
||||
|
||||
it('hydrates RBAC assignment snapshots with contract-driven expand across pages', function () {
|
||||
config()->set('graph_contracts.types.intuneRoleAssignment', [
|
||||
'resource' => 'deviceManagement/roleAssignments',
|
||||
'allowed_select' => ['id', 'displayName', 'description', 'members', 'scopeMembers', 'resourceScopes', 'scopeType'],
|
||||
'allowed_expand' => ['roleDefinition($select=id,displayName,description,isBuiltIn)'],
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-rbac-123',
|
||||
'app_client_id' => 'client-rbac-123',
|
||||
'app_client_secret' => 'secret-rbac-123',
|
||||
]);
|
||||
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||
|
||||
$responses = [
|
||||
new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
'value' => [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'displayName' => 'Help Desk Assignment',
|
||||
'members' => ['group-1'],
|
||||
'scopeMembers' => ['scope-member-1'],
|
||||
'resourceScopes' => ['scope-1'],
|
||||
'roleDefinition' => [
|
||||
'id' => 'role-definition-1',
|
||||
'displayName' => 'Help Desk Operator',
|
||||
],
|
||||
],
|
||||
],
|
||||
'@odata.nextLink' => 'https://graph.microsoft.com/beta/deviceManagement/roleAssignments?$skiptoken=rbac',
|
||||
],
|
||||
),
|
||||
new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
'value' => [
|
||||
[
|
||||
'id' => 'assignment-2',
|
||||
'displayName' => 'Operations Assignment',
|
||||
'members' => ['group-2'],
|
||||
'resourceScopes' => ['scope-2'],
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
$client = new FoundationSnapshotGraphClient($responses);
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$service = app(FoundationSnapshotService::class);
|
||||
$result = $service->fetchAll($tenant, 'intuneRoleAssignment');
|
||||
|
||||
expect($result['items'])->toHaveCount(2);
|
||||
expect($result['items'][0]['source_id'])->toBe('assignment-1');
|
||||
expect($result['items'][0]['metadata']['kind'])->toBe('intuneRoleAssignment');
|
||||
expect($result['items'][0]['metadata']['graph']['resource'])->toBe('deviceManagement/roleAssignments');
|
||||
expect($result['items'][0]['payload']['roleDefinition']['displayName'])->toBe('Help Desk Operator');
|
||||
expect($result['items'][1]['source_id'])->toBe('assignment-2');
|
||||
|
||||
expect($client->requests[0]['path'])->toBe('deviceManagement/roleAssignments');
|
||||
expect($client->requests[0]['options']['query']['$select'])->toBe('id,displayName,description,members,scopeMembers,resourceScopes,scopeType');
|
||||
expect($client->requests[0]['options']['query']['$expand'])->toBe('roleDefinition($select=id,displayName,description,isBuiltIn)');
|
||||
expect($client->requests[1]['path'])->toBe('deviceManagement/roleAssignments?$skiptoken=rbac');
|
||||
expect($client->requests[1]['options']['query'])->toBe([]);
|
||||
});
|
||||
|
||||
@ -29,6 +29,26 @@
|
||||
],
|
||||
]);
|
||||
|
||||
config()->set('graph_contracts.types.intuneRoleDefinition', [
|
||||
'resource' => 'deviceManagement/roleDefinitions',
|
||||
'allowed_select' => ['id', 'displayName', 'description', 'isBuiltIn', 'rolePermissions', 'roleScopeTagIds'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.roleDefinition',
|
||||
'#microsoft.graph.deviceAndAppManagementRoleDefinition',
|
||||
],
|
||||
]);
|
||||
|
||||
config()->set('graph_contracts.types.intuneRoleAssignment', [
|
||||
'resource' => 'deviceManagement/roleAssignments',
|
||||
'allowed_select' => ['id', 'displayName', 'description', 'members', 'scopeMembers', 'resourceScopes', 'scopeType'],
|
||||
'allowed_expand' => ['roleDefinition($select=id,displayName,description,isBuiltIn)'],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.roleAssignment',
|
||||
'#microsoft.graph.deviceAndAppManagementRoleAssignment',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->registry = app(GraphContractRegistry::class);
|
||||
});
|
||||
|
||||
@ -157,6 +177,28 @@
|
||||
expect($this->registry->matchesTypeFamily('settingsCatalogPolicy', '#microsoft.graph.deviceManagementConfigurationPolicy'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('provides helper paths and contract families for inventory-grade RBAC contracts', function () {
|
||||
expect($this->registry->intuneRoleDefinitionPolicyType())->toBe('intuneRoleDefinition');
|
||||
expect($this->registry->intuneRoleDefinitionListPath())->toBe('/deviceManagement/roleDefinitions');
|
||||
expect($this->registry->intuneRoleDefinitionItemPath('role-def/1'))->toBe('/deviceManagement/roleDefinitions/role-def%2F1');
|
||||
|
||||
expect($this->registry->intuneRoleAssignmentPolicyType())->toBe('intuneRoleAssignment');
|
||||
expect($this->registry->intuneRoleAssignmentListPath())->toBe('/deviceManagement/roleAssignments');
|
||||
expect($this->registry->intuneRoleAssignmentItemPath('assignment/1'))->toBe('/deviceManagement/roleAssignments/assignment%2F1');
|
||||
|
||||
expect($this->registry->matchesTypeFamily('intuneRoleDefinition', '#microsoft.graph.roleDefinition'))->toBeTrue();
|
||||
expect($this->registry->matchesTypeFamily('intuneRoleAssignment', '#microsoft.graph.deviceAndAppManagementRoleAssignment'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('sanitizes role-assignment expands against the inventory-grade RBAC contract', function () {
|
||||
$result = $this->registry->sanitizeQuery('intuneRoleAssignment', [
|
||||
'$expand' => 'roleDefinition($select=id,displayName,description,isBuiltIn),badExpand',
|
||||
]);
|
||||
|
||||
expect($result['query']['$expand'])->toBe('roleDefinition($select=id,displayName,description,isBuiltIn)');
|
||||
expect($result['warnings'])->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('sanitizes update payloads for settings catalog policies', function () {
|
||||
$payload = [
|
||||
'id' => 'scp-1',
|
||||
|
||||
94
tests/Unit/IntuneRoleAssignmentNormalizerTest.php
Normal file
94
tests/Unit/IntuneRoleAssignmentNormalizerTest.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Intune\IntuneRoleAssignmentNormalizer;
|
||||
|
||||
it('normalizes intune role assignments with readable role, member, and scope details', function (): void {
|
||||
$normalizer = app(IntuneRoleAssignmentNormalizer::class);
|
||||
|
||||
$snapshot = [
|
||||
'@odata.type' => '#microsoft.graph.deviceAndAppManagementRoleAssignment',
|
||||
'displayName' => 'Helpdesk Assignment',
|
||||
'description' => 'Delegated access for helpdesk operators',
|
||||
'scopeType' => 'allDevicesAssignment',
|
||||
'members' => [
|
||||
['displayName' => 'Helpdesk Group', 'id' => 'group-1'],
|
||||
'group-2',
|
||||
],
|
||||
'scopeMembers' => [
|
||||
['displayName' => 'Berlin Devices', 'id' => 'scope-group-1'],
|
||||
],
|
||||
'resourceScopes' => [
|
||||
'/deviceManagement/managedDevices',
|
||||
'/',
|
||||
],
|
||||
'roleDefinition' => [
|
||||
'id' => 'role-1',
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
],
|
||||
];
|
||||
|
||||
$result = $normalizer->normalize($snapshot, 'intuneRoleAssignment', 'all');
|
||||
|
||||
$summary = collect($result['settings'])->firstWhere('title', 'Role assignment');
|
||||
$members = collect($result['settings'])->firstWhere('title', 'Members');
|
||||
$scopeMembers = collect($result['settings'])->firstWhere('title', 'Scope members');
|
||||
$resourceScopes = collect($result['settings'])->firstWhere('title', 'Resource scopes');
|
||||
$summaryEntries = collect($summary['entries'] ?? [])->keyBy('key');
|
||||
|
||||
expect($result['status'])->toBe('ok');
|
||||
expect($summaryEntries['Role definition']['value'] ?? null)->toBe('Policy and Profile Manager (role-1)');
|
||||
expect($summaryEntries['Members count']['value'] ?? null)->toBe(2);
|
||||
expect($summaryEntries['Scope members count']['value'] ?? null)->toBe(1);
|
||||
expect($summaryEntries['Resource scopes count']['value'] ?? null)->toBe(2);
|
||||
expect($members['entries'][0]['value'] ?? null)->toBe([
|
||||
'Helpdesk Group (group-1)',
|
||||
'group-2',
|
||||
]);
|
||||
expect($scopeMembers['entries'][0]['value'] ?? null)->toBe([
|
||||
'Berlin Devices (scope-group-1)',
|
||||
]);
|
||||
expect($resourceScopes['entries'][0]['value'] ?? null)->toBe([
|
||||
'/',
|
||||
'/deviceManagement/managedDevices',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses identifier fallbacks and stable ordering when expanded role assignment data is incomplete', function (): void {
|
||||
$normalizer = app(IntuneRoleAssignmentNormalizer::class);
|
||||
|
||||
$firstSnapshot = [
|
||||
'displayName' => 'Fallback Assignment',
|
||||
'members' => ['group-2', 'group-1'],
|
||||
'scopeMembers' => [
|
||||
['id' => 'scope-2'],
|
||||
['id' => 'scope-1'],
|
||||
],
|
||||
'resourceScopes' => ['/b', '/a'],
|
||||
'roleDefinition' => [
|
||||
'id' => 'role-fallback',
|
||||
],
|
||||
];
|
||||
|
||||
$secondSnapshot = [
|
||||
'displayName' => 'Fallback Assignment',
|
||||
'members' => ['group-1', 'group-2'],
|
||||
'scopeMembers' => [
|
||||
['id' => 'scope-1'],
|
||||
['id' => 'scope-2'],
|
||||
],
|
||||
'resourceScopes' => ['/a', '/b'],
|
||||
'roleDefinition' => [
|
||||
'id' => 'role-fallback',
|
||||
],
|
||||
];
|
||||
|
||||
$normalized = $normalizer->normalize($firstSnapshot, 'intuneRoleAssignment', 'all');
|
||||
$summary = collect($normalized['settings'])->firstWhere('title', 'Role assignment');
|
||||
$summaryEntries = collect($summary['entries'] ?? [])->keyBy('key');
|
||||
|
||||
expect($normalized['status'])->toBe('warning');
|
||||
expect($normalized['warnings'])->toContain('Role definition display name unavailable; using identifier fallback.');
|
||||
expect($summaryEntries['Role definition']['value'] ?? null)->toBe('role-fallback');
|
||||
expect($normalizer->flattenForDiff($firstSnapshot, 'intuneRoleAssignment', 'all'))
|
||||
->toBe($normalizer->flattenForDiff($secondSnapshot, 'intuneRoleAssignment', 'all'));
|
||||
});
|
||||
91
tests/Unit/IntuneRoleDefinitionNormalizerTest.php
Normal file
91
tests/Unit/IntuneRoleDefinitionNormalizerTest.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
||||
|
||||
it('normalizes built-in intune role definitions into readable permission blocks', function (): void {
|
||||
$normalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||
|
||||
$snapshot = [
|
||||
'@odata.type' => '#microsoft.graph.deviceAndAppManagementRoleDefinition',
|
||||
'displayName' => 'Policy and Profile Manager',
|
||||
'description' => 'Built-in RBAC role',
|
||||
'isBuiltIn' => true,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
'Microsoft.Intune/deviceConfigurations/create',
|
||||
],
|
||||
'notAllowedResourceActions' => [
|
||||
'Microsoft.Intune/deviceConfigurations/delete',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'roleScopeTagIds' => ['scope-1', '0'],
|
||||
];
|
||||
|
||||
$result = $normalizer->normalize($snapshot, 'intuneRoleDefinition', 'all');
|
||||
|
||||
$summary = collect($result['settings'])->firstWhere('title', 'Role definition');
|
||||
$permissionBlock = collect($result['settings'])->firstWhere('title', 'Permission block 1');
|
||||
$summaryEntries = collect($summary['entries'] ?? [])->keyBy('key');
|
||||
$permissionEntries = collect($permissionBlock['entries'] ?? [])->keyBy('key');
|
||||
|
||||
expect($result['status'])->toBe('ok');
|
||||
expect($summaryEntries['Role source']['value'] ?? null)->toBe('Built-in');
|
||||
expect($summaryEntries['Permission blocks']['value'] ?? null)->toBe(1);
|
||||
expect($summaryEntries['Scope tag IDs']['value'] ?? null)->toBe(['scope-1', '0']);
|
||||
expect($permissionEntries['Allowed actions']['value'] ?? null)->toBe([
|
||||
'Microsoft.Intune/deviceConfigurations/create',
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
]);
|
||||
expect($permissionEntries['Denied actions']['value'] ?? null)->toBe([
|
||||
'Microsoft.Intune/deviceConfigurations/delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('flattens custom intune role definitions deterministically regardless of permission block order', function (): void {
|
||||
$normalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||
|
||||
$firstSnapshot = [
|
||||
'displayName' => 'Custom RBAC Role',
|
||||
'isBuiltIn' => false,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => [
|
||||
'Microsoft.Intune/deviceCompliancePolicies/read',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
],
|
||||
'condition' => '@Resource[Microsoft.Intune/deviceConfigurations] Exists',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$secondSnapshot = [
|
||||
'displayName' => 'Custom RBAC Role',
|
||||
'isBuiltIn' => false,
|
||||
'rolePermissions' => [
|
||||
$firstSnapshot['rolePermissions'][1],
|
||||
$firstSnapshot['rolePermissions'][0],
|
||||
],
|
||||
];
|
||||
|
||||
expect($normalizer->flattenForDiff($firstSnapshot, 'intuneRoleDefinition', 'all'))
|
||||
->toBe($normalizer->flattenForDiff($secondSnapshot, 'intuneRoleDefinition', 'all'));
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user