TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php
ahmido 9b58a5696d feat: add Exchange PowerShell adapter contract slice 1 (#497)
Spec 430: Exchange PowerShell adapter contract slice 1. Validation was not rerun during PR creation handoff; branch contains implementation report and tests.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #497
2026-07-05 12:08:16 +00:00

584 lines
21 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class ExchangePowerShellCommandContracts
{
public const SOURCE_SURFACE = 'exchange_online_powershell_rest';
public const ADAPTER_PATTERN = 'new_exchange_powershell_adapter';
public const COMMAND_CONTRACT_VERSION = 'exchange-powershell-command-contract-v1';
public const ALLOWED_INTERNAL_CLAIM = 'Exchange PowerShell adapter contracts exist for transportRule, remoteDomain, and inboundConnector.';
/**
* @var list<string>
*/
private const MUTATION_COMMAND_PREFIXES = [
'Set',
'New',
'Remove',
'Enable',
'Disable',
'Update',
'Start',
'Stop',
'Invoke',
'Search',
'Export',
'Import',
];
/**
* @return list<string>
*/
public function includedCanonicalTypes(): array
{
return array_keys($this->contracts());
}
/**
* @return list<string>
*/
public function commandNames(): array
{
return array_values(array_map(
static fn (array $contract): string => (string) $contract['command_name'],
$this->contracts(),
));
}
/**
* @return list<string>
*/
public function excludedCanonicalTypes(): array
{
return [
'acceptedDomain',
'organizationConfig',
'mailboxPlan',
'outboundConnector',
'sharingPolicy',
'appPermissionPolicy',
'appSetupPolicy',
'meetingPolicy',
'messagingPolicy',
'teamsUpdateManagementPolicy',
'teamsChannelsPolicy',
'externalAccessPolicy',
];
}
public function hasContractForCanonicalType(string $canonicalType): bool
{
return array_key_exists($canonicalType, $this->contracts());
}
/**
* @return array<string, mixed>|null
*/
public function contractForCanonicalType(string $canonicalType): ?array
{
return $this->contracts()[$canonicalType] ?? null;
}
/**
* @return array<string, mixed>|null
*/
public function contractForCommandName(string $commandName): ?array
{
foreach ($this->contracts() as $contract) {
if (($contract['command_name'] ?? null) === $commandName) {
return $contract;
}
}
return null;
}
/**
* @return array{accepted: bool, reason_code: string|null}
*/
public function validateCommandName(mixed $commandName): array
{
if (! is_string($commandName) || trim($commandName) === '') {
return $this->rejected('raw_command_rejected');
}
$commandName = trim($commandName);
if ($this->containsUnsafeCommandText($commandName)) {
return $this->rejected('unsafe_command_text_rejected');
}
foreach (self::MUTATION_COMMAND_PREFIXES as $prefix) {
if (str_starts_with($commandName, $prefix.'-')) {
return $this->rejected('mutation_command_rejected');
}
}
if (! in_array($commandName, $this->commandNames(), true)) {
return $this->rejected('command_not_allowlisted');
}
return $this->accepted();
}
/**
* @param array<string, mixed> $parameters
* @return array{accepted: bool, reason_code: string|null, rejected_parameter?: string}
*/
public function validateInvocation(mixed $contract, array $parameters = []): array
{
if (! is_array($contract) || array_is_list($contract)) {
return $this->rejected('raw_command_rejected');
}
$commandName = $contract['command_name'] ?? null;
$commandValidation = $this->validateCommandName($commandName);
if (! $commandValidation['accepted']) {
return $commandValidation;
}
$allowlistedContract = $this->contractForCommandName((string) $commandName);
if ($allowlistedContract === null
|| ($contract['contract_key'] ?? null) !== $allowlistedContract['contract_key']
|| ($contract['read_only'] ?? null) !== true
) {
return $this->rejected('invalid_command_contract');
}
$allowedParameters = $contract['allowed_parameters'] ?? [];
if (! is_array($allowedParameters) || ! array_is_list($allowedParameters)) {
return $this->rejected('invalid_allowed_parameter_policy');
}
foreach (array_keys($parameters) as $parameterName) {
if (! is_string($parameterName) || ! in_array($parameterName, $allowedParameters, true)) {
return [
...$this->rejected('unknown_parameter_rejected'),
'rejected_parameter' => (string) $parameterName,
];
}
}
foreach ($parameters as $parameterName => $parameterValue) {
if ($this->containsUnsafeParameterValue($parameterValue)) {
return [
...$this->rejected('unsafe_parameter_value_rejected'),
'rejected_parameter' => (string) $parameterName,
];
}
}
return $this->accepted();
}
/**
* @return array<string, array<string, mixed>>
*/
public function contracts(): array
{
return [
'transportRule' => $this->contract(
canonicalType: 'transportRule',
humanLabel: 'Transport rule',
commandName: 'Get-TransportRule',
identityFields: ['id', 'sourceId', 'Guid', 'RuleId'],
derivedIdentityFields: [],
safeFields: ['Guid', 'RuleId', 'DisplayName', 'Name', 'Identity', 'Priority', 'Mode', 'State', 'Enabled'],
protectedFields: [
'SenderDomainIs',
'RecipientDomainIs',
'From',
'SentTo',
'ExceptIfFrom',
'ExceptIfRecipientDomainIs',
'HeaderName',
'SetHeaderName',
'RulePattern',
],
sensitiveFields: [
'SubjectContainsWords',
'SubjectOrBodyContainsWords',
'BodyContainsWords',
'ApplyHtmlDisclaimerText',
'AttachmentContainsWords',
'HeaderContainsWords',
'SetHeaderValue',
],
volatileFields: ['WhenChanged', 'whenChanged', 'ExchangeVersion', 'RunspaceId'],
identityNote: 'Rule GUID-like identifiers are stable candidates; display names, priority, and order are not stable identity.',
permissionNotes: ['Mail-flow rule reads require Exchange Online PowerShell application permission and Exchange RBAC validation in a later execution spec.'],
),
'remoteDomain' => $this->contract(
canonicalType: 'remoteDomain',
humanLabel: 'Remote domain',
commandName: 'Get-RemoteDomain',
identityFields: ['id', 'sourceId', 'Guid', 'RemoteDomainId', 'Identity'],
derivedIdentityFields: ['DomainName', 'domainName'],
safeFields: ['Guid', 'RemoteDomainId', 'Identity', 'DomainName', 'Name', 'DisplayName', 'IsDefault', 'AllowedOOFType', 'AutoReplyEnabled'],
protectedFields: [
'DomainName',
'TargetDeliveryDomain',
'TrustedMailOutboundEnabled',
'TNEFEnabled',
'AutoForwardEnabled',
],
sensitiveFields: [
'TargetDeliveryDomain',
'MailTipsAccessLevel',
'MailTipsAccessScope',
'AllowedOOFType',
],
volatileFields: ['WhenChanged', 'whenChanged', 'ExchangeVersion', 'RunspaceId'],
identityNote: 'Default and custom remote domains must be distinguished; domain/name alone is a derived candidate until source stability is proven.',
permissionNotes: ['Remote-domain reads require Exchange Online PowerShell application permission and runtime RBAC validation in a later execution spec.'],
),
'inboundConnector' => $this->contract(
canonicalType: 'inboundConnector',
humanLabel: 'Inbound connector',
commandName: 'Get-InboundConnector',
identityFields: ['id', 'sourceId', 'Guid', 'ConnectorId', 'Identity'],
derivedIdentityFields: [],
safeFields: ['Guid', 'ConnectorId', 'Identity', 'Name', 'DisplayName', 'ConnectorType', 'Enabled', 'RequireTls'],
protectedFields: [
'SenderDomains',
'SenderIPAddresses',
'TlsSenderCertificateName',
'AssociatedAcceptedDomains',
'RestrictDomainsToIPAddresses',
'CloudServicesMailEnabled',
],
sensitiveFields: [
'SenderIPAddresses',
'TlsSenderCertificateName',
'ConnectorSource',
'SmartHosts',
'Comment',
],
volatileFields: ['WhenChanged', 'whenChanged', 'ExchangeVersion', 'RunspaceId'],
identityNote: 'Connector identity must use provider/source identifiers where available; display names alone are unsafe.',
permissionNotes: ['Connector reads require Exchange Online PowerShell application permission and runtime RBAC validation in a later execution spec.'],
),
];
}
/**
* @param list<string> $identityFields
* @param list<string> $derivedIdentityFields
* @param list<string> $safeFields
* @param list<string> $protectedFields
* @param list<string> $sensitiveFields
* @param list<string> $volatileFields
* @param list<string> $permissionNotes
* @return array<string, mixed>
*/
private function contract(
string $canonicalType,
string $humanLabel,
string $commandName,
array $identityFields,
array $derivedIdentityFields,
array $safeFields,
array $protectedFields,
array $sensitiveFields,
array $volatileFields,
string $identityNote,
array $permissionNotes,
): array {
$contractKey = 'exchange_powershell.'.$canonicalType;
return [
'contract_key' => $contractKey,
'canonical_type' => $canonicalType,
'human_label' => $humanLabel,
'workload' => 'exchange',
'source_surface' => self::SOURCE_SURFACE,
'adapter_pattern' => self::ADAPTER_PATTERN,
'provider_owned_source_detail' => true,
'command_name' => $commandName,
'command_contract_version' => self::COMMAND_CONTRACT_VERSION,
'read_only' => true,
'allowed_parameters' => [],
'collection_shape' => 'collection',
'fake_runner_testable' => true,
'provider_calls_allowed' => false,
'execution_enabled' => false,
'capture_eligibility_state' => 'pending_capture',
'provider_adapter_state' => 'adapter_contract_available',
'source_contract_state' => CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE,
'permission_model' => $this->permissionModel($canonicalType, $permissionNotes),
'response_shape' => $this->responseShape(
identityFields: $identityFields,
derivedIdentityFields: $derivedIdentityFields,
safeFields: $safeFields,
protectedFields: $protectedFields,
sensitiveFields: $sensitiveFields,
volatileFields: $volatileFields,
),
'identity_handoff' => $this->identityHandoff($identityFields, $derivedIdentityFields, $identityNote),
'redaction_rules' => $this->redactionRules($sensitiveFields, $protectedFields),
'normalization_handoff' => $this->normalizationHandoff($canonicalType, $contractKey, $identityFields, $derivedIdentityFields, $volatileFields, $sensitiveFields),
'claim_boundary' => $this->claimBoundary(),
'restore_tier' => 'not_restorable',
'future_execution_scope' => [
'requires_workspace_id' => true,
'requires_managed_environment_id' => true,
'requires_provider_connection_id' => true,
'provider_native_scope_identifiers_are_metadata_only' => true,
],
];
}
/**
* @param list<string> $permissionNotes
* @return array<string, mixed>
*/
private function permissionModel(string $canonicalType, array $permissionNotes): array
{
return [
'status' => 'runtime_validation_pending',
'documentation_notes' => $permissionNotes,
'known_permission_names' => ['Exchange.ManageAsApp'],
'least_privilege_runtime_validated' => false,
'admin_consent_required' => true,
'exchange_rbac_runtime_validated' => false,
'permission_failure_modes' => [
'permission_denied',
'command_unavailable',
'adapter_unavailable',
],
'permission_failure_mode' => 'block_without_provider_call',
'redacted_permission_context' => true,
'resource_type' => $canonicalType,
];
}
/**
* @param list<string> $identityFields
* @param list<string> $derivedIdentityFields
* @param list<string> $safeFields
* @param list<string> $protectedFields
* @param list<string> $sensitiveFields
* @param list<string> $volatileFields
* @return array<string, mixed>
*/
private function responseShape(
array $identityFields,
array $derivedIdentityFields,
array $safeFields,
array $protectedFields,
array $sensitiveFields,
array $volatileFields,
): array {
return [
'raw_payload_shape' => 'exchange_powershell_structured_collection',
'collection_semantics' => 'collection',
'collection_item_path' => 'items',
'required_identity_candidate_fields' => $identityFields,
'derived_identity_candidate_fields' => $derivedIdentityFields,
'safe_fields' => $safeFields,
'protected_configuration_fields' => $protectedFields,
'sensitive_fields' => $sensitiveFields,
'volatile_fields' => $volatileFields,
'unsupported_fields' => [],
'empty_collection_meaning' => 'valid_empty_collection',
'permission_denied_response_meaning' => 'permission_denied',
'command_unavailable_response_meaning' => 'command_unavailable',
'adapter_unavailable_response_meaning' => 'adapter_unavailable',
'malformed_response_meaning' => 'malformed_response',
'unexpected_object_shape_meaning' => 'unexpected_object_shape',
'response_shape_safety' => 'fail_closed_until_empty_denied_unavailable_malformed_and_unexpected_shapes_are_distinct',
];
}
/**
* @param list<string> $identityFields
* @param list<string> $derivedIdentityFields
* @return array<string, mixed>
*/
private function identityHandoff(array $identityFields, array $derivedIdentityFields, string $identityNote): array
{
return [
'preferred_identity_fields' => $identityFields,
'derived_identity_fields' => $derivedIdentityFields,
'fallback_identity_fields' => [],
'identity_stability_class' => 'stable_candidate',
'derived_identity_stability_class' => $derivedIdentityFields === [] ? null : 'derived_candidate',
'singleton_or_collection_identity_rule' => 'collection_item_identity_required',
'display_name_is_stable_identity' => false,
'known_identity_risks' => [
'display_name_not_stable',
'identity_field_can_be_display_like',
'order_and_payload_hash_not_stable_identity',
],
'note' => $identityNote,
];
}
/**
* @param list<string> $sensitiveFields
* @param list<string> $protectedFields
* @return array<string, mixed>
*/
private function redactionRules(array $sensitiveFields, array $protectedFields): array
{
return [
'raw_payload_default_visible' => false,
'permission_context_default_visible' => false,
'redacted_permission_context' => true,
'protected_configuration_fields' => $protectedFields,
'sensitive_fields' => [
...$sensitiveFields,
'tokens',
'secrets',
'authorization_headers',
'cookies',
'certificate_private_material',
'passwords',
'raw_transcripts',
'raw_shell_stdout',
'raw_shell_stderr',
'mail_body_content',
'mailbox_content',
'file_content',
'teams_transcript_content',
],
'forbidden_default_output' => [
'raw_provider_payload',
'provider_response',
'authorization_header',
'cookies',
'tokens',
'secrets',
'credentials',
'certificate_private_material',
'passwords',
'raw_transcripts',
'raw_shell_stdout',
'raw_shell_stderr',
'mail_body_content',
'mailbox_content',
'file_content',
'teams_transcript_content',
],
];
}
/**
* @param list<string> $identityFields
* @param list<string> $derivedIdentityFields
* @param list<string> $volatileFields
* @param list<string> $sensitiveFields
* @return array<string, mixed>
*/
private function normalizationHandoff(
string $canonicalType,
string $contractKey,
array $identityFields,
array $derivedIdentityFields,
array $volatileFields,
array $sensitiveFields,
): array {
return [
'resource_type' => $canonicalType,
'source_surface' => self::SOURCE_SURFACE,
'source_contract_key' => $contractKey,
'source_version' => self::COMMAND_CONTRACT_VERSION,
'raw_payload_shape' => 'exchange_powershell_structured_collection',
'expected_normalized_shape' => 'future_exchange_powershell_normalized_payload',
'identity_fields' => [
...$identityFields,
...$derivedIdentityFields,
],
'volatile_fields' => $volatileFields,
'sensitive_fields' => $sensitiveFields,
'collection_item_path' => 'items',
];
}
/**
* @return array<string, mixed>
*/
private function claimBoundary(): array
{
return [
'internal_operator_safe_statement' => self::ALLOWED_INTERNAL_CLAIM,
'customer_claims_allowed' => false,
'evidence_promotion_allowed' => false,
'compare_render_promotion_allowed' => false,
'certification_allowed' => false,
'restore_allowed' => false,
'not_customer_claimable' => true,
];
}
/**
* @return array{accepted: true, reason_code: null}
*/
private function accepted(): array
{
return [
'accepted' => true,
'reason_code' => null,
];
}
/**
* @return array{accepted: false, reason_code: string}
*/
private function rejected(string $reasonCode): array
{
return [
'accepted' => false,
'reason_code' => $reasonCode,
];
}
private function containsUnsafeCommandText(string $value): bool
{
$trimmed = trim($value);
$lower = strtolower($trimmed);
foreach ([';', '|', '>', '<', '`', "\n", "\r", '$(', '{', '}'] as $fragment) {
if (str_contains($trimmed, $fragment)) {
return true;
}
}
foreach (['install-module', 'invoke-expression', 'start-process', 'out-file', 'set-content', 'add-content'] as $fragment) {
if (str_contains($lower, $fragment)) {
return true;
}
}
return false;
}
private function containsUnsafeParameterValue(mixed $value): bool
{
if (is_string($value)) {
return $this->containsUnsafeCommandText($value);
}
if (! is_array($value)) {
return false;
}
foreach ($value as $nestedValue) {
if ($this->containsUnsafeParameterValue($nestedValue)) {
return true;
}
}
return false;
}
}