TenantAtlas/apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php
ahmido 8cbf1f7fe3 feat: implement canonical identity engine (#484)
Automated PR provided by Codex via Gitea API.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #484
2026-06-26 06:50:25 +00:00

343 lines
13 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, 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,
};
}
private function stableKeyKind(TenantConfigurationResourceType $resourceType, bool $experimental): CanonicalKeyKind
{
if ($experimental) {
return CanonicalKeyKind::ExperimentalSourceKey;
}
$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 !== '',
));
}
}