355 lines
14 KiB
PHP
355 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantConfiguration;
|
|
|
|
use App\Models\TenantConfigurationResourceType;
|
|
use App\Support\TenantConfiguration\CanonicalKeyKind;
|
|
use App\Support\TenantConfiguration\IdentityState;
|
|
use App\Support\TenantConfiguration\SourceClass;
|
|
|
|
final class CanonicalIdentityResolver
|
|
{
|
|
public function __construct(
|
|
private readonly CoverageIdentityStrategyRegistry $strategies,
|
|
private readonly CoverageSecondaryKeyBuilder $secondaryKeys,
|
|
private readonly IdentityConflictDiagnosticsBuilder $diagnostics,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @param array<string, mixed> $sourceMetadata
|
|
*/
|
|
public function resolve(TenantConfigurationResourceType $resourceType, array $payload, array $sourceMetadata = []): CanonicalIdentityResult
|
|
{
|
|
$strategy = $this->strategies->strategyFor($resourceType);
|
|
$canonicalType = (string) $strategy['canonical_type'];
|
|
$strategyIdentifier = (string) $strategy['strategy_identifier'];
|
|
$secondaryKeys = $this->secondaryKeys->build($strategy, $payload, $sourceMetadata);
|
|
|
|
if (($strategy['supported'] ?? false) !== true) {
|
|
return $this->result(
|
|
strategyIdentifier: $strategyIdentifier,
|
|
identityState: IdentityState::UnsupportedIdentity,
|
|
keyKind: CanonicalKeyKind::Unsupported,
|
|
canonicalType: $canonicalType,
|
|
identityValues: ['canonical_type' => $canonicalType],
|
|
secondaryKeys: $secondaryKeys,
|
|
diagnostics: $this->diagnostics->build(
|
|
reasonCode: 'unsupported_identity_strategy',
|
|
identityState: IdentityState::UnsupportedIdentity,
|
|
keyKind: CanonicalKeyKind::Unsupported,
|
|
metadata: ['strategy_identifier' => $strategyIdentifier],
|
|
),
|
|
);
|
|
}
|
|
|
|
$preferred = $this->firstScalarField($strategy['preferred_identity_fields'] ?? [], $payload, $sourceMetadata);
|
|
|
|
if ($preferred !== null) {
|
|
$keyKind = $this->stableKeyKind(
|
|
resourceType: $resourceType,
|
|
strategy: $strategy,
|
|
experimental: (bool) ($strategy['allows_experimental_identity'] ?? false),
|
|
);
|
|
$identityState = $keyKind === CanonicalKeyKind::ExperimentalSourceKey
|
|
? IdentityState::Derived
|
|
: IdentityState::Stable;
|
|
|
|
return $this->result(
|
|
strategyIdentifier: $strategyIdentifier,
|
|
identityState: $identityState,
|
|
keyKind: $keyKind,
|
|
canonicalType: $canonicalType,
|
|
identityValues: [
|
|
'field' => $preferred['field'],
|
|
'value' => $preferred['value'],
|
|
],
|
|
secondaryKeys: $secondaryKeys,
|
|
diagnostics: $this->diagnostics->build(
|
|
reasonCode: $identityState === IdentityState::Stable ? 'stable_identity_resolved' : 'experimental_identity_resolved',
|
|
identityState: $identityState,
|
|
keyKind: $keyKind,
|
|
metadata: ['strategy_identifier' => $strategyIdentifier, 'field' => $preferred['field']],
|
|
),
|
|
derivedClaimsAllowed: false,
|
|
);
|
|
}
|
|
|
|
$fallback = $this->firstScalarField($strategy['fallback_identity_fields'] ?? [], $payload, $sourceMetadata);
|
|
|
|
if ($fallback !== null) {
|
|
$keyKind = (bool) ($strategy['allows_experimental_identity'] ?? false)
|
|
? CanonicalKeyKind::ExperimentalSourceKey
|
|
: CanonicalKeyKind::ProviderExternalId;
|
|
$identityState = $keyKind === CanonicalKeyKind::ExperimentalSourceKey
|
|
? IdentityState::Derived
|
|
: IdentityState::Stable;
|
|
|
|
return $this->result(
|
|
strategyIdentifier: $strategyIdentifier,
|
|
identityState: $identityState,
|
|
keyKind: $keyKind,
|
|
canonicalType: $canonicalType,
|
|
identityValues: [
|
|
'field' => $fallback['field'],
|
|
'value' => $fallback['value'],
|
|
],
|
|
secondaryKeys: $secondaryKeys,
|
|
diagnostics: $this->diagnostics->build(
|
|
reasonCode: $identityState === IdentityState::Stable ? 'fallback_identity_resolved' : 'experimental_fallback_identity_resolved',
|
|
identityState: $identityState,
|
|
keyKind: $keyKind,
|
|
metadata: ['strategy_identifier' => $strategyIdentifier, 'field' => $fallback['field']],
|
|
),
|
|
derivedClaimsAllowed: false,
|
|
);
|
|
}
|
|
|
|
$sourceComposite = $this->compositeValues($strategy['source_composite_fields'] ?? [], $payload, $sourceMetadata);
|
|
|
|
if ($sourceComposite['values'] !== [] && (bool) ($strategy['allows_derived_identity'] ?? false)) {
|
|
return $this->result(
|
|
strategyIdentifier: $strategyIdentifier,
|
|
identityState: IdentityState::Derived,
|
|
keyKind: CanonicalKeyKind::SourceComposite,
|
|
canonicalType: $canonicalType,
|
|
identityValues: $sourceComposite['values'],
|
|
secondaryKeys: $secondaryKeys,
|
|
diagnostics: $this->diagnostics->build(
|
|
reasonCode: 'source_composite_identity_resolved',
|
|
identityState: IdentityState::Derived,
|
|
keyKind: CanonicalKeyKind::SourceComposite,
|
|
metadata: ['strategy_identifier' => $strategyIdentifier, 'fields' => array_keys($sourceComposite['values'])],
|
|
),
|
|
derivedClaimsAllowed: (bool) ($strategy['derived_claims_allowed'] ?? false),
|
|
);
|
|
}
|
|
|
|
$derivedComposite = $this->compositeValues($strategy['derived_composite_fields'] ?? [], $payload, $sourceMetadata);
|
|
|
|
if ($derivedComposite['values'] !== [] && (bool) ($strategy['allows_derived_identity'] ?? false)) {
|
|
return $this->result(
|
|
strategyIdentifier: $strategyIdentifier,
|
|
identityState: IdentityState::Derived,
|
|
keyKind: CanonicalKeyKind::DerivedComposite,
|
|
canonicalType: $canonicalType,
|
|
identityValues: $derivedComposite['values'],
|
|
secondaryKeys: $secondaryKeys,
|
|
diagnostics: $this->diagnostics->build(
|
|
reasonCode: 'derived_composite_identity_resolved',
|
|
identityState: IdentityState::Derived,
|
|
keyKind: CanonicalKeyKind::DerivedComposite,
|
|
metadata: ['strategy_identifier' => $strategyIdentifier, 'fields' => array_keys($derivedComposite['values'])],
|
|
),
|
|
derivedClaimsAllowed: (bool) ($strategy['derived_claims_allowed'] ?? false),
|
|
);
|
|
}
|
|
|
|
$missingFields = array_values(array_unique([
|
|
...$this->list($strategy['preferred_identity_fields'] ?? []),
|
|
...$sourceComposite['missing'],
|
|
...$derivedComposite['missing'],
|
|
]));
|
|
|
|
return $this->result(
|
|
strategyIdentifier: $strategyIdentifier,
|
|
identityState: IdentityState::MissingExternalId,
|
|
keyKind: CanonicalKeyKind::Unsupported,
|
|
canonicalType: $canonicalType,
|
|
identityValues: [
|
|
'missing_external_id' => $canonicalType,
|
|
'secondary_fingerprint' => hash('sha256', json_encode($secondaryKeys, JSON_THROW_ON_ERROR)),
|
|
],
|
|
secondaryKeys: $secondaryKeys,
|
|
diagnostics: $this->diagnostics->build(
|
|
reasonCode: 'missing_external_id',
|
|
identityState: IdentityState::MissingExternalId,
|
|
keyKind: CanonicalKeyKind::Unsupported,
|
|
missingFields: $missingFields,
|
|
metadata: ['strategy_identifier' => $strategyIdentifier],
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $identityValues
|
|
* @param array<string, mixed> $secondaryKeys
|
|
* @param array<string, mixed> $diagnostics
|
|
*/
|
|
private function result(
|
|
string $strategyIdentifier,
|
|
IdentityState $identityState,
|
|
CanonicalKeyKind $keyKind,
|
|
string $canonicalType,
|
|
array $identityValues,
|
|
array $secondaryKeys,
|
|
array $diagnostics,
|
|
bool $derivedClaimsAllowed = false,
|
|
): CanonicalIdentityResult {
|
|
$candidateKeyHash = hash('sha256', json_encode([
|
|
'canonical_type' => $canonicalType,
|
|
'key_kind' => $keyKind->value,
|
|
'identity' => $identityValues,
|
|
], JSON_THROW_ON_ERROR));
|
|
$fingerprint = hash('sha256', json_encode([
|
|
'canonical_type' => $canonicalType,
|
|
'key_kind' => $keyKind->value,
|
|
'identity' => $identityValues,
|
|
'secondary' => $secondaryKeys,
|
|
], JSON_THROW_ON_ERROR));
|
|
$canonicalResourceKey = sprintf('%s:%s:%s', $canonicalType, $keyKind->value, $candidateKeyHash);
|
|
|
|
return new CanonicalIdentityResult(
|
|
strategyIdentifier: $strategyIdentifier,
|
|
identityState: $identityState,
|
|
keyKind: $keyKind,
|
|
canonicalResourceKey: $canonicalResourceKey,
|
|
sourceResourceId: $this->sourceResourceId($identityState, $identityValues, $fingerprint),
|
|
sourceIdentity: [
|
|
'strategy_identifier' => $strategyIdentifier,
|
|
'key_kind' => $keyKind->value,
|
|
'candidate_key_hash' => $candidateKeyHash,
|
|
'fingerprint' => $fingerprint,
|
|
'values' => $identityValues,
|
|
],
|
|
secondaryKeys: $secondaryKeys,
|
|
diagnostics: $diagnostics,
|
|
derivedClaimsAllowed: $derivedClaimsAllowed,
|
|
);
|
|
}
|
|
|
|
private function sourceResourceId(IdentityState $identityState, array $identityValues, string $fingerprint): string
|
|
{
|
|
$value = $identityValues['value'] ?? null;
|
|
|
|
if (is_scalar($value) && trim((string) $value) !== '') {
|
|
return mb_substr(trim((string) $value), 0, 240);
|
|
}
|
|
|
|
return match ($identityState) {
|
|
IdentityState::MissingExternalId => 'missing:'.$fingerprint,
|
|
IdentityState::UnsupportedIdentity => 'unsupported:'.$fingerprint,
|
|
default => 'derived:'.$fingerprint,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $strategy
|
|
*/
|
|
private function stableKeyKind(TenantConfigurationResourceType $resourceType, array $strategy, bool $experimental): CanonicalKeyKind
|
|
{
|
|
if ($experimental) {
|
|
return CanonicalKeyKind::ExperimentalSourceKey;
|
|
}
|
|
|
|
$configuredKeyKind = $strategy['stable_key_kind'] ?? null;
|
|
|
|
if (is_string($configuredKeyKind) && CanonicalKeyKind::tryFrom($configuredKeyKind) instanceof CanonicalKeyKind) {
|
|
return CanonicalKeyKind::from($configuredKeyKind);
|
|
}
|
|
|
|
$sourceClass = $resourceType->source_class;
|
|
|
|
if ($sourceClass instanceof SourceClass && $sourceClass === SourceClass::Tcm) {
|
|
return CanonicalKeyKind::TcmResourceIdentifier;
|
|
}
|
|
|
|
if ($sourceClass instanceof SourceClass && $sourceClass->isGraphFallback()) {
|
|
return CanonicalKeyKind::GraphObjectId;
|
|
}
|
|
|
|
return CanonicalKeyKind::ProviderExternalId;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $fields
|
|
* @param array<string, mixed> $payload
|
|
* @param array<string, mixed> $sourceMetadata
|
|
* @return array{field: string, value: string}|null
|
|
*/
|
|
private function firstScalarField(mixed $fields, array $payload, array $sourceMetadata): ?array
|
|
{
|
|
foreach ($this->list($fields) as $field) {
|
|
$value = $this->fieldValue($field, $payload, $sourceMetadata);
|
|
|
|
if (! is_scalar($value)) {
|
|
continue;
|
|
}
|
|
|
|
$value = trim((string) $value);
|
|
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
|
|
return ['field' => $field, 'value' => $value];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $fields
|
|
* @param array<string, mixed> $payload
|
|
* @param array<string, mixed> $sourceMetadata
|
|
* @return array{values: array<string, mixed>, missing: list<string>}
|
|
*/
|
|
private function compositeValues(mixed $fields, array $payload, array $sourceMetadata): array
|
|
{
|
|
$values = [];
|
|
$missing = [];
|
|
|
|
foreach ($this->list($fields) as $field) {
|
|
$value = $this->fieldValue($field, $payload, $sourceMetadata);
|
|
|
|
if ($value === null || $value === '' || (is_array($value) && $value === [])) {
|
|
$missing[] = $field;
|
|
|
|
continue;
|
|
}
|
|
|
|
$values[$field] = $value;
|
|
}
|
|
|
|
if ($missing !== []) {
|
|
return ['values' => [], 'missing' => $missing];
|
|
}
|
|
|
|
return ['values' => $values, 'missing' => []];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @param array<string, mixed> $sourceMetadata
|
|
*/
|
|
private function fieldValue(string $field, array $payload, array $sourceMetadata): mixed
|
|
{
|
|
if (str_starts_with($field, 'source_metadata.')) {
|
|
return data_get($sourceMetadata, substr($field, 16));
|
|
}
|
|
|
|
if (str_starts_with($field, 'payload.')) {
|
|
return data_get($payload, substr($field, 8));
|
|
}
|
|
|
|
return data_get($payload, $field);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function list(mixed $fields): array
|
|
{
|
|
if (! is_array($fields)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields),
|
|
static fn (string $field): bool => $field !== '',
|
|
));
|
|
}
|
|
}
|