TenantAtlas/apps/platform/app/Services/TenantConfiguration/EntraCertifiedComparePackEvaluator.php
ahmido 33e496c182 feat: complete spec 425 enta certified compare pack (#492)
Implements spec 425 with Entra certified compare pack support, coverage, guards, evaluator, fixtures, and tests.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #492
2026-07-01 23:27:16 +00:00

648 lines
23 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Models\TenantConfigurationSupportedScope;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\IdentityState;
use Carbon\CarbonInterface;
use InvalidArgumentException;
use Throwable;
final class EntraCertifiedComparePackEvaluator
{
public const SCOPE_KEY = 'entra_core_compare_certified';
public const CLAIM_LABEL = 'Certified Entra Core Compare Pack: Conditional Access and Security Defaults';
/**
* @var list<string>
*/
public const DENOMINATOR = [
'conditionalAccessPolicy',
'securityDefaults',
];
/**
* @var array<string, string>
*/
private const EXPECTED_CONTRACT_KEYS = [
'conditionalAccessPolicy' => 'conditionalAccessPolicy',
'securityDefaults' => 'securityDefaults',
];
/**
* @var list<string>
*/
private const BLOCKER_PRIORITY = [
EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE,
EntraCertifiedComparePackResult::BLOCKED_IDENTITY,
EntraCertifiedComparePackResult::BLOCKED_COMPARE,
EntraCertifiedComparePackResult::BLOCKED_RENDER,
EntraCertifiedComparePackResult::BLOCKED_REDACTION,
EntraCertifiedComparePackResult::BLOCKED_CLAIM_GUARD,
];
/**
* @var list<string>
*/
private const SENSITIVE_KEY_PARTS = [
'access_token',
'authorization',
'bearer',
'certificate',
'client_secret',
'cookie',
'credential',
'id_token',
'password',
'private_key',
'refresh_token',
'secret',
'set-cookie',
'token',
];
public function __construct(
private readonly SupportedScopeResolver $supportedScopes,
private readonly EntraCoverageComparator $comparator,
private readonly EntraRenderableSummaryBuilder $summaryBuilder,
private readonly CoveragePayloadRedactor $redactor,
private readonly ClaimGuard $claimGuard,
) {}
public function evaluate(
ManagedEnvironment $environment,
ProviderConnection $providerConnection,
): EntraCertifiedComparePackResult {
$this->assertSameScope($environment, $providerConnection);
$scope = $this->supportedScopes->findActive(self::SCOPE_KEY);
if (! $scope instanceof TenantConfigurationSupportedScope) {
return new EntraCertifiedComparePackResult(
scopeKey: self::SCOPE_KEY,
denominator: self::DENOMINATOR,
state: EntraCertifiedComparePackResult::NOT_EVALUATED,
blockers: ['supported_scope_missing'],
);
}
$scopeBlockers = $this->scopeBlockers($scope);
if ($scopeBlockers !== []) {
return new EntraCertifiedComparePackResult(
scopeKey: self::SCOPE_KEY,
denominator: self::DENOMINATOR,
state: EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE,
blockers: $scopeBlockers,
);
}
$resourceResults = [];
$blockers = [];
foreach (self::DENOMINATOR as $canonicalType) {
$typeResult = $this->evaluateCanonicalType($canonicalType, $environment, $providerConnection);
$resourceResults[] = $typeResult;
$blockers = [...$blockers, ...$typeResult['blockers']];
}
$blockers = $this->uniqueStrings($blockers);
$claimState = null;
if ($blockers === []) {
$claimState = $this->claimGuard->evaluateCertifiedComparePackStatement(
claim: self::CLAIM_LABEL,
packPassed: true,
internalOperatorOnly: true,
);
if ($claimState !== ClaimState::InternalOnly) {
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_CLAIM_GUARD;
}
}
return new EntraCertifiedComparePackResult(
scopeKey: self::SCOPE_KEY,
denominator: self::DENOMINATOR,
state: $this->overallState($blockers),
resourceResults: $resourceResults,
blockers: $this->uniqueStrings($blockers),
claimState: $claimState,
);
}
/**
* @return array<string, mixed>
*/
private function evaluateCanonicalType(
string $canonicalType,
ManagedEnvironment $environment,
ProviderConnection $providerConnection,
): array {
$resourceType = TenantConfigurationResourceType::query()
->active()
->where('canonical_type', $canonicalType)
->orderBy('source_class')
->first();
if (! $resourceType instanceof TenantConfigurationResourceType) {
return $this->blockedTypeResult($canonicalType, ['resource_type_missing']);
}
$resources = TenantConfigurationResource::query()
->where('workspace_id', (int) $environment->workspace_id)
->where('managed_environment_id', (int) $environment->getKey())
->where('provider_connection_id', (int) $providerConnection->getKey())
->where('resource_type_id', (int) $resourceType->getKey())
->where('canonical_type', $canonicalType)
->with([
'latestEvidence.operationRun:id,workspace_id,managed_environment_id',
])
->orderBy('canonical_resource_key')
->get();
if ($resources->isEmpty()) {
return $this->blockedTypeResult($canonicalType, ['current_same_scope_resource_missing']);
}
$resourceResults = $resources
->map(fn (TenantConfigurationResource $resource): array => $this->evaluateResource($resource, $canonicalType))
->values();
$blockers = $this->uniqueStrings($resourceResults
->flatMap(fn (array $result): array => $result['blockers'])
->all());
return [
'canonical_type' => $canonicalType,
'resource_count' => $resources->count(),
'criteria' => [
'evidence' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['evidence']),
'identity' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['identity']),
'compare' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['compare']),
'render' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['render']),
'redaction' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['redaction']),
],
'certified' => $blockers === [],
'blockers' => $blockers,
'resources' => $resourceResults->all(),
];
}
/**
* @return array<string, mixed>
*/
private function evaluateResource(TenantConfigurationResource $resource, string $canonicalType): array
{
$evidence = $resource->latestEvidence;
$blockers = [];
$criteria = [
'evidence' => false,
'identity' => false,
'compare' => false,
'render' => false,
'redaction' => false,
];
$reasons = [];
[$criteria['evidence'], $evidenceReasons] = $this->evidencePasses($resource, $evidence, $canonicalType);
$reasons = [...$reasons, ...$evidenceReasons];
if (! $criteria['evidence']) {
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE;
}
[$criteria['identity'], $identityReasons] = $this->identityPasses($resource);
$reasons = [...$reasons, ...$identityReasons];
if (! $criteria['identity']) {
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_IDENTITY;
}
$renderSummary = null;
if ($evidence instanceof TenantConfigurationResourceEvidence && is_array($evidence->normalized_payload)) {
[$criteria['compare'], $compareReasons] = $this->comparePasses($canonicalType, $evidence);
[$criteria['render'], $renderSummary, $renderReasons] = $this->renderPasses($resource, $canonicalType, $evidence);
[$criteria['redaction'], $redactionReasons] = $this->redactionPasses($evidence, $renderSummary);
$reasons = [...$reasons, ...$compareReasons, ...$renderReasons, ...$redactionReasons];
}
if (! $criteria['compare']) {
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_COMPARE;
}
if (! $criteria['render']) {
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_RENDER;
}
if (! $criteria['redaction']) {
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_REDACTION;
}
return [
'resource_id' => (int) $resource->getKey(),
'canonical_resource_key' => (string) $resource->canonical_resource_key,
'criteria' => $criteria,
'certified' => $blockers === [],
'blockers' => $this->uniqueStrings($blockers),
'reasons' => $this->uniqueStrings($reasons),
];
}
/**
* @return array{0: bool, 1: list<string>}
*/
private function evidencePasses(
TenantConfigurationResource $resource,
mixed $evidence,
string $canonicalType,
): array {
$reasons = [];
if (! $evidence instanceof TenantConfigurationResourceEvidence) {
return [false, ['latest_evidence_missing']];
}
if ((int) $resource->latest_evidence_id !== (int) $evidence->getKey()) {
$reasons[] = 'latest_evidence_pointer_mismatch';
}
foreach ([
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $resource->workspace_id,
'managed_environment_id' => (int) $resource->managed_environment_id,
'provider_connection_id' => (int) $resource->provider_connection_id,
'resource_type_id' => (int) $resource->resource_type_id,
] as $column => $expected) {
if ((int) $evidence->{$column} !== $expected) {
$reasons[] = $column.'_scope_mismatch';
}
}
if ($evidence->evidence_state !== EvidenceState::ContentBacked) {
$reasons[] = 'evidence_not_content_backed';
}
if ($evidence->capture_outcome !== CaptureOutcome::Captured) {
$reasons[] = 'evidence_not_captured';
}
if (! is_array($evidence->raw_payload) || $evidence->raw_payload === []) {
$reasons[] = 'raw_payload_missing';
}
if (! is_array($evidence->normalized_payload) || $evidence->normalized_payload === []) {
$reasons[] = 'normalized_payload_missing';
}
if (! is_string($evidence->payload_hash) || ! preg_match('/^[a-f0-9]{64}$/', $evidence->payload_hash)) {
$reasons[] = 'payload_hash_invalid';
}
if ((string) $resource->latest_payload_hash !== (string) $evidence->payload_hash) {
$reasons[] = 'latest_payload_hash_mismatch';
}
if ((string) $evidence->source_contract_key !== self::EXPECTED_CONTRACT_KEYS[$canonicalType]) {
$reasons[] = 'source_contract_mismatch';
}
foreach (['source_endpoint', 'source_version'] as $field) {
if (! is_string($evidence->{$field}) || trim($evidence->{$field}) === '') {
$reasons[] = $field.'_missing';
}
}
if (! $evidence->captured_at instanceof CarbonInterface) {
$reasons[] = 'captured_at_missing';
}
if ((int) $evidence->operation_run_id <= 0 || ! $evidence->operationRun) {
$reasons[] = 'operation_run_link_missing';
} elseif ((int) $evidence->operationRun->workspace_id !== (int) $resource->workspace_id
|| (int) $evidence->operationRun->managed_environment_id !== (int) $resource->managed_environment_id
) {
$reasons[] = 'operation_run_scope_mismatch';
}
if ($this->hasNewerEvidence($resource, $evidence)) {
$reasons[] = 'latest_evidence_not_current';
}
return [$reasons === [], $reasons];
}
/**
* @return array{0: bool, 1: list<string>}
*/
private function identityPasses(TenantConfigurationResource $resource): array
{
return $resource->latest_identity_state === IdentityState::Stable
? [true, []]
: [false, ['identity_state_'.$this->stateValue($resource->latest_identity_state)]];
}
/**
* @return array{0: bool, 1: list<string>}
*/
private function comparePasses(string $canonicalType, TenantConfigurationResourceEvidence $evidence): array
{
$normalizedPayload = is_array($evidence->normalized_payload) ? $evidence->normalized_payload : [];
$result = $this->comparator->compare($canonicalType, $normalizedPayload, $normalizedPayload);
$changes = collect(is_array($result['changes'] ?? null) ? $result['changes'] : []);
$unsupportedFields = data_get($normalizedPayload, 'diagnostics.unsupported_fields', []);
$reasons = [];
if (($result['supported'] ?? false) !== true) {
$reasons[] = 'compare_not_supported';
}
if (($result['classification'] ?? null) !== 'unchanged' || ($result['changed'] ?? true) !== false) {
$reasons[] = 'compare_not_deterministic';
}
if ($changes->contains(fn (mixed $change): bool => is_array($change) && ($change['classification'] ?? null) === 'unsupported_field')
|| (is_array($unsupportedFields) && $unsupportedFields !== [])
) {
$reasons[] = 'unsupported_fields_present';
}
return [$reasons === [], $reasons];
}
/**
* @return array{0: bool, 1: array<string, mixed>|null, 2: list<string>}
*/
private function renderPasses(
TenantConfigurationResource $resource,
string $canonicalType,
TenantConfigurationResourceEvidence $evidence,
): array {
$reasons = [];
$coverageLevel = $this->coverageLevel($evidence->coverage_level);
if (! $coverageLevel?->meets(CoverageLevel::Renderable)) {
$reasons[] = 'coverage_not_renderable';
}
$summary = $this->summaryBuilder->build($canonicalType, $evidence->normalized_payload, [
'claim_state' => $resource->latest_claim_state,
'identity_state' => $resource->latest_identity_state,
'evidence_state' => $resource->latest_evidence_state,
'coverage_level' => $evidence->coverage_level,
'last_captured' => $resource->latest_captured_at?->toDayDateTimeString(),
'source_version' => $evidence->source_version,
'source_schema_hash' => $evidence->source_schema_hash,
]);
if (! is_array($summary) || $summary === []) {
$reasons[] = 'render_summary_missing';
}
return [$reasons === [], $summary, $reasons];
}
/**
* @param array<string, mixed>|null $summary
* @return array{0: bool, 1: list<string>}
*/
private function redactionPasses(TenantConfigurationResourceEvidence $evidence, ?array $summary): array
{
$reasons = [];
$redactedRaw = $this->redactor->redact($evidence->raw_payload);
$sensitiveValues = $this->sensitiveValues($evidence->raw_payload, $redactedRaw);
$safeOutput = [
'normalized_payload' => $evidence->normalized_payload,
'render_summary' => $summary,
'claim_label' => self::CLAIM_LABEL,
];
$encoded = json_encode($safeOutput, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$lowerEncoded = strtolower($encoded);
foreach (['raw_payload', 'raw graph response', 'raw_graph_response', 'permission_context'] as $forbiddenToken) {
if (str_contains($lowerEncoded, $forbiddenToken)) {
$reasons[] = 'forbidden_output_token_'.$forbiddenToken;
}
}
foreach ($sensitiveValues as $value) {
if ($value !== '' && str_contains($encoded, $value)) {
$reasons[] = 'sensitive_value_leaked';
break;
}
}
return [$reasons === [], $reasons];
}
/**
* @return list<string>
*/
private function scopeBlockers(TenantConfigurationSupportedScope $scope): array
{
$metadata = is_array($scope->metadata) ? $scope->metadata : [];
$reasons = [];
try {
$resolved = $this->supportedScopes->resolveDefinition(
$scope,
TenantConfigurationResourceType::query()
->active()
->get(['canonical_type', 'source_class']),
);
} catch (Throwable) {
return ['supported_scope_unresolvable'];
}
if ($resolved['included_resource_types'] !== self::DENOMINATOR) {
$reasons[] = 'supported_scope_denominator_mismatch';
}
if ($resolved['minimum_coverage_level'] !== CoverageLevel::Certified) {
$reasons[] = 'supported_scope_minimum_not_certified';
}
if ($resolved['allow_beta'] !== false) {
$reasons[] = 'supported_scope_beta_allowed';
}
if ($resolved['customer_claims_allowed'] !== false) {
$reasons[] = 'supported_scope_customer_claims_allowed';
}
if (($metadata['graph_fallback_allowlist'] ?? null) !== ['securityDefaults']) {
$reasons[] = 'supported_scope_graph_fallback_allowlist_mismatch';
}
if (($metadata['resource_type_denominator'] ?? null) !== self::DENOMINATOR) {
$reasons[] = 'supported_scope_metadata_denominator_mismatch';
}
return $reasons;
}
/**
* @param list<string> $reasons
* @return array<string, mixed>
*/
private function blockedTypeResult(string $canonicalType, array $reasons): array
{
return [
'canonical_type' => $canonicalType,
'resource_count' => 0,
'criteria' => [
'evidence' => false,
'identity' => false,
'compare' => false,
'render' => false,
'redaction' => false,
],
'certified' => false,
'blockers' => [EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE],
'resources' => [],
'reasons' => $reasons,
];
}
private function assertSameScope(ManagedEnvironment $environment, ProviderConnection $providerConnection): void
{
if ((int) $providerConnection->workspace_id !== (int) $environment->workspace_id
|| (int) $providerConnection->managed_environment_id !== (int) $environment->getKey()
) {
throw new InvalidArgumentException('Provider connection scope mismatch while evaluating Entra certified compare pack.');
}
}
private function hasNewerEvidence(
TenantConfigurationResource $resource,
TenantConfigurationResourceEvidence $evidence,
): bool {
if (! $evidence->captured_at instanceof CarbonInterface) {
return true;
}
return TenantConfigurationResourceEvidence::query()
->where('resource_id', (int) $resource->getKey())
->where('workspace_id', (int) $resource->workspace_id)
->where('managed_environment_id', (int) $resource->managed_environment_id)
->where('provider_connection_id', (int) $resource->provider_connection_id)
->where('resource_type_id', (int) $resource->resource_type_id)
->where('id', '<>', (int) $evidence->getKey())
->where('evidence_state', EvidenceState::ContentBacked->value)
->where('capture_outcome', CaptureOutcome::Captured->value)
->where(function ($query) use ($evidence): void {
$query->where('captured_at', '>', $evidence->captured_at)
->orWhere(function ($query) use ($evidence): void {
$query->where('captured_at', '=', $evidence->captured_at)
->where('id', '>', (int) $evidence->getKey());
});
})
->exists();
}
private function overallState(array $blockers): string
{
$blockers = $this->uniqueStrings($blockers);
if ($blockers === []) {
return EntraCertifiedComparePackResult::PASSED;
}
foreach (self::BLOCKER_PRIORITY as $blocker) {
if (in_array($blocker, $blockers, true)) {
return $blocker;
}
}
return EntraCertifiedComparePackResult::NOT_EVALUATED;
}
private function coverageLevel(mixed $value): ?CoverageLevel
{
if ($value instanceof CoverageLevel) {
return $value;
}
return is_string($value) ? CoverageLevel::tryFrom($value) : null;
}
/**
* @return list<string>
*/
private function uniqueStrings(array $values): array
{
return array_values(array_unique(array_filter(
array_map(static fn (mixed $value): string => is_scalar($value) ? trim((string) $value) : '', $values),
static fn (string $value): bool => $value !== '',
)));
}
private function stateValue(mixed $state): string
{
return $state instanceof \BackedEnum ? (string) $state->value : (string) $state;
}
/**
* @return list<string>
*/
private function sensitiveValues(mixed $raw, mixed $redacted): array
{
$values = [];
if (! is_array($raw)) {
return [];
}
foreach ($raw as $key => $value) {
$key = (string) $key;
$redactedValue = is_array($redacted) ? ($redacted[$key] ?? null) : null;
if ($redactedValue === '[redacted]' && is_scalar($value)) {
$values[] = (string) $value;
continue;
}
if ($this->isSensitiveKey($key) && is_scalar($value)) {
$values[] = (string) $value;
}
if (is_array($value)) {
$values = [...$values, ...$this->sensitiveValues($value, $redactedValue)];
}
}
return $this->uniqueStrings($values);
}
private function isSensitiveKey(string $key): bool
{
$normalized = strtolower($key);
$compact = str_replace(['_', '-', ' '], '', $normalized);
foreach (self::SENSITIVE_KEY_PARTS as $part) {
$compactPart = str_replace(['_', '-', ' '], '', $part);
if (str_contains($normalized, $part) || str_contains($compact, $compactPart)) {
return true;
}
}
return false;
}
}