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
584 lines
21 KiB
PHP
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;
|
|
}
|
|
}
|