feat(baselines): implement baseline matching canonicalization (#453)

Replaced legacy tenant and environment bindings in the BaselineDriftEngine with the new ProviderResourceIdentity framework as defined in Spec 382. This ensures cross-environment compatibility and deterministic baseline matching.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #453
This commit is contained in:
ahmido 2026-06-15 22:48:48 +00:00
parent 04d0d6184f
commit 788efee1c2
76 changed files with 3897 additions and 519 deletions

View File

@ -26,15 +26,19 @@
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Baselines\SubjectClass;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Resources\ResourceIdentity;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
@ -260,7 +264,6 @@ public function handle(
$inventoryResult = $this->collectInventorySubjects(
sourceTenant: $sourceTenant,
scope: $effectiveScope,
identity: $identity,
latestInventorySyncRunId: $latestInventorySyncRunId,
policyTypes: $truthfulTypes,
);
@ -581,6 +584,9 @@ public function handle(
* subject_key: string,
* policy_type: string,
* identity_strategy: string,
* provider_resource_identity: array<string, mixed>,
* provider_resource_fingerprint: string,
* subject_class: string,
* display_name: ?string,
* category: ?string,
* platform: ?string,
@ -593,7 +599,6 @@ public function handle(
private function collectInventorySubjects(
ManagedEnvironment $sourceTenant,
BaselineScope $scope,
BaselineSnapshotIdentity $identity,
?int $latestInventorySyncRunId = null,
?array $policyTypes = null,
): array {
@ -606,17 +611,14 @@ private function collectInventorySubjects(
$query->whereIn('policy_type', is_array($policyTypes) && $policyTypes !== [] ? $policyTypes : $scope->allTypes());
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, provider_resource_identity: array<string, mixed>, provider_resource_fingerprint: string, subject_class: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
$inventoryByKey = [];
/** @var array<string, int> $gaps */
$gaps = [];
/**
* Ensure we only include unambiguous subjects when matching by subject_key (derived from display name).
*
* When multiple inventory items share the same "policy_type|subject_key" we cannot reliably map them
* across tenants, so we treat them as an evidence gap and exclude them from the snapshot.
* Ensure we only include unambiguous canonical provider-resource subjects.
*
* @var array<string, true> $ambiguousKeys
*/
@ -629,16 +631,27 @@ private function collectInventorySubjects(
$query->orderBy('policy_type')
->orderBy('external_id')
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey, $identity): void {
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey): void {
foreach ($inventoryItems as $inventoryItem) {
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null;
$policyType = (string) $inventoryItem->policy_type;
$tenantSubjectExternalId = is_string($inventoryItem->external_id) ? $inventoryItem->external_id : null;
$subjectKey = $identity->subjectKey($policyType, $displayName, $tenantSubjectExternalId);
$resourceIdentity = $this->resourceIdentityFromInventoryItem($inventoryItem, $metaJsonb);
$subjectClass = InventoryPolicyTypeMeta::isFoundation($policyType)
? SubjectClass::FoundationBacked
: SubjectClass::PolicyBacked;
$subjectKey = $resourceIdentity instanceof ResourceIdentity
? BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: $subjectClass,
subjectTypeKey: $policyType,
identity: $resourceIdentity,
)
: null;
if ($subjectKey === null) {
$gaps['missing_subject_key'] = ($gaps['missing_subject_key'] ?? 0) + 1;
$gaps['identity_required'] = ($gaps['identity_required'] ?? 0) + 1;
continue;
}
@ -660,7 +673,7 @@ private function collectInventorySubjects(
continue;
}
$workspaceSafeId = $identity->workspaceSafeSubjectExternalId($policyType, $displayName, $tenantSubjectExternalId);
$workspaceSafeId = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, $subjectKey);
if (! is_string($workspaceSafeId) || $workspaceSafeId === '') {
$gaps['missing_subject_external_reference'] = ($gaps['missing_subject_external_reference'] ?? 0) + 1;
@ -676,7 +689,10 @@ private function collectInventorySubjects(
'workspace_subject_external_id' => $workspaceSafeId,
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'identity_strategy' => InventoryPolicyTypeMeta::baselineCompareIdentityStrategy($policyType),
'identity_strategy' => 'provider_resource',
'provider_resource_identity' => $resourceIdentity->toArray(),
'provider_resource_fingerprint' => $resourceIdentity->fingerprint(),
'subject_class' => $subjectClass->value,
'display_name' => $displayName,
'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null,
'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null,
@ -705,6 +721,71 @@ private function collectInventorySubjects(
];
}
/**
* @param array<string, mixed> $metaJsonb
*/
private function resourceIdentityFromInventoryItem(InventoryItem $inventoryItem, array $metaJsonb): ?ResourceIdentity
{
$descriptorPayload = $metaJsonb['provider_resource_descriptor'] ?? null;
if (is_array($descriptorPayload)) {
$identityPayload = $descriptorPayload['identity'] ?? null;
if (is_array($identityPayload)) {
try {
return ResourceIdentity::fromArray($identityPayload);
} catch (InvalidArgumentException) {
return null;
}
}
}
$identityPayload = $metaJsonb['provider_resource_identity'] ?? null;
if (is_array($identityPayload)) {
try {
return ResourceIdentity::fromArray($identityPayload);
} catch (InvalidArgumentException) {
return null;
}
}
$externalId = is_string($inventoryItem->external_id) ? trim($inventoryItem->external_id) : '';
$providerKey = $this->metadataString($metaJsonb, ['provider_key', 'provider']) ?? 'inventory';
$resourceType = $this->metadataString($metaJsonb, [
'provider_resource_type',
'resource_type',
'provider_object_type',
]) ?? (string) $inventoryItem->policy_type;
if ($externalId === '') {
return null;
}
try {
return ResourceIdentity::providerResource($providerKey, $resourceType, $externalId);
} catch (InvalidArgumentException) {
return null;
}
}
/**
* @param array<string, mixed> $metadata
* @param list<string> $keys
*/
private function metadataString(array $metadata, array $keys): ?string
{
foreach ($keys as $key) {
$value = data_get($metadata, $key);
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
return null;
}
/**
* @param array<string, mixed> $context
* @return list<string>
@ -733,6 +814,9 @@ private function truthfulTypesFromContext(array $context, BaselineScope $effecti
* subject_key: string,
* policy_type: string,
* identity_strategy: string,
* provider_resource_identity: array<string, mixed>,
* provider_resource_fingerprint: string,
* subject_class: string,
* display_name: ?string,
* category: ?string,
* platform: ?string,
@ -796,6 +880,13 @@ private function buildSnapshotItems(
'baseline_hash' => $evidence->hash,
'meta_jsonb' => [
'display_name' => $inventoryItem['display_name'],
'provider_key' => is_string($inventoryItem['provider_resource_identity']['provider_key'] ?? null)
? $inventoryItem['provider_resource_identity']['provider_key']
: null,
'provider_resource_identity' => $inventoryItem['provider_resource_identity'],
'provider_resource_fingerprint' => $inventoryItem['provider_resource_fingerprint'],
'subject_domain' => 'baseline',
'subject_class' => $inventoryItem['subject_class'],
'category' => $inventoryItem['category'],
'platform' => $inventoryItem['platform'],
'evidence' => $provenance,

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,6 @@
namespace App\Services\Baselines;
use App\Services\Drift\DriftHasher;
use App\Support\Baselines\BaselineSubjectKey;
/**
* Computes the snapshot_identity_hash for baseline snapshot content dedupe.
@ -48,16 +47,6 @@ public function computeIdentity(array $items): string
return hash('sha256', implode("\n", $normalized));
}
public function subjectKey(string $policyType, ?string $displayName = null, ?string $subjectExternalId = null): ?string
{
return BaselineSubjectKey::forPolicy($policyType, $displayName, $subjectExternalId);
}
public function workspaceSafeSubjectExternalId(string $policyType, ?string $displayName = null, ?string $subjectExternalId = null): ?string
{
return BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy($policyType, $displayName, $subjectExternalId);
}
/**
* Compute a stable content hash for a single inventory item's metadata.
*

View File

@ -8,7 +8,11 @@
use App\Models\PolicyVersion;
use App\Models\ManagedEnvironment;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Resources\ResourceIdentity;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
use Throwable;
final class BaselinePolicyVersionResolver
@ -119,11 +123,7 @@ private function buildIndex(int $tenantId, string $policyType): array
continue;
}
$key = BaselineSubjectKey::forPolicy(
$policyType,
is_string($policy->display_name) ? $policy->display_name : null,
is_string($policy->external_id) ? $policy->external_id : null,
);
$key = $this->canonicalSubjectKeyForPolicy($policyType, $policy);
if ($key === null) {
continue;
@ -144,4 +144,28 @@ private function buildIndex(int $tenantId, string $policyType): array
return $index;
}
private function canonicalSubjectKeyForPolicy(string $policyType, Policy $policy): ?string
{
$externalId = is_string($policy->external_id) ? trim($policy->external_id) : '';
if ($externalId === '') {
return null;
}
try {
$identity = ResourceIdentity::providerResource('inventory', $policyType, $externalId);
} catch (InvalidArgumentException) {
return null;
}
return BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: InventoryPolicyTypeMeta::isFoundation($policyType)
? SubjectClass::FoundationBacked
: SubjectClass::PolicyBacked,
subjectTypeKey: $policyType,
identity: $identity,
);
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\Matching;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Resources\ResourceIdentity;
final readonly class FoundationCoverageResolver
{
public function __construct(
private BaselineSupportCapabilityGuard $capabilityGuard,
) {}
/**
* @return array{
* policy_type: string,
* coverage: string,
* compare_capability: string,
* capture_capability: string,
* source_model_expected: ?string,
* support_mode: string,
* reason_code: ?string,
* identity_kind: ?string
* }
*/
public function coverageFor(string $policyType, ?ResourceIdentity $identity = null): array
{
$capability = $this->capabilityGuard->inspectType($policyType);
$supportMode = $capability->supportModeFor('compare');
$identityKind = $identity?->identityKind;
$isFoundation = InventoryPolicyTypeMeta::isFoundation($policyType);
if ($supportMode === 'invalid_support_config') {
return $this->record($policyType, 'unsupported', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'invalid_support_config', $identityKind);
}
if ($supportMode === 'excluded') {
return $this->record($policyType, 'unsupported', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'unsupported_subject', $identityKind);
}
if ($identity instanceof ResourceIdentity && in_array($identity->identityKind, [
ResourceIdentity::CanonicalBuiltin,
ResourceIdentity::CanonicalDefault,
ResourceIdentity::CanonicalVirtualTarget,
], true)) {
return $this->record($policyType, 'canonical_only', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, null, $identityKind);
}
if ($isFoundation && $capability->sourceModelExpected === 'inventory') {
return $this->record($policyType, 'inventory_only', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'foundation_not_policy_backed', $identityKind);
}
if ($supportMode === 'limited') {
return $this->record($policyType, 'identity_only', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'accepted_limitation', $identityKind);
}
return $this->record($policyType, 'fully_comparable', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, null, $identityKind);
}
/**
* @return array{
* policy_type: string,
* coverage: string,
* compare_capability: string,
* capture_capability: string,
* source_model_expected: ?string,
* support_mode: string,
* reason_code: ?string,
* identity_kind: ?string
* }
*/
private function record(
string $policyType,
string $coverage,
string $compareCapability,
string $captureCapability,
?string $sourceModelExpected,
string $supportMode,
?string $reasonCode,
?string $identityKind,
): array {
return [
'policy_type' => $policyType,
'coverage' => $coverage,
'compare_capability' => $compareCapability,
'capture_capability' => $captureCapability,
'source_model_expected' => $sourceModelExpected,
'support_mode' => $supportMode,
'reason_code' => $reasonCode,
'identity_kind' => $identityKind,
];
}
}

View File

@ -0,0 +1,447 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\Matching;
use App\Models\ProviderResourceBinding;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\Matching\BaselineSubjectDescriptor;
use App\Support\Baselines\Matching\MatchingOutcome;
use App\Support\Resources\ProviderResourceBindingStatus;
use App\Support\Resources\ProviderResourceDescriptor;
use App\Support\Resources\ProviderResourceResolutionMode;
use App\Support\Resources\ResourceIdentity;
final class SubjectMatchingPipeline
{
public function __construct(
private readonly FoundationCoverageResolver $foundationCoverageResolver,
) {}
/**
* @param list<BaselineSubjectDescriptor> $baselineSubjects
* @param list<ProviderResourceDescriptor> $currentDescriptors
* @return array{
* outcomes: list<MatchingOutcome>,
* diagnostics: array<string, mixed>
* }
*/
public function matchAll(int $workspaceId, int $managedEnvironmentId, array $baselineSubjects, array $currentDescriptors): array
{
$currentIndex = $this->indexCurrentDescriptors($currentDescriptors);
$bindingIndex = $this->activeBindingsByCanonicalKey($workspaceId, $managedEnvironmentId, $baselineSubjects);
$outcomes = [];
foreach ($baselineSubjects as $subject) {
$outcomes[] = $this->matchOne($subject, $currentIndex, $bindingIndex);
}
return [
'outcomes' => $outcomes,
'diagnostics' => $this->diagnostics($outcomes, $bindingIndex),
];
}
/**
* @param array{
* by_canonical_key: array<string, list<ProviderResourceDescriptor>>,
* by_identity_key: array<string, list<ProviderResourceDescriptor>>
* } $currentIndex
* @param array<string, list<ProviderResourceBinding>> $bindingIndex
*/
private function matchOne(BaselineSubjectDescriptor $subject, array $currentIndex, array $bindingIndex): MatchingOutcome
{
$canonicalKey = $this->subjectCanonicalKey($subject);
if ($canonicalKey !== null && isset($bindingIndex[$canonicalKey])) {
$bindings = $bindingIndex[$canonicalKey];
if (count($bindings) !== 1) {
return MatchingOutcome::ambiguous($subject, [
'match_stage' => 'active_binding',
'canonical_subject_key' => $canonicalKey,
'binding_count' => count($bindings),
]);
}
return $this->matchBinding($subject, $bindings[0], $currentIndex);
}
if ($canonicalKey !== null) {
$byCanonical = $currentIndex['by_canonical_key'][$canonicalKey] ?? [];
if (count($byCanonical) === 1) {
return MatchingOutcome::resolved(
subject: $subject,
matchedDescriptor: $byCanonical[0],
matchedSubjectKey: $canonicalKey,
reasonCode: 'canonical_subject_key',
trust: 'high',
proof: [
'match_stage' => 'canonical_subject_key',
'canonical_subject_key' => $canonicalKey,
],
);
}
if (count($byCanonical) > 1) {
return MatchingOutcome::ambiguous($subject, [
'match_stage' => 'canonical_subject_key',
'canonical_subject_key' => $canonicalKey,
'candidate_count' => count($byCanonical),
]);
}
}
if ($subject->providerResourceDescriptor instanceof ProviderResourceDescriptor) {
$byCanonical = $this->currentByCanonicalSubject($subject->providerResourceDescriptor, $currentIndex);
if (count($byCanonical) === 1) {
return MatchingOutcome::resolved(
subject: $subject,
matchedDescriptor: $byCanonical[0],
matchedSubjectKey: $canonicalKey ?? $this->currentCanonicalKey($byCanonical[0]) ?? $subject->comparisonSubjectKey(),
reasonCode: 'provider_identity',
trust: 'high',
proof: [
'match_stage' => 'provider_identity',
'canonical_subject_key' => $canonicalKey,
],
);
}
if (count($byCanonical) > 1) {
return MatchingOutcome::ambiguous($subject, [
'match_stage' => 'provider_identity',
'canonical_subject_key' => $canonicalKey,
'candidate_count' => count($byCanonical),
]);
}
}
$coverageOutcome = $this->foundationCoverageOutcome($subject);
if ($coverageOutcome instanceof MatchingOutcome) {
return $coverageOutcome;
}
if ($canonicalKey !== null || $subject->providerResourceDescriptor instanceof ProviderResourceDescriptor) {
return MatchingOutcome::missingLocalEvidence($subject, [
'match_stage' => $canonicalKey !== null ? 'canonical_subject_key' : 'provider_identity',
'canonical_subject_key' => $canonicalKey,
]);
}
return MatchingOutcome::unresolvedIdentity($subject, [
'match_stage' => 'identity_required',
]);
}
private function foundationCoverageOutcome(BaselineSubjectDescriptor $subject): ?MatchingOutcome
{
$coverage = $this->foundationCoverageResolver->coverageFor(
$subject->subjectTypeKey,
$subject->providerResourceDescriptor?->identity,
);
$proof = [
'match_stage' => 'foundation_coverage',
'coverage' => $coverage['coverage'],
'support_mode' => $coverage['support_mode'],
'source_model_expected' => $coverage['source_model_expected'],
'identity_kind' => $coverage['identity_kind'],
];
return match ($coverage['coverage']) {
'unsupported' => MatchingOutcome::unsupported($subject, $proof + [
'reason_code' => $coverage['reason_code'] ?? 'unsupported_subject',
]),
'inventory_only' => MatchingOutcome::limited(
subject: $subject,
reasonCode: $coverage['reason_code'] ?? 'foundation_not_policy_backed',
proof: $proof,
),
'identity_only', 'canonical_only' => MatchingOutcome::limited(
subject: $subject,
reasonCode: $coverage['reason_code'] ?? 'accepted_limitation',
proof: $proof,
),
default => null,
};
}
/**
* @param array{
* by_canonical_key: array<string, list<ProviderResourceDescriptor>>,
* by_identity_key: array<string, list<ProviderResourceDescriptor>>
* } $currentIndex
*/
private function matchBinding(BaselineSubjectDescriptor $subject, ProviderResourceBinding $binding, array $currentIndex): MatchingOutcome
{
$proof = [
'match_stage' => 'active_binding',
'provider_resource_binding_id' => (int) $binding->getKey(),
'provider_key' => (string) $binding->provider_key,
'canonical_subject_key' => (string) $binding->canonical_subject_key,
'resolution_mode' => $this->resolutionModeValue($binding),
'binding_status' => $this->bindingStatusValue($binding),
];
return match ($this->resolutionModeValue($binding)) {
ProviderResourceResolutionMode::ExcludedNonGoverned->value => MatchingOutcome::excluded($subject, $proof),
ProviderResourceResolutionMode::AcceptedLimitation->value => MatchingOutcome::limited($subject, 'accepted_limitation', $proof),
ProviderResourceResolutionMode::UnsupportedCoverage->value => MatchingOutcome::unsupported($subject, $proof),
ProviderResourceResolutionMode::MissingExpected->value => MatchingOutcome::missingProviderResource($subject, $proof),
default => $this->matchActiveBindingToCurrentDescriptor($subject, $binding, $currentIndex, $proof),
};
}
/**
* @param array{
* by_canonical_key: array<string, list<ProviderResourceDescriptor>>,
* by_identity_key: array<string, list<ProviderResourceDescriptor>>
* } $currentIndex
* @param array<string, string|int|float|bool|null> $proof
*/
private function matchActiveBindingToCurrentDescriptor(BaselineSubjectDescriptor $subject, ProviderResourceBinding $binding, array $currentIndex, array $proof): MatchingOutcome
{
$candidates = $currentIndex['by_canonical_key'][(string) $binding->canonical_subject_key] ?? [];
if ($candidates === []) {
$identityKey = $this->bindingIdentityKey($binding);
$candidates = $identityKey !== null
? ($currentIndex['by_identity_key'][$identityKey] ?? [])
: [];
}
if (count($candidates) === 1) {
return MatchingOutcome::resolved(
subject: $subject,
matchedDescriptor: $candidates[0],
matchedSubjectKey: (string) $binding->canonical_subject_key,
reasonCode: 'active_provider_resource_binding',
trust: 'authoritative',
proof: $proof,
);
}
if (count($candidates) > 1) {
return MatchingOutcome::ambiguous($subject, $proof + [
'candidate_count' => count($candidates),
]);
}
return MatchingOutcome::missingLocalEvidence($subject, $proof);
}
/**
* @param list<ProviderResourceDescriptor> $currentDescriptors
* @return array{
* by_canonical_key: array<string, list<ProviderResourceDescriptor>>,
* by_identity_key: array<string, list<ProviderResourceDescriptor>>
* }
*/
private function indexCurrentDescriptors(array $currentDescriptors): array
{
$index = [
'by_canonical_key' => [],
'by_identity_key' => [],
];
foreach ($currentDescriptors as $descriptor) {
if (! $descriptor instanceof ProviderResourceDescriptor) {
continue;
}
$canonicalKey = $this->currentCanonicalKey($descriptor);
if ($canonicalKey !== null) {
$index['by_canonical_key'][$canonicalKey][] = $descriptor;
}
$identityKey = $this->descriptorIdentityKey($descriptor);
if ($identityKey !== null) {
$index['by_identity_key'][$identityKey][] = $descriptor;
}
}
return $index;
}
/**
* @param list<BaselineSubjectDescriptor> $baselineSubjects
* @return array<string, list<ProviderResourceBinding>>
*/
private function activeBindingsByCanonicalKey(int $workspaceId, int $managedEnvironmentId, array $baselineSubjects): array
{
$keys = [];
foreach ($baselineSubjects as $subject) {
if (! $subject instanceof BaselineSubjectDescriptor) {
continue;
}
$canonicalKey = $this->subjectCanonicalKey($subject);
if ($canonicalKey !== null) {
$keys[$canonicalKey] = true;
}
}
if ($keys === []) {
return [];
}
$bindings = ProviderResourceBinding::query()
->where('workspace_id', $workspaceId)
->where('managed_environment_id', $managedEnvironmentId)
->whereIn('canonical_subject_key', array_keys($keys))
->where('binding_status', ProviderResourceBindingStatus::Active->value)
->orderBy('id')
->get();
$index = [];
foreach ($bindings as $binding) {
if (! $binding instanceof ProviderResourceBinding) {
continue;
}
$index[(string) $binding->canonical_subject_key][] = $binding;
}
return $index;
}
private function subjectCanonicalKey(BaselineSubjectDescriptor $subject): ?string
{
if (BaselineSubjectKey::isProviderResourceCanonicalKey($subject->canonicalSubjectKey)) {
return $subject->canonicalSubjectKey;
}
if (! $subject->providerResourceDescriptor instanceof ProviderResourceDescriptor) {
return null;
}
return BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: $subject->providerResourceDescriptor->subjectDomain,
subjectClass: $subject->providerResourceDescriptor->subjectClass,
subjectTypeKey: $subject->providerResourceDescriptor->subjectTypeKey,
identity: $subject->providerResourceDescriptor->identity,
);
}
private function currentCanonicalKey(ProviderResourceDescriptor $descriptor): ?string
{
return BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: $descriptor->subjectDomain,
subjectClass: $descriptor->subjectClass,
subjectTypeKey: $descriptor->subjectTypeKey,
identity: $descriptor->identity,
);
}
/**
* @param array{
* by_canonical_key: array<string, list<ProviderResourceDescriptor>>
* } $currentIndex
* @return list<ProviderResourceDescriptor>
*/
private function currentByCanonicalSubject(ProviderResourceDescriptor $descriptor, array $currentIndex): array
{
$canonicalKey = $this->currentCanonicalKey($descriptor);
return $canonicalKey !== null
? ($currentIndex['by_canonical_key'][$canonicalKey] ?? [])
: [];
}
private function descriptorIdentityKey(ProviderResourceDescriptor $descriptor): ?string
{
return $this->identityKey($descriptor->identity);
}
private function bindingIdentityKey(ProviderResourceBinding $binding): ?string
{
$identityKind = is_string($binding->provider_resource_identity_kind) ? trim($binding->provider_resource_identity_kind) : '';
$providerKey = is_string($binding->provider_key) ? trim($binding->provider_key) : '';
$resourceType = is_string($binding->provider_resource_type) ? trim($binding->provider_resource_type) : '';
$stableIdentity = $identityKind === ResourceIdentity::ProviderResource
? (is_string($binding->provider_resource_id) ? trim($binding->provider_resource_id) : '')
: (is_string($binding->provider_resource_discriminator) ? trim($binding->provider_resource_discriminator) : '');
if ($providerKey === '' || $resourceType === '' || $identityKind === '' || $stableIdentity === '') {
return null;
}
return implode('|', [$providerKey, $resourceType, $identityKind, $stableIdentity]);
}
private function identityKey(ResourceIdentity $identity): ?string
{
$stableIdentity = $identity->stableIdentityValue();
if ($identity->providerResourceType === null || $stableIdentity === null) {
return null;
}
return implode('|', [
$identity->providerKey,
$identity->providerResourceType,
$identity->identityKind,
$stableIdentity,
]);
}
private function resolutionModeValue(ProviderResourceBinding $binding): string
{
return $binding->resolution_mode instanceof ProviderResourceResolutionMode
? $binding->resolution_mode->value
: (string) $binding->resolution_mode;
}
private function bindingStatusValue(ProviderResourceBinding $binding): string
{
return $binding->binding_status instanceof ProviderResourceBindingStatus
? $binding->binding_status->value
: (string) $binding->binding_status;
}
/**
* @param list<MatchingOutcome> $outcomes
* @param array<string, list<ProviderResourceBinding>> $bindingIndex
* @return array<string, mixed>
*/
private function diagnostics(array $outcomes, array $bindingIndex): array
{
$byStatus = [];
$byReason = [];
$activeBindingsConsidered = 0;
foreach ($bindingIndex as $bindings) {
$activeBindingsConsidered += count($bindings);
}
foreach ($outcomes as $outcome) {
if (! $outcome instanceof MatchingOutcome) {
continue;
}
$byStatus[$outcome->status] = ($byStatus[$outcome->status] ?? 0) + 1;
$byReason[$outcome->reasonCode] = ($byReason[$outcome->reasonCode] ?? 0) + 1;
}
ksort($byStatus);
ksort($byReason);
return [
'subjects_total' => count($outcomes),
'active_bindings_considered' => $activeBindingsConsidered,
'by_status' => $byStatus,
'by_reason' => $byReason,
];
}
}

View File

@ -141,8 +141,8 @@ protected function identityHint(BaselineSnapshotItem $item, array $meta): string
{
$identityStrategy = data_get($meta, 'identity.strategy');
if ($identityStrategy === 'external_id' && is_string($item->subject_key) && $item->subject_key !== '') {
return 'External ID hash '.Str::limit($item->subject_key, 12, '…');
if ($identityStrategy === 'provider_resource' && is_string($item->subject_key) && $item->subject_key !== '') {
return 'Canonical subject '.Str::limit($item->subject_key, 12, '…');
}
if (is_string($item->subject_key) && trim($item->subject_key) !== '') {

View File

@ -40,9 +40,10 @@ public function render(BaselineSnapshotItem $item): RenderedSnapshotItem
$attributes[] = new RenderedAttribute(
'Identity',
data_get($meta, 'identity.strategy') === 'external_id'
? 'Role definition ID'
: 'Display name',
match (data_get($meta, 'identity.strategy')) {
'provider_resource' => 'Role definition ID',
default => 'Identity required',
},
);
return $this->buildItem(

View File

@ -154,13 +154,25 @@ private function createDecision(
$subjectDomain = $this->requiredString($attributes, 'subject_domain');
$subjectClass = $this->requiredSubjectClass($attributes['subject_class'] ?? null);
$subjectTypeKey = $this->requiredString($attributes, 'subject_type_key');
$canonicalSubjectKey = $this->nullableString($attributes['canonical_subject_key'] ?? null)
?? BaselineSubjectKey::forProviderResourceIdentity($subjectDomain, $subjectClass, $subjectTypeKey, $identity);
$computedCanonicalSubjectKey = BaselineSubjectKey::forProviderResourceIdentity($subjectDomain, $subjectClass, $subjectTypeKey, $identity);
$providedCanonicalSubjectKey = $this->nullableString($attributes['canonical_subject_key'] ?? null);
if ($canonicalSubjectKey === null) {
if ($computedCanonicalSubjectKey === null) {
throw new InvalidArgumentException('Provider resource binding requires a canonical subject key.');
}
if (
$providedCanonicalSubjectKey !== null
&& (
! BaselineSubjectKey::isProviderResourceCanonicalKey($providedCanonicalSubjectKey)
|| $providedCanonicalSubjectKey !== $computedCanonicalSubjectKey
)
) {
throw new InvalidArgumentException('Provider resource binding canonical subject keys must be generated from the supplied provider resource identity.');
}
$canonicalSubjectKey = $providedCanonicalSubjectKey ?? $computedCanonicalSubjectKey;
$scope = [
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
@ -206,7 +218,6 @@ private function createDecision(
'subject_domain' => $subjectDomain,
'subject_class' => $subjectClass instanceof SubjectClass ? $subjectClass->value : $subjectClass,
'subject_type_key' => $subjectTypeKey,
'legacy_subject_key' => $this->nullableString($attributes['legacy_subject_key'] ?? null),
'canonical_subject_key' => $canonicalSubjectKey,
'provider_resource_type' => $identity->providerResourceType,
'provider_resource_id' => $identity->providerResourceId,

View File

@ -533,14 +533,13 @@ private static function duplicateNameStats(ManagedEnvironment $tenant, BaselineS
->orderBy('id')
->chunkById(1_000, function ($inventoryItems) use (&$countsByKey): void {
foreach ($inventoryItems as $inventoryItem) {
$displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null;
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$displayLabel = is_string($inventoryItem->display_name) ? trim(mb_strtolower($inventoryItem->display_name)) : '';
if ($subjectKey === null) {
if ($displayLabel === '') {
continue;
}
$logicalKey = (string) $inventoryItem->policy_type.'|'.$subjectKey;
$logicalKey = (string) $inventoryItem->policy_type.'|'.$displayLabel;
$countsByKey[$logicalKey] = ($countsByKey[$logicalKey] ?? 0) + 1;
}
});

View File

@ -4,74 +4,15 @@
namespace App\Support\Baselines;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Resources\ResourceIdentity;
final class BaselineSubjectKey
{
public static function forPolicy(string $policyType, ?string $displayName = null, ?string $subjectExternalId = null): ?string
{
return match (InventoryPolicyTypeMeta::baselineCompareIdentityStrategy($policyType)) {
'external_id' => self::fromExternalId($policyType, $subjectExternalId),
default => self::fromDisplayName($displayName),
};
}
public static function fromDisplayName(?string $displayName): ?string
{
if (! is_string($displayName)) {
return null;
}
$trimmed = trim($displayName);
if ($trimmed === '') {
return null;
}
$collapsed = preg_replace('/\\s+/u', ' ', $trimmed);
$collapsed = is_string($collapsed) ? $collapsed : $trimmed;
$normalized = mb_strtolower($collapsed);
$normalized = trim($normalized);
return $normalized !== '' ? $normalized : null;
}
public static function fromExternalId(string $policyType, ?string $subjectExternalId): ?string
{
if (! is_string($subjectExternalId)) {
return null;
}
$normalizedId = trim(mb_strtolower($subjectExternalId));
if ($normalizedId === '') {
return null;
}
return hash('sha256', trim(mb_strtolower($policyType)).'|'.$normalizedId);
}
public static function workspaceSafeSubjectExternalId(string $policyType, string $subjectKey): string
{
return hash('sha256', $policyType.'|'.$subjectKey);
}
public static function workspaceSafeSubjectExternalIdForPolicy(string $policyType, ?string $displayName = null, ?string $subjectExternalId = null): ?string
{
$identityInput = match (InventoryPolicyTypeMeta::baselineCompareIdentityStrategy($policyType)) {
'external_id' => is_string($subjectExternalId) ? trim(mb_strtolower($subjectExternalId)) : null,
default => self::fromDisplayName($displayName),
};
if (! is_string($identityInput) || $identityInput === '') {
return null;
}
return self::workspaceSafeSubjectExternalId($policyType, $identityInput);
}
public static function forProviderResourceIdentity(
string $subjectDomain,
SubjectClass|string $subjectClass,
@ -125,6 +66,33 @@ public static function fromProviderResourceIdentity(
);
}
public static function isProviderResourceCanonicalKey(?string $subjectKey): bool
{
if (! is_string($subjectKey) || trim($subjectKey) === '') {
return false;
}
$parts = explode(':', trim($subjectKey));
if (count($parts) !== 9) {
return false;
}
[$prefix, $version, $domain, $class, $type, $provider, $resourceType, $identityKind, $hash] = $parts;
if ($prefix !== 'provider-resource' || $version !== 'v1') {
return false;
}
foreach ([$domain, $class, $type, $provider, $resourceType, $identityKind] as $segment) {
if (! is_string($segment) || ! preg_match('/^[a-z0-9._-]+$/', $segment)) {
return false;
}
}
return preg_match('/^[a-f0-9]{64}$/', $hash) === 1;
}
private static function canonicalSegment(?string $value): ?string
{
if (! is_string($value)) {

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Matching;
use App\Support\Baselines\SubjectClass;
use App\Support\Resources\ProviderResourceDescriptor;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
/**
* @implements Arrayable<string, mixed>
*/
final readonly class BaselineSubjectDescriptor implements Arrayable, JsonSerializable
{
/**
* @param array<string, string|int|float|bool|null> $sourceReferences
* @param array<string, mixed> $metadata
*/
public function __construct(
public string $subjectDomain,
public SubjectClass|string $subjectClass,
public string $subjectTypeKey,
public ?string $subjectType,
public ?string $subjectExternalId,
public ?string $canonicalSubjectKey,
public ?string $displayLabel,
public ?ProviderResourceDescriptor $providerResourceDescriptor = null,
public array $sourceReferences = [],
public array $metadata = [],
) {}
public function subjectClassValue(): string
{
return $this->subjectClass instanceof SubjectClass
? $this->subjectClass->value
: $this->subjectClass;
}
public function comparisonSubjectKey(): string
{
return $this->canonicalSubjectKey
?? $this->subjectExternalId
?? 'unknown';
}
public function hasProviderResourceIdentity(): bool
{
return $this->providerResourceDescriptor instanceof ProviderResourceDescriptor;
}
/**
* @return array{
* subject_domain: string,
* subject_class: string,
* subject_type_key: string,
* subject_type: ?string,
* subject_external_id: ?string,
* canonical_subject_key: ?string,
* display_label: ?string,
* provider_resource_descriptor: ?array<string, mixed>,
* source_references: array<string, string|int|float|bool|null>,
* metadata: array<string, mixed>
* }
*/
public function toArray(): array
{
return [
'subject_domain' => $this->subjectDomain,
'subject_class' => $this->subjectClassValue(),
'subject_type_key' => $this->subjectTypeKey,
'subject_type' => $this->subjectType,
'subject_external_id' => $this->subjectExternalId,
'canonical_subject_key' => $this->canonicalSubjectKey,
'display_label' => $this->displayLabel,
'provider_resource_descriptor' => $this->providerResourceDescriptor?->toArray(),
'source_references' => $this->safeSourceReferences($this->sourceReferences),
'metadata' => $this->safeMetadata($this->metadata),
];
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @param array<string, mixed> $sourceReferences
* @return array<string, string|int|float|bool|null>
*/
private function safeSourceReferences(array $sourceReferences): array
{
$safe = [];
foreach ($sourceReferences as $key => $value) {
if (! is_string($key) || trim($key) === '' || (! is_scalar($value) && $value !== null)) {
continue;
}
$safe[$key] = $value;
}
return $safe;
}
/**
* @param array<string, mixed> $metadata
* @return array<string, mixed>
*/
private function safeMetadata(array $metadata): array
{
$safe = [];
foreach ($metadata as $key => $value) {
if (! is_string($key) || trim($key) === '') {
continue;
}
if (is_scalar($value) || $value === null) {
$safe[$key] = $value;
}
}
ksort($safe);
return $safe;
}
}

View File

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Matching;
use App\Support\Resources\ProviderResourceDescriptor;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
/**
* @implements Arrayable<string, mixed>
*/
final readonly class MatchingOutcome implements Arrayable, JsonSerializable
{
public const string Resolved = 'resolved';
public const string Ambiguous = 'ambiguous';
public const string MissingProviderResource = 'missing_provider_resource';
public const string MissingLocalEvidence = 'missing_local_evidence';
public const string UnresolvedIdentity = 'unresolved_identity';
public const string Unsupported = 'unsupported';
public const string Limited = 'limited';
public const string Excluded = 'excluded';
/**
* @param array<string, string|int|float|bool|null> $proof
*/
public function __construct(
public string $status,
public string $reasonCode,
public BaselineSubjectDescriptor $subject,
public ?ProviderResourceDescriptor $matchedDescriptor = null,
public ?string $matchedSubjectKey = null,
public string $trust = 'none',
public array $proof = [],
) {}
/**
* @param array<string, string|int|float|bool|null> $proof
*/
public static function resolved(
BaselineSubjectDescriptor $subject,
ProviderResourceDescriptor $matchedDescriptor,
string $matchedSubjectKey,
string $reasonCode,
string $trust,
array $proof = [],
): self {
return new self(
status: self::Resolved,
reasonCode: $reasonCode,
subject: $subject,
matchedDescriptor: $matchedDescriptor,
matchedSubjectKey: $matchedSubjectKey,
trust: $trust,
proof: $proof,
);
}
/**
* @param array<string, string|int|float|bool|null> $proof
*/
public static function ambiguous(BaselineSubjectDescriptor $subject, array $proof = []): self
{
return new self(
status: self::Ambiguous,
reasonCode: 'ambiguous_match',
subject: $subject,
trust: 'none',
proof: $proof,
);
}
/**
* @param array<string, string|int|float|bool|null> $proof
*/
public static function missingLocalEvidence(BaselineSubjectDescriptor $subject, array $proof = []): self
{
return new self(
status: self::MissingLocalEvidence,
reasonCode: 'missing_local_evidence',
subject: $subject,
trust: 'none',
proof: $proof,
);
}
/**
* @param array<string, string|int|float|bool|null> $proof
*/
public static function missingProviderResource(BaselineSubjectDescriptor $subject, array $proof = []): self
{
return new self(
status: self::MissingProviderResource,
reasonCode: 'missing_provider_resource',
subject: $subject,
trust: 'none',
proof: $proof,
);
}
/**
* @param array<string, string|int|float|bool|null> $proof
*/
public static function unresolvedIdentity(BaselineSubjectDescriptor $subject, array $proof = []): self
{
return new self(
status: self::UnresolvedIdentity,
reasonCode: 'identity_required',
subject: $subject,
trust: 'none',
proof: $proof,
);
}
/**
* @param array<string, string|int|float|bool|null> $proof
*/
public static function unsupported(BaselineSubjectDescriptor $subject, array $proof = []): self
{
return new self(
status: self::Unsupported,
reasonCode: 'unsupported_subject',
subject: $subject,
trust: 'none',
proof: $proof,
);
}
/**
* @param array<string, string|int|float|bool|null> $proof
*/
public static function limited(BaselineSubjectDescriptor $subject, string $reasonCode = 'accepted_limitation', array $proof = []): self
{
return new self(
status: self::Limited,
reasonCode: $reasonCode,
subject: $subject,
trust: 'limited',
proof: $proof,
);
}
/**
* @param array<string, string|int|float|bool|null> $proof
*/
public static function excluded(BaselineSubjectDescriptor $subject, array $proof = []): self
{
return new self(
status: self::Excluded,
reasonCode: 'excluded_non_governed',
subject: $subject,
trust: 'none',
proof: $proof,
);
}
/**
* @param array<string, string|int|float|bool|null> $proof
*/
public function isComparable(): bool
{
return $this->status === self::Resolved;
}
public function isGap(): bool
{
return ! $this->isComparable();
}
public function requiresWarning(): bool
{
return false;
}
public function toArray(): array
{
return [
'status' => $this->status,
'reason_code' => $this->reasonCode,
'subject' => $this->subject->toArray(),
'matched_descriptor' => $this->matchedDescriptor?->toArray(),
'matched_subject_key' => $this->matchedSubjectKey,
'trust' => $this->trust,
'proof' => $this->safeProof($this->proof),
];
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @param array<string, mixed> $proof
* @return array<string, string|int|float|bool|null>
*/
private function safeProof(array $proof): array
{
$safe = [];
foreach ($proof as $key => $value) {
if (! is_string($key) || trim($key) === '' || (! is_scalar($value) && $value !== null)) {
continue;
}
$safe[$key] = $value;
}
ksort($safe);
return $safe;
}
}

View File

@ -186,16 +186,10 @@ private function normalizeSubjectKey(string $policyType, ?string $subjectExterna
return $trimmedSubjectKey;
}
$generated = BaselineSubjectKey::forPolicy($policyType, subjectExternalId: $subjectExternalId);
if (is_string($generated) && $generated !== '') {
return $generated;
}
$fallbackExternalId = is_string($subjectExternalId) && trim($subjectExternalId) !== ''
? trim($subjectExternalId)
: 'unknown';
return trim($policyType).'|'.$fallbackExternalId;
return 'identity-required:'.trim($policyType).':'.$fallbackExternalId;
}
}

View File

@ -246,9 +246,9 @@ public static function baselineCompareIdentityStrategy(?string $type): string
{
$strategy = static::baselineCompareMeta($type)['identity_strategy'] ?? null;
return in_array($strategy, ['display_name', 'external_id'], true)
return in_array($strategy, ['provider_resource', 'identity_required'], true)
? (string) $strategy
: 'display_name';
: 'identity_required';
}
public static function baselineCompareLabel(?string $type): ?string
@ -346,8 +346,11 @@ private static function defaultBaselineSupportContract(?string $type): array
if (static::isFoundation($type)) {
$supported = (bool) (static::baselineCompareMeta($type)['supported'] ?? false);
$identityStrategy = static::baselineCompareIdentityStrategy($type);
$usesPolicyPath = $identityStrategy === 'external_id';
$resolution = static::baselineCompareMeta($type)['resolution'] ?? [];
$sourceModelExpected = is_array($resolution) && is_string($resolution['source_model_expected'] ?? null)
? (string) $resolution['source_model_expected']
: 'inventory';
$usesPolicyPath = $sourceModelExpected === 'policy';
return [
'config_supported' => $supported,

View File

@ -10,8 +10,12 @@
use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Resources\ResourceIdentity;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException;
final class CrossEnvironmentComparePreviewBuilder
{
@ -131,11 +135,7 @@ private function indexEnvironmentSubjects(ManagedEnvironment $environment, array
}
$policyType = trim((string) $inventoryItem->policy_type);
$subjectKey = BaselineSubjectKey::forPolicy(
$policyType,
is_string($inventoryItem->display_name ?? null) ? (string) $inventoryItem->display_name : null,
is_string($inventoryItem->external_id ?? null) ? (string) $inventoryItem->external_id : null,
);
$subjectKey = $this->canonicalSubjectKeyForInventoryItem($inventoryItem, $policyType);
$subjectRecord = $this->inventorySubjectRecord($environment, $inventoryItem, $policyType, $subjectKey);
@ -375,6 +375,30 @@ private function resolvedEvidenceForSubject(array $evidenceMap, array $subject):
return $evidence instanceof ResolvedEvidence ? $evidence : null;
}
private function canonicalSubjectKeyForInventoryItem(InventoryItem $inventoryItem, string $policyType): ?string
{
$externalId = is_string($inventoryItem->external_id) ? trim($inventoryItem->external_id) : '';
if ($externalId === '') {
return null;
}
try {
$identity = ResourceIdentity::providerResource('inventory', $policyType, $externalId);
} catch (InvalidArgumentException) {
return null;
}
return BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: InventoryPolicyTypeMeta::isFoundation($policyType)
? SubjectClass::FoundationBacked
: SubjectClass::PolicyBacked,
subjectTypeKey: $policyType,
identity: $identity,
);
}
/**
* @return array{
* hash: string,

View File

@ -497,7 +497,7 @@
'risk' => 'low',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'display_name',
'identity_strategy' => 'provider_resource',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',
@ -518,7 +518,7 @@
'risk' => 'low',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'display_name',
'identity_strategy' => 'provider_resource',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',
@ -539,7 +539,7 @@
'risk' => 'high',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'external_id',
'identity_strategy' => 'provider_resource',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
@ -560,7 +560,7 @@
'risk' => 'high',
'baseline_compare' => [
'supported' => false,
'identity_strategy' => 'external_id',
'identity_strategy' => 'provider_resource',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
@ -581,7 +581,7 @@
'risk' => 'low',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'display_name',
'identity_strategy' => 'provider_resource',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',

View File

@ -5,6 +5,8 @@
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\Resources\ResourceIdentity;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@ -21,11 +23,15 @@ public function definition(): array
{
$displayName = fake()->words(3, true);
$policyType = 'deviceConfiguration';
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', fake()->uuid());
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectExternalId = $subjectKey !== null
? BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, $subjectKey)
: fake()->uuid();
$subjectKey = BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: $policyType,
identity: $identity,
);
$subjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKey);
return [
'baseline_snapshot_id' => BaselineSnapshot::factory(),
@ -34,7 +40,12 @@ public function definition(): array
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'baseline_hash' => hash('sha256', fake()->uuid()),
'meta_jsonb' => ['display_name' => $displayName],
'meta_jsonb' => [
'display_name' => $displayName,
'provider_key' => $identity->providerKey,
'provider_resource_identity' => $identity->toArray(),
'provider_resource_fingerprint' => $identity->fingerprint(),
],
];
}
}

View File

@ -49,7 +49,6 @@ public function definition(): array
'subject_domain' => $subjectDomain,
'subject_class' => $subjectClass->value,
'subject_type_key' => $subjectTypeKey,
'legacy_subject_key' => null,
'canonical_subject_key' => BaselineSubjectKey::forProviderResourceIdentity(
$subjectDomain,
$subjectClass,

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('provider_resource_bindings', 'legacy_subject_key')) {
return;
}
Schema::table('provider_resource_bindings', function (Blueprint $table): void {
$table->dropColumn('legacy_subject_key');
});
}
public function down(): void
{
if (Schema::hasColumn('provider_resource_bindings', 'legacy_subject_key')) {
return;
}
Schema::table('provider_resource_bindings', function (Blueprint $table): void {
$table->string('legacy_subject_key')->nullable()->after('subject_type_key');
});
}
};

View File

@ -79,7 +79,7 @@
->where('baseline_profile_id', (int) $profile->getKey())
->sole();
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(

View File

@ -95,7 +95,7 @@
->where('baseline_profile_id', (int) $profile->getKey())
->sole();
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(

View File

@ -156,7 +156,7 @@ public function capture(
->where('baseline_profile_id', (int) $profile->getKey())
->sole();
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(

View File

@ -89,7 +89,7 @@
->where('baseline_profile_id', (int) $profile->getKey())
->sole();
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(

View File

@ -58,7 +58,7 @@
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
@ -182,7 +182,7 @@
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([

View File

@ -67,7 +67,7 @@
metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [],
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
@ -176,7 +176,7 @@
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([

View File

@ -182,7 +182,7 @@ function createComplianceActionCompareFixture(array $baselineSnapshotPayload, ar
'display_name' => 'Bitlocker Require',
]);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
PolicyVersion::factory()->create([

View File

@ -56,7 +56,7 @@
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
@ -195,7 +195,7 @@
metaJsonb: $baselineMetaJsonb,
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([

View File

@ -56,7 +56,7 @@
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([

View File

@ -99,7 +99,7 @@
->count(),
)->toBe(1);
$subjectKey = BaselineSubjectKey::fromDisplayName('Unique Policy');
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'unique-1');
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
@ -212,7 +212,7 @@
->count(),
)->toBe(2);
$standardSubjectKey = BaselineSubjectKey::fromDisplayName('Standard');
$standardSubjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'current-standard');
expect($standardSubjectKey)->not->toBeNull();
$standardExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(

View File

@ -14,6 +14,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\OperationRunType;
it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void {
@ -135,8 +136,12 @@
->where('baseline_snapshot_id', (int) $snapshot->getKey())
->sole();
$expectedSubjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', 'Security Reader', 'role-def-1');
$expectedExternalReference = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy('intuneRoleDefinition', 'Security Reader', 'role-def-1');
$expectedSubjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
'role-def-1',
SubjectClass::FoundationBacked,
);
$expectedExternalReference = BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $expectedSubjectKey);
expect($item->policy_type)->toBe('intuneRoleDefinition');
expect($item->subject_key)->toBe($expectedSubjectKey);
@ -145,7 +150,7 @@
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
expect(data_get($meta, 'identity.strategy'))->toBe('external_id');
expect(data_get($meta, 'identity.strategy'))->toBe('provider_resource');
expect(data_get($meta, 'rbac.is_built_in'))->toBeFalse();
expect(data_get($meta, 'rbac.role_permission_count'))->toBe(1);
expect(data_get($meta, 'version_reference.policy_version_id'))->toBe((int) $version->getKey());

View File

@ -366,9 +366,9 @@ function runBaselineCaptureJob(
->orderBy('subject_external_id')
->get();
$subjectKeyA = BaselineSubjectKey::fromDisplayName((string) $inventoryA->display_name);
$subjectKeyB = BaselineSubjectKey::fromDisplayName((string) $inventoryB->display_name);
$subjectKeyC = BaselineSubjectKey::fromDisplayName((string) $inventoryC->display_name);
$subjectKeyA = baselineProviderResourceSubjectKeyForTest((string) $inventoryA->policy_type, (string) $inventoryA->external_id);
$subjectKeyB = baselineProviderResourceSubjectKeyForTest((string) $inventoryB->policy_type, (string) $inventoryB->external_id);
$subjectKeyC = baselineProviderResourceSubjectKeyForTest((string) $inventoryC->policy_type, (string) $inventoryC->external_id);
expect($subjectKeyA)->not->toBeNull();
expect($subjectKeyB)->not->toBeNull();

View File

@ -10,12 +10,18 @@
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use App\Support\Resources\ResourceIdentity;
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
it('treats duplicate subject_key matches as an evidence gap and suppresses findings', function () {
it('treats duplicate provider identity matches as an evidence gap and suppresses findings', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'duplicate-provider-id');
$subjectKey = spec382AmbiguousSubjectKey($identity);
$displayName = 'Duplicate Policy';
$meta = spec382AmbiguousMeta($identity, $displayName, 'baseline-etag');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
@ -30,24 +36,14 @@
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$displayName = 'Duplicate Policy';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
$baselineSubjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
policyType: 'deviceConfiguration',
subjectKey: (string) $subjectKey,
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => $baselineSubjectExternalId,
'subject_key' => (string) $subjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', $subjectKey),
'subject_key' => $subjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline'),
'meta_jsonb' => [
'display_name' => $displayName,
'meta_jsonb' => $meta + [
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
@ -61,14 +57,13 @@
statusByType: ['deviceConfiguration' => 'succeeded'],
);
// Two current policies with the same display name (→ same subject_key).
InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'dup-1',
'policy_type' => 'deviceConfiguration',
'display_name' => $displayName,
'meta_jsonb' => ['etag' => 'E1'],
'meta_jsonb' => spec382AmbiguousMeta($identity, $displayName, 'E1'),
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
@ -77,8 +72,8 @@
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'dup-2',
'policy_type' => 'deviceConfiguration',
'display_name' => $displayName,
'meta_jsonb' => ['etag' => 'E2'],
'display_name' => 'Renamed Duplicate Policy',
'meta_jsonb' => spec382AmbiguousMeta($identity, 'Renamed Duplicate Policy', 'E2'),
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
@ -127,5 +122,31 @@
->and(data_get($ambiguousSubject, 'subject_class'))->toBe('policy_backed')
->and(data_get($ambiguousSubject, 'resolution_outcome'))->toBe('ambiguous_match')
->and(data_get($ambiguousSubject, 'operator_action_category'))->toBe('inspect_subject_mapping')
->and(data_get($ambiguousSubject, 'subject_key'))->toContain('duplicate policy');
->and(data_get($ambiguousSubject, 'subject_key'))->toBe($subjectKey);
});
function spec382AmbiguousSubjectKey(ResourceIdentity $identity): string
{
$subjectKey = BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
identity: $identity,
);
expect($subjectKey)->not->toBeNull();
return (string) $subjectKey;
}
function spec382AmbiguousMeta(ResourceIdentity $identity, string $displayName, string $etag): array
{
return [
'display_name' => $displayName,
'provider_key' => $identity->providerKey,
'provider_resource_identity' => $identity->toArray(),
'provider_resource_fingerprint' => $identity->fingerprint(),
'odata_type' => 'fake.policy',
'etag' => $etag,
];
}

View File

@ -37,7 +37,8 @@
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$displayName = 'Audit Compare Policy';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$externalId = 'audit-compare-policy';
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', $externalId);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
@ -60,7 +61,7 @@
$policy = Policy::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
'external_id' => 'audit-compare-policy',
'external_id' => $externalId,
'platform' => 'windows',
'display_name' => $displayName,
]);

View File

@ -45,7 +45,7 @@
);
$coveredDisplayName = 'Covered Policy';
$coveredSubjectKey = BaselineSubjectKey::fromDisplayName($coveredDisplayName);
$coveredSubjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'covered-uuid');
expect($coveredSubjectKey)->not->toBeNull();
$coveredWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $coveredSubjectKey);
@ -66,7 +66,7 @@
);
$uncoveredDisplayName = 'Uncovered Policy';
$uncoveredSubjectKey = BaselineSubjectKey::fromDisplayName($uncoveredDisplayName);
$uncoveredSubjectKey = baselineProviderResourceSubjectKeyForTest('deviceCompliancePolicy', 'uncovered-uuid');
expect($uncoveredSubjectKey)->not->toBeNull();
$uncoveredWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceCompliancePolicy', (string) $uncoveredSubjectKey);
@ -387,7 +387,7 @@
]);
$deviceConfigurationDisplayName = 'Covered Device Configuration';
$deviceConfigurationSubjectKey = BaselineSubjectKey::fromDisplayName($deviceConfigurationDisplayName);
$deviceConfigurationSubjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'covered-device-configuration');
expect($deviceConfigurationSubjectKey)->not->toBeNull();
$deviceConfigurationWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(

View File

@ -36,7 +36,7 @@
$coveredExternalId = 'covered-uuid';
$coveredDisplayName = 'Covered Policy';
$coveredKey = BaselineSubjectKey::fromDisplayName($coveredDisplayName);
$coveredKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'covered-uuid');
expect($coveredKey)->not->toBeNull();
$coveredWorkspaceId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
@ -68,7 +68,7 @@
]);
$uncoveredDisplayName = 'Uncovered Policy';
$uncoveredKey = BaselineSubjectKey::fromDisplayName($uncoveredDisplayName);
$uncoveredKey = baselineProviderResourceSubjectKeyForTest('deviceCompliancePolicy', 'uncovered-uuid');
expect($uncoveredKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([

View File

@ -13,9 +13,10 @@
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\OperationRunType;
it('matches baseline to tenant inventory by policy_type + subject_key (cross-tenant)', function () {
it('matches baseline to tenant inventory by provider-resource identity cross-tenant', function () {
[$user, $sourceTenant] = createUserWithTenant(role: 'owner');
$targetTenant = ManagedEnvironment::factory()->create([
@ -37,7 +38,7 @@
]);
$displayName = 'Shared Policy';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'tenant-policy-uuid');
expect($subjectKey)->not->toBeNull();
$baselineSubjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
@ -168,8 +169,12 @@
$sourceExternalId = 'source-role-definition-id';
$targetExternalId = 'target-role-definition-id';
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', $displayName, $sourceExternalId);
$baselineSubjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy('intuneRoleDefinition', $displayName, $sourceExternalId);
$subjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
$sourceExternalId,
SubjectClass::FoundationBacked,
);
$baselineSubjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $subjectKey);
expect($subjectKey)->not->toBeNull();
expect($baselineSubjectExternalId)->not->toBeNull();

View File

@ -13,6 +13,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable;
use Tests\Support\AssertsDriftEvidenceContract;
@ -85,8 +86,12 @@ function rbacContractBaselineItem(
bool $isBuiltIn,
int $rolePermissionCount = 1,
): BaselineSnapshotItem {
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', $displayName, $externalId);
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy('intuneRoleDefinition', $displayName, $externalId);
$subjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
$externalId,
SubjectClass::FoundationBacked,
);
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $subjectKey);
expect($subjectKey)->not->toBeNull();
expect($workspaceSafeExternalId)->not->toBeNull();
@ -111,7 +116,7 @@ function rbacContractBaselineItem(
'observed_at' => $version->captured_at?->toIso8601String(),
],
'identity' => [
'strategy' => 'external_id',
'strategy' => 'provider_resource',
'subject_key' => (string) $subjectKey,
'workspace_subject_external_id' => (string) $workspaceSafeExternalId,
],

View File

@ -21,7 +21,8 @@
$policyType = 'deviceConfiguration';
$displayName = 'Policy Alpha';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$externalId = 'policy-alpha-uuid';
$subjectKey = baselineProviderResourceSubjectKeyForTest($policyType, $externalId);
expect($subjectKey)->not->toBeNull();
@ -43,7 +44,6 @@
statusByType: [$policyType => 'succeeded'],
);
$externalId = 'policy-alpha-uuid';
$policy = Policy::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => $externalId,
@ -161,7 +161,8 @@
$policyType = 'deviceConfiguration';
$displayName = 'Policy Alpha';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$externalId = 'policy-alpha-uuid';
$subjectKey = baselineProviderResourceSubjectKeyForTest($policyType, $externalId);
expect($subjectKey)->not->toBeNull();
@ -185,7 +186,7 @@
$policy = Policy::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'policy-alpha-uuid',
'external_id' => $externalId,
'policy_type' => $policyType,
'platform' => 'windows10',
'display_name' => $displayName,

View File

@ -10,6 +10,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\OperationRunType;
it('uses a stable recurrence key independent of hashes and snapshot id', function () {
@ -21,7 +22,7 @@
]);
$displayName = 'Policy X';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'policy-x-uuid');
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
@ -195,8 +196,12 @@ function rbacRecurrenceSnapshot(string $displayName, string $description, array
$capturedAt = now()->subMinutes(5);
$displayName = 'Security Reader';
$externalId = 'rbac-role-stable';
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', $displayName, $externalId);
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy('intuneRoleDefinition', $displayName, $externalId);
$subjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
$externalId,
SubjectClass::FoundationBacked,
);
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $subjectKey);
expect($subjectKey)->not->toBeNull();
expect($workspaceSafeExternalId)->not->toBeNull();

View File

@ -46,7 +46,7 @@
$policyType = 'deviceConfiguration';
$displayNameA = 'Policy A';
$subjectKeyA = BaselineSubjectKey::fromDisplayName($displayNameA);
$subjectKeyA = baselineProviderResourceSubjectKeyForTest($policyType, 'policy-a-uuid');
expect($subjectKeyA)->not->toBeNull();
$workspaceSafeExternalIdA = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyA);
$baselineHashA = app(BaselineSnapshotIdentity::class)->hashItemContent(
@ -56,7 +56,7 @@
);
$displayNameB = 'Policy B';
$subjectKeyB = BaselineSubjectKey::fromDisplayName($displayNameB);
$subjectKeyB = baselineProviderResourceSubjectKeyForTest($policyType, 'policy-b-uuid');
expect($subjectKeyB)->not->toBeNull();
$workspaceSafeExternalIdB = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyB);
$baselineHashB = app(BaselineSnapshotIdentity::class)->hashItemContent(
@ -188,7 +188,7 @@
$policyType = 'deviceConfiguration';
$displayNameA = 'Policy A';
$subjectKeyA = BaselineSubjectKey::fromDisplayName($displayNameA);
$subjectKeyA = baselineProviderResourceSubjectKeyForTest($policyType, 'policy-a-uuid');
expect($subjectKeyA)->not->toBeNull();
$workspaceSafeExternalIdA = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyA);
$baselineHashA = app(BaselineSnapshotIdentity::class)->hashItemContent(
@ -198,7 +198,7 @@
);
$displayNameB = 'Policy B';
$subjectKeyB = BaselineSubjectKey::fromDisplayName($displayNameB);
$subjectKeyB = baselineProviderResourceSubjectKeyForTest($policyType, 'policy-b-uuid');
expect($subjectKeyB)->not->toBeNull();
$workspaceSafeExternalIdB = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyB);
$baselineHashB = app(BaselineSnapshotIdentity::class)->hashItemContent(
@ -327,7 +327,7 @@
);
$displayName = 'Settings Catalog A';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectKey = baselineProviderResourceSubjectKeyForTest('settingsCatalogPolicy', 'settings-catalog-a');
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('settingsCatalogPolicy', (string) $subjectKey);
@ -418,7 +418,7 @@
);
$displayName = 'Policy X';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'policy-x-uuid');
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
@ -560,7 +560,7 @@
);
$displayName = 'Policy X';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'policy-x-uuid');
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
@ -679,7 +679,7 @@
$baselineHash = $hasher->hashNormalized($baselineContract);
$displayName = 'Policy X';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'policy-x-uuid');
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
@ -821,7 +821,7 @@
));
$displayName = 'Matching Policy';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'matching-uuid');
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
@ -918,7 +918,7 @@
));
$displayName = 'Matching Policy';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'matching-uuid');
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
@ -933,7 +933,7 @@
]);
$foundationDisplayName = 'Foundation Template';
$foundationSubjectKey = BaselineSubjectKey::fromDisplayName($foundationDisplayName);
$foundationSubjectKey = baselineProviderResourceSubjectKeyForTest('notificationMessageTemplate', 'foundation-template', \App\Support\Baselines\SubjectClass::FoundationBacked, 'fake-provider', 'notification-template');
expect($foundationSubjectKey)->not->toBeNull();
$foundationWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
'notificationMessageTemplate',
@ -1030,7 +1030,7 @@
// 2 baseline items: one will be missing (high), one will be different (medium)
$missingDisplayName = 'Missing Policy';
$missingSubjectKey = BaselineSubjectKey::fromDisplayName($missingDisplayName);
$missingSubjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'missing-policy');
expect($missingSubjectKey)->not->toBeNull();
$missingWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $missingSubjectKey);
@ -1045,7 +1045,7 @@
]);
$changedDisplayName = 'Changed Policy';
$changedSubjectKey = BaselineSubjectKey::fromDisplayName($changedDisplayName);
$changedSubjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'changed-policy');
expect($changedSubjectKey)->not->toBeNull();
$changedWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $changedSubjectKey);
@ -1374,7 +1374,7 @@
);
$missingDisplayName = 'Missing Policy';
$missingSubjectKey = BaselineSubjectKey::fromDisplayName($missingDisplayName);
$missingSubjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'missing-policy');
expect($missingSubjectKey)->not->toBeNull();
$missingWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $missingSubjectKey);
@ -1389,7 +1389,7 @@
]);
$differentDisplayName = 'Different Policy';
$differentSubjectKey = BaselineSubjectKey::fromDisplayName($differentDisplayName);
$differentSubjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'different-policy');
expect($differentSubjectKey)->not->toBeNull();
$differentWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $differentSubjectKey);

View File

@ -7,14 +7,13 @@
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Models\ProviderResourceBinding;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\OperationRunType;
use App\Support\Resources\ProviderResourceResolutionMode;
use App\Support\Resources\ResourceIdentity;
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
@ -43,40 +42,31 @@
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$policySubjectKey = BaselineSubjectKey::fromDisplayName('Missing Compare Policy');
$foundationSubjectKey = BaselineSubjectKey::fromDisplayName('Structural Compare Foundation');
expect($policySubjectKey)->not->toBeNull()
->and($foundationSubjectKey)->not->toBeNull();
ProviderResourceBinding::factory()
->providerResource(ResourceIdentity::providerResource('fake-provider', 'policy', 'compare-missing-policy'))
->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'canonical_subject_key' => (string) $policySubjectKey,
'display_label' => 'Fake Provider Binding For Missing Compare Policy',
'resolution_mode' => ProviderResourceResolutionMode::ManualBinding->value,
]);
$policyIdentity = ResourceIdentity::providerResource('fake-provider', 'policy', 'compare-missing-policy');
$foundationIdentity = ResourceIdentity::providerResource('fake-provider', 'scope-tag', 'compare-scope-tag');
$policySubjectKey = spec382GapSubjectKey($policyIdentity, SubjectClass::PolicyBacked, 'deviceConfiguration');
$foundationSubjectKey = spec382GapSubjectKey($foundationIdentity, SubjectClass::FoundationBacked, 'roleScopeTag');
$policyMeta = spec382GapMeta($policyIdentity, 'Missing Compare Policy', 'etag-compare-policy');
$foundationMeta = spec382GapMeta($foundationIdentity, 'Structural Compare Foundation', 'etag-compare-foundation');
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $policySubjectKey),
'subject_key' => (string) $policySubjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', $policySubjectKey),
'subject_key' => $policySubjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-policy'),
'meta_jsonb' => ['display_name' => 'Missing Compare Policy'],
'meta_jsonb' => $policyMeta,
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('roleScopeTag', (string) $foundationSubjectKey),
'subject_key' => (string) $foundationSubjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('roleScopeTag', $foundationSubjectKey),
'subject_key' => $foundationSubjectKey,
'policy_type' => 'roleScopeTag',
'baseline_hash' => hash('sha256', 'baseline-foundation'),
'meta_jsonb' => ['display_name' => 'Structural Compare Foundation'],
'meta_jsonb' => $foundationMeta,
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
@ -94,7 +84,7 @@
'external_id' => 'compare-missing-policy',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Missing Compare Policy',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'etag-compare-policy'],
'meta_jsonb' => $policyMeta,
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
@ -105,7 +95,7 @@
'external_id' => 'compare-scope-tag',
'policy_type' => 'roleScopeTag',
'display_name' => 'Structural Compare Foundation',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.roleScopeTag', 'etag' => 'etag-compare-foundation'],
'meta_jsonb' => $foundationMeta,
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
@ -157,3 +147,29 @@
->and(data_get($subjectsByType['roleScopeTag'], 'reason_code'))->toBe('foundation_not_policy_backed')
->and(data_get($subjectsByType['roleScopeTag'], 'operator_action_category'))->toBe('product_follow_up');
});
function spec382GapSubjectKey(ResourceIdentity $identity, SubjectClass $subjectClass, string $subjectTypeKey): string
{
$subjectKey = BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: $subjectClass,
subjectTypeKey: $subjectTypeKey,
identity: $identity,
);
expect($subjectKey)->not->toBeNull();
return (string) $subjectKey;
}
function spec382GapMeta(ResourceIdentity $identity, string $displayName, string $etag): array
{
return [
'display_name' => $displayName,
'provider_key' => $identity->providerKey,
'provider_resource_identity' => $identity->toArray(),
'provider_resource_fingerprint' => $identity->fingerprint(),
'odata_type' => $identity->providerResourceType,
'etag' => $etag,
];
}

View File

@ -86,7 +86,7 @@
'redaction_version' => $baselineProtected->redactionVersion,
]);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([

View File

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\ProviderResourceBinding;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use App\Support\Resources\ProviderResourceResolutionMode;
use App\Support\Resources\ResourceIdentity;
it('consumes active provider resource bindings before canonical identity matching during baseline compare', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'bound-policy-2');
$displayName = 'Duplicate Provider Policy';
$canonicalSubjectKey = spec382CompareSubjectKey($identity);
$currentMeta = spec382CompareMeta($identity, $displayName, 'etag-bound-policy-2');
[$profile, $snapshot] = spec382CompareProfileAndSnapshot($tenant);
ProviderResourceBinding::factory()
->providerResource($identity)
->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'canonical_subject_key' => $canonicalSubjectKey,
'display_label' => $displayName,
'resolution_mode' => ProviderResourceResolutionMode::ManualBinding->value,
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', $canonicalSubjectKey),
'subject_key' => $canonicalSubjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => app(BaselineSnapshotIdentity::class)->hashItemContent('deviceConfiguration', 'bound-policy-2', $currentMeta),
'meta_jsonb' => $currentMeta,
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
spec382CompareInventoryItem(
tenant: $tenant,
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'bound-policy-1'),
externalId: 'bound-policy-1',
displayName: $displayName,
etag: 'etag-bound-policy-1',
inventorySyncRunId: (int) $inventorySyncRun->getKey(),
);
spec382CompareInventoryItem(
tenant: $tenant,
identity: $identity,
externalId: 'bound-policy-2',
displayName: $displayName,
etag: 'etag-bound-policy-2',
inventorySyncRunId: (int) $inventorySyncRun->getKey(),
);
$run = spec382RunCompare($tenant, $user, $profile, $snapshot);
$run->refresh();
$context = is_array($run->context) ? $run->context : [];
expect($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
->and(data_get($context, 'baseline_compare.matching.active_bindings_considered'))->toBe(1)
->and(data_get($context, 'baseline_compare.matching.by_reason.active_provider_resource_binding'))->toBe(1)
->and(data_get($context, 'baseline_compare.evidence_gaps.by_reason.ambiguous_match'))->toBeNull()
->and(data_get($context, 'baseline_compare.evidence_gaps.by_reason.identity_required'))->toBeNull()
->and(Finding::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->where('subject_external_id', 'bound-policy-2')
->exists())->toBeFalse();
});
it('turns old subject-key and display-only baseline subjects into identity-required gaps', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$displayName = 'Display Only Match Policy';
[$profile, $snapshot] = spec382CompareProfileAndSnapshot($tenant);
$meta = [
'display_name' => $displayName,
'etag' => 'display-only-etag',
];
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', 'old-display-subject-key'),
'subject_key' => 'old-display-subject-key',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => app(BaselineSnapshotIdentity::class)->hashItemContent('deviceConfiguration', 'display-only-policy', $meta),
'meta_jsonb' => $meta,
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'display-only-policy',
'policy_type' => 'deviceConfiguration',
'display_name' => $displayName,
'meta_jsonb' => $meta,
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$run = spec382RunCompare($tenant, $user, $profile, $snapshot);
$run->refresh();
$context = is_array($run->context) ? $run->context : [];
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value)
->and(data_get($context, 'baseline_compare.matching.by_reason.identity_required'))->toBe(1)
->and(data_get($context, 'baseline_compare.evidence_gaps.by_reason.identity_required'))->toBe(1)
->and(data_get($context, 'baseline_compare.matching.by_reason.canonical_subject_key'))->toBeNull();
});
function spec382CompareProfileAndSnapshot($tenant): array
{
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
return [$profile, $snapshot];
}
function spec382CompareSubjectKey(ResourceIdentity $identity): string
{
$subjectKey = BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
identity: $identity,
);
expect($subjectKey)->not->toBeNull();
return (string) $subjectKey;
}
function spec382CompareMeta(ResourceIdentity $identity, string $displayName, string $etag): array
{
return [
'display_name' => $displayName,
'provider_key' => $identity->providerKey,
'provider_resource_identity' => $identity->toArray(),
'provider_resource_fingerprint' => $identity->fingerprint(),
'odata_type' => 'fake.policy',
'etag' => $etag,
];
}
function spec382CompareInventoryItem(
$tenant,
ResourceIdentity $identity,
string $externalId,
string $displayName,
string $etag,
int $inventorySyncRunId,
): InventoryItem {
return InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => $externalId,
'policy_type' => 'deviceConfiguration',
'display_name' => $displayName,
'meta_jsonb' => spec382CompareMeta($identity, $displayName, $etag),
'last_seen_operation_run_id' => $inventorySyncRunId,
'last_seen_at' => now(),
]);
}
function spec382RunCompare($tenant, $user, BaselineProfile $profile, BaselineSnapshot $snapshot)
{
$operationRunService = app(OperationRunService::class);
$run = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRunService,
);
return $run;
}

View File

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
use App\Jobs\CompareBaselineToTenantJob;
it('does not automatically consume provider resource bindings during baseline compare v1', function (): void {
$compareJobFile = (new ReflectionClass(CompareBaselineToTenantJob::class))->getFileName();
$compareJobSource = is_string($compareJobFile) ? file_get_contents($compareJobFile) : false;
expect($compareJobFile)->toBeString()
->and($compareJobSource)->toBeString()
->and($compareJobSource)->not->toContain('ProviderResourceBinding')
->and($compareJobSource)->not->toContain('ProviderResourceBindingService');
});

View File

@ -14,6 +14,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable;
@ -80,8 +81,12 @@ function createBaselineRoleDefinitionSnapshotItem(
bool $isBuiltIn,
int $rolePermissionCount = 1,
): BaselineSnapshotItem {
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', $displayName, $externalId);
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy('intuneRoleDefinition', $displayName, $externalId);
$subjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
$externalId,
SubjectClass::FoundationBacked,
);
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $subjectKey);
expect($subjectKey)->not->toBeNull();
expect($workspaceSafeExternalId)->not->toBeNull();
@ -108,7 +113,7 @@ function createBaselineRoleDefinitionSnapshotItem(
'observed_at' => $version->captured_at?->toIso8601String(),
],
'identity' => [
'strategy' => 'external_id',
'strategy' => 'provider_resource',
'subject_key' => (string) $subjectKey,
'workspace_subject_external_id' => (string) $workspaceSafeExternalId,
],

View File

@ -39,9 +39,10 @@
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$displayNames = ['Resume Idempotent A', 'Resume Idempotent B'];
$providerResourceIds = ['resume-idem-a', 'resume-idem-b'];
foreach ($displayNames as $displayName) {
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
foreach ($displayNames as $index => $displayName) {
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', $providerResourceIds[$index]);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
@ -68,7 +69,7 @@
$policies[] = Policy::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
'external_id' => $i === 0 ? 'resume-idem-a' : 'resume-idem-b',
'external_id' => $providerResourceIds[$i],
'platform' => 'windows',
'display_name' => $displayName,
]);

View File

@ -42,9 +42,10 @@
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$displayNames = ['Resume Token A', 'Resume Token B'];
$providerResourceIds = ['resume-token-a', 'resume-token-b'];
foreach ($displayNames as $displayName) {
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
foreach ($displayNames as $index => $displayName) {
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', $providerResourceIds[$index]);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
@ -71,7 +72,7 @@
$policies[] = Policy::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
'external_id' => $i === 0 ? 'resume-token-a' : 'resume-token-b',
'external_id' => $providerResourceIds[$i],
'platform' => 'windows',
'display_name' => $displayName,
]);
@ -197,7 +198,7 @@ public function capture(
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$displayName = 'Missing Capture Policy';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'missing-capture-policy');
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([

View File

@ -17,6 +17,7 @@
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Baselines\SubjectClass;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
@ -112,7 +113,7 @@
$externalId = 'policy-uuid';
$displayName = 'Stable Policy';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', $externalId);
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
@ -276,12 +277,12 @@
'scope_tags' => [],
]);
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', 'Stable RBAC Role', 'rbac-role-stable');
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy(
$subjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
'Stable RBAC Role',
'rbac-role-stable',
SubjectClass::FoundationBacked,
);
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $subjectKey);
expect($subjectKey)->not->toBeNull();
expect($workspaceSafeExternalId)->not->toBeNull();
@ -306,7 +307,7 @@
'observed_at' => $baselineVersion->captured_at?->toIso8601String(),
],
'identity' => [
'strategy' => 'external_id',
'strategy' => 'provider_resource',
'subject_key' => (string) $subjectKey,
'workspace_subject_external_id' => (string) $workspaceSafeExternalId,
],
@ -440,7 +441,7 @@
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([

View File

@ -15,6 +15,7 @@
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
@ -44,8 +45,12 @@
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$policySubjectKey = BaselineSubjectKey::fromDisplayName('Deterministic Missing Policy');
$foundationSubjectKey = BaselineSubjectKey::fromDisplayName('Deterministic Foundation');
$policySubjectKey = baselineProviderResourceSubjectKeyForTest('deviceConfiguration', 'deterministic-policy');
$foundationSubjectKey = baselineProviderResourceSubjectKeyForTest(
'roleScopeTag',
'deterministic-foundation',
SubjectClass::FoundationBacked,
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),

View File

@ -25,7 +25,7 @@ function appendBrokenFoundationSupportConfig(): void
'label' => 'Broken Foundation',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'external_id',
'identity_strategy' => 'provider_resource',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',

View File

@ -6,6 +6,8 @@
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
it('shows captured intune rbac role definition references on the baseline snapshot detail page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -18,12 +20,17 @@
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$rbacSubjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
'role-def-1',
SubjectClass::FoundationBacked,
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => hash('sha256', 'role-def-1'),
'subject_key' => hash('sha256', 'intuneRoleDefinition|role-def-1'),
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $rbacSubjectKey),
'subject_key' => $rbacSubjectKey,
'policy_type' => 'intuneRoleDefinition',
'baseline_hash' => hash('sha256', 'rbac-content'),
'meta_jsonb' => [
@ -32,7 +39,7 @@
'observed_at' => '2026-03-09T10:00:00+00:00',
],
'identity' => [
'strategy' => 'external_id',
'strategy' => 'provider_resource',
],
'version_reference' => [
'policy_version_id' => 42,

View File

@ -7,6 +7,8 @@
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
it('renders the baseline snapshot detail page as summary-first with grouped governed-subject browsing', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -31,11 +33,19 @@
],
]);
$rbacSubjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
'security-reader',
SubjectClass::FoundationBacked,
);
$complianceSubjectKey = baselineProviderResourceSubjectKeyForTest('deviceCompliancePolicy', 'bitlocker-require');
$fallbackSubjectKey = baselineProviderResourceSubjectKeyForTest('mysteryPolicyType', 'mystery-policy');
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => 'intuneRoleDefinition',
'subject_key' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'subject_external_id' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'subject_key' => $rbacSubjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $rbacSubjectKey),
'meta_jsonb' => [
'display_name' => 'Security Reader',
'evidence' => [
@ -43,7 +53,7 @@
'source' => 'policy_version',
'observed_at' => '2026-03-09T12:00:00+00:00',
],
'identity' => ['strategy' => 'external_id'],
'identity' => ['strategy' => 'provider_resource'],
'rbac' => [
'is_built_in' => false,
'role_permission_count' => 2,
@ -55,8 +65,8 @@
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'bitlocker require',
'subject_external_id' => hash('sha256', 'deviceCompliancePolicy|bitlocker require'),
'subject_key' => $complianceSubjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceCompliancePolicy', $complianceSubjectKey),
'meta_jsonb' => [
'display_name' => 'Bitlocker Require',
'platform' => 'windows',
@ -72,8 +82,8 @@
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => 'mysteryPolicyType',
'subject_key' => 'mystery policy',
'subject_external_id' => hash('sha256', 'mysteryPolicyType|mystery policy'),
'subject_key' => $fallbackSubjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('mysteryPolicyType', $fallbackSubjectKey),
'meta_jsonb' => [
'display_name' => 'Mystery Policy',
'platform' => 'windows',

View File

@ -7,6 +7,7 @@
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use Filament\Facades\Filament;
use Livewire\Livewire;
@ -126,11 +127,12 @@
setAdminPanelContext($tenant);
$rawSubjectExternalId = 'rbac-role-1';
$subjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy(
$subjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
'Security Reader',
$rawSubjectExternalId,
SubjectClass::FoundationBacked,
);
$subjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $subjectKey);
InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
@ -153,7 +155,7 @@
'evidence_jsonb' => [
'change_type' => 'missing_policy',
'policy_type' => 'intuneRoleDefinition',
'subject_key' => hash('sha256', 'intuneRoleDefinition|rbac-role-1'),
'subject_key' => $subjectKey,
'display_name' => 'Security Reader',
'summary' => [
'kind' => 'rbac_role_definition',
@ -184,11 +186,12 @@
*/
function findingViewRbacFinding(\App\Models\ManagedEnvironment $tenant, array $rbacOverrides = []): Finding
{
$subjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy(
$subjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
'Security Reader',
'rbac-role-1',
SubjectClass::FoundationBacked,
);
$subjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $subjectKey);
return Finding::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
@ -201,7 +204,7 @@ function findingViewRbacFinding(\App\Models\ManagedEnvironment $tenant, array $r
'evidence_jsonb' => [
'change_type' => 'different_version',
'policy_type' => 'intuneRoleDefinition',
'subject_key' => hash('sha256', 'intuneRoleDefinition|rbac-role-1'),
'subject_key' => $subjectKey,
'display_name' => 'Security Reader',
'summary' => [
'kind' => 'rbac_role_definition',

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Models\Finding;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -101,11 +102,12 @@ function findingsDefaultIndicatorLabels($component): array
$this->actingAs($user);
Filament::setTenant($tenant, true);
$subjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy(
$subjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
'Security Reader',
'rbac-role-1',
SubjectClass::FoundationBacked,
);
$subjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $subjectKey);
$finding = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,

View File

@ -10,6 +10,7 @@
use App\Models\ProviderResourceBinding;
use App\Services\Resources\ProviderResourceBindingService;
use App\Support\Audit\AuditActionId;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\Resources\ProviderResourceBindingStatus;
use App\Support\Resources\ProviderResourceResolutionMode;
@ -158,6 +159,37 @@ function providerResourceBindingAttributes(array $overrides = []): array
))->toThrow(InvalidArgumentException::class, 'operator note');
});
it('rejects arbitrary canonical subject key overrides for provider resource decisions', function (): void {
[$actor, $tenant] = createUserWithTenant(role: 'owner');
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'canonical-validation');
$expectedCanonicalKey = BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
identity: $identity,
);
expect(fn () => app(ProviderResourceBindingService::class)->createManualBinding(
actor: $actor,
environment: $tenant,
identity: $identity,
attributes: providerResourceBindingAttributes([
'canonical_subject_key' => 'duplicate policy',
]),
))->toThrow(InvalidArgumentException::class, 'canonical subject keys');
$binding = app(ProviderResourceBindingService::class)->createManualBinding(
actor: $actor,
environment: $tenant,
identity: $identity,
attributes: providerResourceBindingAttributes([
'canonical_subject_key' => $expectedCanonicalKey,
]),
);
expect($binding->canonical_subject_key)->toBe($expectedCanonicalKey);
});
it('persists every v1 resolution mode without Microsoft literals', function (string $method, ResourceIdentity $identity, ProviderResourceResolutionMode $mode): void {
[$actor, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -1677,3 +1677,24 @@ function expectedPolicyVersionContentHash(
'redaction_version' => $redactionVersion,
]);
}
function baselineProviderResourceSubjectKeyForTest(
string $subjectTypeKey,
string $stableResourceId,
\App\Support\Baselines\SubjectClass $subjectClass = \App\Support\Baselines\SubjectClass::PolicyBacked,
string $providerKey = 'inventory',
?string $resourceType = null,
): string {
$subjectKey = \App\Support\Baselines\BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: $subjectClass,
subjectTypeKey: $subjectTypeKey,
identity: \App\Support\Resources\ResourceIdentity::providerResource($providerKey, $resourceType ?? $subjectTypeKey, $stableResourceId),
);
if (! is_string($subjectKey) || $subjectKey === '') {
throw new RuntimeException('Unable to create provider-resource subject key for test fixture.');
}
return $subjectKey;
}

View File

@ -4,7 +4,7 @@
use App\Models\PolicyVersion;
use App\Models\ManagedEnvironment;
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -17,16 +17,14 @@
test('resolves baseline policy version id within observed second', function () {
$tenant = ManagedEnvironment::factory()->create();
$displayName = 'Policy Alpha';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
$policy = Policy::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'policy_type' => 'settingsCatalogPolicy',
'display_name' => $displayName,
'display_name' => 'Policy Alpha',
]);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
$capturedAt = CarbonImmutable::parse('2026-03-05 12:00:00.123456');
@ -76,7 +74,7 @@
'display_name' => 'Policy Alpha',
]);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
@ -93,16 +91,14 @@
test('uses a deterministic tie-breaker when multiple candidates exist', function () {
$tenant = ManagedEnvironment::factory()->create();
$displayName = 'Policy Alpha';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
$policy = Policy::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'policy_type' => 'settingsCatalogPolicy',
'display_name' => $displayName,
'display_name' => 'Policy Alpha',
]);
$subjectKey = baselineProviderResourceSubjectKeyForTest((string) $policy->policy_type, (string) $policy->external_id);
expect($subjectKey)->not->toBeNull();
$versionEarly = PolicyVersion::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
@ -152,7 +148,11 @@
'captured_at' => $capturedAt,
]);
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', 'Security Reader', 'role-def-42');
$subjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
'role-def-42',
SubjectClass::FoundationBacked,
);
expect($subjectKey)->not->toBeNull();
$resolved = $this->resolver->resolve(

View File

@ -10,6 +10,8 @@
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\SnapshotTypeRendererRegistry;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
it('builds summary rows and grouped output for mixed snapshot types', function (): void {
@ -46,12 +48,19 @@
]);
$snapshot->setRelation('baselineProfile', new BaselineProfile(['name' => 'Security Baseline']));
$rbacSubjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
'security-reader',
SubjectClass::FoundationBacked,
);
$complianceSubjectKey = baselineProviderResourceSubjectKeyForTest('deviceCompliancePolicy', 'bitlocker-require');
$fallbackSubjectKey = baselineProviderResourceSubjectKeyForTest('mysteryPolicyType', 'mystery-policy');
$snapshot->setRelation('items', new EloquentCollection([
new BaselineSnapshotItem([
'id' => 1,
'policy_type' => 'intuneRoleDefinition',
'subject_key' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'subject_external_id' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'subject_key' => $rbacSubjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $rbacSubjectKey),
'meta_jsonb' => [
'display_name' => 'Security Reader',
'evidence' => [
@ -59,7 +68,7 @@
'source' => 'policy_version',
'observed_at' => '2026-03-09T12:00:00+00:00',
],
'identity' => ['strategy' => 'external_id'],
'identity' => ['strategy' => 'provider_resource'],
'rbac' => [
'is_built_in' => false,
'role_permission_count' => 2,
@ -70,8 +79,8 @@
new BaselineSnapshotItem([
'id' => 2,
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'bitlocker require',
'subject_external_id' => hash('sha256', 'deviceCompliancePolicy|bitlocker require'),
'subject_key' => $complianceSubjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceCompliancePolicy', $complianceSubjectKey),
'meta_jsonb' => [
'display_name' => 'Bitlocker Require',
'platform' => 'windows',
@ -86,8 +95,8 @@
new BaselineSnapshotItem([
'id' => 3,
'policy_type' => 'mysteryPolicyType',
'subject_key' => 'mystery policy',
'subject_external_id' => hash('sha256', 'mysteryPolicyType|mystery policy'),
'subject_key' => $fallbackSubjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('mysteryPolicyType', $fallbackSubjectKey),
'meta_jsonb' => [
'display_name' => 'Mystery Policy',
'category' => 'Other',

View File

@ -4,14 +4,16 @@
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
use App\Support\Baselines\BaselineSubjectKey;
it('renders structured compliance details when metadata is available', function (): void {
$renderer = new DeviceComplianceSnapshotTypeRenderer;
$subjectKey = baselineProviderResourceSubjectKeyForTest('deviceCompliancePolicy', 'bitlocker-require');
$item = new BaselineSnapshotItem([
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'bitlocker require',
'subject_external_id' => hash('sha256', 'deviceCompliancePolicy|bitlocker require'),
'subject_key' => $subjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceCompliancePolicy', $subjectKey),
'meta_jsonb' => [
'display_name' => 'Bitlocker Require',
'platform' => 'windows',

View File

@ -4,14 +4,16 @@
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
use App\Support\Baselines\BaselineSubjectKey;
it('renders a minimum contract for unsupported snapshot types', function (): void {
$renderer = new FallbackSnapshotTypeRenderer;
$subjectKey = baselineProviderResourceSubjectKeyForTest('mysteryPolicyType', 'mystery-policy');
$item = new BaselineSnapshotItem([
'policy_type' => 'mysteryPolicyType',
'subject_key' => 'mystery policy',
'subject_external_id' => hash('sha256', 'mysteryPolicyType|mystery policy'),
'subject_key' => $subjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('mysteryPolicyType', $subjectKey),
'meta_jsonb' => [
'display_name' => 'Mystery Policy',
'category' => 'Other',

View File

@ -4,14 +4,21 @@
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
it('renders RBAC-specific enrichment on top of the shared item contract', function (): void {
$renderer = new IntuneRoleDefinitionSnapshotTypeRenderer;
$subjectKey = baselineProviderResourceSubjectKeyForTest(
'intuneRoleDefinition',
'security-reader',
SubjectClass::FoundationBacked,
);
$item = new BaselineSnapshotItem([
'policy_type' => 'intuneRoleDefinition',
'subject_key' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'subject_external_id' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'subject_key' => $subjectKey,
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('intuneRoleDefinition', $subjectKey),
'meta_jsonb' => [
'display_name' => 'Security Reader',
'platform' => 'all',
@ -21,7 +28,7 @@
'observed_at' => '2026-03-09T12:00:00+00:00',
],
'identity' => [
'strategy' => 'external_id',
'strategy' => 'provider_resource',
],
'version_reference' => [
'policy_version_id' => 42,

View File

@ -11,7 +11,7 @@
'label' => 'Assignment Filter',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'display_name',
'identity_strategy' => 'provider_resource',
],
],
[
@ -19,7 +19,7 @@
'label' => 'Intune RBAC Role Definition',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'external_id',
'identity_strategy' => 'provider_resource',
],
],
[
@ -27,7 +27,7 @@
'label' => 'Intune RBAC Role Assignment',
'baseline_compare' => [
'supported' => false,
'identity_strategy' => 'external_id',
'identity_strategy' => 'provider_resource',
],
],
]);
@ -37,6 +37,6 @@
expect(InventoryPolicyTypeMeta::isBaselineSupportedFoundation('intuneRoleDefinition'))->toBeTrue();
expect(InventoryPolicyTypeMeta::isBaselineSupportedFoundation('intuneRoleAssignment'))->toBeFalse();
expect(InventoryPolicyTypeMeta::baselineCompareIdentityStrategy('intuneRoleDefinition'))->toBe('external_id');
expect(InventoryPolicyTypeMeta::baselineCompareIdentityStrategy('assignmentFilter'))->toBe('display_name');
expect(InventoryPolicyTypeMeta::baselineCompareIdentityStrategy('intuneRoleDefinition'))->toBe('provider_resource');
expect(InventoryPolicyTypeMeta::baselineCompareIdentityStrategy('assignmentFilter'))->toBe('provider_resource');
});

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use App\Services\Baselines\Matching\FoundationCoverageResolver;
use App\Support\Resources\ResourceIdentity;
it('classifies foundation inventory-only and canonical-only coverage through the existing support contract', function (): void {
$resolver = app(FoundationCoverageResolver::class);
$inventoryOnly = $resolver->coverageFor('roleScopeTag');
$canonicalOnly = $resolver->coverageFor(
'roleScopeTag',
ResourceIdentity::canonicalBuiltin('fake-provider', 'scope-tag', 'default'),
);
$unsupported = $resolver->coverageFor('intuneRoleAssignment');
expect($inventoryOnly['coverage'])->toBe('inventory_only')
->and($inventoryOnly['reason_code'])->toBe('foundation_not_policy_backed')
->and($canonicalOnly['coverage'])->toBe('canonical_only')
->and($canonicalOnly['identity_kind'])->toBe(ResourceIdentity::CanonicalBuiltin)
->and($unsupported['coverage'])->toBe('unsupported')
->and($unsupported['reason_code'])->toBe('unsupported_subject');
});

View File

@ -45,3 +45,17 @@
->and($virtual)->toStartWith('provider-resource:v1:baseline:foundation_backed:assignmenttarget:fake-provider:target:canonical_virtual_target:')
->and($builtin)->not->toBe($virtual);
});
it('validates only provider-resource canonical keys as canonical provider subject keys', function (): void {
$key = BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'provider-resource-1'),
);
expect(BaselineSubjectKey::isProviderResourceCanonicalKey($key))->toBeTrue()
->and(BaselineSubjectKey::isProviderResourceCanonicalKey('duplicate policy'))->toBeFalse()
->and(BaselineSubjectKey::isProviderResourceCanonicalKey('provider-resource:v1:baseline:policy_backed:deviceconfiguration:fake-provider:policy:provider_resource:not-a-sha'))->toBeFalse()
->and(BaselineSubjectKey::isProviderResourceCanonicalKey(null))->toBeFalse();
});

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\Matching\BaselineSubjectDescriptor;
use App\Support\Baselines\SubjectClass;
use App\Support\Resources\ProviderResourceDescriptor;
use App\Support\Resources\ResourceIdentity;
it('serializes baseline subject descriptors with sanitized references and metadata', function (): void {
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'policy-1');
$canonicalSubjectKey = BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
identity: $identity,
);
$descriptor = new BaselineSubjectDescriptor(
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
subjectType: 'policy',
subjectExternalId: 'workspace-safe-subject',
canonicalSubjectKey: $canonicalSubjectKey,
displayLabel: 'Duplicate Policy',
providerResourceDescriptor: ProviderResourceDescriptor::fromIdentity(
identity: $identity,
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
displayLabel: 'Duplicate Policy',
sourceReferences: ['inventory_item_id' => 123, 'unsafe' => ['nested']],
),
sourceReferences: ['baseline_snapshot_item_id' => 456, 'unsafe' => ['nested']],
metadata: ['evidence_fidelity' => 'meta', 'unsafe' => ['nested']],
);
expect($descriptor->comparisonSubjectKey())->toBe($canonicalSubjectKey)
->and($descriptor->hasProviderResourceIdentity())->toBeTrue()
->and($descriptor->toArray()['source_references'])->toBe(['baseline_snapshot_item_id' => 456])
->and($descriptor->toArray()['metadata'])->toBe(['evidence_fidelity' => 'meta']);
});

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\Matching\BaselineSubjectDescriptor;
use App\Support\Baselines\Matching\MatchingOutcome;
use App\Support\Baselines\SubjectClass;
use App\Support\Resources\ProviderResourceDescriptor;
use App\Support\Resources\ResourceIdentity;
it('keeps identity-required outcomes non-comparable with sanitized proof', function (): void {
$subject = new BaselineSubjectDescriptor(
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
subjectType: 'policy',
subjectExternalId: 'workspace-safe-subject',
canonicalSubjectKey: null,
displayLabel: 'Display Only Policy',
);
$outcome = MatchingOutcome::unresolvedIdentity($subject, [
'match_stage' => 'identity_required',
'unsafe' => ['nested'],
]);
expect($outcome->isComparable())->toBeFalse()
->and($outcome->requiresWarning())->toBeFalse()
->and($outcome->reasonCode)->toBe('identity_required')
->and($outcome->toArray()['proof'])->toBe(['match_stage' => 'identity_required']);
});
it('keeps explicit canonical identity matches comparable', function (): void {
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'policy-1');
$canonicalSubjectKey = BaselineSubjectKey::forProviderResourceIdentity(
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
identity: $identity,
);
$subject = new BaselineSubjectDescriptor(
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
subjectType: 'policy',
subjectExternalId: 'workspace-safe-subject',
canonicalSubjectKey: $canonicalSubjectKey,
displayLabel: 'Policy Label',
);
$matched = ProviderResourceDescriptor::fromIdentity(
identity: $identity,
subjectDomain: 'baseline',
subjectClass: SubjectClass::PolicyBacked,
subjectTypeKey: 'deviceConfiguration',
displayLabel: 'Renamed Policy Label',
);
$outcome = MatchingOutcome::resolved(
subject: $subject,
matchedDescriptor: $matched,
matchedSubjectKey: (string) $canonicalSubjectKey,
reasonCode: 'canonical_subject_key',
trust: 'high',
);
expect($outcome->isComparable())->toBeTrue()
->and($outcome->requiresWarning())->toBeFalse()
->and($outcome->reasonCode)->toBe('canonical_subject_key');
});

View File

@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
use App\Models\ProviderResourceBinding;
use App\Services\Baselines\Matching\SubjectMatchingPipeline;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\Matching\BaselineSubjectDescriptor;
use App\Support\Baselines\Matching\MatchingOutcome;
use App\Support\Baselines\SubjectClass;
use App\Support\Resources\ProviderResourceDescriptor;
use App\Support\Resources\ProviderResourceResolutionMode;
use App\Support\Resources\ResourceIdentity;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves active bindings before same-label canonical identity candidates', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'bound-policy-2');
$canonicalSubjectKey = spec382SubjectKey($identity);
ProviderResourceBinding::factory()
->providerResource($identity)
->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'canonical_subject_key' => $canonicalSubjectKey,
'resolution_mode' => ProviderResourceResolutionMode::ManualBinding->value,
]);
$result = app(SubjectMatchingPipeline::class)->matchAll(
workspaceId: (int) $tenant->workspace_id,
managedEnvironmentId: (int) $tenant->getKey(),
baselineSubjects: [spec382MatchingSubject('Duplicate Policy', $identity)],
currentDescriptors: [
spec382CurrentDescriptor(ResourceIdentity::providerResource('fake-provider', 'policy', 'bound-policy-1'), 1, 'Duplicate Policy'),
spec382CurrentDescriptor($identity, 2, 'Duplicate Policy'),
],
);
/** @var MatchingOutcome $outcome */
$outcome = $result['outcomes'][0];
expect($outcome->status)->toBe(MatchingOutcome::Resolved)
->and($outcome->reasonCode)->toBe('active_provider_resource_binding')
->and($outcome->matchedDescriptor?->sourceReferences['inventory_item_id'] ?? null)->toBe(2)
->and(data_get($result, 'diagnostics.active_bindings_considered'))->toBe(1);
});
it('treats same display labels with different provider ids as different resources', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'provider-id-2');
$result = app(SubjectMatchingPipeline::class)->matchAll(
workspaceId: (int) $tenant->workspace_id,
managedEnvironmentId: (int) $tenant->getKey(),
baselineSubjects: [spec382MatchingSubject('Duplicate Policy', $identity)],
currentDescriptors: [
spec382CurrentDescriptor(ResourceIdentity::providerResource('fake-provider', 'policy', 'provider-id-1'), 1, 'Duplicate Policy'),
spec382CurrentDescriptor($identity, 2, 'Duplicate Policy'),
],
);
/** @var MatchingOutcome $outcome */
$outcome = $result['outcomes'][0];
expect($outcome->status)->toBe(MatchingOutcome::Resolved)
->and($outcome->reasonCode)->toBe('canonical_subject_key')
->and($outcome->matchedDescriptor?->sourceReferences['inventory_item_id'] ?? null)->toBe(2);
});
it('keeps duplicate tenant-owned resources with the same provider identity ambiguous without a binding', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'duplicate-provider-id');
$result = app(SubjectMatchingPipeline::class)->matchAll(
workspaceId: (int) $tenant->workspace_id,
managedEnvironmentId: (int) $tenant->getKey(),
baselineSubjects: [spec382MatchingSubject('Policy Label', $identity)],
currentDescriptors: [
spec382CurrentDescriptor($identity, 10, 'Policy Label'),
spec382CurrentDescriptor($identity, 11, 'Renamed Policy Label'),
],
);
/** @var MatchingOutcome $outcome */
$outcome = $result['outcomes'][0];
expect($outcome->status)->toBe(MatchingOutcome::Ambiguous)
->and($outcome->reasonCode)->toBe('ambiguous_match')
->and($outcome->proof['match_stage'])->toBe('canonical_subject_key')
->and($outcome->proof['candidate_count'])->toBe(2);
});
it('keeps valid provider identity comparable as missing local evidence when current resource is absent', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'missing-provider-id');
$result = app(SubjectMatchingPipeline::class)->matchAll(
workspaceId: (int) $tenant->workspace_id,
managedEnvironmentId: (int) $tenant->getKey(),
baselineSubjects: [spec382MatchingSubject('Missing Policy', $identity)],
currentDescriptors: [],
);
/** @var MatchingOutcome $outcome */
$outcome = $result['outcomes'][0];
expect($outcome->status)->toBe(MatchingOutcome::MissingLocalEvidence)
->and($outcome->reasonCode)->toBe('missing_local_evidence')
->and($outcome->isComparable())->toBeFalse()
->and($outcome->proof['match_stage'])->toBe('canonical_subject_key');
});
it('requires canonical identity instead of resolving from display labels', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$result = app(SubjectMatchingPipeline::class)->matchAll(
workspaceId: (int) $tenant->workspace_id,
managedEnvironmentId: (int) $tenant->getKey(),
baselineSubjects: [spec382MatchingSubject('Display Only Policy')],
currentDescriptors: [
spec382CurrentDescriptor(ResourceIdentity::providerResource('fake-provider', 'policy', 'display-only-current'), 20, 'Display Only Policy'),
],
);
/** @var MatchingOutcome $outcome */
$outcome = $result['outcomes'][0];
expect($outcome->status)->toBe(MatchingOutcome::UnresolvedIdentity)
->and($outcome->reasonCode)->toBe('identity_required')
->and($outcome->isComparable())->toBeFalse()
->and($outcome->proof['match_stage'])->toBe('identity_required');
});
it('does not resolve from old subject_key strings', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$result = app(SubjectMatchingPipeline::class)->matchAll(
workspaceId: (int) $tenant->workspace_id,
managedEnvironmentId: (int) $tenant->getKey(),
baselineSubjects: [
spec382MatchingSubject('Old Subject Key Policy', canonicalSubjectKey: 'old subject key policy'),
],
currentDescriptors: [
spec382CurrentDescriptor(ResourceIdentity::providerResource('fake-provider', 'policy', 'old-subject-key-current'), 21, 'Old Subject Key Policy'),
],
);
/** @var MatchingOutcome $outcome */
$outcome = $result['outcomes'][0];
expect($outcome->status)->toBe(MatchingOutcome::UnresolvedIdentity)
->and($outcome->reasonCode)->toBe('identity_required')
->and($outcome->isComparable())->toBeFalse();
});
it('does not resolve from matching fingerprints when provider identity differs', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$result = app(SubjectMatchingPipeline::class)->matchAll(
workspaceId: (int) $tenant->workspace_id,
managedEnvironmentId: (int) $tenant->getKey(),
baselineSubjects: [
spec382MatchingSubject(
displayLabel: 'Fingerprint Policy',
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'baseline-only-policy'),
fingerprint: 'shared-provider-fingerprint',
),
],
currentDescriptors: [
spec382CurrentDescriptor(
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'current-only-policy'),
inventoryItemId: 30,
displayLabel: 'Fingerprint Policy',
fingerprint: 'shared-provider-fingerprint',
),
],
);
/** @var MatchingOutcome $outcome */
$outcome = $result['outcomes'][0];
expect($outcome->status)->toBe(MatchingOutcome::MissingLocalEvidence)
->and($outcome->reasonCode)->toBe('missing_local_evidence')
->and($outcome->proof['match_stage'])->toBe('canonical_subject_key');
});
it('matches fake-provider canonical built-ins without provider object ids', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$identity = ResourceIdentity::canonicalBuiltin('fake-provider', 'assignment-target', 'all-principals');
$result = app(SubjectMatchingPipeline::class)->matchAll(
workspaceId: (int) $tenant->workspace_id,
managedEnvironmentId: (int) $tenant->getKey(),
baselineSubjects: [spec382MatchingSubject('Provider Default Target', $identity, SubjectClass::FoundationBacked, 'assignmentTarget')],
currentDescriptors: [
spec382CurrentDescriptor($identity, 40, 'Renamed Default Target', SubjectClass::FoundationBacked, 'assignmentTarget'),
],
);
/** @var MatchingOutcome $outcome */
$outcome = $result['outcomes'][0];
expect($outcome->status)->toBe(MatchingOutcome::Resolved)
->and($outcome->reasonCode)->toBe('canonical_subject_key')
->and($outcome->matchedSubjectKey)->toStartWith('provider-resource:v1:baseline:foundation_backed:assignmenttarget:fake-provider:assignment-target:canonical_builtin:');
});
it('matches fake-provider virtual targets without directory group ids', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$identity = ResourceIdentity::virtualTarget('fake-provider', 'assignment-target', 'all-managed-devices');
$result = app(SubjectMatchingPipeline::class)->matchAll(
workspaceId: (int) $tenant->workspace_id,
managedEnvironmentId: (int) $tenant->getKey(),
baselineSubjects: [spec382MatchingSubject('Provider Virtual Target', $identity, SubjectClass::FoundationBacked, 'assignmentTarget')],
currentDescriptors: [
spec382CurrentDescriptor($identity, 41, 'Renamed Virtual Target', SubjectClass::FoundationBacked, 'assignmentTarget'),
],
);
/** @var MatchingOutcome $outcome */
$outcome = $result['outcomes'][0];
expect($outcome->status)->toBe(MatchingOutcome::Resolved)
->and($outcome->reasonCode)->toBe('canonical_subject_key')
->and($outcome->matchedSubjectKey)->toStartWith('provider-resource:v1:baseline:foundation_backed:assignmenttarget:fake-provider:assignment-target:canonical_virtual_target:');
});
it('classifies inventory-only foundation subjects as a non-comparable limitation', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$result = app(SubjectMatchingPipeline::class)->matchAll(
workspaceId: (int) $tenant->workspace_id,
managedEnvironmentId: (int) $tenant->getKey(),
baselineSubjects: [spec382MatchingSubject('Foundation Scope Tag', subjectClass: SubjectClass::FoundationBacked, subjectTypeKey: 'roleScopeTag')],
currentDescriptors: [
spec382CurrentDescriptor(ResourceIdentity::providerResource('fake-provider', 'scope-tag', 'scope-tag-1'), 50, 'Foundation Scope Tag', SubjectClass::FoundationBacked, 'roleScopeTag'),
],
);
/** @var MatchingOutcome $outcome */
$outcome = $result['outcomes'][0];
expect($outcome->status)->toBe(MatchingOutcome::Limited)
->and($outcome->reasonCode)->toBe('foundation_not_policy_backed')
->and($outcome->isComparable())->toBeFalse()
->and($outcome->proof['match_stage'])->toBe('foundation_coverage');
});
function spec382MatchingSubject(
string $displayLabel,
?ResourceIdentity $identity = null,
SubjectClass $subjectClass = SubjectClass::PolicyBacked,
string $subjectTypeKey = 'deviceConfiguration',
?string $canonicalSubjectKey = null,
?string $fingerprint = null,
): BaselineSubjectDescriptor {
$providerDescriptor = $identity instanceof ResourceIdentity
? ProviderResourceDescriptor::fromIdentity(
identity: $identity,
subjectDomain: 'baseline',
subjectClass: $subjectClass,
subjectTypeKey: $subjectTypeKey,
displayLabel: $displayLabel,
fingerprint: $fingerprint,
)
: null;
$canonicalSubjectKey ??= $providerDescriptor instanceof ProviderResourceDescriptor
? spec382SubjectKey($providerDescriptor->identity, $subjectClass, $subjectTypeKey)
: null;
return new BaselineSubjectDescriptor(
subjectDomain: 'baseline',
subjectClass: $subjectClass,
subjectTypeKey: $subjectTypeKey,
subjectType: 'policy',
subjectExternalId: spec382SubjectExternalId($subjectTypeKey, $canonicalSubjectKey, $identity),
canonicalSubjectKey: $canonicalSubjectKey,
displayLabel: $displayLabel,
providerResourceDescriptor: $providerDescriptor,
);
}
function spec382CurrentDescriptor(
ResourceIdentity $identity,
int $inventoryItemId,
string $displayLabel,
SubjectClass $subjectClass = SubjectClass::PolicyBacked,
string $subjectTypeKey = 'deviceConfiguration',
?string $fingerprint = null,
): ProviderResourceDescriptor {
return ProviderResourceDescriptor::fromIdentity(
identity: $identity,
subjectDomain: 'baseline',
subjectClass: $subjectClass,
subjectTypeKey: $subjectTypeKey,
displayLabel: $displayLabel,
sourceReferences: [
'inventory_item_id' => $inventoryItemId,
],
fingerprint: $fingerprint,
);
}
function spec382SubjectKey(
ResourceIdentity $identity,
SubjectClass $subjectClass = SubjectClass::PolicyBacked,
string $subjectTypeKey = 'deviceConfiguration',
): string {
$subjectKey = BaselineSubjectKey::forProviderResourceIdentity('baseline', $subjectClass, $subjectTypeKey, $identity);
expect($subjectKey)->not->toBeNull();
return (string) $subjectKey;
}
function spec382SubjectExternalId(string $subjectTypeKey, ?string $canonicalSubjectKey, ?ResourceIdentity $identity): string
{
$identityInput = $canonicalSubjectKey
?? ($identity instanceof ResourceIdentity ? $identity->fingerprint() : hash('sha256', $subjectTypeKey.'|identity-required'));
return BaselineSubjectKey::workspaceSafeSubjectExternalId($subjectTypeKey, $identityInput);
}

View File

@ -66,7 +66,7 @@
'label' => 'Intune RBAC Role Assignment',
'baseline_compare' => [
'supported' => false,
'identity_strategy' => 'external_id',
'identity_strategy' => 'provider_resource',
],
],
[

View File

@ -4,7 +4,7 @@
use App\Support\Inventory\InventoryPolicyTypeMeta;
it('keeps display-name foundation support truthful as limited inventory-backed capability', function (): void {
it('keeps provider-resource foundation support truthful as limited inventory-backed capability', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('roleScopeTag');
expect($contract['config_supported'])->toBeTrue()
@ -35,7 +35,7 @@
'label' => 'Broken Foundation',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'external_id',
'identity_strategy' => 'provider_resource',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',

View File

@ -49,7 +49,7 @@
'label' => 'Intune RBAC Role Assignment',
'baseline_compare' => [
'supported' => false,
'identity_strategy' => 'external_id',
'identity_strategy' => 'provider_resource',
],
],
[

View File

@ -0,0 +1,57 @@
# Requirements Checklist: Spec 382 - Baseline Matching Pipeline and Canonicalization v1
**Purpose**: Validate that the preparation artifacts define a bounded, implementable, constitution-aligned runtime slice for baseline matching and canonicalization.
**Created**: 2026-06-15
**Feature**: [spec.md](../spec.md)
**Note**: This checklist covers preparation quality only. It does not mark implementation work complete.
## Applicability And Scope
- [x] CHK001 The selected candidate is user-provided and directly follows completed Spec 381.
- [x] CHK002 Related completed specs are treated as historical/dependency context only.
- [x] CHK003 The spec excludes resolution UI, result semantics rewrite, evidence/review readiness, customer-facing report changes, and generic workflow engine scope.
- [x] CHK004 The spec states no new persisted entity/table/artifact is approved.
## UI And Filament
- [x] CHK010 The spec includes exactly one UI Surface Impact decision: checked `No UI surface impact` with rationale.
- [x] CHK011 The plan states no Filament Resource, Page, RelationManager, action, route, navigation, Livewire component, Blade view, or asset change is planned.
- [x] CHK012 Browser screenshots and page reports are not required because no reachable UI surface changes.
## Provider Boundary And Matching Truth
- [x] CHK020 The provider/platform boundary is classified as mixed.
- [x] CHK021 Core matching is required to stay provider-neutral and avoid Microsoft/Intune display-label hardcoding.
- [x] CHK022 Fake-provider tests are required to prove the canonicalization seam.
- [x] CHK023 Active provider resource bindings are required to resolve before canonical/provider identity matching.
- [x] CHK024 Display names are UI/descriptive labels only and are not matching, canonical-key, or binding lookup inputs.
- [x] CHK025 Tenant-owned duplicate provider-resource identity candidates without binding remain unresolved ambiguity.
## Proportionality And Bloat Control
- [x] CHK030 The new pipeline/registry/outcome abstractions have a proportionality review.
- [x] CHK031 The plan rejects a generic provider workflow engine and broad multi-provider framework.
- [x] CHK032 The plan requires spec/plan updates before any new persistence, UI, broad result taxonomy, or evidence/review behavior is added.
- [x] CHK033 Foundation coverage must reuse existing metadata before introducing a new classification source.
## RBAC, Isolation, Audit, And OperationRun
- [x] CHK040 Matching and binding reads are scoped by workspace and managed environment.
- [x] CHK041 Non-member access is deny-as-not-found and member-without-capability remains forbidden where relevant.
- [x] CHK042 Matching proof metadata must be sanitized and exclude secrets/raw sensitive provider payloads.
- [x] CHK043 Existing baseline compare OperationRun lifecycle is reused without new start/completion/link UX.
- [x] CHK044 No direct `OperationRun.status` or `OperationRun.outcome` transitions are approved.
## Test Readiness
- [x] CHK050 Unit and feature lanes are explicitly named as the narrowest proof.
- [x] CHK051 PostgreSQL-backed validation is required because Spec 382 drops the committed `legacy_subject_key` column.
- [x] CHK052 Tasks include tests for binding-first matching, duplicate ambiguity, fake-provider canonicalization, foundation coverage, canonical-key rejection, and compare strategy preservation.
- [x] CHK053 Tasks require validation commands, Pint, and `git diff --check`.
## Preparation Gate Outcome
- [x] CHK060 Candidate Selection Gate result: PASS.
- [x] CHK061 Spec Readiness Gate preparation status: ready pending analyze.
- [x] CHK062 Workflow outcome: keep as narrowed Core Enterprise runtime slice.

View File

@ -0,0 +1,68 @@
# Implementation Close-Out: Spec 382 - Baseline Matching Pipeline and Canonicalization v1
Date: 2026-06-15
## Scope Delivered
- Added provider-resource canonical key validation to prevent display-derived strings from being accepted as canonical provider subject keys.
- Added matching support objects and services:
- `BaselineSubjectDescriptor`
- `MatchingOutcome`
- `FoundationCoverageResolver`
- `SubjectMatchingPipeline`
- Integrated matching into baseline compare before `policy_type|subject_key` keying can collapse candidates.
- Kept payload drift/no-drift ownership inside existing compare strategies.
- Removed legacy subject-key matching, display-name matching fallback, display-name-derived canonical key generation, and old compare payload readers from the Spec 382 matching path.
- Added binding-aware compare coverage, identity-required gap coverage, and fake-provider canonical identity coverage.
- Added a Spec 382 migration that drops `provider_resource_bindings.legacy_subject_key` because the column was introduced by the committed Spec 381 migration.
## No Surface Impact
- UI impact: none.
- Filament impact: none.
- Livewire impact: none; project remains on Livewire v4.1.4.
- Panel/provider registration impact: none; Laravel 12 panel providers remain registered through `bootstrap/providers.php`.
- Global search impact: none; no resources/pages were added or changed.
- Destructive/high-impact actions: none added. Existing provider-resource binding decisions keep existing authorization and audit behavior.
- Asset strategy: none added; no global or on-demand assets changed. `filament:assets` deployment behavior is unchanged.
- Migrations/schema impact: `provider_resource_bindings.legacy_subject_key` is dropped by a Spec 382 migration. No new persisted entity, index family, env var, queue, scheduler, storage, route, UI, asset, Filament, or Livewire surface is introduced.
- Environment variables: none.
- Queues/scheduler/storage: no new queue names, scheduler entries, or storage paths.
- Browser smoke: not run because this spec has no UI surface.
## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/Matching tests/Unit/Services/Baselines/Matching tests/Unit/Support/Baselines/BaselineSubjectKeyCanonicalIdentityTest.php tests/Unit/Support/Resources/ResourceIdentityTest.php tests/Unit/Support/Resources/ProviderResourceDescriptorTest.php`
- 19 passed, 103 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php tests/Feature/ProviderResources/ProviderResourceBindingAuthorizationTest.php`
- 20 passed, 75 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareProviderResourceBindingCanonicalIdentityTest.php tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`
- 4 passed, 70 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`
- 9 passed, 43 assertions.
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- pass.
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --test --format agent`
- pass.
- `git diff --check`
- pass.
## PostgreSQL Lane
Targeted database-backed Sail tests ran through Laravel migrations, including the Spec 382 drop-column migration. No new indexes, constraints, locks, or PostgreSQL-specific query behavior were added.
## Post-Implementation Analysis
- No changes were made to completed dependency specs `specs/163-baseline-subject-resolution/`, `specs/380-management-report-pdf-staging-runtime-validation/`, or `specs/381-provider-resource-identity-binding/`.
- No matching code calls `GraphClientInterface`, provider gateways, or provider runtime clients.
- Matching proof metadata is sanitized and does not include raw operator notes.
- Core matching does not branch on Microsoft display labels.
- Display names are labels/descriptions only; they are not matching inputs, canonical-key inputs, binding lookup inputs, or successful compare resolution inputs.
- No UI, Filament, Livewire, Blade, route, config, scheduler, queue-name, storage, env, or asset file changed.
- No in-scope post-implementation findings remain open.
## Deployment Impact
- Staging: normal application deployment and test validation are sufficient.
- Production: run normal Laravel migrations so the legacy identity column is dropped. No queue worker, scheduler, volume, or environment-variable step is required.
- Rollback: rolling back the Spec 382 migration restores the dropped column, but product behavior intentionally remains no-legacy-identity unless code is rolled back too.

View File

@ -0,0 +1,242 @@
# Implementation Plan: Spec 382 - Baseline Matching Pipeline and Canonicalization v1
**Branch**: `382-baseline-matching-canonicalization` | **Date**: 2026-06-15 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/382-baseline-matching-canonicalization/spec.md`
## Summary
Add a deterministic baseline subject matching layer that consumes Spec 381 provider resource identities and active managed-environment-scoped bindings before existing baseline compare item keying and payload drift comparison. The runtime slice removes legacy subject-key and display-name matching, preserves real ambiguity, classifies built-ins/virtual targets/foundations through provider-neutral seams, and avoids UI, evidence/review readiness, result taxonomy rewrite, production provider registries, and new persisted entities.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, PostgreSQL 16 through Sail/Dokploy
**Storage**: Existing PostgreSQL tables only; no new persisted entity approved. Existing `provider_resource_bindings` is consumed and the old `legacy_subject_key` column is dropped.
**Testing**: Pest unit and feature tests; PostgreSQL lane only if implementation changes indexes, constraints, migrations, or PostgreSQL-specific queries.
**Validation Lanes**: fast-feedback, confidence; conditional pgsql.
**Target Platform**: Laravel monolith in `apps/platform`.
**Project Type**: Web admin application, backend runtime change only.
**Performance Goals**: Deterministic matching should remain in-process and use already persisted descriptors/bindings; no Graph, provider gateway, provider runtime client, or UI-render remote calls.
**Constraints**: Workspace/environment scoped reads, binding-first priority, no customer-facing UI or report presentation changes, no historical payload readers.
**Scale/Scope**: Existing baseline compare workload and current provider/resource identity foundation.
## Existing Repository Surfaces Likely Affected
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Services/Baselines/BaselineCompareService.php` only if start/context plumbing requires a narrow adjustment
- `apps/platform/app/Support/Baselines/BaselineSubjectKey.php`
- `apps/platform/app/Support/Baselines/SubjectResolver.php`
- `apps/platform/app/Support/Baselines/ResolutionOutcome.php`
- `apps/platform/app/Support/Baselines/ResolutionOutcomeRecord.php`
- `apps/platform/app/Support/Baselines/Compare/CompareState.php`
- `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`
- `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php`
- `apps/platform/app/Support/Inventory/InventoryPolicyTypeMeta.php`
- `apps/platform/app/Support/Baselines/BaselineSupportCapabilityGuard.php`
- `apps/platform/app/Support/Resources/ResourceIdentity.php`
- `apps/platform/app/Support/Resources/ProviderResourceDescriptor.php`
- `apps/platform/app/Models/ProviderResourceBinding.php`
- `apps/platform/app/Services/Resources/ProviderResourceBindingService.php`
- `apps/platform/database/factories/ProviderResourceBindingFactory.php`
- `apps/platform/tests/Unit/Support/Baselines/*`
- `apps/platform/tests/Unit/Support/Resources/*`
- `apps/platform/tests/Feature/Baselines/*`
- `apps/platform/tests/Feature/ProviderResources/*`
- `apps/platform/tests/Feature/Evidence/BaselineDriftPostureSourceTest.php`
## UI / Surface Guardrail Plan
- **Guardrail scope**: no operator-facing surface change.
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: N/A.
- **No-impact class, if applicable**: backend runtime matching only.
- **Native vs custom classification summary**: N/A.
- **Shared-family relevance**: baseline identity and compare runtime, not UI.
- **State layers in scope**: backend compare result proof only; no page/detail/query state.
- **Audience modes in scope**: N/A.
- **Decision/diagnostic/raw hierarchy plan**: N/A for UI; matching proof metadata must be sanitized and internal.
- **Raw/support gating plan**: N/A.
- **One-primary-action / duplicate-truth control**: N/A.
- **Handling modes by drift class or surface**: result/gap semantics are deferred to Spec 383.
- **Repository-signal treatment**: report-only if UI files are touched accidentally; implementation must stop and update spec if UI becomes necessary.
- **Special surface test profiles**: N/A.
- **Required tests or manual browser validation**: no browser validation. Targeted unit/feature tests only.
- **Exception path and spread control**: none.
- **Active feature PR close-out entry**: Baseline Matching Pipeline / Provider Identity Consumption.
- **UI/Productization coverage decision**: No UI surface impact.
- **Coverage artifacts to update**: none.
- **No-impact rationale**: Existing surfaces continue rendering existing compare/operation channels. V1 changes backend matching, not reachable product surfaces.
- **Navigation / Filament provider-panel handling**: unchanged.
- **Screenshot or page-report need**: no.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes.
- **Systems touched**: baseline subject identity, provider resource identity, provider resource bindings, compare job, compare strategy input, OperationRun proof context.
- **Shared abstractions reused**: `ResourceIdentity`, `ProviderResourceDescriptor`, `BaselineSubjectKey`, `ProviderResourceBinding`, existing compare strategy registry, existing OperationRun lifecycle.
- **New abstraction introduced? why?**: yes, a narrow `SubjectMatchingPipeline`, baseline subject descriptor, matching outcome, provider-owned canonicalization seam, and foundation coverage resolver. They replace scattered old identity assumptions before compare strategies run.
- **Why the existing abstraction was sufficient or insufficient**: Existing compare strategies remain sufficient for payload comparison. Existing `SubjectResolver` is insufficient as the primary identity resolver because it does not consume active bindings before heuristics.
- **Bounded deviation / spread control**: The matching pipeline is baseline-compare-owned, not a general provider workflow engine. Evidence/review/report consumption is follow-up scope. A production canonicalizer registry/interface is not planned for V1 and must be justified in spec/plan before introduction.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no.
- **Central contract reused**: existing baseline compare operation lifecycle.
- **Delegated UX behaviors**: N/A.
- **Surface-owned behavior kept local**: N/A.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: existing lifecycle only.
- **Exception path**: none.
Implementation may store sanitized matching proof in existing compare operation context or result payloads. It must not introduce new `OperationRun` types, new start UX, new notifications, or status/outcome transitions outside the existing service-owned path.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes.
- **Provider-owned seams**: built-in/default/virtual target canonicalization through a direct provider-owned seam/service; provider resource type and discriminator interpretation; Microsoft-specific signals if a minimal Microsoft adapter is implemented.
- **Platform-core seams**: matching priority, descriptor shape, binding lookup, canonical subject key validation, outcome proof structure.
- **Neutral platform terms / contracts preserved**: provider, provider key, managed environment, governed subject, baseline subject descriptor, provider resource descriptor, matching outcome, foundation coverage.
- **Retained provider-specific semantics and why**: provider resource type/id/discriminator remain necessary identity fields.
- **Bounded extraction or follow-up path**: document-in-feature for contained provider-specific canonicalization; follow-up-spec for broad Microsoft built-in catalog or customer-facing result semantics.
## Constitution Check
- Inventory-first: compare consumes persisted inventory/snapshot/provider descriptors as last observed truth; Microsoft remains external truth.
- Read/write separation: V1 does not add write actions. Existing compare operation remains queued/observable. Binding mutations remain Spec 381 service behavior.
- Graph contract path: no new Graph calls; no Graph/provider runtime calls during UI render or matching.
- Deterministic capabilities: no new capability family planned.
- RBAC-UX: workspace/environment entitlement is enforced before binding/candidate reads; non-members 404, members missing capability 403 where relevant.
- Workspace isolation: binding and descriptor reads are workspace scoped.
- Tenant isolation: managed-environment scoped bindings must not affect other environments.
- Run observability: existing baseline compare `OperationRun` remains canonical execution truth.
- OperationRun start UX: unchanged.
- Ops-UX lifecycle: no direct status/outcome transitions may be added.
- Data minimization: matching proof metadata must be sanitized.
- Test governance: unit and feature lanes are narrowest; no browser/heavy family.
- Proportionality: new runtime abstractions are justified by activating Spec 381's implemented foundation and preventing false compare identity.
- No premature abstraction: no generic provider workflow engine and no production canonicalizer registry/interface by default; fake provider proves the seam without a broad multi-provider framework.
- Persisted truth: no new table/entity approved.
- Behavioral state: any matching outcome/reason value must change matching behavior or proof, not just display.
- UI semantics: no UI semantics.
- Shared pattern first: existing compare and OperationRun paths are reused.
- Provider boundary: platform-core matching stays provider-neutral; provider-specific canonicalization stays behind seam.
- V1 explicitness / few layers: direct baseline compare matching layer only.
- Spec discipline / bloat check: Specs 383-385 remain follow-up; this spec does not absorb UI/evidence/report scope.
- Filament-native UI: no Filament changes.
- UI/Productization coverage: checked no UI surface impact with rationale.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for matching components; Feature for compare integration, binding lookup, isolation, and canonical-key rejection.
- **Affected validation lanes**: fast-feedback, confidence; pgsql only if migrations/indexes/constraints change.
- **Why this lane mix is the narrowest sufficient proof**: Matching is deterministic service behavior plus existing DB-backed compare workflow. No UI/browser proof is needed.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/Matching`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareProviderResourceBindingCanonicalIdentityTest.php tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`
- **Fixture / helper / factory / seed / context cost risks**: fake-provider fixtures must stay local; no global provider/workspace defaults.
- **Expensive defaults or shared helper growth introduced?**: no.
- **Heavy-family additions, promotions, or visibility changes**: none.
- **Surface-class relief / special coverage rule**: N/A.
- **Closing validation and reviewer handoff**: reviewers verify no UI impact, no new persistence, no core Microsoft literals, and no evidence/review behavior change.
- **Budget / baseline / trend follow-up**: none expected.
- **Review-stop questions**: lane fit, hidden provider fixture cost, binding query isolation, and bloat scope.
- **Escalation path**: document-in-feature for contained provider-specific canonicalization; follow-up-spec for broader semantics/UI/evidence.
- **Active feature PR close-out entry**: Baseline Matching Pipeline / Provider Identity Consumption.
- **Why no dedicated follow-up spec is needed**: Matching activation is the smallest direct follow-up to Spec 381. Result semantics, UI, and evidence readiness already have follow-up spec candidates.
## Project Structure
### Documentation (this feature)
```text
specs/382-baseline-matching-canonicalization/
├── checklists/
│ └── requirements.md
├── plan.md
├── spec.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/app/
├── Jobs/
│ └── CompareBaselineToTenantJob.php
├── Services/
│ ├── Baselines/
│ │ └── Matching/ # new focused matching services if implementation keeps this namespace
│ └── Resources/
│ └── ProviderResourceBindingService.php
└── Support/
├── Baselines/
│ ├── BaselineSubjectKey.php
│ ├── SubjectResolver.php
│ └── Matching/ # new descriptor/outcome/value support if implementation keeps this namespace
├── Inventory/
│ └── InventoryPolicyTypeMeta.php
└── Resources/
├── ProviderResourceDescriptor.php
└── ResourceIdentity.php
apps/platform/tests/
├── Unit/Support/Baselines/Matching/
├── Unit/Support/Resources/
├── Feature/Baselines/
├── Feature/ProviderResources/
└── Feature/Evidence/
```
**Structure Decision**: Use the existing Laravel monolith under `apps/platform`. Keep matching code in baseline-owned namespaces. Do not create a new package, module root, provider framework, or persistence layer.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
| New matching pipeline/resolver family | Existing compare paths still encounter old subject-key/display-name identity data and do not consume active bindings first | Patching each compare strategy would duplicate identity priority rules and keep provider logic scattered |
| Canonicalization seam | Built-ins/defaults/virtual targets need provider-owned knowledge without hardcoding provider labels in core | Hardcoding Microsoft labels in core violates provider boundary rules; a registry/interface is broader than V1 unless a current-release need is documented |
| Matching outcome object/reason family | Compare needs to distinguish resolved, ambiguous, missing, unsupported, limited, excluded, and unresolved-identity outcomes before drift comparison | Reusing overloaded states keeps false blockers and false green risk |
## Proportionality Review
- **Current operator problem**: Baseline compare can still produce false ambiguity, false blockers, or false confidence when display names or legacy subject keys are treated as identity.
- **Existing structure is insufficient because**: Spec 381 persistence is passive until matching consumes it; existing compare strategies are drift comparers, not binding-first identity resolvers.
- **Narrowest correct implementation**: One pre-compare matching layer, no new persistence, no UI, no evidence/review readiness, no result taxonomy rewrite beyond internal matching proof.
- **Ownership cost created**: Focused matching services/value objects and tests; reviewer vigilance against turning this into a generic provider engine.
- **Alternative intentionally rejected**: Leave bindings passive until resolution UI. That would keep the core compare workflow unsafe and make UI decisions depend on stale identity behavior.
- **Release truth**: Current-release runtime truth required immediately after Spec 381.
## Domain And Data Model Implications
- Existing `provider_resource_bindings` remains the durable binding source.
- Matching outcomes are derived runtime/result truth, not new persisted domain records.
- `canonical_subject_key` validation must prevent arbitrary legacy/display-name keys from masquerading as provider-resource canonical keys.
- `legacy_subject_key` is removed from active code paths and dropped by the Spec 382 migration.
- Baseline descriptor source precedence must be repo-real: use existing `ProviderResourceDescriptor` or binding identity fields when present; otherwise derive current-side descriptors from scoped `InventoryItem` fields (`external_id`, `policy_type`, `display_name`, `meta_jsonb`) and baseline-side descriptors from `BaselineSnapshotItem` fields (`subject_key`, `subject_external_id`, `policy_type`, `meta_jsonb`). Provider key/resource identity must come from existing provider connection, binding, descriptor, or explicit test fixture context, not platform-core display-label assumptions.
- If implementation needs a new table, enum family beyond matching behavior, or durable artifact, stop and update spec/plan before code changes continue.
## Implementation Phases
1. Confirm repo state and completed dependency guardrails.
2. Add tests for binding-first matching, duplicate-name ambiguity, canonical built-ins/virtual targets, foundation coverage, canonical-key rejection, and compare strategy preservation.
3. Implement or extend canonical key validation so arbitrary overrides are rejected.
4. Add baseline subject descriptor and matching outcome support.
5. Add the narrow matching pipeline with active binding lookup, canonicalization seam, exact identity, unresolved-identity, and missing/unsupported/limitation outcomes.
6. Integrate the pipeline into `CompareBaselineToTenantJob` before old `policy_type|subject_key` keying can collapse candidates, and before compare strategy invocation.
7. Replace the Spec 381 no-op binding-consumption test with binding-consumption and identity-required coverage.
8. Run targeted tests, Pint, and diff check.
## Filament v5 Output Contract For Later Implementation Report
- Livewire v4.0+ compliance: unchanged; no Livewire code is planned.
- Provider registration location: unchanged; Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`.
- Global search: no resource is added or changed; no global search behavior is planned.
- Destructive/high-impact actions: no Filament action is added. Existing compare start behavior remains governed by existing authorization/OperationRun rules.
- Asset strategy: no Filament assets are registered; no Spec 382-specific `filament:assets` deployment concern beyond normal release process.
- Testing plan: unit/feature tests cover matching components and compare integration; no Livewire/browser tests unless implementation unexpectedly touches UI, in which case spec/plan must be updated first.
## Rollout And Deployment Considerations
- No environment variables, queue names, scheduler entries, storage volumes, reverse proxy changes, or asset build changes are expected.
- Spec 382 includes a migration to drop `provider_resource_bindings.legacy_subject_key`. No new persisted entity, index family, or storage surface is introduced.
- Staging validation should run the targeted compare/matching test commands and normal formatting checks before production promotion.
- Because TenantPilot is pre-production, no legacy identity mapper or historical OperationRun payload reader is required.

View File

@ -0,0 +1,373 @@
# Feature Specification: Spec 382 - Baseline Matching Pipeline and Canonicalization v1
**Feature Branch**: `382-baseline-matching-canonicalization`
**Created**: 2026-06-15
**Status**: Draft / Ready for implementation preparation review
**Input**: User-provided draft candidate "Spec 382 - Baseline Matching Pipeline & Canonicalization v1" from `/Users/ahmeddarrazi/.codex/attachments/a49d917f-b4a7-4fd7-9add-b5a242fb0afc/pasted-text.txt`.
## Repo-Truth Adjustment
The user supplied a complete numbered draft for Spec 382. Repo truth confirms that `specs/381-provider-resource-identity-binding/` is implemented and closed out, and that Spec 381 explicitly lists Spec 382 as the follow-up that consumes resource identities, provider resource descriptors, canonical subject keys, and active provider resource bindings in baseline matching.
This prepared Spec 382 narrows the draft to the smallest implementation-ready runtime slice:
- V1 consumes the Spec 381 identity and binding foundation inside baseline compare matching only.
- V1 removes display-name and legacy subject-key matching completely. Display labels remain UI/descriptive metadata only.
- V1 uses active scoped `provider_resource_bindings` before heuristics.
- V1 introduces only the narrow matching/canonicalization abstractions needed to support current baseline compare behavior.
- V1 does not add a resolution UI, customer-facing report rewrite, evidence/review readiness mapping, generic workflow engine, or historical payload support layer.
- V1 does not add a new persisted entity. If implementation discovers new durable truth is required, the spec and plan must be updated before code changes continue.
## Candidate Selection Gate
- **Selected candidate**: Spec 382 - Baseline Matching Pipeline and Canonicalization v1.
- **Source**: Direct user-provided candidate attachment, plus Spec 381 follow-up references in `specs/381-provider-resource-identity-binding/spec.md` and `implementation-close-out.md`.
- **Why selected**: It is the next numbered manual candidate after completed Spec 381 and unlocks safer compare semantics before the proposed Specs 383-385 can be prepared or implemented.
- **Roadmap relationship**: Supports provider-neutral baseline identity, governance truth, and anti-drift hardening by replacing mutable display-label identity with durable provider/resource identity before final result semantics and operator resolution surfaces are added.
- **Close alternatives deferred**:
- Spec 383 - Baseline Compare Result Semantics & Gap Classification v1: depends on matching outcomes produced by Spec 382.
- Spec 384 - Baseline Subject Resolution UI & Operator Decisions v1: depends on matching outcomes and binding consumption from Spec 382.
- Spec 385 - Evidence & Review Readiness Integration v1: depends on stable matching and result semantics.
- Broader report, management PDF, or productization candidates are unrelated to the immediate baseline identity blocker.
- **Completed-spec guardrail result**:
- `specs/381-provider-resource-identity-binding/` is completed and validated. It is dependency context only and must not be rewritten.
- `specs/163-baseline-subject-resolution/` has completed implementation history referenced by Spec 381. It is historical context only.
- `specs/380-management-report-pdf-staging-runtime-validation/` is completed validation context and is not modified.
- No local or remote `382-*` spec or branch collision was found before the create script ran.
- **Smallest viable implementation slice**: Binding-aware baseline subject matching and canonicalization consumed by baseline compare before payload comparison, with identity-required gaps for subjects lacking provider-resource identity and follow-up result/UI/evidence work excluded.
- **Gate result**: PASS. The candidate is user-provided, unspecced, not completed, directly follows completed Spec 381, and can be narrowed to a bounded runtime slice.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Baseline compare can still treat mutable display labels or legacy `policy_type + subject_key` values as authoritative subject identity even after Spec 381 added provider resource identity and bindings.
- **Today's failure**: Duplicate tenant-owned objects, copied/restored/test resources, provider built-ins, virtual assignment targets, and foundation objects can still collapse into ambiguous or false compare states when display names or legacy subject keys drive matching.
- **User-visible improvement**: Operators receive fewer false blockers and fewer false green matches because compare resolves known subjects only by provider identity or active binding, preserves real ambiguity, and records identity-required gaps when durable identity is absent.
- **Smallest enterprise-capable version**: Add a narrow matching pipeline for baseline compare, consume active provider resource bindings first, add provider-neutral descriptor/outcome objects, add a minimal provider-owned canonicalization seam with fake-provider proof, classify foundation coverage from existing metadata where possible, and integrate the pipeline before existing compare strategies.
- **Explicit non-goals**: No resolution UI, no customer-facing report copy rewrite, no Evidence Snapshot readiness change, no Review Pack readiness change, no Spec 383 result taxonomy rewrite, no generic workflow engine, no new persistent table, no historical OperationRun payload reader, no old-payload mapper, and no broad Microsoft built-in catalog.
- **Permanent complexity imported**: One matching pipeline service, one baseline-side descriptor, one matching outcome object, a small provider-owned canonicalization seam, a narrow foundation coverage resolver, tests for binding priority and provider neutrality, and updated compare tests. No new persistence is approved in this spec. A production canonicalizer registry/interface is not part of V1 unless implementation proves a current-release security, isolation, or correctness need that must be recorded in this spec/plan before continuing.
- **Why now**: Spec 381 deliberately left bindings passive. Keeping them passive would leave the main baseline compare workflow exposed to the same display-name identity risk that Spec 381 was created to remove.
- **Why not local**: A one-off compare patch would either preserve display-name identity or bury provider-specific matching inside compare strategy code. The current workflow needs one deterministic matching step before drift comparison so strategies compare the right subjects.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: New resolver/pipeline, outcome object, foundation language, canonicalization seam, and a multi-spec sequence. Defense: the scope is runtime-only, has no new persistence or UI, reuses Spec 381 persistence, uses fake-provider proof without a production registry/interface, and leaves result semantics/UI/evidence readiness to explicit follow-ups.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve as a narrowed Core Enterprise runtime slice.
## Problem Statement
Baseline compare needs to answer "which current provider resource corresponds to this baseline subject?" before it answers "does the payload drift?" Today, compare can still encounter local/dev records that only carry display-name-derived subject keys or policy-centric identity assumptions. Those records are invalid for Spec 382 matching and must become identity-required gaps unless they contain `ResourceIdentity` or a valid provider-resource canonical subject key. Display labels are mutable and non-unique, and some governed subjects are provider built-ins, virtual assignment targets, inventory-only foundations, or accepted limitations rather than ordinary tenant-owned policies.
Spec 381 added durable identity primitives and managed-environment-scoped binding decisions. Spec 382 makes baseline compare consume that foundation so matching is stable, provider-neutral, and honest about unresolved ambiguity.
## Business / Product Value
- Reduces false compare blockers caused by expected provider defaults or virtual targets.
- Reduces false green matches caused by duplicate names.
- Makes active manual bindings operationally meaningful instead of passive records.
- Keeps identity resolution separate from drift/no-drift judgment.
- Creates clean input for later result semantics, resolution UI, and evidence/review readiness specs.
## Primary Users / Operators
- MSP or tenant operator reviewing baseline compare outcomes.
- Workspace manager responsible for baseline governance accuracy across managed environments.
- Support/platform operator diagnosing why a baseline subject matched, failed to match, or remained ambiguous.
- Release reviewer validating that compare matching remains provider-neutral and tenant-safe.
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant-owned baseline compare runtime within established workspace and managed-environment boundaries.
- **Primary Routes**: No new route, page, or navigation entry. Existing baseline compare start/detail/operation surfaces are regression surfaces only.
- **Data Ownership**: Existing `provider_resource_bindings` rows remain tenant-owned operational truth. Existing baseline snapshots, baseline snapshot items, inventory items, policy versions, findings, evidence snapshots, review packs, and operation runs keep their current ownership. No new persisted entity is approved.
- **RBAC**: Baseline compare continues to require established workspace and managed-environment entitlement. Non-members are denied as not found. Entitled users missing compare capability receive forbidden. Binding-aware reads must not reveal bindings or candidate resources outside the current workspace/environment.
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: Not applicable. Spec 382 adds no canonical-view route.
- **Explicit entitlement checks preventing cross-tenant leakage**: Matching queries must scope binding and candidate descriptor reads by workspace and managed environment before returning outcomes.
## UI Surface Impact *(mandatory - UI-COV-001)*
Does this spec add, remove, rename, or materially change any reachable UI surface?
- [x] No UI surface impact
- [ ] Existing page changed
- [ ] New page/route added
- [ ] Navigation changed
- [ ] Filament panel/provider surface changed
- [ ] New modal/drawer/wizard/action added
- [ ] New table/form/state added
- [ ] Customer-facing surface changed
- [ ] Dangerous action changed
- [ ] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
## UI/Productization Coverage
N/A - no reachable UI surface impact. Spec 382 changes backend matching behavior consumed by an existing baseline compare operation. It does not add or materially refactor pages, routes, navigation, actions, modals, tables, forms, customer-facing output, status presentation, Evidence Snapshot readiness, Review Pack readiness, or operator resolution UI. Follow-up Specs 383-385 own result semantics, UI decisions, and customer-facing readiness.
## Cross-Cutting / Shared Pattern Reuse
- **Cross-cutting feature?**: yes.
- **Interaction class(es)**: no UI interaction class. Backend shared families include baseline subject identity, provider resource identity, compare strategy input, binding decisions, OperationRun proof context, and provider boundary vocabulary.
- **Systems touched**: `BaselineSubjectKey`, `ResourceIdentity`, `ProviderResourceDescriptor`, `ProviderResourceBinding`, `ProviderResourceBindingService`, `SubjectResolver`, `CompareBaselineToTenantJob` item loading/keying, `CompareStrategyRegistry`, `InventoryPolicyTypeMeta`, and baseline compare tests.
- **Existing pattern(s) to extend**: Spec 381 identity and binding foundation, existing compare strategy registry, existing subject/resolution support, existing OperationRun compare job, existing capability and audit semantics.
- **Shared contract / presenter / builder / renderer to reuse**: no UI renderer. Reuse existing compare strategy output and OperationRun lifecycle paths; do not create a local operation UX contract.
- **Why the existing shared path is sufficient or insufficient**: Existing compare strategies are sufficient for payload drift comparison after identity has been resolved. They are insufficient as the primary identity resolver because they still operate too close to policy type/display-label assumptions.
- **Allowed deviation and why**: one new matching pipeline is allowed because it replaces scattered legacy identity decisions and makes active bindings consumeable before compare strategies run.
- **Consistency impact**: Matching outcome truth must not become a second result taxonomy. Spec 383 owns final compare result semantics if more operator-facing distinctions are needed.
- **Review focus**: binding-first priority, no Microsoft label hardcoding in core, no display-name authoritative path, no cross-environment binding leakage, and no evidence/review behavior change.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no.
- **Shared OperationRun UX contract/layer reused**: Existing baseline compare start/completion behavior remains unchanged.
- **Delegated start/completion UX behaviors**: N/A - no queued toast/link/event/notification semantics are added or changed.
- **Local surface-owned behavior that remains**: N/A.
- **Queued DB-notification policy**: N/A - no new queued DB notifications.
- **Terminal notification path**: Existing baseline compare operation lifecycle only.
- **Exception required?**: none.
Spec 382 may add sanitized matching proof metadata to existing compare operation context or compare result payloads. It must not transition `OperationRun.status` or `OperationRun.outcome` outside the existing service-owned lifecycle.
## Provider Boundary / Platform Core Check
- **Shared provider/platform boundary touched?**: yes.
- **Boundary classification**: mixed. Matching priority, canonical subject keys, descriptors, bindings, and outcomes are platform-core. Provider-specific built-in/default/virtual detection stays provider-owned behind a canonicalizer seam.
- **Seams affected**: baseline subject identity, provider resource descriptors, binding lookup, compare strategy input, foundation coverage classification, outcome proof metadata.
- **Neutral platform terms preserved or introduced**: provider, managed environment, governed subject, baseline subject descriptor, provider resource descriptor, canonical subject key, binding, matching outcome, foundation coverage.
- **Provider-specific semantics retained and why**: provider key, provider resource type, provider resource ID, and canonical discriminator remain necessary provider-owned identity fields. Microsoft/Intune labels and Graph endpoint assumptions must not enter platform-core matching logic.
- **Why this does not deepen provider coupling accidentally**: A fake-provider seam test is required. Core matching must accept provider descriptors and canonicalization results without branching on Microsoft display labels such as `All users`, `All devices`, or `Default`, and without adding a production provider registry only for test proof.
- **Follow-up path**: Spec 383 may refine result/gap semantics. Spec 384 may add operator resolution UI. Spec 385 may map outcomes into evidence/review readiness.
## UI / Surface Guardrail Impact
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / N/A Note |
|---|---|---|---|---|---|---|
| Baseline matching pipeline consumed by compare job | no | N/A | baseline identity, provider boundary, compare runtime | backend runtime truth only | no | Existing UI surfaces render existing compare/operation channels; no page, action, navigation, or presentation contract changes in v1 |
## Decision-First Surface Role
N/A - no operator-facing surface change.
## Audience-Aware Disclosure
N/A - no customer/operator-facing detail or status surface change. Later disclosure of matching detail belongs to Spec 383/384/385.
## UI/UX Surface Classification
N/A - no operator-facing list, detail, queue, audit, config, report, or workflow surface is added or materially changed.
## Operator Surface Contract
N/A - no new operator-facing page or material page refactor.
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no new persisted truth. Matching outcomes are derived runtime/result truth from existing baseline snapshots, inventory/current provider descriptors, and active `provider_resource_bindings`.
- **New persisted entity/table/artifact?**: no.
- **New abstraction?**: yes. `SubjectMatchingPipeline`, baseline subject descriptor, matching outcome, provider-owned canonicalization seam, and foundation coverage resolver are introduced narrowly for baseline compare matching.
- **New enum/state/reason family?**: possibly a small matching outcome/reason family. Each value must change matching behavior, result proof, or follow-up operator action; presentation-only labels are forbidden.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: Operators need baseline compare to stop treating names as identity while still preserving real ambiguity and limitations.
- **Existing structure is insufficient because**: `SubjectResolver` and compare strategies classify existing compare states, but they do not consume active provider resource bindings first or isolate provider-specific canonicalization away from platform-core compare logic.
- **Narrowest correct implementation**: A deterministic pre-compare matching layer that reuses Spec 381 persistence, feeds existing compare strategies, and avoids UI/evidence/report scope.
- **Ownership cost**: A focused service/object family and tests must be maintained alongside compare strategy tests. Reviewers must prevent it from growing into a generic provider workflow framework.
- **Alternative intentionally rejected**: Patch display-name matching inside each compare strategy. That would duplicate priority rules, keep provider-specific logic scattered, and make active bindings inconsistently consumed.
- **Release truth**: Current-release runtime truth. Spec 381 is already implemented; this spec activates that foundation for the existing baseline compare workflow.
### Compatibility posture
TenantPilot is pre-production. V1 must not add legacy identity aliases, dual old/new identity readers, historical OperationRun context readers, old compare payload mappers, legacy subject-key matching, display-name matching, or display-name-derived canonical keys. Existing local/dev compare data may be invalidated or reset during implementation. `legacy_subject_key` is removed from active code paths and dropped from `provider_resource_bindings` by a Spec 382 migration because Spec 381 already introduced the column.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit for descriptor/outcome/pipeline/canonicalizer behavior; Feature for compare integration, binding consumption, workspace/environment isolation, and rejected canonical override behavior.
- **Validation lane(s)**: fast-feedback and confidence. PostgreSQL lane only if implementation changes migrations, indexes, constraints, or PostgreSQL-specific query behavior.
- **Why this classification and these lanes are sufficient**: The feature changes deterministic runtime matching and existing DB-backed compare behavior, not UI, browser behavior, or new persistence.
- **New or expanded test families**: focused `tests/Unit/Support/Baselines/Matching` and `tests/Feature/Baselines` coverage. No browser or heavy-governance family.
- **Fixture / helper cost impact**: fake-provider descriptors/canonicalizers must stay local to matching tests or explicit fixtures. No global default provider/workspace setup may be widened.
- **Heavy-family visibility / justification**: none.
- **Special surface test profile**: N/A - no Filament or operator surface.
- **Standard-native relief or required special coverage**: no UI coverage required.
- **Reviewer handoff**: verify binding-first order before existing compare item keying can discard candidates, identity-required gaps for display-only/old subject-key data, duplicate provider-identity ambiguity, provider-neutral fake-provider proof without a production registry/interface, no Microsoft literals in core, no Graph/provider runtime calls, no new persisted entity, and no evidence/review/customer output changes.
- **Budget / baseline / trend impact**: expected small increase in unit/feature runtime only. Escalate as follow-up-spec if implementation requires broad compare suite restructuring.
- **Escalation needed**: document-in-feature for contained provider-boundary exceptions; follow-up-spec for result semantics, resolution UI, evidence/review readiness, or new persistence.
- **Active feature PR close-out entry**: Baseline Matching Pipeline / Provider Identity Consumption.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/Matching`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/BaselineSubjectKeyCanonicalIdentityTest.php tests/Unit/Support/Resources/ResourceIdentityTest.php tests/Unit/Support/Resources/ProviderResourceDescriptorTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareProviderResourceBindingCanonicalIdentityTest.php tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareGapClassificationTest.php tests/Feature/Evidence/BaselineDriftPostureSourceTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php tests/Feature/ProviderResources/ProviderResourceBindingAuthorizationTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --test --format agent`
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Resolve known subjects by provider identity or binding (Priority: P1)
As a baseline governance operator, I need compare to match a baseline subject to the right current provider resource using stable provider identity or an active binding before names are considered, so duplicate names and renamed objects do not create false results.
**Why this priority**: This is the core trust gap and the reason Spec 381 must be consumed.
**Independent Test**: A feature test creates duplicate display labels, one active binding, and current descriptors. Compare resolves the bound subject first and leaves the unbound duplicate ambiguous.
**Acceptance Scenarios**:
1. **Given** an active binding for a baseline subject canonical key, **When** baseline compare evaluates candidates, **Then** the binding is consumed before provider identity/canonical identity matching.
2. **Given** two tenant-owned candidates with the same display label but different provider resource IDs, **When** compare runs, **Then** they are treated as different resources and no name match is selected.
3. **Given** a resource with a stable provider resource ID, **When** the display label changes, **Then** compare still resolves by provider identity.
---
### User Story 2 - Canonicalize provider built-ins and virtual targets without hardcoding core labels (Priority: P1)
As a release reviewer, I need built-ins, defaults, and virtual targets to resolve through provider-specific canonicalization behind a provider-neutral seam, so platform-core matching does not become Microsoft-label-specific.
**Why this priority**: Built-ins and virtual targets are common false blockers and provider coupling hotspots.
**Independent Test**: A fake-provider canonicalization test double resolves a built-in and a virtual target without Microsoft labels, while core matching remains provider-neutral.
**Acceptance Scenarios**:
1. **Given** a provider built-in represented by stable provider metadata, **When** matching runs, **Then** it can resolve through canonicalization without display-name matching.
2. **Given** a virtual assignment target, **When** matching runs, **Then** it resolves as a canonical virtual resource without requiring a directory group record.
3. **Given** unsupported provider labels only, **When** core matching runs, **Then** it does not branch on Microsoft display strings.
---
### User Story 3 - Classify foundations and missing evidence honestly (Priority: P2)
As an operator reviewing compare health, I need inventory-only, identity-only, unsupported, and missing-local-evidence subjects to avoid false policy-missing blockers, so I can distinguish real drift from coverage limitations.
**Why this priority**: It prevents false red output without pretending that limited evidence is healthy.
**Independent Test**: Foundation coverage cases produce limitation or unsupported outcomes without entering policy-backed payload comparison.
**Acceptance Scenarios**:
1. **Given** an inventory-only foundation resource, **When** matching evaluates it, **Then** it returns a limitation outcome rather than a missing policy result. Existing internal reason codes such as `foundation_not_policy_backed` may remain only if they continue to mean inventory-only limitation.
2. **Given** missing local evidence without proven provider absence, **When** matching cannot find a current descriptor, **Then** it records missing local evidence rather than missing provider resource.
3. **Given** an unsupported resource class, **When** matching evaluates it, **Then** it records unsupported coverage and does not attempt payload comparison.
---
### User Story 4 - Preserve existing compare strategy behavior after matching (Priority: P2)
As a release owner, I need existing compare strategies to keep producing drift/no-drift results after subject matching succeeds, so identity matching does not become a false "healthy" signal.
**Why this priority**: Matching proves the compared subject, not the payload outcome.
**Independent Test**: Existing drift and no-drift compare tests pass after a successful matching outcome, and Spec 381 no-op tests are inverted so binding consumption is expected.
**Acceptance Scenarios**:
1. **Given** a successfully matched comparable subject with payload drift, **When** compare strategy runs, **Then** drift is still detected.
2. **Given** a successfully matched comparable subject without payload drift, **When** compare strategy runs, **Then** no drift is still reported through existing result semantics.
3. **Given** an active provider resource binding, **When** the old no-op regression is run, **Then** the expected behavior is binding consumption, not ignoring the binding.
### Edge Cases
- Active binding exists but the referenced current provider descriptor is absent.
- Binding exists for the same canonical subject key in another managed environment.
- Candidate descriptors have identical display labels but different provider resource IDs.
- Provider canonicalizer returns no result for a supported provider.
- Fingerprints collide or produce multiple candidates; matching must not select a fingerprint winner because fingerprints are not an active matching source.
- Display labels match exactly but provider identity is missing or different.
- Legacy `subject_key` values are present in older local/dev records and must become `identity_required` gaps when no `ResourceIdentity` or valid provider-resource canonical subject key exists.
- Existing compare strategy expects policy-shaped input for a foundation resource.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-382-001**: TenantPilot MUST run a baseline subject matching pipeline before payload comparison for baseline compare.
- **FR-382-002**: The pipeline MUST consume active scoped `provider_resource_bindings` before heuristic matching.
- **FR-382-003**: Binding lookup MUST respect workspace, managed environment, provider key, canonical subject key, active binding status, and provider connection where applicable.
- **FR-382-004**: `ResourceIdentity` and provider-resource canonical subject keys MUST be primary matching primitives.
- **FR-382-005**: Arbitrary `canonical_subject_key` override strings MUST be rejected unless they are generated or validated as provider-resource canonical keys.
- **FR-382-006**: `legacy_subject_key` MUST be removed from service inputs, DTOs, factories, tests, and active matching. If a committed prior migration created the column, Spec 382 MUST add a migration that drops it.
- **FR-382-007**: `BaselineSubjectKey::fromDisplayName()` and related display-name-derived canonical key generation MUST be removed from baseline matching/canonical key generation. Display labels may only be stored or rendered as UI/descriptive labels.
- **FR-382-008**: A baseline subject without `ResourceIdentity` or a valid provider-resource canonical subject key MUST produce an explicit non-comparable `identity_required`/`unresolved_identity` gap. Display-name-only or old subject-key-only data MUST NOT resolve successfully.
- **FR-382-009**: Tenant-owned duplicate candidates without active binding MUST remain unresolved ambiguity.
- **FR-382-010**: Provider built-ins/defaults/virtual targets MUST resolve only through provider-specific canonicalization behind a provider-neutral seam. V1 MUST NOT introduce a production canonicalizer registry/interface solely for fake-provider tests.
- **FR-382-011**: Core matching logic MUST NOT hardcode Microsoft/Intune display names, Graph endpoint strings, or Intune-only policy type assumptions.
- **FR-382-012**: Virtual targets MUST be representable as canonical virtual resources and must not require normal directory group identity.
- **FR-382-013**: Foundation resource classes MUST be classified by coverage capability rather than forced into policy-backed comparison.
- **FR-382-014**: Matching MUST distinguish missing provider resource from missing local evidence when the available proof supports that distinction.
- **FR-382-015**: Existing compare strategies MUST continue to own payload drift/no-drift decisions after successful matching.
- **FR-382-016**: Spec 381 tests that assert provider resource bindings are not consumed MUST be deleted or inverted so consumption is expected.
- **FR-382-017**: No operator resolution UI, customer-facing report rewrite, evidence readiness change, review readiness change, or historical payload mapper may be introduced by this spec.
- **FR-382-018**: A fake-provider seam test MUST prove the matching and canonicalization seam works without Microsoft-specific logic.
### Non-Functional Requirements
- **NFR-382-001**: Matching MUST be deterministic for the same baseline, descriptor, binding, and coverage inputs.
- **NFR-382-002**: Matching outcomes MUST be tenant-safe and workspace-safe; no cross-environment binding or candidate may influence another environment.
- **NFR-382-003**: Matching proof metadata MUST be sanitized and must not include secrets, credential payloads, raw sensitive provider payloads, or raw Graph error bodies.
- **NFR-382-004**: No Graph call may happen during UI rendering or matching. Canonicalization and matching MUST NOT call `GraphClientInterface`, provider gateways, or provider runtime clients; any provider data consumed by matching must come from existing persisted inventory, snapshots, policy versions, bindings, or provider descriptors.
- **NFR-382-005**: Matching identity resolution MUST NOT be treated as no drift.
- **NFR-382-006**: The implementation MUST avoid broad provider frameworks until real provider variance proves the need beyond the fake-provider seam test and the current Microsoft provider.
- **NFR-382-007**: Test fixtures must keep provider/workspace/member setup opt-in and must not widen global defaults.
## UI Action Matrix *(mandatory when Filament is changed)*
N/A - no Filament Resource, RelationManager, Page, action, table, form, modal, navigation, or panel provider is changed by this spec.
### Key Entities *(include if feature involves data)*
- **Baseline subject descriptor**: Derived runtime representation of a baseline-side governed subject, including stable identity where available, canonical subject key, subject class/type, provider key, display label as metadata, and source references.
- **Provider resource descriptor**: Existing Spec 381 descriptor for current provider/inventory-side resources used as candidate matches.
- **Matching outcome**: Derived runtime/result object indicating resolved, ambiguous, missing, unsupported, excluded, limited, or unresolved-identity outcome plus source proof.
- **Canonicalization result**: Derived provider-owned result that maps built-ins/defaults/virtual targets to provider-resource canonical identity. In V1 this is a direct seam/result shape, not a required production registry.
- **Foundation coverage classification**: Derived support level for resource classes such as fully comparable, identity-only, inventory-only, canonical-only, unsupported, requires manual binding, or excluded by profile.
No new persisted entity is approved.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-382-001**: In automated tests, active scoped provider resource bindings resolve before canonical/provider identity matching in 100% of covered binding-first cases.
- **SC-382-002**: In automated tests, duplicate tenant-owned provider-resource identity candidates without active binding remain unresolved ambiguity in 100% of covered duplicate-identity cases, while same-label resources with different provider IDs remain distinct.
- **SC-382-003**: In automated tests, built-in and virtual fake-provider subjects resolve without Microsoft display-label literals in core matching.
- **SC-382-004**: Existing drift and no-drift compare tests still pass after matching integration.
- **SC-382-005**: Targeted static or unit assertions show no core matching branch depends on literal Microsoft display labels such as `All users`, `All devices`, or `Default`.
- **SC-382-006**: No new route, navigation entry, Filament resource/page/action, Livewire component, Blade view, migration, or customer-facing report surface is introduced.
## Acceptance Criteria
- **AC-382-001**: Baseline compare calls the matching pipeline before compare strategy payload comparison and before legacy `policy_type|subject_key` keyed maps can collapse or discard duplicate candidates.
- **AC-382-002**: Active scoped bindings are first-class matching input.
- **AC-382-003**: Legacy/display-name subject keys are not matching inputs and produce identity-required gaps when no provider-resource identity exists.
- **AC-382-004**: Built-ins/defaults/virtual targets are canonicalized behind provider-owned seams.
- **AC-382-005**: Foundation resources return honest limitation/unsupported outcomes instead of false policy-missing blockers.
- **AC-382-006**: Existing compare strategies still own drift/no-drift.
- **AC-382-007**: Fake-provider tests pass and prove provider neutrality.
- **AC-382-008**: No application UI implementation is included.
## Assumptions
- Spec 381 remains the source of truth for provider resource binding persistence.
- `BaselineSubjectKey` remains the canonical-key helper unless implementation proves a value object is required; adding such a value object requires updating this spec/plan first.
- Foundation coverage v1 should reuse `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, or existing capability metadata before adding new classification sources.
- Canonicalization v1 should use a direct provider-owned seam/service or existing dependency-injection pattern. A production registry/interface must not be added solely to support fake-provider tests.
- Microsoft-specific canonicalization in v1 is limited to stable existing metadata if available. A broad Microsoft built-in catalog is out of scope.
- Existing local/dev compare payloads do not require old-payload readers.
## Risks
- **Silent false resolution**: Mitigated by binding-first priority, provider-resource identity only, identity-required gaps, and unresolved duplicate ambiguity.
- **Provider coupling**: Mitigated by fake-provider tests and a ban on Microsoft label hardcoding in core.
- **Layer growth**: Mitigated by no new persistence, narrow runtime scope, and explicit follow-up boundaries.
- **Broken compare strategies**: Mitigated by integrating matching before strategies and preserving strategy-owned drift/no-drift behavior.
- **False green output**: Mitigated by treating identity resolution separately from drift/no-drift.
## Open Questions
None blocking. Implementation must stop and update this spec if it needs new persistence, new UI surfaces, broad result taxonomy changes, or evidence/review readiness behavior.
## Follow-up Spec Candidates
- Spec 383 - Baseline Compare Result Semantics & Gap Classification v1.
- Spec 384 - Baseline Subject Resolution UI & Operator Decisions v1.
- Spec 385 - Evidence & Review Readiness Integration v1.

View File

@ -0,0 +1,150 @@
# Tasks: Spec 382 - Baseline Matching Pipeline and Canonicalization v1
**Input**: Design documents from `/specs/382-baseline-matching-canonicalization/`
**Prerequisites**: `spec.md`, `plan.md`, Spec 381 implementation close-out
**Tests**: Runtime behavior changes require Pest unit and feature tests before or alongside implementation. Browser tests are not required because this spec has no UI surface impact.
## Test Governance Checklist
- [x] TGC001 Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] TGC002 New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- [x] TGC003 Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [x] TGC004 Planned validation commands cover the change without pulling in unrelated lane cost.
- [x] TGC005 The declared surface test profile or `standard-native-filament` relief is explicit.
- [x] TGC006 Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Preparation And Guardrails
**Purpose**: Protect completed-spec history and confirm the implementation slice before runtime changes.
- [x] T001 Confirm `specs/381-provider-resource-identity-binding/implementation-close-out.md` exists and treat Spec 381 as completed dependency context only.
- [x] T002 Confirm no code changes are made to `specs/163-baseline-subject-resolution/`, `specs/380-management-report-pdf-staging-runtime-validation/`, or `specs/381-provider-resource-identity-binding/`.
- [x] T003 Re-read `apps/platform/app/Support/Resources/ResourceIdentity.php`, `apps/platform/app/Support/Resources/ProviderResourceDescriptor.php`, `apps/platform/app/Models/ProviderResourceBinding.php`, and `apps/platform/app/Services/Resources/ProviderResourceBindingService.php` before implementation.
- [x] T004 Re-read `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Support/Baselines/SubjectResolver.php`, `apps/platform/app/Support/Baselines/BaselineSubjectKey.php`, and `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php` before implementation.
- [x] T005 Document in `specs/382-baseline-matching-canonicalization/implementation-close-out.md` that Spec 382 has no UI, Filament, Livewire, asset, migration, environment variable, queue-name, scheduler, or storage surface impact unless the spec is updated first.
---
## Phase 2: Tests First - Matching Core
**Purpose**: Lock the business truth before changing compare behavior.
- [x] T006 [P] [US1] Add `tests/Unit/Support/Baselines/Matching/BaselineSubjectDescriptorTest.php` covering provider identity, canonical subject key, display label as metadata, and source-reference sanitization.
- [x] T007 [P] [US1] Add `tests/Unit/Support/Baselines/Matching/MatchingOutcomeTest.php` covering resolved, ambiguous, missing-provider-resource, missing-local-evidence, unsupported, limited, excluded, and unresolved-identity outcomes.
- [x] T008 [P] [US1] Add `tests/Unit/Support/Baselines/Matching/SubjectMatchingPipelineTest.php` covering binding-first priority, exact provider identity, canonical provider-resource keys, duplicate provider-identity ambiguity, missing identity gaps, same-label/different-provider-id separation, and deterministic ordering.
- [x] T009 [P] [US2] Add built-in/default/virtual fake-provider proof in `tests/Unit/Support/Baselines/Matching/SubjectMatchingPipelineTest.php` without adding a production canonicalizer registry.
- [x] T010 [P] [US3] Add `tests/Unit/Services/Baselines/Matching/FoundationCoverageResolverTest.php` covering inventory-only, canonical-only, unsupported, and existing support-contract classification.
---
## Phase 3: Tests First - Integration And Regression
**Purpose**: Prove binding-aware compare behavior, isolation, and identity-required gaps before implementation.
- [x] T011 [P] [US4] Replace `tests/Feature/Baselines/BaselineCompareProviderResourceBindingNoOpTest.php` with canonical identity coverage so active provider resource bindings are expected to be consumed rather than ignored.
- [x] T012 [P] [US1] Confirm `tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php` proves duplicate provider-resource identity candidates without active binding remain unresolved ambiguity, while same labels alone do not match.
- [x] T013 [P] [US3] Confirm `tests/Feature/Baselines/BaselineCompareGapClassificationTest.php` still proves inventory-only or unsupported foundation resources do not become false policy-missing blockers.
- [x] T014 [P] [US1] Extend `tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php` so arbitrary `canonical_subject_key` overrides are rejected unless generated or validated as provider-resource canonical keys.
- [x] T015 [P] [US1] Cover binding-aware compare behavior in `tests/Feature/Baselines/BaselineCompareProviderResourceBindingCanonicalIdentityTest.php` and matching proof/priority behavior in `tests/Unit/Support/Baselines/Matching/SubjectMatchingPipelineTest.php`.
- [x] T016 [P] [US2] Cover fake-provider built-ins and virtual targets in `tests/Unit/Support/Baselines/Matching/SubjectMatchingPipelineTest.php` without Microsoft label hardcoding or provider runtime calls.
- [x] T017 [P] [US4] Confirm `tests/Feature/Evidence/BaselineDriftPostureSourceTest.php` and `tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php` still prove no intentional evidence/review readiness behavior change.
---
## Phase 4: Canonical Key And Legacy Removal
**Purpose**: Prevent old display-name subject keys from masquerading as canonical provider-resource identity and remove legacy identity residue.
- [x] T018 [US1] Update `apps/platform/app/Support/Baselines/BaselineSubjectKey.php` with provider-resource canonical key validation helpers if existing generation helpers are insufficient.
- [x] T019 [US1] Update `apps/platform/app/Services/Resources/ProviderResourceBindingService.php` so supplied canonical keys are accepted only when validated as provider-resource canonical keys for the supplied subject scope and `ResourceIdentity`.
- [x] T020 [US1] Remove `legacy_subject_key` from `apps/platform/app/Services/Resources/ProviderResourceBindingService.php`, DTO/service inputs, factories, and active tests; add a Spec 382 migration that drops the column because Spec 381 already created it.
- [x] T021 [US1] Confirm `apps/platform/database/factories/ProviderResourceBindingFactory.php` generates provider-resource canonical keys by default and does not include legacy subject-key attributes.
---
## Phase 5: Matching Support Types
**Purpose**: Add the narrow runtime objects needed for pre-compare matching.
- [x] T022 [P] [US1] Add `apps/platform/app/Support/Baselines/Matching/BaselineSubjectDescriptor.php` for baseline-side subject identity and sanitized source references.
- [x] T023 [P] [US1] Add `apps/platform/app/Support/Baselines/Matching/MatchingOutcome.php` for resolved, ambiguous, missing, unsupported, limited, excluded, and unresolved-identity outcomes.
- [x] T024 [P] [US1] Keep confidence as derived strings inside `MatchingOutcome`; no separate `MatchingConfidence` class needed.
- [x] T025 [US1] Add descriptor-builder methods in `CompareBaselineToTenantJob` to derive baseline descriptors from `BaselineSnapshotItem` without making display labels authoritative.
- [x] T026 [US1] Add current provider descriptor collection helpers in `CompareBaselineToTenantJob` where existing `ProviderResourceDescriptor` construction was insufficient.
---
## Phase 6: Canonicalization And Foundation Coverage
**Purpose**: Keep provider-specific built-in/default/virtual logic behind narrow seams and avoid false policy-backed blockers.
- [x] T027 [P] [US2] Avoid adding a production canonicalizer interface/registry by default; use `ResourceIdentity` provider metadata and fake-provider coverage instead.
- [x] T028 [P] [US2] Add a minimal provider-owned built-in/default/virtual canonicalization seam through provider `ResourceIdentity` metadata, with fake-provider proof and no Microsoft label hardcoding in core.
- [x] T029 [P] [US2] Do not add `CanonicalizationResult`; `ResourceIdentity` plus `MatchingOutcome` is sufficient for v1 behavior and proof metadata.
- [x] T030 [US3] Add `apps/platform/app/Services/Baselines/Matching/FoundationCoverageResolver.php` that reuses `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, and existing support metadata before introducing any new classification source.
- [x] T031 [US2] Confirm any Microsoft/Intune canonicalization remains provider-owned, uses stable persisted provider metadata rather than display labels such as `All users`, `All devices`, or `Default`, and does not call `GraphClientInterface`, provider gateways, or provider runtime clients.
---
## Phase 7: Subject Matching Pipeline
**Purpose**: Implement the deterministic matching priority before payload comparison.
- [x] T032 [US1] Add `apps/platform/app/Services/Baselines/Matching/SubjectMatchingPipeline.php` with matching order: active binding, provider identity/canonical identity, unresolved duplicate ambiguity, identity-required gaps, and missing/unsupported/limitation. Fingerprints, legacy keys, and display labels are not matching sources.
- [x] T033 [US1] Ensure `SubjectMatchingPipeline` scopes active binding lookup by `workspace_id`, `managed_environment_id`, canonical subject key, and active binding status; provider key is embedded in the canonical key and rechecked through binding identity fields.
- [x] T034 [US1] Ensure `SubjectMatchingPipeline` never uses display names, fingerprints, or legacy subject keys as matching evidence.
- [x] T035 [US3] Ensure `SubjectMatchingPipeline` returns missing-local-evidence instead of missing-provider-resource when provider absence is not proven.
- [x] T036 [US4] Ensure `SubjectMatchingPipeline` treats identity resolution separately from drift/no-drift and does not mark matched resources healthy by itself.
---
## Phase 8: Baseline Compare Integration
**Purpose**: Consume matching outcomes in compare without broad result/UI/evidence scope.
- [x] T037 [US4] Integrate `SubjectMatchingPipeline` into `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` before old `policy_type|subject_key` keying can collapse or discard candidates, remove the unused old keyed readers, and run before compare strategy payload comparison.
- [x] T038 [US4] Keep existing compare strategies in `apps/platform/app/Support/Baselines/Compare/` responsible for payload drift/no-drift after a successful comparable match.
- [x] T039 [US4] Leave `apps/platform/app/Support/Baselines/SubjectResolver.php` unchanged; matching outcomes are mapped in the compare job adapter, so SubjectResolver remains non-authoritative for identity.
- [x] T040 [US3] Store only sanitized matching proof metadata in existing compare result or operation context paths; no secrets, raw sensitive provider payloads, raw Graph errors, credentials, or operator notes are stored.
- [x] T041 [US4] Confirm no Evidence Snapshot readiness, Review Pack readiness, customer-facing report wording, or operator resolution UI behavior changes are introduced.
---
## Phase 9: Validation And Close-Out
**Purpose**: Prove the prepared scope and keep implementation reviewable.
- [x] T042 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/Matching`.
- [x] T043 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/BaselineSubjectKeyCanonicalIdentityTest.php tests/Unit/Support/Resources/ResourceIdentityTest.php tests/Unit/Support/Resources/ProviderResourceDescriptorTest.php`.
- [x] T044 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareProviderResourceBindingCanonicalIdentityTest.php tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareGapClassificationTest.php tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`.
- [x] T045 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php tests/Feature/ProviderResources/ProviderResourceBindingAuthorizationTest.php`.
- [x] T046 Confirm the Spec 382 migration is limited to dropping `provider_resource_bindings.legacy_subject_key`; targeted database-backed tests run through Laravel migrations in the normal PostgreSQL-backed Sail test lane.
- [x] T047 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --test --format agent`.
- [x] T048 Run `git diff --check`.
- [x] T049 Record `specs/382-baseline-matching-canonicalization/implementation-close-out.md` with Livewire v4 compliance, provider registration location, global search status, destructive/high-impact action status, asset strategy, tests run, and deployment impact.
## Dependencies
- Phase 1 must finish before implementation.
- Phases 2 and 3 should be written before or alongside Phases 4-8.
- Phase 4 must complete before active binding consumption can be trusted.
- Phase 5 and Phase 6 unblock Phase 7.
- Phase 7 unblocks Phase 8.
- Phase 9 validates the completed implementation.
## Parallel Opportunities
- T006-T010 can be drafted in parallel.
- T011-T017 can be drafted in parallel if each test file remains scoped.
- T022-T024 can be implemented in parallel with T027-T030 after Phase 4 is understood.
- T042-T045 can be run independently after implementation, but final close-out should use the complete targeted set.
## Explicit Non-Goals
- Do not add new persisted entities without updating spec and plan first. The only approved Spec 382 migration drops `provider_resource_bindings.legacy_subject_key`.
- Do not add or change Filament resources, pages, actions, Livewire components, Blade views, navigation, or assets.
- Do not add operator resolution UI.
- Do not change Evidence Snapshot readiness, Review Pack readiness, or customer-facing report semantics.
- Do not add historical payload mappers or OperationRun context readers.
- Do not create a generic provider workflow engine or broad multi-provider framework.