TenantAtlas/app/Support/Baselines/BaselineScope.php
ahmido ef41c9193a feat: add Intune RBAC baseline compare support (#156)
## Summary
- add Intune RBAC Role Definition baseline scope support, capture references, compare classification, findings evidence, and landing/detail UI labels
- keep Intune Role Assignments explicitly excluded from baseline compare scope, summaries, findings, and restore messaging
- add focused Pest coverage for baseline scope selection, capture, compare behavior, recurrence, isolation, findings rendering, inventory anchoring, and RBAC summaries

## Verification
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php tests/Unit/Baselines/BaselinePolicyVersionResolverTest.php tests/Unit/Baselines/BaselineScopeTest.php tests/Unit/IntuneRoleDefinitionNormalizerTest.php tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php tests/Feature/Filament/BaselineProfileFoundationScopeTest.php tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php tests/Feature/Filament/FindingViewRbacEvidenceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/DriftStaleAutoResolveTest.php tests/Feature/Inventory/InventorySyncButtonTest.php tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php`
- result: `71 passed (467 assertions)`

## Filament / Platform Notes
- Livewire compliance: unchanged and compatible with Livewire v4.0+
- Provider registration: no panel/provider changes; `bootstrap/providers.php` remains the registration location
- Global search: no new globally searchable resource added; existing global search behavior is unchanged
- Destructive actions: no new destructive actions introduced; existing confirmed actions remain unchanged
- Assets: no new Filament assets introduced; deploy asset handling remains unchanged, including `php artisan filament:assets`
- Testing plan covered: baseline profile scope, snapshot detail, compare job, findings recurrence, findings detail, compare landing labels, inventory sync anchoring, and tenant isolation

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #156
2026-03-09 18:49:20 +00:00

221 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Support\Inventory\InventoryPolicyTypeMeta;
/**
* Value object for baseline scope resolution.
*
* A scope defines which policy types are included in a baseline profile.
*
* Spec 116 semantics:
* - Empty policy_types means "all supported policy types" (excluding foundations).
* - Empty foundation_types means "none".
*/
final class BaselineScope
{
/**
* @param array<string> $policyTypes
* @param array<string> $foundationTypes
*/
public function __construct(
public readonly array $policyTypes = [],
public readonly array $foundationTypes = [],
) {}
/**
* Create from the scope_jsonb column value.
*
* @param array<string, mixed>|null $scopeJsonb
*/
public static function fromJsonb(?array $scopeJsonb): self
{
if ($scopeJsonb === null) {
return new self;
}
$policyTypes = $scopeJsonb['policy_types'] ?? [];
$foundationTypes = $scopeJsonb['foundation_types'] ?? [];
$policyTypes = is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [];
$foundationTypes = is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [];
return new self(
policyTypes: $policyTypes === [] ? [] : self::normalizePolicyTypes($policyTypes),
foundationTypes: self::normalizeFoundationTypes($foundationTypes),
);
}
/**
* Normalize the effective scope by intersecting profile scope with an optional override.
*
* Override can only narrow the profile scope (subset enforcement).
* Empty override means "no override".
*/
public static function effective(self $profileScope, ?self $overrideScope): self
{
$profileScope = $profileScope->expandDefaults();
if ($overrideScope === null || $overrideScope->isEmpty()) {
return $profileScope;
}
$overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes);
$overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes);
$effectivePolicyTypes = $overridePolicyTypes !== []
? array_values(array_intersect($profileScope->policyTypes, $overridePolicyTypes))
: $profileScope->policyTypes;
$effectiveFoundationTypes = $overrideFoundationTypes !== []
? array_values(array_intersect($profileScope->foundationTypes, $overrideFoundationTypes))
: $profileScope->foundationTypes;
return new self(
policyTypes: self::uniqueSorted($effectivePolicyTypes),
foundationTypes: self::uniqueSorted($effectiveFoundationTypes),
);
}
/**
* An empty scope means "no override" (for override_scope semantics).
*/
public function isEmpty(): bool
{
return $this->policyTypes === [] && $this->foundationTypes === [];
}
/**
* Apply Spec 116 defaults and filter to supported types.
*/
public function expandDefaults(): self
{
$policyTypes = $this->policyTypes === []
? self::supportedPolicyTypes()
: self::normalizePolicyTypes($this->policyTypes);
$foundationTypes = self::normalizeFoundationTypes($this->foundationTypes);
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
);
}
/**
* @return list<string>
*/
public function allTypes(): array
{
$expanded = $this->expandDefaults();
return self::uniqueSorted(array_merge(
$expanded->policyTypes,
$expanded->foundationTypes,
));
}
/**
* @return array<string, mixed>
*/
public function toJsonb(): array
{
return [
'policy_types' => $this->policyTypes,
'foundation_types' => $this->foundationTypes,
];
}
/**
* Effective scope payload for OperationRun.context.
*
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
*/
public function toEffectiveScopeContext(): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
return [
'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
];
}
/**
* @return list<string>
*/
private static function supportedPolicyTypes(): array
{
$supported = config('tenantpilot.supported_policy_types', []);
if (! is_array($supported)) {
return [];
}
$types = collect($supported)
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
}
/**
* @return list<string>
*/
private static function supportedFoundationTypes(): array
{
$types = collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizePolicyTypes(array $types): array
{
$supported = self::supportedPolicyTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizeFoundationTypes(array $types): array
{
$supported = self::supportedFoundationTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<int, string> $types
* @return list<string>
*/
private static function uniqueSorted(array $types): array
{
$types = array_values(array_unique(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== '')));
sort($types, SORT_STRING);
return $types;
}
}