$payload * @param array $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 $identityValues * @param array $secondaryKeys * @param array $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 $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 $payload * @param array $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 $payload * @param array $sourceMetadata * @return array{values: array, missing: list} */ 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 $payload * @param array $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 */ 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 !== '', )); } }