Automated PR for spec 427 Exchange Teams verified source contract enablement. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #494
417 lines
16 KiB
PHP
417 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantConfiguration;
|
|
|
|
use App\Models\TenantConfigurationResourceType;
|
|
use App\Services\Graph\GraphContractRegistry;
|
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
|
use App\Support\TenantConfiguration\SourceClass;
|
|
use App\Support\TenantConfiguration\SupportState;
|
|
|
|
final class CoverageSourceContractResolver
|
|
{
|
|
/**
|
|
* Explicit source contract mappings only. Missing entries remain blocked.
|
|
*
|
|
* @var array<string, string>
|
|
*/
|
|
private const CONTRACT_KEYS = [
|
|
'conditionalAccessPolicy' => 'conditionalAccessPolicy',
|
|
'securityDefaults' => 'securityDefaults',
|
|
'deviceAndAppManagementAssignmentFilter' => 'assignmentFilter',
|
|
'notificationMessageTemplate' => 'notificationMessageTemplate',
|
|
'roleScopeTag' => 'roleScopeTag',
|
|
];
|
|
|
|
/**
|
|
* Out-of-scope catalog rows that have a bounded, explicit source contract.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private const EXPLICIT_OUT_OF_SCOPE_CAPTURE_TYPES = [
|
|
'conditionalAccessPolicy',
|
|
];
|
|
|
|
/**
|
|
* Resource types that must fail closed until a production-safe source contract exists.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private const MISSING_SOURCE_CONTRACT_TYPES = [
|
|
'dlpCompliancePolicy',
|
|
];
|
|
|
|
/**
|
|
* Source-contract review targets that have safe helper/identity metadata but no verified repo adapter.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private const EXCHANGE_TEAMS_REVIEWED_CONTRACT_TYPES = [
|
|
'transportRule',
|
|
'acceptedDomain',
|
|
'appPermissionPolicy',
|
|
'meetingPolicy',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly GraphContractRegistry $contracts,
|
|
) {}
|
|
|
|
public function resolve(TenantConfigurationResourceType $resourceType, bool $allowBetaCapture = false): CoverageSourceContractDecision
|
|
{
|
|
$canonicalType = (string) $resourceType->canonical_type;
|
|
$sourceClass = $resourceType->source_class instanceof SourceClass
|
|
? $resourceType->source_class
|
|
: SourceClass::tryFrom((string) $resourceType->source_class);
|
|
$supportState = $resourceType->support_state instanceof SupportState
|
|
? $resourceType->support_state
|
|
: SupportState::tryFrom((string) $resourceType->support_state);
|
|
|
|
$contractKey = self::CONTRACT_KEYS[$canonicalType] ?? null;
|
|
|
|
if (! is_string($contractKey) || $contractKey === '') {
|
|
if (in_array($canonicalType, self::EXCHANGE_TEAMS_REVIEWED_CONTRACT_TYPES, true)) {
|
|
return $this->blockedReviewedSourceContract($canonicalType, $sourceClass, $supportState);
|
|
}
|
|
|
|
if (in_array($canonicalType, self::MISSING_SOURCE_CONTRACT_TYPES, true)) {
|
|
return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping');
|
|
}
|
|
|
|
if (in_array($supportState, [SupportState::Unsupported, SupportState::OutOfScope], true)) {
|
|
return $this->blocked($canonicalType, CaptureOutcome::BlockedUnsupported, 'resource_type_unsupported');
|
|
}
|
|
|
|
return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping');
|
|
}
|
|
|
|
if ($sourceClass === SourceClass::GraphBetaExperimental && ! $allowBetaCapture) {
|
|
return $this->blocked($canonicalType, CaptureOutcome::BlockedBeta, 'beta_capture_disabled');
|
|
}
|
|
|
|
if (in_array($supportState, [SupportState::Unsupported, SupportState::OutOfScope], true)
|
|
&& ! in_array($canonicalType, self::EXPLICIT_OUT_OF_SCOPE_CAPTURE_TYPES, true)
|
|
) {
|
|
return $this->blocked($canonicalType, CaptureOutcome::BlockedUnsupported, 'resource_type_unsupported');
|
|
}
|
|
|
|
$contract = $this->contracts->get($contractKey);
|
|
$resource = is_string($contract['resource'] ?? null) ? trim((string) $contract['resource']) : '';
|
|
|
|
if ($contract === [] || $resource === '') {
|
|
return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_graph_contract_resource');
|
|
}
|
|
|
|
$sourceVersion = $this->sourceVersion($contract);
|
|
$sourceSchemaHash = $this->sourceSchemaHash($contract);
|
|
$metadata = [
|
|
'source_contract_key' => $contractKey,
|
|
'source_endpoint' => '/'.ltrim($resource, '/'),
|
|
'source_class' => $sourceClass?->value,
|
|
'registry_source_class' => $sourceClass?->value,
|
|
'support_state' => $supportState?->value,
|
|
'registry_support_state' => $supportState?->value,
|
|
'source_version' => $sourceVersion,
|
|
'source_schema_hash' => $sourceSchemaHash,
|
|
'source_schema_hash_available' => $sourceSchemaHash !== null,
|
|
];
|
|
|
|
return new CoverageSourceContractDecision(
|
|
canonicalType: $canonicalType,
|
|
outcome: CaptureOutcome::Captured,
|
|
contractKey: $contractKey,
|
|
sourceEndpoint: '/'.ltrim($resource, '/'),
|
|
sourceVersion: $sourceVersion,
|
|
sourceSchemaHash: $sourceSchemaHash,
|
|
reasonCode: null,
|
|
contract: $contract,
|
|
sourceMetadata: array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
);
|
|
}
|
|
|
|
private function blocked(string $canonicalType, CaptureOutcome $outcome, string $reasonCode): CoverageSourceContractDecision
|
|
{
|
|
return new CoverageSourceContractDecision(
|
|
canonicalType: $canonicalType,
|
|
outcome: $outcome,
|
|
reasonCode: $reasonCode,
|
|
sourceMetadata: ['reason_code' => $reasonCode],
|
|
);
|
|
}
|
|
|
|
private function blockedReviewedSourceContract(
|
|
string $canonicalType,
|
|
?SourceClass $sourceClass,
|
|
?SupportState $supportState,
|
|
): CoverageSourceContractDecision {
|
|
$state = CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING;
|
|
$metadata = [
|
|
'reason_code' => $state,
|
|
'source_contract_state' => $state,
|
|
'contract_blocker_reason' => $state,
|
|
'capture_eligibility_state' => 'blocked',
|
|
'source_class' => $sourceClass?->value,
|
|
'registry_source_class' => $sourceClass?->value,
|
|
'support_state' => $supportState?->value,
|
|
'registry_support_state' => $supportState?->value,
|
|
'workload' => $this->exchangeTeamsWorkload($canonicalType),
|
|
'source_version' => 'not_verified',
|
|
'source_contract_name' => $this->exchangeTeamsContractName($canonicalType),
|
|
'source_contract_version' => 'review-2026-07-03',
|
|
'provider_adapter_state' => 'missing',
|
|
'provider_adapter_proof' => 'No repo provider adapter or explicit Graph contract exists for this source contract.',
|
|
'provider_calls_allowed' => false,
|
|
'evidence_promotion_allowed' => false,
|
|
'customer_claims_allowed' => false,
|
|
'restore_allowed' => false,
|
|
'certification_allowed' => false,
|
|
'not_certifiable' => true,
|
|
'not_customer_claimable' => true,
|
|
'permission_model' => $this->exchangeTeamsPermissionModel($canonicalType),
|
|
'response_shape' => $this->exchangeTeamsResponseShape($canonicalType),
|
|
'identity_handoff' => $this->exchangeTeamsIdentityHandoff($canonicalType),
|
|
'normalization_handoff' => $this->exchangeTeamsNormalizationHandoff($canonicalType),
|
|
'redaction_rules' => $this->exchangeTeamsRedactionRules($canonicalType),
|
|
'documentation_reference' => 'repo-proof: Spec 426 removed unverified Graph endpoint claims; Spec 427 keeps the target blocked until a repo adapter/source contract exists.',
|
|
];
|
|
|
|
return new CoverageSourceContractDecision(
|
|
canonicalType: $canonicalType,
|
|
outcome: CaptureOutcome::BlockedMissingContract,
|
|
sourceVersion: 'not_verified',
|
|
reasonCode: $state,
|
|
sourceContractState: $state,
|
|
sourceMetadata: array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
);
|
|
}
|
|
|
|
private function exchangeTeamsWorkload(string $canonicalType): string
|
|
{
|
|
return match ($canonicalType) {
|
|
'transportRule', 'acceptedDomain' => 'exchange',
|
|
'appPermissionPolicy', 'meetingPolicy' => 'teams',
|
|
default => 'microsoft_365',
|
|
};
|
|
}
|
|
|
|
private function exchangeTeamsContractName(string $canonicalType): string
|
|
{
|
|
return match ($canonicalType) {
|
|
'transportRule' => 'exchange.transportRule.source_contract_review',
|
|
'acceptedDomain' => 'exchange.acceptedDomain.source_contract_review',
|
|
'appPermissionPolicy' => 'teams.appPermissionPolicy.source_contract_review',
|
|
'meetingPolicy' => 'teams.meetingPolicy.source_contract_review',
|
|
default => 'microsoft365.unknown.source_contract_review',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function exchangeTeamsPermissionModel(string $canonicalType): array
|
|
{
|
|
return [
|
|
'status' => 'not_productized',
|
|
'required_application_permissions' => [],
|
|
'delegated_permissions' => [],
|
|
'admin_consent_required' => true,
|
|
'least_privilege_note' => 'Blocked until a repo-owned source adapter and least-privilege permission path are productized.',
|
|
'permission_failure_mode' => 'block_without_provider_call',
|
|
'redacted_permission_context' => true,
|
|
'resource_type' => $canonicalType,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function exchangeTeamsResponseShape(string $canonicalType): array
|
|
{
|
|
return [
|
|
'raw_payload_shape' => 'provider_collection_candidate',
|
|
'collection_semantics' => 'collection_candidate',
|
|
'pagination_model' => 'blocked_until_adapter_exists',
|
|
'collection_item_path' => 'value',
|
|
'pagination_cursor_path' => '@odata.nextLink',
|
|
'empty_response_meaning' => 'must_be_distinguished_by_future_adapter',
|
|
'permission_denied_response_meaning' => 'must_be_distinguished_by_future_adapter',
|
|
'unsupported_response_meaning' => 'must_be_distinguished_by_future_adapter',
|
|
'source_unavailable_response_meaning' => 'must_be_distinguished_by_future_adapter',
|
|
'malformed_response_meaning' => 'must_be_distinguished_by_future_adapter',
|
|
'response_shape_safety' => 'blocked_until_empty_denied_unsupported_unavailable_and_malformed_are_distinguishable',
|
|
'display_safe_fields' => $this->exchangeTeamsDisplayFields($canonicalType),
|
|
'sensitive_fields' => $this->exchangeTeamsSensitiveFields($canonicalType),
|
|
'volatile_fields' => ['@odata.context', '@odata.etag', 'createdDateTime', 'modifiedDateTime', 'whenChanged'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function exchangeTeamsIdentityHandoff(string $canonicalType): array
|
|
{
|
|
return [
|
|
'preferred_identity_fields' => $this->exchangeTeamsPreferredIdentityFields($canonicalType),
|
|
'fallback_identity_fields' => $this->exchangeTeamsFallbackIdentityFields($canonicalType),
|
|
'identity_stability_class' => 'stable_candidate',
|
|
'singleton_or_collection_identity_rule' => 'collection_item_identity_required',
|
|
'known_identity_risks' => [
|
|
'display_name_not_stable',
|
|
'identity_field_can_be_display_like',
|
|
'order_and_payload_hash_not_stable_identity',
|
|
],
|
|
'display_name_is_stable_identity' => false,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function exchangeTeamsNormalizationHandoff(string $canonicalType): array
|
|
{
|
|
return [
|
|
'resource_type' => $canonicalType,
|
|
'source_class' => SourceClass::Tcm->value,
|
|
'source_contract_name' => $this->exchangeTeamsContractName($canonicalType),
|
|
'source_version' => 'not_verified',
|
|
'raw_payload_shape' => 'provider_collection_candidate',
|
|
'expected_normalized_shape' => 'exchange_teams_comparable_payload',
|
|
'identity_fields' => [
|
|
...$this->exchangeTeamsPreferredIdentityFields($canonicalType),
|
|
...$this->exchangeTeamsFallbackIdentityFields($canonicalType),
|
|
],
|
|
'volatile_fields' => ['@odata.context', '@odata.etag', 'createdDateTime', 'modifiedDateTime', 'whenChanged'],
|
|
'sensitive_fields' => $this->exchangeTeamsSensitiveFields($canonicalType),
|
|
'collection_item_path' => 'value',
|
|
'pagination_cursor_path' => '@odata.nextLink',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function exchangeTeamsRedactionRules(string $canonicalType): array
|
|
{
|
|
return [
|
|
'raw_payload_default_visible' => false,
|
|
'permission_context_default_visible' => false,
|
|
'redacted_permission_context' => true,
|
|
'sensitive_fields' => $this->exchangeTeamsSensitiveFields($canonicalType),
|
|
'forbidden_default_output' => [
|
|
'raw_provider_payload',
|
|
'provider_response',
|
|
'authorization_header',
|
|
'tokens',
|
|
'credentials',
|
|
'mail_content',
|
|
'teams_chat_message_file_recording_or_transcript_content',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function exchangeTeamsPreferredIdentityFields(string $canonicalType): array
|
|
{
|
|
return match ($canonicalType) {
|
|
'transportRule' => ['id', 'sourceId', 'Guid', 'RuleId'],
|
|
'acceptedDomain' => ['id', 'sourceId'],
|
|
'appPermissionPolicy', 'meetingPolicy' => ['id', 'sourceId', 'policyId'],
|
|
default => ['id', 'sourceId'],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function exchangeTeamsFallbackIdentityFields(string $canonicalType): array
|
|
{
|
|
return match ($canonicalType) {
|
|
'acceptedDomain' => ['DomainName', 'domainName'],
|
|
default => [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function exchangeTeamsDisplayFields(string $canonicalType): array
|
|
{
|
|
return match ($canonicalType) {
|
|
'acceptedDomain' => ['DomainName', 'domainName', 'DisplayName', 'displayName', 'Name', 'name'],
|
|
default => ['DisplayName', 'displayName', 'Name', 'name'],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function exchangeTeamsSensitiveFields(string $canonicalType): array
|
|
{
|
|
$common = ['authorization', 'access_token', 'refresh_token', 'client_secret', 'password', 'credential', 'provider_response', 'raw_payload'];
|
|
|
|
return match ($canonicalType) {
|
|
'transportRule' => [
|
|
...$common,
|
|
'SubjectContainsWords',
|
|
'SubjectOrBodyContainsWords',
|
|
'BodyContainsWords',
|
|
'ApplyHtmlDisclaimerText',
|
|
'SetHeaderValue',
|
|
],
|
|
'meetingPolicy' => [
|
|
...$common,
|
|
'RecordingContent',
|
|
'RecordingUrl',
|
|
'TranscriptContent',
|
|
'TranscriptText',
|
|
'ChatContent',
|
|
],
|
|
default => $common,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $contract
|
|
*/
|
|
private function sourceVersion(array $contract): string
|
|
{
|
|
$version = $contract['version'] ?? $contract['graph_version'] ?? null;
|
|
|
|
return is_string($version) && trim($version) !== ''
|
|
? trim($version)
|
|
: 'v1.0';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $contract
|
|
*/
|
|
private function sourceSchemaHash(array $contract): ?string
|
|
{
|
|
if ($contract === []) {
|
|
return null;
|
|
}
|
|
|
|
$schema = [
|
|
'resource' => $contract['resource'] ?? null,
|
|
'allowed_select' => $contract['allowed_select'] ?? [],
|
|
'type_family' => $contract['type_family'] ?? null,
|
|
];
|
|
|
|
return hash('sha256', $this->canonicalJson($schema));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function canonicalJson(array $payload): string
|
|
{
|
|
ksort($payload);
|
|
|
|
return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
|
}
|
|
}
|