TenantAtlas/apps/platform/app/Services/Baselines/Matching/SubjectMatchingPipeline.php
Ahmed Darrazi dd9512ed10
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m12s
feat(baselines): implement baseline matching canonicalization
Replaced legacy tenant and environment bindings in the BaselineDriftEngine with the new ProviderResourceIdentity framework as defined in Spec 382.
2026-06-16 00:48:01 +02:00

448 lines
17 KiB
PHP

<?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,
];
}
}