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
448 lines
17 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|