From 9b58a5696d881b995dc0bf8430a6a5fb4a2bfced Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 5 Jul 2026 12:08:16 +0000 Subject: [PATCH] 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 Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/497 --- .../TenantConfiguration/ClaimGuard.php | 12 + .../CoverageIdentityStrategyRegistry.php | 28 + .../CoverageSourceContractResolver.php | 94 ++- .../ExchangePowerShellCommandContracts.php | 583 ++++++++++++++++++ .../ResourceTypeRegistry.php | 1 + .../SupportedScopeResolver.php | 1 + .../Spec419M365RegistryExpansionTest.php | 15 +- ...ExchangeTeamsCoreEvidenceReadinessTest.php | 14 +- ...27ExchangeTeamsNoEvidencePromotionTest.php | 8 +- ...ec430ExchangePowerShellNoPromotionTest.php | 195 ++++++ ...tConfigurationResourceTypeRegistryTest.php | 2 +- ...430ExchangePowerShellFakeCommandRunner.php | 55 ++ ...17CoverageIdentityStrategyRegistryTest.php | 2 + .../Spec419M365WorkloadRegistryTest.php | 7 +- .../Spec420M365CaptureEligibilityTest.php | 18 +- ...xchangeTeamsSourceContractResolverTest.php | 3 +- ...27ExchangeTeamsSourceContractStateTest.php | 3 +- ...ec427ExchangeTransportRuleContractTest.php | 17 +- ...ec427SourceContractIdentityHandoffTest.php | 2 - ...27SourceContractPermissionMetadataTest.php | 1 - .../Spec427SourceContractRedactionTest.php | 1 - ...Spec427SourceContractResponseShapeTest.php | 1 - ...ExchangePowerShellCommandAllowlistTest.php | 109 ++++ .../Spec430ExchangePowerShellMetadataTest.php | 108 ++++ .../Spec430ExchangePowerShellResolverTest.php | 121 ++++ ...430InboundConnectorCommandContractTest.php | 36 ++ ...Spec430RemoteDomainCommandContractTest.php | 35 ++ ...pec430TransportRuleCommandContractTest.php | 36 ++ .../checklists/requirements.md | 161 +++++ .../implementation-report.md | 183 ++++++ .../plan.md | 267 ++++++++ .../spec.md | 437 +++++++++++++ .../tasks.md | 180 ++++++ 33 files changed, 2691 insertions(+), 45 deletions(-) create mode 100644 apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec430ExchangePowerShellNoPromotionTest.php create mode 100644 apps/platform/tests/Support/TenantConfiguration/Spec430ExchangePowerShellFakeCommandRunner.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellCommandAllowlistTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellMetadataTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellResolverTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec430InboundConnectorCommandContractTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec430RemoteDomainCommandContractTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec430TransportRuleCommandContractTest.php create mode 100644 specs/430-exchange-powershell-adapter-contract-slice-1/checklists/requirements.md create mode 100644 specs/430-exchange-powershell-adapter-contract-slice-1/implementation-report.md create mode 100644 specs/430-exchange-powershell-adapter-contract-slice-1/plan.md create mode 100644 specs/430-exchange-powershell-adapter-contract-slice-1/spec.md create mode 100644 specs/430-exchange-powershell-adapter-contract-slice-1/tasks.md diff --git a/apps/platform/app/Services/TenantConfiguration/ClaimGuard.php b/apps/platform/app/Services/TenantConfiguration/ClaimGuard.php index c4075be5..8d44b526 100644 --- a/apps/platform/app/Services/TenantConfiguration/ClaimGuard.php +++ b/apps/platform/app/Services/TenantConfiguration/ClaimGuard.php @@ -23,6 +23,10 @@ public function evaluateStatement(string $claim, bool $internalOperatorOnly = fa $tokens = $this->claimTokens($claim); $registryScoped = $this->isRegistryScopedStatement($tokens); + if ($this->isExchangePowerShellAdapterContractClaim($tokens)) { + return $internalOperatorOnly ? ClaimState::InternalOnly : ClaimState::ClaimBlocked; + } + if ($this->hasUnsafeBroadCoverageClaim($tokens, $registryScoped)) { return ClaimState::ClaimBlocked; } @@ -294,6 +298,14 @@ private function hasScopedWorkloadReference(array $tokens): bool || $this->hasAnyToken($tokens, ['label', 'labels']); } + /** + * @param list $tokens + */ + private function isExchangePowerShellAdapterContractClaim(array $tokens): bool + { + return $tokens === $this->claimTokens(ExchangePowerShellCommandContracts::ALLOWED_INTERNAL_CLAIM); + } + /** * @param list $tokens */ diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php b/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php index be46b290..298832b0 100644 --- a/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php +++ b/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php @@ -133,6 +133,34 @@ final class CoverageIdentityStrategyRegistry 'derived_claims_allowed' => false, 'stable_key_kind' => 'tcm_resource_identifier', ], + 'remoteDomain' => [ + 'strategy_identifier' => 'tcm.exchange.remote_domain.v1', + 'preferred_identity_fields' => ['id', 'sourceId', 'Guid', 'RemoteDomainId', 'Identity'], + 'fallback_identity_fields' => [], + 'source_composite_fields' => [], + 'derived_composite_fields' => ['DomainName', 'domainName'], + 'display_fields' => ['displayName', 'DisplayName', 'name', 'Name', 'DomainName', 'domainName'], + 'secondary_fields' => ['IsDefault', 'isDefault', 'AllowedOOFType', 'allowedOOFType', 'AutoReplyEnabled', 'autoReplyEnabled', 'source_metadata.source_contract_key', 'source_metadata.source_version'], + 'requires_provider_connection_scope' => true, + 'allows_derived_identity' => false, + 'allows_experimental_identity' => false, + 'derived_claims_allowed' => false, + 'stable_key_kind' => 'tcm_resource_identifier', + ], + 'inboundConnector' => [ + 'strategy_identifier' => 'tcm.exchange.inbound_connector.v1', + 'preferred_identity_fields' => ['id', 'sourceId', 'Guid', 'ConnectorId', 'Identity'], + 'fallback_identity_fields' => [], + 'source_composite_fields' => [], + 'derived_composite_fields' => [], + 'display_fields' => ['displayName', 'DisplayName', 'name', 'Name'], + 'secondary_fields' => ['ConnectorType', 'connectorType', 'Enabled', 'enabled', 'RequireTls', 'requireTls', 'source_metadata.source_contract_key', 'source_metadata.source_version'], + 'requires_provider_connection_scope' => true, + 'allows_derived_identity' => false, + 'allows_experimental_identity' => false, + 'derived_claims_allowed' => false, + 'stable_key_kind' => 'tcm_resource_identifier', + ], 'acceptedDomain' => [ 'strategy_identifier' => 'tcm.exchange.accepted_domain.v1', 'preferred_identity_fields' => ['id', 'sourceId'], diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php index 225492c5..5903261a 100644 --- a/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php +++ b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php @@ -55,9 +55,14 @@ final class CoverageSourceContractResolver 'meetingPolicy', ]; + private readonly ExchangePowerShellCommandContracts $exchangePowerShellContracts; + public function __construct( private readonly GraphContractRegistry $contracts, - ) {} + ?ExchangePowerShellCommandContracts $exchangePowerShellContracts = null, + ) { + $this->exchangePowerShellContracts = $exchangePowerShellContracts ?? new ExchangePowerShellCommandContracts; + } public function resolve(TenantConfigurationResourceType $resourceType, bool $allowBetaCapture = false): CoverageSourceContractDecision { @@ -72,6 +77,10 @@ public function resolve(TenantConfigurationResourceType $resourceType, bool $all $contractKey = self::CONTRACT_KEYS[$canonicalType] ?? null; if (! is_string($contractKey) || $contractKey === '') { + if ($this->exchangePowerShellContracts->hasContractForCanonicalType($canonicalType)) { + return $this->verifiedExchangePowerShellAdapterContract($canonicalType, $sourceClass, $supportState); + } + if (in_array($canonicalType, self::EXCHANGE_TEAMS_REVIEWED_CONTRACT_TYPES, true)) { return $this->blockedReviewedSourceContract($canonicalType, $sourceClass, $supportState); } @@ -187,6 +196,76 @@ private function blockedReviewedSourceContract( ); } + private function verifiedExchangePowerShellAdapterContract( + string $canonicalType, + ?SourceClass $sourceClass, + ?SupportState $supportState, + ): CoverageSourceContractDecision { + $contract = $this->exchangePowerShellContracts->contractForCanonicalType($canonicalType); + + if ($contract === null) { + return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping'); + } + + $state = CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE; + $contractKey = (string) $contract['contract_key']; + $metadata = [ + 'source_contract_key' => $contractKey, + 'source_contract_name' => $contractKey, + 'source_contract_state' => $state, + 'contract_blocker_reason' => null, + 'capture_eligibility_state' => 'pending_capture', + 'source_class' => $sourceClass?->value, + 'registry_source_class' => $sourceClass?->value, + 'support_state' => $supportState?->value, + 'registry_support_state' => $supportState?->value, + 'workload' => 'exchange', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'adapter_pattern' => ExchangePowerShellCommandContracts::ADAPTER_PATTERN, + 'source_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, + 'source_schema_hash' => $this->sourceSchemaHash($contract), + 'source_schema_hash_available' => true, + 'provider_adapter_state' => 'adapter_contract_available', + 'provider_adapter_proof' => 'Spec 430 verifies a structured Exchange PowerShell command contract only; live execution is deferred.', + 'provider_calls_allowed' => false, + 'execution_enabled' => false, + 'fake_runner_testable' => true, + 'evidence_promotion_allowed' => false, + 'customer_claims_allowed' => false, + 'restore_allowed' => false, + 'certification_allowed' => false, + 'not_certifiable' => true, + 'not_customer_claimable' => true, + 'command_contract' => [ + 'command_name' => $contract['command_name'], + 'command_contract_version' => $contract['command_contract_version'], + 'read_only' => $contract['read_only'], + 'allowed_parameters' => $contract['allowed_parameters'], + ], + 'permission_model' => $contract['permission_model'], + 'response_shape' => $contract['response_shape'], + 'identity_handoff' => $contract['identity_handoff'], + 'normalization_handoff' => $contract['normalization_handoff'], + 'redaction_rules' => $contract['redaction_rules'], + 'claim_boundary' => $contract['claim_boundary'], + 'restore_tier' => $contract['restore_tier'], + 'future_execution_scope' => $contract['future_execution_scope'], + 'documentation_reference' => 'repo-proof: Spec 430 introduces a no-live-execution Exchange PowerShell adapter contract boundary for transportRule, remoteDomain, and inboundConnector.', + ]; + + return new CoverageSourceContractDecision( + canonicalType: $canonicalType, + outcome: CaptureOutcome::BlockedMissingContract, + contractKey: $contractKey, + sourceVersion: ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, + sourceSchemaHash: $this->sourceSchemaHash($contract), + reasonCode: $state, + sourceContractState: $state, + contract: $contract, + sourceMetadata: array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== ''), + ); + } + private function exchangeTeamsWorkload(string $canonicalType): string { return match ($canonicalType) { @@ -395,6 +474,19 @@ private function sourceSchemaHash(array $contract): ?string return null; } + if (isset($contract['command_name'])) { + $schema = [ + 'adapter_pattern' => $contract['adapter_pattern'] ?? null, + 'allowed_parameters' => $contract['allowed_parameters'] ?? [], + 'canonical_type' => $contract['canonical_type'] ?? null, + 'command_name' => $contract['command_name'] ?? null, + 'response_shape' => $contract['response_shape'] ?? [], + 'source_surface' => $contract['source_surface'] ?? null, + ]; + + return hash('sha256', $this->canonicalJson($schema)); + } + $schema = [ 'resource' => $contract['resource'] ?? null, 'allowed_select' => $contract['allowed_select'] ?? [], diff --git a/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php new file mode 100644 index 00000000..a7b8fcce --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php @@ -0,0 +1,583 @@ + + */ + private const MUTATION_COMMAND_PREFIXES = [ + 'Set', + 'New', + 'Remove', + 'Enable', + 'Disable', + 'Update', + 'Start', + 'Stop', + 'Invoke', + 'Search', + 'Export', + 'Import', + ]; + + /** + * @return list + */ + public function includedCanonicalTypes(): array + { + return array_keys($this->contracts()); + } + + /** + * @return list + */ + public function commandNames(): array + { + return array_values(array_map( + static fn (array $contract): string => (string) $contract['command_name'], + $this->contracts(), + )); + } + + /** + * @return list + */ + 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|null + */ + public function contractForCanonicalType(string $canonicalType): ?array + { + return $this->contracts()[$canonicalType] ?? null; + } + + /** + * @return array|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 $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> + */ + 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 $identityFields + * @param list $derivedIdentityFields + * @param list $safeFields + * @param list $protectedFields + * @param list $sensitiveFields + * @param list $volatileFields + * @param list $permissionNotes + * @return array + */ + 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 $permissionNotes + * @return array + */ + 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 $identityFields + * @param list $derivedIdentityFields + * @param list $safeFields + * @param list $protectedFields + * @param list $sensitiveFields + * @param list $volatileFields + * @return array + */ + 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 $identityFields + * @param list $derivedIdentityFields + * @return array + */ + 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 $sensitiveFields + * @param list $protectedFields + * @return array + */ + 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 $identityFields + * @param list $derivedIdentityFields + * @param list $volatileFields + * @param list $sensitiveFields + * @return array + */ + 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 + */ + 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; + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php b/apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php index f4ba22ba..5c35a92e 100644 --- a/apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php +++ b/apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php @@ -224,6 +224,7 @@ private static function m365RepresentativeDefinitions(): array ['sharedMailbox', 'Shared mailbox', RestoreTier::PreviewOnly, 'medium', ['sharedMailboxes']], ['remoteDomain', 'Remote domain', RestoreTier::PreviewOnly, 'medium', ['remoteDomains']], ['mailboxPlan', 'Mailbox plan', RestoreTier::PreviewOnly, 'medium', ['mailboxPlans']], + ['inboundConnector', 'Inbound connector', RestoreTier::NotRestorable, 'high', ['inboundConnectors']], ['organizationConfig', 'Organization configuration', RestoreTier::NotRestorable, 'high', ['organizationConfiguration']], ]), ...self::m365ResourceDefinitions(Workload::Teams, [ diff --git a/apps/platform/app/Services/TenantConfiguration/SupportedScopeResolver.php b/apps/platform/app/Services/TenantConfiguration/SupportedScopeResolver.php index de1708de..23e4a0dd 100644 --- a/apps/platform/app/Services/TenantConfiguration/SupportedScopeResolver.php +++ b/apps/platform/app/Services/TenantConfiguration/SupportedScopeResolver.php @@ -316,6 +316,7 @@ private static function m365PlanningScopes(): array 'sharedMailbox', 'remoteDomain', 'mailboxPlan', + 'inboundConnector', 'organizationConfig', ]; $teams = [ diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec419M365RegistryExpansionTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec419M365RegistryExpansionTest.php index eef5f734..d1ee21d6 100644 --- a/apps/platform/tests/Feature/TenantConfiguration/Spec419M365RegistryExpansionTest.php +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec419M365RegistryExpansionTest.php @@ -141,7 +141,8 @@ ->and($aggregate->included_resource_types)->toContain('conditionalAccessPolicy') ->toContain('transportRule') ->toContain('meetingPolicy') - ->toContain('dlpCompliancePolicy'); + ->toContain('dlpCompliancePolicy') + ->not->toContain('inboundConnector'); }); it('Spec419 keeps registry sync idempotent and non-capturing', function (): void { @@ -150,10 +151,16 @@ (new SupportedScopeResolver)->syncDefaults(); (new SupportedScopeResolver)->syncDefaults(); - expect(TenantConfigurationResourceType::query()->count())->toBe(32) - ->and(TenantConfigurationSupportedScope::query()->count())->toBe(9) + $aggregate = TenantConfigurationSupportedScope::query() + ->where('scope_key', 'm365_tcm_registry_detected') + ->firstOrFail(); + + expect(TenantConfigurationResourceType::query()->count())->toBe(33) + ->and(TenantConfigurationSupportedScope::query()->count())->toBe(10) ->and(TenantConfigurationResource::query()->count())->toBe(0) - ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0) + ->and($aggregate->included_resource_types)->toHaveCount(25) + ->and($aggregate->included_resource_types)->toContain('inboundConnector'); }); it('Spec419 rollback deletes only exact Spec419 rows and preserves later non-Intune promotions', function (): void { diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php index 20f517cd..c311970b 100644 --- a/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php @@ -42,10 +42,10 @@ expect($graph->calls)->toBe([]) ->and($result['summary_counts'])->toMatchArray([ - 'total' => 4, - 'processed' => 4, + 'total' => 3, + 'processed' => 3, 'succeeded' => 0, - 'skipped' => 4, + 'skipped' => 3, 'failed' => 0, 'errors_recorded' => 0, ]) @@ -88,10 +88,10 @@ ->and($run->status)->toBe(OperationRunStatus::Completed->value) ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and($run->summary_counts)->toMatchArray([ - 'total' => 4, - 'processed' => 4, + 'total' => 3, + 'processed' => 3, 'succeeded' => 0, - 'skipped' => 4, + 'skipped' => 3, 'failed' => 0, 'errors_recorded' => 0, ]) @@ -112,7 +112,7 @@ */ function spec426CoreTypes(): array { - return ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', 'transportRule']; + return ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy']; } function spec426CoreRun($user, $environment, ProviderConnection $connection, array $resourceTypes): OperationRun diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoEvidencePromotionTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoEvidencePromotionTest.php index e5b737ae..fcfa7877 100644 --- a/apps/platform/tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoEvidencePromotionTest.php +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoEvidencePromotionTest.php @@ -38,10 +38,10 @@ expect($graph->calls)->toBe([]) ->and($result['summary_counts'])->toMatchArray([ - 'total' => 4, - 'processed' => 4, + 'total' => 3, + 'processed' => 3, 'succeeded' => 0, - 'skipped' => 4, + 'skipped' => 3, 'failed' => 0, 'errors_recorded' => 0, ]) @@ -62,7 +62,7 @@ */ function spec427NoEvidenceTypes(): array { - return ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', 'transportRule']; + return ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy']; } function spec427NoEvidenceRun($user, $environment, ProviderConnection $connection, array $resourceTypes): OperationRun diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec430ExchangePowerShellNoPromotionTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec430ExchangePowerShellNoPromotionTest.php new file mode 100644 index 00000000..edb77a3a --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec430ExchangePowerShellNoPromotionTest.php @@ -0,0 +1,195 @@ +count(); + + foreach (spec430IncludedTypes() as $canonicalType) { + app(CoverageSourceContractResolver::class)->resolve(spec430NoPromotionResourceType($canonicalType)); + } + + expect(OperationRun::query()->count())->toBe($beforeRuns) + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec430 capture path skips included adapter contracts without provider calls or evidence rows', function (): void { + app(ResourceTypeRegistry::class)->syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'scopes_granted' => ['Exchange.ManageAsApp'], + ]); + $graph = spec430NoPromotionGraphClient(); + app()->instance(GraphClientInterface::class, $graph); + + $run = OperationRun::factory()->withUser($user)->forTenant($environment)->create([ + 'type' => OperationRunType::TenantConfigurationCapture->value, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'target_scope' => [ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + ], + 'resource_types' => spec430IncludedTypes(), + 'required_capability' => 'evidence.manage', + ], + ]); + + $result = app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalTypes: spec430IncludedTypes(), + ); + + expect($graph->calls)->toBe([]) + ->and($result['summary_counts'])->toMatchArray([ + 'total' => 3, + 'processed' => 3, + 'succeeded' => 0, + 'skipped' => 3, + 'failed' => 0, + 'errors_recorded' => 0, + ]) + ->and($result['run_outcome'])->toBe(OperationRunOutcome::Blocked->value) + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); + + foreach (collect($result['outcomes'])->keyBy('canonical_type') as $canonicalType => $outcome) { + expect(spec430IncludedTypes())->toContain($canonicalType) + ->and($outcome['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value) + ->and($outcome['reason_code'])->toBe('contract_verified_pending_capture') + ->and($outcome['source_contract_key'])->toBe('exchange_powershell.'.$canonicalType); + } +}); + +it('Spec430 registry defaults remain internal non-evidence non-certified and non-restorable', function (): void { + app(ResourceTypeRegistry::class)->syncDefaults(); + + $rows = TenantConfigurationResourceType::query() + ->whereIn('canonical_type', spec430IncludedTypes()) + ->orderBy('canonical_type') + ->get(); + + expect($rows)->toHaveCount(3); + + foreach ($rows as $row) { + expect($row->default_coverage_level)->toBe(CoverageLevel::Detected) + ->and($row->default_evidence_state)->toBe(EvidenceState::NotCaptured) + ->and($row->default_claim_state)->toBe(ClaimState::InternalOnly) + ->and($row->allows_certified_claims)->toBeFalse() + ->and($row->allows_graph_fallback_claims)->toBeFalse() + ->and($row->metadata['customer_claims_allowed'])->toBeFalse() + ->and($row->restore_tier)->not->toBe(RestoreTier::Restorable); + } +}); + +it('Spec430 introduces no UI routes navigation legacy ownership or Exchange mini-platform artifacts', function (): void { + $runtimeFiles = [ + app_path('Services/TenantConfiguration/ExchangePowerShellCommandContracts.php'), + app_path('Services/TenantConfiguration/CoverageSourceContractResolver.php'), + app_path('Services/TenantConfiguration/ResourceTypeRegistry.php'), + app_path('Services/TenantConfiguration/SupportedScopeResolver.php'), + app_path('Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php'), + ]; + $runtimeSource = collect($runtimeFiles) + ->map(fn (string $file): string => file_get_contents($file) ?: '') + ->implode("\n"); + + expect($runtimeSource) + ->not->toContain('tenant_id') + ->not->toContain('provider_tenant_id') + ->not->toContain('entra_tenant_id') + ->and(File::exists(app_path('Services/TenantConfiguration/Exchange')))->toBeFalse() + ->and(File::exists(app_path('Services/Providers/ExchangeProviderAdapter.php')))->toBeFalse() + ->and(glob(base_path('routes/*exchange*')) ?: [])->toBe([]) + ->and(glob(resource_path('views/**/*exchange*')) ?: [])->toBe([]) + ->and(glob(app_path('Filament/**/*ExchangePowerShell*')) ?: [])->toBe([]); +}); + +/** + * @return list + */ +function spec430IncludedTypes(): array +{ + return ['transportRule', 'remoteDomain', 'inboundConnector']; +} + +function spec430NoPromotionResourceType(string $canonicalType): TenantConfigurationResourceType +{ + $definition = collect(ResourceTypeRegistry::defaultDefinitions()) + ->firstWhere('canonical_type', $canonicalType); + + expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}."); + + return new TenantConfigurationResourceType($definition); +} + +function spec430NoPromotionGraphClient(): GraphClientInterface +{ + return new class implements GraphClientInterface + { + /** + * @var list}> + */ + public array $calls = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->calls[] = ['policy_type' => $policyType, 'options' => $options]; + + return new GraphResponse(true, [['id' => 'unexpected-provider-call']]); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + }; +} diff --git a/apps/platform/tests/Feature/TenantConfiguration/TenantConfigurationResourceTypeRegistryTest.php b/apps/platform/tests/Feature/TenantConfiguration/TenantConfigurationResourceTypeRegistryTest.php index 7d5630e3..8c151c0f 100644 --- a/apps/platform/tests/Feature/TenantConfiguration/TenantConfigurationResourceTypeRegistryTest.php +++ b/apps/platform/tests/Feature/TenantConfiguration/TenantConfigurationResourceTypeRegistryTest.php @@ -21,7 +21,7 @@ $registry->syncDefaults(); $registry->syncDefaults(); - expect(TenantConfigurationResourceType::query()->count())->toBe(32) + expect(TenantConfigurationResourceType::query()->count())->toBe(33) ->and(TenantConfigurationResourceType::query() ->where('canonical_type', 'roleScopeTag') ->where('source_class', SourceClass::GraphBetaExperimental->value) diff --git a/apps/platform/tests/Support/TenantConfiguration/Spec430ExchangePowerShellFakeCommandRunner.php b/apps/platform/tests/Support/TenantConfiguration/Spec430ExchangePowerShellFakeCommandRunner.php new file mode 100644 index 00000000..eb4e0099 --- /dev/null +++ b/apps/platform/tests/Support/TenantConfiguration/Spec430ExchangePowerShellFakeCommandRunner.php @@ -0,0 +1,55 @@ +}> + */ + public array $calls = []; + + public function __construct( + private readonly ?ExchangePowerShellCommandContracts $contracts = null, + ) {} + + /** + * @param array $parameters + * @param list> $items + * @return array + */ + public function run(mixed $contract, array $parameters = [], array $items = []): array + { + $contracts = $this->contracts ?? new ExchangePowerShellCommandContracts; + $validation = $contracts->validateInvocation($contract, $parameters); + + if (! $validation['accepted']) { + return [ + 'ok' => false, + 'reason_code' => $validation['reason_code'], + 'source' => 'spec430_fake_exchange_powershell_runner', + 'shell_executed' => false, + 'provider_call_executed' => false, + ]; + } + + $this->calls[] = [ + 'command_name' => (string) $contract['command_name'], + 'parameters' => $parameters, + ]; + + return [ + 'ok' => true, + 'reason_code' => null, + 'source' => 'spec430_fake_exchange_powershell_runner', + 'command_name' => (string) $contract['command_name'], + 'items' => $items, + 'shell_executed' => false, + 'provider_call_executed' => false, + ]; + } +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php index d8a743b8..73e6dcfd 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php @@ -17,6 +17,8 @@ 'conditionalAccessPolicy', 'securityDefaults', 'transportRule', + 'remoteDomain', + 'inboundConnector', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec419M365WorkloadRegistryTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec419M365WorkloadRegistryTest.php index 60969f68..5c387ced 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec419M365WorkloadRegistryTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec419M365WorkloadRegistryTest.php @@ -55,6 +55,7 @@ 'sharedMailbox', 'remoteDomain', 'mailboxPlan', + 'inboundConnector', 'organizationConfig', ], Workload::Teams->value => [ @@ -75,8 +76,8 @@ ], ]; - expect($definitions)->toHaveCount(32) - ->and($definitions->reject(fn (array $definition): bool => $definition['workload'] === Workload::Intune->value))->toHaveCount(24); + expect($definitions)->toHaveCount(33) + ->and($definitions->reject(fn (array $definition): bool => $definition['workload'] === Workload::Intune->value))->toHaveCount(25); foreach ($expectedByWorkload as $workload => $canonicalTypes) { $workloadDefinitions = $definitions->where('workload', $workload)->values(); @@ -137,7 +138,7 @@ it('Spec419 classifies high-risk restore tiers conservatively', function (): void { $definitions = collect(ResourceTypeRegistry::defaultDefinitions())->keyBy('canonical_type'); - foreach (['conditionalAccessPolicy', 'securityDefaults', 'roleDefinition', 'transportRule', 'organizationConfig', 'labelPolicy', 'retentionCompliancePolicy', 'dlpCompliancePolicy', 'autoSensitivityLabelPolicy', 'protectionAlert', 'complianceTag'] as $canonicalType) { + foreach (['conditionalAccessPolicy', 'securityDefaults', 'roleDefinition', 'transportRule', 'inboundConnector', 'organizationConfig', 'labelPolicy', 'retentionCompliancePolicy', 'dlpCompliancePolicy', 'autoSensitivityLabelPolicy', 'protectionAlert', 'complianceTag'] as $canonicalType) { expect($definitions[$canonicalType]['restore_tier'])->toBe(RestoreTier::NotRestorable->value); } diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php index bca3bf35..f1f2ea1f 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php @@ -18,25 +18,25 @@ use App\Support\TenantConfiguration\SupportState; use App\Support\TenantConfiguration\Workload; -it('Spec420 never derives runtime endpoints from remaining M365 source aliases without explicit contracts', function (string $canonicalType, string $reasonCode): void { +it('Spec420 never derives runtime endpoints from remaining M365 source aliases without explicit contracts', function (string $canonicalType, string $reasonCode, CaptureOutcome $outcome): void { $resourceType = spec420EligibilityResourceType($canonicalType); $aliases = $resourceType->metadata['source_aliases'] ?? []; $decision = (new CoverageSourceContractResolver(new GraphContractRegistry))->resolve($resourceType); expect($aliases)->toBeArray()->not->toBeEmpty() - ->and($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract) + ->and($decision->outcome)->toBe($outcome) ->and($decision->sourceEndpoint)->toBeNull() - ->and($decision->sourceMetadata['reason_code'])->toBe($reasonCode); + ->and($decision->reasonCode)->toBe($reasonCode); })->with([ - 'transportRule' => ['transportRule', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING], - 'acceptedDomain' => ['acceptedDomain', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING], - 'appPermissionPolicy' => ['appPermissionPolicy', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING], - 'meetingPolicy' => ['meetingPolicy', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING], - 'dlpCompliancePolicy' => ['dlpCompliancePolicy', 'missing_source_contract_mapping'], + 'transportRule' => ['transportRule', CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE, CaptureOutcome::BlockedMissingContract], + 'acceptedDomain' => ['acceptedDomain', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING, CaptureOutcome::BlockedMissingContract], + 'appPermissionPolicy' => ['appPermissionPolicy', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING, CaptureOutcome::BlockedMissingContract], + 'meetingPolicy' => ['meetingPolicy', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING, CaptureOutcome::BlockedMissingContract], + 'dlpCompliancePolicy' => ['dlpCompliancePolicy', 'missing_source_contract_mapping', CaptureOutcome::BlockedMissingContract], ]); -it('Spec420 keeps existing beta and unsupported outcomes instead of adding a new outcome family', function (): void { +it('Spec420 keeps bounded capture outcomes without legacy gap vocabulary', function (): void { $resolver = new CoverageSourceContractResolver(new GraphContractRegistry); $beta = $resolver->resolve(spec420EligibilityAdHocResourceType( diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php index a278ae25..fae0a521 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php @@ -9,7 +9,7 @@ use App\Services\TenantConfiguration\ResourceTypeRegistry; use App\Support\TenantConfiguration\CaptureOutcome; -it('Spec426 blocks Exchange and Teams core capture until a verified source contract exists', function (string $canonicalType): void { +it('Spec426 blocks remaining Exchange and Teams core capture until a verified source contract exists', function (string $canonicalType): void { $decision = (new CoverageSourceContractResolver(new GraphContractRegistry)) ->resolve(spec426ContractResourceType($canonicalType)); @@ -20,7 +20,6 @@ ->and($decision->sourceEndpoint)->toBeNull() ->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]); })->with([ - 'transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTeamsSourceContractStateTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTeamsSourceContractStateTest.php index 425420bd..29fd823f 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTeamsSourceContractStateTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTeamsSourceContractStateTest.php @@ -22,7 +22,7 @@ ]); }); -it('Spec427 resolves every target type to an exact non-capturable source-contract blocker', function (string $canonicalType): void { +it('Spec427 resolves remaining target types to exact non-capturable source-contract blockers', function (string $canonicalType): void { $decision = (new CoverageSourceContractResolver(new GraphContractRegistry)) ->resolve(spec427StateResourceType($canonicalType)); @@ -37,7 +37,6 @@ ->and($decision->sourceEndpoint)->toBeNull() ->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]); })->with([ - 'transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTransportRuleContractTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTransportRuleContractTest.php index 5890a719..4a39afda 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTransportRuleContractTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTransportRuleContractTest.php @@ -7,21 +7,24 @@ use App\Services\TenantConfiguration\CoverageSourceContractDecision; use App\Services\TenantConfiguration\CoverageSourceContractResolver; use App\Services\TenantConfiguration\ResourceTypeRegistry; +use App\Support\TenantConfiguration\CaptureOutcome; -it('Spec427 classifies transportRule as adapter-blocked with bounded Exchange source metadata', function (): void { +it('Spec430 supersedes the Spec427 transportRule adapter blocker with bounded pending-capture metadata', function (): void { $decision = (new CoverageSourceContractResolver(new GraphContractRegistry)) ->resolve(spec427TransportRuleResourceType()); - expect($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING) + expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract) + ->and($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE) ->and($decision->sourceMetadata['workload'])->toBe('exchange') ->and($decision->sourceMetadata['source_class'])->toBe('tcm') - ->and($decision->sourceMetadata['source_contract_name'])->toBe('exchange.transportRule.source_contract_review') - ->and($decision->sourceMetadata['source_contract_version'])->toBe('review-2026-07-03') - ->and($decision->sourceMetadata['provider_adapter_state'])->toBe('missing') + ->and($decision->sourceMetadata['source_contract_name'])->toBe('exchange_powershell.transportRule') + ->and($decision->sourceMetadata['source_version'])->toBe('exchange-powershell-command-contract-v1') + ->and($decision->sourceMetadata['provider_adapter_state'])->toBe('adapter_contract_available') + ->and($decision->sourceMetadata['provider_calls_allowed'])->toBeFalse() ->and($decision->sourceMetadata['identity_handoff']['preferred_identity_fields'])->toBe(['id', 'sourceId', 'Guid', 'RuleId']) - ->and($decision->sourceMetadata['identity_handoff']['fallback_identity_fields'])->toBe([]) + ->and($decision->sourceMetadata['identity_handoff']['display_name_is_stable_identity'])->toBeFalse() ->and($decision->sourceMetadata['redaction_rules']['sensitive_fields'])->toContain('SubjectContainsWords') - ->and($decision->sourceMetadata['normalization_handoff']['expected_normalized_shape'])->toBe('exchange_teams_comparable_payload'); + ->and($decision->sourceMetadata['normalization_handoff']['expected_normalized_shape'])->toBe('future_exchange_powershell_normalized_payload'); }); function spec427TransportRuleResourceType(): TenantConfigurationResourceType diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractIdentityHandoffTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractIdentityHandoffTest.php index b20e8b41..8071cec1 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractIdentityHandoffTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractIdentityHandoffTest.php @@ -31,7 +31,6 @@ expect(array_intersect($identityFields, ['displayName', 'DisplayName', 'name', 'Name', 'Identity'])) ->toBe([]); })->with([ - 'transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', @@ -48,7 +47,6 @@ ->and($result->sourceResourceId)->toStartWith('missing:') ->and($result->canonicalResourceKey)->not->toContain('Display-only object'); })->with([ - 'transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractPermissionMetadataTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractPermissionMetadataTest.php index 1393fd50..3fc4d22a 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractPermissionMetadataTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractPermissionMetadataTest.php @@ -23,7 +23,6 @@ ->and($permissions['redacted_permission_context'])->toBeTrue() ->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]); })->with([ - 'transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractRedactionTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractRedactionTest.php index b8fdbaa7..cb9f4366 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractRedactionTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractRedactionTest.php @@ -27,7 +27,6 @@ ->and($metadataJson)->not->toContain('client-secret-value') ->and($metadataJson)->not->toContain('authorization-token-value'); })->with([ - 'transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractResponseShapeTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractResponseShapeTest.php index 305d354e..b29a5d9a 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractResponseShapeTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractResponseShapeTest.php @@ -27,7 +27,6 @@ ->and($shape['collection_item_path'])->toBe('value') ->and($shape['pagination_cursor_path'])->toBe('@odata.nextLink'); })->with([ - 'transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellCommandAllowlistTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellCommandAllowlistTest.php new file mode 100644 index 00000000..95be0601 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellCommandAllowlistTest.php @@ -0,0 +1,109 @@ +includedCanonicalTypes())->toBe([ + 'transportRule', + 'remoteDomain', + 'inboundConnector', + ]) + ->and($contracts->commandNames())->toBe([ + 'Get-TransportRule', + 'Get-RemoteDomain', + 'Get-InboundConnector', + ]); +}); + +it('Spec430 rejects mutation command families before runner execution', function (string $prefix): void { + $validation = (new ExchangePowerShellCommandContracts)->validateCommandName($prefix.'-TransportRule'); + + expect($validation)->toMatchArray([ + 'accepted' => false, + 'reason_code' => 'mutation_command_rejected', + ]); +})->with([ + 'Set', + 'New', + 'Remove', + 'Enable', + 'Disable', + 'Update', + 'Start', + 'Stop', + 'Invoke', + 'Search', + 'Export', + 'Import', +]); + +it('Spec430 rejects raw command text and command-injection fragments', function (mixed $command, string $reasonCode): void { + $contracts = new ExchangePowerShellCommandContracts; + + if (is_string($command)) { + expect($contracts->validateCommandName($command)['reason_code'])->toBe($reasonCode); + + return; + } + + expect($contracts->validateInvocation($command)['reason_code'])->toBe($reasonCode); +})->with([ + 'raw array list' => [['Get-TransportRule'], 'raw_command_rejected'], + 'raw semicolon' => ['Get-TransportRule; Remove-TransportRule test', 'unsafe_command_text_rejected'], + 'pipeline' => ['Get-TransportRule | Out-File rules.txt', 'unsafe_command_text_rejected'], + 'redirection' => ['Get-TransportRule > rules.txt', 'unsafe_command_text_rejected'], + 'script block' => ['Get-TransportRule { Remove-TransportRule test }', 'unsafe_command_text_rejected'], + 'module installation' => ['Install-Module ExchangeOnlineManagement', 'unsafe_command_text_rejected'], +]); + +it('Spec430 rejects unknown command names and arbitrary parameters', function (): void { + $contracts = new ExchangePowerShellCommandContracts; + $contract = $contracts->contractForCanonicalType('transportRule'); + + expect($contracts->validateCommandName('Get-Mailbox'))->toMatchArray([ + 'accepted' => false, + 'reason_code' => 'command_not_allowlisted', + ]) + ->and($contracts->validateInvocation($contract, ['Identity' => 'rule-1']))->toMatchArray([ + 'accepted' => false, + 'reason_code' => 'unknown_parameter_rejected', + 'rejected_parameter' => 'Identity', + ]); +}); + +it('Spec430 fake runner accepts structured contracts only and never executes shell or provider calls', function (): void { + $contracts = new ExchangePowerShellCommandContracts; + $runner = new Spec430ExchangePowerShellFakeCommandRunner($contracts); + $contract = $contracts->contractForCanonicalType('remoteDomain'); + + $success = $runner->run($contract, items: [['Guid' => 'remote-domain-guid']]); + $rawFailure = $runner->run('Get-RemoteDomain'); + + expect($success)->toMatchArray([ + 'ok' => true, + 'command_name' => 'Get-RemoteDomain', + 'shell_executed' => false, + 'provider_call_executed' => false, + ]) + ->and($success['items'])->toBe([['Guid' => 'remote-domain-guid']]) + ->and($rawFailure)->toMatchArray([ + 'ok' => false, + 'reason_code' => 'raw_command_rejected', + 'shell_executed' => false, + 'provider_call_executed' => false, + ]) + ->and($runner->calls)->toHaveCount(1); + + $source = file_get_contents(repo_path('apps/platform/tests/Support/TenantConfiguration/Spec430ExchangePowerShellFakeCommandRunner.php')) ?: ''; + + expect($source) + ->not->toContain('shell_exec(') + ->not->toContain('proc_open(') + ->not->toContain('passthru(') + ->not->toContain('Microsoft\\'); +}); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellMetadataTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellMetadataTest.php new file mode 100644 index 00000000..8a8dce6e --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellMetadataTest.php @@ -0,0 +1,108 @@ +contractForCanonicalType($canonicalType); + $permissions = $contract['permission_model']; + + expect($permissions['status'])->toBe('runtime_validation_pending') + ->and($permissions['known_permission_names'])->toBe(['Exchange.ManageAsApp']) + ->and($permissions['least_privilege_runtime_validated'])->toBeFalse() + ->and($permissions['exchange_rbac_runtime_validated'])->toBeFalse() + ->and($permissions['admin_consent_required'])->toBeTrue() + ->and($permissions['permission_failure_modes'])->toContain('permission_denied') + ->and($permissions['permission_failure_modes'])->toContain('command_unavailable') + ->and($permissions['permission_failure_modes'])->toContain('adapter_unavailable'); +})->with(['transportRule', 'remoteDomain', 'inboundConnector']); + +it('Spec430 response-shape metadata distinguishes empty denied unavailable malformed and unexpected shapes', function (string $canonicalType): void { + $shape = (new ExchangePowerShellCommandContracts)->contractForCanonicalType($canonicalType)['response_shape']; + + expect($shape['raw_payload_shape'])->toBe('exchange_powershell_structured_collection') + ->and($shape['collection_item_path'])->toBe('items') + ->and($shape['empty_collection_meaning'])->toBe('valid_empty_collection') + ->and($shape['permission_denied_response_meaning'])->toBe('permission_denied') + ->and($shape['command_unavailable_response_meaning'])->toBe('command_unavailable') + ->and($shape['adapter_unavailable_response_meaning'])->toBe('adapter_unavailable') + ->and($shape['malformed_response_meaning'])->toBe('malformed_response') + ->and($shape['unexpected_object_shape_meaning'])->toBe('unexpected_object_shape') + ->and($shape['response_shape_safety'])->toBe('fail_closed_until_empty_denied_unavailable_malformed_and_unexpected_shapes_are_distinct'); +})->with(['transportRule', 'remoteDomain', 'inboundConnector']); + +it('Spec430 display-name-only payloads do not produce stable identity for included types', function (string $canonicalType): void { + $result = app(CanonicalIdentityResolver::class)->resolve( + spec430MetadataResourceType($canonicalType), + ['DisplayName' => 'Display-only object', 'Name' => 'Display-only object'], + ['source_contract_key' => 'exchange_powershell.'.$canonicalType, 'source_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION], + ); + + expect($result->identityState)->toBe(IdentityState::MissingExternalId) + ->and($result->sourceResourceId)->toStartWith('missing:') + ->and($result->canonicalResourceKey)->not->toContain('Display-only object'); +})->with(['transportRule', 'remoteDomain', 'inboundConnector']); + +it('Spec430 redaction metadata forbids secrets raw shell output content and protected configuration exposure', function (string $canonicalType): void { + $rules = (new ExchangePowerShellCommandContracts)->contractForCanonicalType($canonicalType)['redaction_rules']; + $json = json_encode($rules, JSON_THROW_ON_ERROR); + + expect($rules['raw_payload_default_visible'])->toBeFalse() + ->and($rules['permission_context_default_visible'])->toBeFalse() + ->and($rules['forbidden_default_output'])->toContain('tokens') + ->and($rules['forbidden_default_output'])->toContain('secrets') + ->and($rules['forbidden_default_output'])->toContain('authorization_header') + ->and($rules['forbidden_default_output'])->toContain('cookies') + ->and($rules['forbidden_default_output'])->toContain('certificate_private_material') + ->and($rules['forbidden_default_output'])->toContain('passwords') + ->and($rules['forbidden_default_output'])->toContain('raw_transcripts') + ->and($rules['forbidden_default_output'])->toContain('raw_shell_stdout') + ->and($rules['forbidden_default_output'])->toContain('raw_shell_stderr') + ->and($rules['forbidden_default_output'])->toContain('mail_body_content') + ->and($rules['forbidden_default_output'])->toContain('mailbox_content') + ->and($rules['forbidden_default_output'])->toContain('file_content') + ->and($rules['forbidden_default_output'])->toContain('teams_transcript_content') + ->and($json)->not->toContain('client-secret-value') + ->and($json)->not->toContain('authorization-token-value'); +})->with(['transportRule', 'remoteDomain', 'inboundConnector']); + +it('Spec430 future execution scope metadata preserves workspace managed-environment and provider-connection handoff', function (string $canonicalType): void { + $scope = (new ExchangePowerShellCommandContracts)->contractForCanonicalType($canonicalType)['future_execution_scope']; + + expect($scope)->toMatchArray([ + 'requires_workspace_id' => true, + 'requires_managed_environment_id' => true, + 'requires_provider_connection_id' => true, + 'provider_native_scope_identifiers_are_metadata_only' => true, + ]); +})->with(['transportRule', 'remoteDomain', 'inboundConnector']); + +it('Spec430 claim guard allows only the exact internal adapter-contract statement', function (): void { + $guard = app(ClaimGuard::class); + + expect($guard->evaluateStatement(ExchangePowerShellCommandContracts::ALLOWED_INTERNAL_CLAIM, internalOperatorOnly: true)) + ->toBe(ClaimState::InternalOnly) + ->and($guard->evaluateStatement(ExchangePowerShellCommandContracts::ALLOWED_INTERNAL_CLAIM, internalOperatorOnly: false)) + ->toBe(ClaimState::ClaimBlocked) + ->and($guard->evaluateStatement('Exchange PowerShell adapter contracts are customer ready', internalOperatorOnly: true)) + ->toBe(ClaimState::ClaimBlocked) + ->and($guard->evaluateStatement('Exchange PowerShell coverage is certified', internalOperatorOnly: true)) + ->toBe(ClaimState::ClaimBlocked); +}); + +function spec430MetadataResourceType(string $canonicalType): TenantConfigurationResourceType +{ + $definition = collect(ResourceTypeRegistry::defaultDefinitions()) + ->firstWhere('canonical_type', $canonicalType); + + expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}."); + + return new TenantConfigurationResourceType($definition); +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellResolverTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellResolverTest.php new file mode 100644 index 00000000..f19992f0 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellResolverTest.php @@ -0,0 +1,121 @@ +resolve(spec430ResolverResourceType($canonicalType)); + + expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract) + ->and($decision->reasonCode)->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE) + ->and($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE) + ->and($decision->contractKey)->toBe('exchange_powershell.'.$canonicalType) + ->and($decision->sourceEndpoint)->toBeNull() + ->and($decision->capturable())->toBeFalse() + ->and($decision->sourceMetadata['source_contract_state'])->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE) + ->and($decision->sourceMetadata['provider_adapter_state'])->toBe('adapter_contract_available') + ->and($decision->sourceMetadata['capture_eligibility_state'])->toBe('pending_capture') + ->and($decision->sourceMetadata['source_surface'])->toBe('exchange_online_powershell_rest') + ->and($decision->sourceMetadata['adapter_pattern'])->toBe('new_exchange_powershell_adapter') + ->and($decision->sourceMetadata['provider_calls_allowed'])->toBeFalse() + ->and($decision->sourceMetadata['execution_enabled'])->toBeFalse() + ->and($decision->sourceMetadata['evidence_promotion_allowed'])->toBeFalse() + ->and($decision->sourceMetadata['command_contract']['command_name'])->toBe($commandName) + ->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]); +})->with([ + 'transportRule' => ['transportRule', 'Get-TransportRule'], + 'remoteDomain' => ['remoteDomain', 'Get-RemoteDomain'], + 'inboundConnector' => ['inboundConnector', 'Get-InboundConnector'], +]); + +it('Spec430 preserves blocked or deferred states for excluded Exchange and Teams types', function (string $canonicalType, Workload $workload): void { + $decision = (new CoverageSourceContractResolver(new GraphContractRegistry)) + ->resolve(spec430AdHocResolverResourceType($canonicalType, $workload)); + + expect($decision->sourceContractState)->not->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE) + ->and($decision->sourceMetadata['provider_adapter_state'] ?? null)->not->toBe('adapter_contract_available') + ->and($decision->capturable())->toBeFalse() + ->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]); +})->with([ + 'acceptedDomain' => ['acceptedDomain', Workload::Exchange], + 'organizationConfig' => ['organizationConfig', Workload::Exchange], + 'mailboxPlan' => ['mailboxPlan', Workload::Exchange], + 'outboundConnector' => ['outboundConnector', Workload::Exchange], + 'sharingPolicy' => ['sharingPolicy', Workload::Exchange], + 'appPermissionPolicy' => ['appPermissionPolicy', Workload::Teams], + 'appSetupPolicy' => ['appSetupPolicy', Workload::Teams], + 'meetingPolicy' => ['meetingPolicy', Workload::Teams], + 'messagingPolicy' => ['messagingPolicy', Workload::Teams], + 'teamsUpdateManagementPolicy' => ['teamsUpdateManagementPolicy', Workload::Teams], + 'teamsChannelsPolicy' => ['teamsChannelsPolicy', Workload::Teams], + 'externalAccessPolicy' => ['externalAccessPolicy', Workload::Teams], +]); + +it('Spec430 adds inboundConnector only to the inactive Exchange planning registry denominator', function (): void { + $definitions = collect(ResourceTypeRegistry::defaultDefinitions()); + $inbound = $definitions->firstWhere('canonical_type', 'inboundConnector'); + + expect($inbound)->not->toBeNull() + ->and($inbound['workload'])->toBe(Workload::Exchange->value) + ->and($inbound['source_class'])->toBe(SourceClass::Tcm->value) + ->and($inbound['support_state'])->toBe(SupportState::OutOfScope->value) + ->and($inbound['default_coverage_level'])->toBe(CoverageLevel::Detected->value) + ->and($inbound['default_evidence_state'])->toBe(EvidenceState::NotCaptured->value) + ->and($inbound['default_claim_state'])->toBe(ClaimState::InternalOnly->value) + ->and($inbound['restore_tier'])->toBe(RestoreTier::NotRestorable->value) + ->and($definitions->firstWhere('canonical_type', 'outboundConnector'))->toBeNull() + ->and($definitions->firstWhere('canonical_type', 'sharingPolicy'))->toBeNull() + ->and($definitions->firstWhere('canonical_type', 'externalAccessPolicy'))->toBeNull(); +}); + +function spec430ResolverResourceType(string $canonicalType): TenantConfigurationResourceType +{ + $definition = collect(ResourceTypeRegistry::defaultDefinitions()) + ->firstWhere('canonical_type', $canonicalType); + + expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}."); + + return new TenantConfigurationResourceType($definition); +} + +function spec430AdHocResolverResourceType(string $canonicalType, Workload $workload): TenantConfigurationResourceType +{ + $definition = collect(ResourceTypeRegistry::defaultDefinitions()) + ->firstWhere('canonical_type', $canonicalType); + + if (is_array($definition)) { + return new TenantConfigurationResourceType($definition); + } + + return new TenantConfigurationResourceType([ + 'canonical_type' => $canonicalType, + 'display_name' => $canonicalType, + 'source_class' => SourceClass::Tcm->value, + 'workload' => $workload->value, + 'resource_class' => ResourceClass::Configuration->value, + 'support_state' => SupportState::OutOfScope->value, + 'default_coverage_level' => CoverageLevel::Detected->value, + 'default_evidence_state' => EvidenceState::NotCaptured->value, + 'default_identity_state' => IdentityState::Derived->value, + 'default_claim_state' => ClaimState::InternalOnly->value, + 'restore_tier' => RestoreTier::NotRestorable->value, + 'is_active' => true, + 'metadata' => ['kernel' => 'coverage_v2'], + ]); +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430InboundConnectorCommandContractTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430InboundConnectorCommandContractTest.php new file mode 100644 index 00000000..85f3d37f --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430InboundConnectorCommandContractTest.php @@ -0,0 +1,36 @@ +contractForCanonicalType('inboundConnector'); + + expect($contract)->not->toBeNull() + ->and($contract['command_name'])->toBe('Get-InboundConnector') + ->and($contract['source_surface'])->toBe('exchange_online_powershell_rest') + ->and($contract['adapter_pattern'])->toBe('new_exchange_powershell_adapter') + ->and($contract['read_only'])->toBeTrue() + ->and($contract['allowed_parameters'])->toBe([]) + ->and($contract['collection_shape'])->toBe('collection') + ->and($contract['fake_runner_testable'])->toBeTrue() + ->and($contract['provider_calls_allowed'])->toBeFalse() + ->and($contract['execution_enabled'])->toBeFalse() + ->and($contract['capture_eligibility_state'])->toBe('pending_capture') + ->and($contract['restore_tier'])->toBe('not_restorable'); +}); + +it('Spec430 inboundConnector contract protects connector routing and certificate metadata', function (): void { + $contract = (new ExchangePowerShellCommandContracts)->contractForCanonicalType('inboundConnector'); + + expect($contract['identity_handoff']['preferred_identity_fields'])->toBe(['id', 'sourceId', 'Guid', 'ConnectorId', 'Identity']) + ->and($contract['identity_handoff']['display_name_is_stable_identity'])->toBeFalse() + ->and($contract['response_shape']['protected_configuration_fields'])->toContain('SenderDomains') + ->and($contract['response_shape']['protected_configuration_fields'])->toContain('SenderIPAddresses') + ->and($contract['response_shape']['protected_configuration_fields'])->toContain('TlsSenderCertificateName') + ->and($contract['redaction_rules']['sensitive_fields'])->toContain('SmartHosts') + ->and($contract['redaction_rules']['sensitive_fields'])->toContain('TlsSenderCertificateName') + ->and($contract['normalization_handoff']['identity_fields'])->toContain('ConnectorId') + ->and($contract['claim_boundary']['restore_allowed'])->toBeFalse(); +}); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430RemoteDomainCommandContractTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430RemoteDomainCommandContractTest.php new file mode 100644 index 00000000..36d60efd --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430RemoteDomainCommandContractTest.php @@ -0,0 +1,35 @@ +contractForCanonicalType('remoteDomain'); + + expect($contract)->not->toBeNull() + ->and($contract['command_name'])->toBe('Get-RemoteDomain') + ->and($contract['source_surface'])->toBe('exchange_online_powershell_rest') + ->and($contract['adapter_pattern'])->toBe('new_exchange_powershell_adapter') + ->and($contract['read_only'])->toBeTrue() + ->and($contract['allowed_parameters'])->toBe([]) + ->and($contract['collection_shape'])->toBe('collection') + ->and($contract['fake_runner_testable'])->toBeTrue() + ->and($contract['provider_calls_allowed'])->toBeFalse() + ->and($contract['execution_enabled'])->toBeFalse() + ->and($contract['capture_eligibility_state'])->toBe('pending_capture') + ->and($contract['restore_tier'])->toBe('not_restorable'); +}); + +it('Spec430 remoteDomain contract distinguishes stable and derived identity candidates', function (): void { + $contract = (new ExchangePowerShellCommandContracts)->contractForCanonicalType('remoteDomain'); + + expect($contract['identity_handoff']['preferred_identity_fields'])->toBe(['id', 'sourceId', 'Guid', 'RemoteDomainId', 'Identity']) + ->and($contract['identity_handoff']['derived_identity_fields'])->toBe(['DomainName', 'domainName']) + ->and($contract['identity_handoff']['display_name_is_stable_identity'])->toBeFalse() + ->and($contract['response_shape']['protected_configuration_fields'])->toContain('DomainName') + ->and($contract['response_shape']['protected_configuration_fields'])->toContain('TargetDeliveryDomain') + ->and($contract['redaction_rules']['protected_configuration_fields'])->toContain('AutoForwardEnabled') + ->and($contract['normalization_handoff']['identity_fields'])->toContain('RemoteDomainId') + ->and($contract['permission_model']['least_privilege_runtime_validated'])->toBeFalse(); +}); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430TransportRuleCommandContractTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430TransportRuleCommandContractTest.php new file mode 100644 index 00000000..80bc303e --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec430TransportRuleCommandContractTest.php @@ -0,0 +1,36 @@ +contractForCanonicalType('transportRule'); + + expect($contract)->not->toBeNull() + ->and($contract['command_name'])->toBe('Get-TransportRule') + ->and($contract['source_surface'])->toBe('exchange_online_powershell_rest') + ->and($contract['adapter_pattern'])->toBe('new_exchange_powershell_adapter') + ->and($contract['read_only'])->toBeTrue() + ->and($contract['allowed_parameters'])->toBe([]) + ->and($contract['collection_shape'])->toBe('collection') + ->and($contract['fake_runner_testable'])->toBeTrue() + ->and($contract['provider_calls_allowed'])->toBeFalse() + ->and($contract['execution_enabled'])->toBeFalse() + ->and($contract['capture_eligibility_state'])->toBe('pending_capture') + ->and($contract['restore_tier'])->toBe('not_restorable'); +}); + +it('Spec430 transportRule contract records identity, response, redaction, and normalization handoff metadata', function (): void { + $contract = (new ExchangePowerShellCommandContracts)->contractForCanonicalType('transportRule'); + + expect($contract['identity_handoff']['preferred_identity_fields'])->toBe(['id', 'sourceId', 'Guid', 'RuleId']) + ->and($contract['identity_handoff']['display_name_is_stable_identity'])->toBeFalse() + ->and($contract['response_shape']['safe_fields'])->toContain('Guid') + ->and($contract['response_shape']['safe_fields'])->toContain('Priority') + ->and($contract['response_shape']['protected_configuration_fields'])->toContain('SenderDomainIs') + ->and($contract['redaction_rules']['sensitive_fields'])->toContain('SubjectOrBodyContainsWords') + ->and($contract['redaction_rules']['forbidden_default_output'])->toContain('raw_shell_stdout') + ->and($contract['normalization_handoff']['identity_fields'])->toContain('RuleId') + ->and($contract['claim_boundary']['customer_claims_allowed'])->toBeFalse(); +}); diff --git a/specs/430-exchange-powershell-adapter-contract-slice-1/checklists/requirements.md b/specs/430-exchange-powershell-adapter-contract-slice-1/checklists/requirements.md new file mode 100644 index 00000000..9420912b --- /dev/null +++ b/specs/430-exchange-powershell-adapter-contract-slice-1/checklists/requirements.md @@ -0,0 +1,161 @@ +# Requirements Checklist: Exchange PowerShell Adapter Contract Slice 1 + +**Purpose**: Validate that Spec 430 is bounded, implementation-ready, and safe to hand to an implementation loop. Checked items mean the requirement is specified by the artifacts, not already implemented in application code. +**Created**: 2026-07-04 +**Feature**: `specs/430-exchange-powershell-adapter-contract-slice-1/spec.md` + +## Preparation Scope + +- [x] `spec.md` exists. +- [x] `plan.md` exists. +- [x] `tasks.md` exists. +- [x] Candidate source is documented from the user-provided draft and Spec 429. +- [x] Completed-spec guardrail was applied to Specs 426-429 as read-only context. +- [x] No existing `specs/430-*` package was found before creating this one. +- [x] Current branch and pre-Spec Kit HEAD/dirty state are recorded. +- [x] No application code was modified during preparation. + +## Candidate Selection Gate + +- [x] Selected candidate is directly provided by the user and aligned with Spec 429. +- [x] Selected candidate is not already covered by an active or completed Spec 430. +- [x] Candidate is scoped as a small implementation-ready slice. +- [x] Close alternatives are deferred instead of hidden in primary scope. +- [x] Roadmap relationship is explicit: Spec 430 unblocks Spec 431. +- [x] Candidate Selection Gate result is recorded as PASS. + +## Scope + +- [x] Only `transportRule` is included. +- [x] Only `remoteDomain` is included. +- [x] Only `inboundConnector` is included. +- [x] No Teams target type is included. +- [x] No Exchange Admin API adapter is included. +- [x] No `outboundConnector` is included. +- [x] No `acceptedDomain` or `organizationConfig` is included. +- [x] No evidence capture is included. +- [x] No OperationRun is included. +- [x] No compare/render is included. +- [x] No certification is included. +- [x] No restore/apply is included. +- [x] No customer claim is included. +- [x] No UI is included. + +## Adapter Safety Requirements + +- [x] Structured command contract requirement is specified. +- [x] Static allowlisted read-only commands are specified. +- [x] Raw command strings must be rejected. +- [x] Unknown parameters must be rejected. +- [x] Mutation commands must be rejected. +- [x] Fake runner must be used in tests. +- [x] No shell execution in unit tests is required. +- [x] No provider calls in tests is required. +- [x] Production live execution remains disabled/inert for this slice. + +## Source Contract Requirements + +- [x] `transportRule` contract requirement exists. +- [x] `remoteDomain` contract requirement exists. +- [x] `inboundConnector` contract requirement exists. +- [x] Resolver must return verified pending capture for included types. +- [x] Missing adapter state must be removed for included types only. +- [x] Missing contract state must be removed for included types only. +- [x] Excluded types must remain blocked or deferred. + +## Metadata Requirements + +- [x] Permission metadata is required. +- [x] Runtime permission validation must remain pending unless proven. +- [x] Response-shape metadata is required. +- [x] Identity handoff metadata is required. +- [x] Display-name-only identity is forbidden. +- [x] Redaction metadata is required. +- [x] Normalization handoff metadata is required. +- [x] Claim boundary metadata is required. +- [x] Restore tier must remain compare-only or stricter. + +## Non-Functional Requirements + +- [x] Fail-closed command and response-shape security posture is specified. +- [x] Offline deterministic test posture is specified. +- [x] Redaction posture is specified for secrets, transcripts, stdout/stderr, and content. +- [x] Provider-boundary posture is specified. +- [x] Workspace/managed-environment/provider-connection scope posture is specified. +- [x] OperationRun observability posture is specified as deferred/no-run for this slice. +- [x] Product surface no-impact posture is specified. +- [x] Test governance posture is specified. + +## No-Promotion Requirements + +- [x] No content-backed evidence. +- [x] No raw provider payload persistence. +- [x] No normalized evidence persistence. +- [x] No comparable level. +- [x] No renderable level. +- [x] No certified level. +- [x] No restore-ready state. +- [x] No customer-ready state. +- [x] Claim Guard/no-claim proof is required. + +## Architecture Requirements + +- [x] Uses Coverage v2 source contract architecture. +- [x] No Exchange mini-platform. +- [x] No direct HTTP bypass. +- [x] No runtime Microsoft documentation fetch. +- [x] No `tenant_id`. +- [x] No legacy shim. +- [x] No fallback reader. +- [x] Provider-native identifiers remain metadata, not ownership truth. + +## Product Surface Requirements + +- [x] UI Surface Impact is checked as no impact. +- [x] Product Surface Impact is `N/A - no rendered product surface changed`. +- [x] Browser proof is `N/A - no rendered UI surface changed`. +- [x] Human Product Sanity is N/A. +- [x] Livewire v4 posture is recorded as no UI change. +- [x] Provider registration location is recorded as no panel change. +- [x] Global search posture is recorded as no resource change. +- [x] Destructive/high-impact action posture is none. +- [x] Asset strategy is no assets. +- [x] Deployment impact is no env/migrations/queues/scheduler/storage/assets expected. + +## Test Readiness Requirements + +- [x] Focused unit tests are required. +- [x] Feature tests are conditional and bounded. +- [x] Spec 426/427/428 regressions are required where test-backed. +- [x] Spec 417/420 regressions are required where relevant. +- [x] Pint is required. +- [x] `git diff --check` is required. +- [x] `git status --short` is required. +- [x] Browser is N/A unless the spec is amended. + +## Implementation Gate Checklist + +These items are checked after the implementation loop completed them, supported by `tasks.md` and `implementation-report.md`. + +- [x] `transportRule`, `remoteDomain`, and `inboundConnector` have adapter contracts. +- [x] Only `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector` are allowlisted. +- [x] Mutation commands are rejected. +- [x] Raw command strings are rejected. +- [x] Fake runner proves structured execution behavior. +- [x] Resolver returns verified pending capture for included types. +- [x] Excluded types remain blocked/deferred. +- [x] No shell execution occurs in unit tests. +- [x] No provider calls occur in tests. +- [x] No evidence or OperationRun is created. +- [x] No compare/render/certification/restore/customer claim appears. +- [x] No `tenant_id` is introduced. +- [x] Exchange cmdlets and response fields remain provider-owned source details, not platform-core ownership truth or customer vocabulary. +- [x] Workspace/managed-environment/provider-connection scope is preserved for future execution handoff, with provider-native tenant identifiers metadata-only. +- [x] No Exchange mini-platform is introduced. +- [x] Focused tests and regressions pass. + +## Review Outcome + +- [x] Review outcome class for preparation: acceptable-special-case. +- [x] Workflow outcome for preparation: keep. +- [x] Final note location: implementation report after the later implementation loop. diff --git a/specs/430-exchange-powershell-adapter-contract-slice-1/implementation-report.md b/specs/430-exchange-powershell-adapter-contract-slice-1/implementation-report.md new file mode 100644 index 00000000..ecbd7db4 --- /dev/null +++ b/specs/430-exchange-powershell-adapter-contract-slice-1/implementation-report.md @@ -0,0 +1,183 @@ +# Implementation Report: Spec 430 - Exchange PowerShell Adapter Contract Slice 1 + +## Preflight + +- **Active spec**: `specs/430-exchange-powershell-adapter-contract-slice-1/` +- **Branch**: `430-exchange-powershell-adapter-contract-slice-1` +- **HEAD before implementation**: `0e2cea30 spec: add Exchange Teams source-surface catalog adapter strategy (#496)` +- **Initial dirty state**: untracked active spec package only: `specs/430-exchange-powershell-adapter-contract-slice-1/` +- **Activated skills/gates**: + - `spec-kit-implementation-loop`: requested implementation loop. + - `.agent/workflows/spec-readiness-gate`: active Spec Kit implementation readiness check. + - `.agent/repo-contracts/workspace-scope-safety`: provider-connection and future execution handoff scope proof. + - `.agent/repo-contracts/provider-freshness-semantics`: provider permission/source-boundary metadata proof. + - `.agent/temporary-migrations/tcm-cutover-guard`: Coverage v2/source-contract no-promotion guard. + - `pest-testing`: focused Pest 4 test implementation and validation. +- **Hard-gate stop conditions checked**: no unrelated dirty files; no completed spec rewrite; no live provider execution; no evidence rows; no OperationRun creation; no UI/routes/navigation/Filament/Livewire changes; no migrations; no `tenant_id`; no fallback readers, legacy shims, dual writes, customer output, restore/certification/customer claim, or Exchange mini-platform required. + +## Repo Truth And Path Decisions + +- **Spec 429 source path**: `specs/429-exchange-teams-source-surface-catalog-adapter-strategy/implementation-report.md` identifies `new_exchange_powershell_adapter` as the recommended first Spec 430 adapter path and lists `transportRule`, `remoteDomain`, and `inboundConnector` as the first Exchange PowerShell slice candidates. +- **Runtime support path**: use the existing TenantConfiguration/Coverage v2 service path under `apps/platform/app/Services/TenantConfiguration/`. +- **Command contract support file**: `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php`. +- **Resolver integration**: `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php`. +- **Registry metadata updates**: `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php`, `SupportedScopeResolver.php`, and `CoverageIdentityStrategyRegistry.php` only where required for `inboundConnector` and the three included identity handoffs. +- **Fake runner boundary**: `apps/platform/tests/Support/TenantConfiguration/Spec430ExchangePowerShellFakeCommandRunner.php`; no production runner is introduced or wired. +- **Focused tests**: new Spec 430 Pest files under `apps/platform/tests/Unit/Support/TenantConfiguration/` and `apps/platform/tests/Feature/TenantConfiguration/`. + +## Included And Excluded Types + +- **Included**: `transportRule`, `remoteDomain`, `inboundConnector`. +- **Excluded/deferred**: `acceptedDomain`, `organizationConfig`, `mailboxPlan`, `outboundConnector`, `sharingPolicy`, Teams policy types including `appPermissionPolicy`, `appSetupPolicy`, `meetingPolicy`, `messagingPolicy`, `teamsUpdateManagementPolicy`, `teamsChannelsPolicy`, `externalAccessPolicy`, and the rest of Cohort 1. + +## Implementation Summary + +Implemented the first Exchange PowerShell adapter-contract slice as a structured, no-live-execution command contract boundary for: + +- `transportRule` -> `Get-TransportRule` +- `remoteDomain` -> `Get-RemoteDomain` +- `inboundConnector` -> `Get-InboundConnector` + +The contract boundary is metadata-only and fake-runner-testable. It does not introduce a production runner, shell execution, provider calls, Microsoft service calls, evidence promotion, OperationRun creation, compare/render/certification/restore/customer readiness, UI, routes, navigation, downloads, or customer-facing copy. + +## Files Changed + +Runtime: + +- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php` +- `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` +- `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php` +- `apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php` +- `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php` +- `apps/platform/app/Services/TenantConfiguration/SupportedScopeResolver.php` + +Tests: + +- `apps/platform/tests/Support/TenantConfiguration/Spec430ExchangePowerShellFakeCommandRunner.php` +- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellCommandAllowlistTest.php` +- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec430TransportRuleCommandContractTest.php` +- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec430RemoteDomainCommandContractTest.php` +- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec430InboundConnectorCommandContractTest.php` +- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellResolverTest.php` +- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellMetadataTest.php` +- `apps/platform/tests/Feature/TenantConfiguration/Spec430ExchangePowerShellNoPromotionTest.php` +- Focused Spec 417/419/420/426/427 regression expectations updated for the verified-pending source-contract state and `inboundConnector` default-sync denominator. + +Spec artifacts: + +- `specs/430-exchange-powershell-adapter-contract-slice-1/implementation-report.md` + +## Command Boundary And Allowlist Proof + +- Only the three read-only command names are allowlisted: `Get-TransportRule`, `Get-RemoteDomain`, `Get-InboundConnector`. +- Mutation command families are rejected before fake-runner execution: `Set-*`, `New-*`, `Remove-*`, `Enable-*`, `Disable-*`, `Update-*`, `Start-*`, `Stop-*`, `Invoke-*`, `Search-*`, `Export-*`, and `Import-*`. +- Raw command strings, shell metacharacters, pipelines, script blocks, semicolon-separated commands, redirection, file writes, module installation fragments, unknown commands, and unknown parameters are rejected. +- The fake runner accepts only structured contract arrays and returns test-only structured results. No production runner was added. + +## Resolver Proof + +- `transportRule`, `remoteDomain`, and `inboundConnector` now resolve to: + - `source_contract_state`: `contract_verified_pending_capture` + - `provider_adapter_state`: `adapter_contract_available` + - `outcome`: `capture_blocked_missing_contract` + - `provider_calls_allowed`: `false` + - `execution_enabled`: `false` + - `evidence_promotion_allowed`: `false` + - `customer_claims_allowed`: `false` + - `restore_allowed`: `false` + - `certification_allowed`: `false` +- `CoverageSourceContractDecision::capturable()` remains false for these decisions because there is no source endpoint and the outcome is blocked pending capture. +- Excluded Exchange and Teams types remain blocked/deferred. No guessed Graph endpoint or fallback reader was added. + +## Metadata Proof + +- Permission metadata is `runtime_validation_pending`, includes `Exchange.ManageAsApp` only as a pending runtime validation note, and keeps Exchange RBAC validation deferred to a future execution spec. +- Response-shape metadata distinguishes empty collection, permission denied, command unavailable, adapter unavailable, malformed response, and unexpected object shape. +- Identity handoff rejects display-name-only identity and records stable/derived candidates per type. +- Redaction metadata forbids tokens, secrets, authorization headers, cookies, certificate private material, passwords, raw transcripts, raw shell stdout/stderr, mail body/content, mailbox content, file content, and Teams transcript/content. +- Protected configuration examples cover email/domain/rule-pattern fields for transport rules, domain fields for remote domains, and IP/certificate/routing metadata for inbound connectors. +- Provider-owned Exchange cmdlet and response-field details remain internal source-contract metadata, not customer vocabulary, platform-core ownership truth, or product surface copy. +- Future execution scope requires workspace, managed environment, and provider connection. Provider-native scope identifiers are metadata only and cannot bypass workspace/provider scope. + +## Claim And Promotion Boundary + +- The only allowed claim wording is the exact internal/operator-only statement: `Exchange PowerShell adapter contracts exist for transportRule, remoteDomain, and inboundConnector.` +- The same statement is blocked when not marked internal/operator-only. +- No customer claim, certification, restore, compare/render, content-backed evidence, or readiness promotion is introduced. +- No `TenantConfigurationResource`, `TenantConfigurationResourceEvidence`, or OperationRun record is created by this slice. + +## Product Surface And Filament Close-Out + +- Product Surface Impact: no rendered product surface changed. +- UI Surface Impact: none. +- Page archetype/surface budgets/Technical Annex/deep-link demotion/canonical status vocabulary: `N/A - no rendered UI surface changed`. +- Product Surface exceptions: none. +- Focused browser proof: `N/A - no rendered UI surface changed`. +- Human Product Sanity: `N/A - no product surface changed`. +- Visible complexity outcome: unchanged. +- No-legacy posture: no legacy shim, fallback reader, dual truth, v1 adapter, or mini-platform added. +- Livewire v4 compliance: no Livewire/Filament runtime code changed; repo baseline remains Livewire v4. +- Provider registration: no panel/provider changes; Laravel providers remain in `apps/platform/bootstrap/providers.php`. +- Global search: no Filament resource/global search surface changed. +- Destructive/high-impact actions: none added. +- Asset strategy: no assets registered; `filament:assets` is not required for this slice. +- Deployment impact: no env vars, migrations, queues, scheduler, storage, assets, or browser build required. Runtime default sync now adds `inboundConnector` to registry/scope defaults when `ResourceTypeRegistry::syncDefaults()` and `SupportedScopeResolver::syncDefaults()` are run; migration baseline stays historical at 32 resource rows. + +## Validation + +- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec430 --compact` + - 64 passed, 479 assertions. +- Selected Spec 426 regressions: + - `Spec426ExchangeTeamsSourceContractResolverTest` + - `Spec426ExchangeTeamsCoreEvidenceReadinessTest` + - `Spec426ExchangeTeamsNoCertificationTest` + - `Spec426ExchangeTeamsNoRestoreTest` + - `Spec426ExchangeTeamsNoMiniPlatformTest` + - `Spec426ExchangeTeamsNoTenantIdTest` + - 11 passed, 123 assertions. +- Selected Spec 427 regressions: + - `Spec427ExchangeTeamsSourceContractStateTest` + - `Spec427ExchangeTransportRuleContractTest` + - `Spec427SourceContractIdentityHandoffTest` + - `Spec427SourceContractPermissionMetadataTest` + - `Spec427SourceContractRedactionTest` + - `Spec427SourceContractResponseShapeTest` + - `Spec427ExchangeTeamsNoEvidencePromotionTest` + - `Spec427ExchangeTeamsNoCompareRenderCertificationTest` + - `Spec427ExchangeTeamsNoCustomerRestoreClaimTest` + - `Spec427ExchangeTeamsNoMiniPlatformTest` + - `Spec427ExchangeTeamsNoTenantIdTest` + - 30 passed, 306 assertions. +- Registry/identity/capture outcome regressions: + - `Spec415CoverageCaptureOutcomeTest` + - `Spec419M365WorkloadRegistryTest` + - `Spec419M365RegistryExpansionTest` + - `TenantConfigurationResourceTypeRegistryTest` + - `Spec420M365CaptureEligibilityTest` + - `Spec417CoverageIdentityStrategyRegistryTest` + - 21 passed, 983 assertions, 1 PostgreSQL-only check skipped in the local driver. +- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - pass. +- `git diff --check` + - pass. +- `rg -- "- \[ \]" specs/430-exchange-powershell-adapter-contract-slice-1/tasks.md` + - no unchecked Spec 430 tasks remain. +- Broad parallel `Spec426`/`Spec427`/`Spec428` filter attempts were not used as final proof because Sail processes were killed with signal 9 under parallel load. Final proof uses serial direct-file runs listed above. +- Spec 428 executable test status: no `apps/platform/tests/**/Spec428*.php` files exist. Spec 428 remains a prepared no-op spec package; runtime no-evidence proof is covered by the Spec430 and selected Spec427 no-promotion tests above. + +## Post-Implementation Analysis + +- Iteration 1 finding: adding `capture_blocked_pending_capture` as a new `CaptureOutcome` enum value would have diverged from the historical evidence-table check constraint while this spec explicitly avoids migrations and does not persist blocked evidence. +- Fix applied: keep `contract_verified_pending_capture` as the resolver/source-contract state and use the existing `capture_blocked_missing_contract` capture outcome for non-capturable transient capture result rows. +- Post-fix validation: final Spec430, Spec426, Spec427, registry/identity/capture outcome, Pint, and `git diff --check` validations passed. +- Browser Smoke Test Gate: passed as N/A because no rendered UI surface changed. +- Remaining confirmed in-scope findings: none. + +## Final Repository State + +- Final dirty state is expected implementation work: runtime TenantConfiguration/Coverage v2 service updates, focused Spec 417/419/420/426/427 regression updates, new Spec 430 tests/support fake runner, and the active Spec 430 package. +- No unrelated dirty files were observed. + +## Deferred Work + +- A future spec must add any production Exchange PowerShell execution adapter, runtime RBAC/permission validation, provider connection handshake, command execution, capture normalization, evidence promotion, compare/render/certification/restore behavior, UI, or customer-facing claims. diff --git a/specs/430-exchange-powershell-adapter-contract-slice-1/plan.md b/specs/430-exchange-powershell-adapter-contract-slice-1/plan.md new file mode 100644 index 00000000..779e2c08 --- /dev/null +++ b/specs/430-exchange-powershell-adapter-contract-slice-1/plan.md @@ -0,0 +1,267 @@ +# Implementation Plan: Exchange PowerShell Adapter Contract Slice 1 + +**Branch**: `430-exchange-powershell-adapter-contract-slice-1` | **Date**: 2026-07-04 | **Spec**: `specs/430-exchange-powershell-adapter-contract-slice-1/spec.md` +**Input**: Feature specification from `specs/430-exchange-powershell-adapter-contract-slice-1/spec.md` + +## Summary + +Implement a narrow Coverage v2 Exchange PowerShell adapter contract slice for exactly `transportRule`, `remoteDomain`, and `inboundConnector`. The implementation must add structured, allowlisted read-only command contracts, fake-runner testability, resolver support, metadata for permissions/identity/response shape/redaction/normalization, and no-promotion tests. It must not perform live provider execution, create evidence, create OperationRuns, add UI, add migrations by default, or claim compare/render/certification/restore/customer readiness. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament 5, Livewire 4. +**Primary Dependencies**: existing Laravel app, Coverage v2 TenantConfiguration services, Pest 4. +**Storage**: PostgreSQL via Sail, but no new persistence or migrations are expected. +**Testing**: Pest 4 unit and focused feature tests. +**Validation Lanes**: fast-feedback for focused unit/feature tests; confidence for selected regression tests if existing filters require it. +**Target Platform**: Laravel platform app under `apps/platform`. +**Project Type**: monorepo with Laravel platform application. +**Performance Goals**: no live provider work; resolver/metadata checks should remain deterministic and cheap. +**Constraints**: no shell execution in tests, no Microsoft service calls, no evidence persistence, no OperationRuns, no UI, no `tenant_id`, no fallback readers, no legacy shims. +**Scale/Scope**: three Exchange target types and their adapter-contract metadata only. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: no operator-facing surface change. +- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: N/A. +- **No-impact class, if applicable**: backend-only source-contract behavior. +- **Native vs custom classification summary**: N/A. +- **Shared-family relevance**: none. +- **State layers in scope**: none for UI; backend resolver state only. +- **Audience modes in scope**: N/A. +- **Decision/diagnostic/raw hierarchy plan**: N/A. +- **Raw/support gating plan**: no raw provider payloads, transcripts, stdout/stderr, or diagnostics are rendered or persisted. +- **One-primary-action / duplicate-truth control**: N/A. +- **Handling modes by drift class or surface**: report-only, because no rendered surface changes. +- **Repository-signal treatment**: hard stop if UI files, routes, Filament providers/resources/pages/widgets, Livewire components, reports, downloads, or customer outputs enter scope. +- **Special surface test profiles**: N/A. +- **Required tests or manual smoke**: browser proof is `N/A - no rendered UI surface changed`. +- **Exception path and spread control**: none. +- **Active feature PR close-out entry**: implementation report. +- **UI/Productization coverage decision**: No UI surface impact. +- **Coverage artifacts to update**: none. +- **No-impact rationale**: adapter contracts are backend/source-contract metadata only. +- **Navigation / Filament provider-panel handling**: no panel/provider change. +- **Screenshot or page-report need**: no. + +## Product Surface Contract Plan + +- **Product Surface Contract reference**: N/A for runtime; no rendered product surface changed. +- **No-legacy posture**: canonical addition; no legacy aliases, fallback readers, hidden routes, or duplicate UI. +- **Page archetype and surface budget plan**: N/A. +- **Technical Annex and deep-link demotion plan**: N/A; no OperationRun/evidence/raw IDs/source keys/payloads are rendered. +- **Canonical status vocabulary plan**: no product-facing status vocabulary changes. +- **Product Surface exceptions**: none. +- **Browser verification plan**: `N/A - no rendered UI surface changed`. +- **Human Product Sanity plan**: N/A. +- **Visible complexity outcome target**: neutral. +- **Implementation report target**: `specs/430-exchange-powershell-adapter-contract-slice-1/implementation-report.md`. + +## Filament / Livewire / Deployment Posture + +- **Livewire v4 compliance**: N/A - no runtime UI change; repo baseline is Livewire v4. +- **Panel provider registration location**: no panel change; Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`. +- **Global search posture**: no Filament Resource changed; no global search change. +- **Destructive/high-impact action posture**: none; no actions exposed. +- **Asset strategy**: no assets; `filament:assets` not required by this spec. +- **Testing plan**: no pages/widgets/relation managers/actions/browser smoke. Backend unit/feature tests only. +- **Deployment impact**: no env vars, no migrations expected, no queues/scheduler/storage/assets. If a migration becomes necessary, stop and amend this plan. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: no operator-facing cross-cutting interaction class. +- **Systems touched**: Coverage v2 source-contract/resolver path and claim guard/no-promotion tests. +- **Shared abstractions reused**: existing `CoverageSourceContractResolver`, `ResourceTypeRegistry`, `CoverageIdentityStrategyRegistry`, Claim Guard or repo-equivalent guard path, and TenantConfiguration test patterns where applicable. +- **New abstraction introduced? why?**: a narrow command contract/runner boundary may be introduced or extended because structured command allowlisting and fake-runner proof are the core safety requirement. +- **Why the existing abstraction was sufficient or insufficient**: existing Coverage v2 can block missing contracts and capture eligibility, but does not yet encode allowlisted Exchange PowerShell command contracts. +- **Bounded deviation / spread control**: no Exchange mini-platform; keep provider-specific command details behind source-contract metadata. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no. +- **Central contract reused**: N/A. +- **Delegated UX behaviors**: N/A. +- **Surface-owned behavior kept local**: none. +- **Queued DB-notification policy**: N/A. +- **Terminal notification path**: N/A. +- **Exception path**: none. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes. +- **Provider-owned seams**: Exchange Online PowerShell source surface, cmdlet names, command response shapes, permission notes, provider-native identifiers, protected configuration details. +- **Platform-core seams**: workspace, managed environment, provider connection, Coverage v2 source-contract state, target type registry, identity handoff, evidence/no-evidence gates, claim boundaries. +- **Neutral platform terms / contracts preserved**: source surface, adapter contract, command contract, target type, provider connection, managed environment, evidence state, claim boundary, restore tier. +- **Retained provider-specific semantics and why**: the three `Get-*` cmdlets are the safety allowlist and must remain explicit. +- **Bounded extraction or follow-up path**: document-in-feature. Spec 431 handles live capture/execution; Teams and Exchange Admin API get later slices. + +## Constitution Check + +*GATE: Must pass before implementation. Re-check after design and before close-out.* + +- Inventory-first: no inventory/evidence observations are created in this slice. +- Read/write separation: only read-only command contracts are representable; no write/change behavior. +- Graph contract path: no Graph calls. Do not guess Graph endpoints for these Exchange types. +- Deterministic capabilities: resolver output and claim boundaries must be testable. +- RBAC-UX: no user-facing action; permission metadata only. Future execution must enforce server-side authorization and scope. +- Workspace isolation: provider connection scope remains workspace + managed environment + provider connection; no provider-native tenant ID becomes ownership truth. +- Tenant isolation: no tenant-scoped routes or UI. +- Run observability: no OperationRun in this slice; future live provider work must be OperationRun-backed. +- OperationRun start UX: N/A. +- Data minimization: no raw transcripts/stdout/stderr/provider payloads; redaction metadata must exist before capture. +- Test governance: Unit/Feature lane classification is explicit; no browser or heavy-governance expansion planned. +- Proportionality: the new adapter boundary is justified by three concrete target types and command-injection/no-provider-call safety. +- No premature abstraction: do not build a generic multi-provider PowerShell framework; implement the narrow Exchange PowerShell contract boundary only. +- Persisted truth: no new table/entity/artifact. +- Behavioral state: reuse repo-equivalent states; do not introduce a broad status family. +- UI semantics: no UI. +- Provider boundary: provider-specific command semantics stay behind source-contract metadata. +- V1 explicitness / few layers: explicit target-type contracts before any generalized framework. +- Spec discipline / bloat check: adjacent evidence/UI/Teams/Admin API concerns are follow-up specs. +- Filament/UI rules: N/A. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for command contracts, fake runner, metadata, resolver decisions; Feature only if resolver/no-runtime-capture behavior is already feature-tested or needs DB-backed proof. +- **Affected validation lanes**: fast-feedback plus selected confidence regressions. +- **Why this lane mix is the narrowest sufficient proof**: risks are backend safety and no-promotion behavior, not UI. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec430 --compact` + - selected Spec 426/427/428/417/420 regressions +- **Fixture / helper / factory / seed / context cost risks**: response fixtures and fake runner should remain local and cheap; no default provider/workspace/browser setup. +- **Expensive defaults or shared helper growth introduced?**: no; any fake runner helper must be opt-in. +- **Heavy-family additions, promotions, or visibility changes**: none expected. +- **Surface-class relief / special coverage rule**: browser N/A. +- **Closing validation and reviewer handoff**: record exact focused tests, pass counts/assertions, regressions, Pint, `git diff --check`, and `git status --short` in the implementation report. +- **Budget / baseline / trend follow-up**: none expected. +- **Review-stop questions**: reject/split if tests require live provider setup, shell execution, broad workspace context defaults, or UI/browser proof. +- **Escalation path**: split if live execution, evidence, UI, additional target types, or broad adapter framework appears. +- **Active feature PR close-out entry**: implementation report. +- **Why no dedicated follow-up spec is needed**: this spec is itself the narrow adapter-contract slice; follow-ups are already named for execution/evidence/UI/customer readiness. + +## Project Structure + +### Documentation (this feature) + +```text +specs/430-exchange-powershell-adapter-contract-slice-1/ +├── spec.md +├── plan.md +├── tasks.md +└── checklists/ + └── requirements.md +``` + +### Source Code (expected implementation surfaces) + +Use current repo conventions before adding files. Likely touched surfaces: + +```text +apps/platform/app/Services/TenantConfiguration/ +├── CoverageSourceContractResolver.php +├── ResourceTypeRegistry.php +├── CoverageIdentityStrategyRegistry.php +└── Exchange PowerShell adapter contract support files named after T003 confirms sibling conventions + +apps/platform/config/ +└── source-contract or provider contract config only if T003 confirms the current repo pattern requires it + +apps/platform/tests/Unit/Support/TenantConfiguration/ +└── Spec430*Test.php + +apps/platform/tests/Feature/TenantConfiguration/ +└── Spec430*Test.php (only if DB-backed/source-contract feature proof is required) +``` + +Forbidden source surfaces: + +```text +apps/platform/routes/** +apps/platform/app/Filament/** +apps/platform/app/Livewire/** +apps/platform/database/migrations/** (unless spec is amended) +jobs, scheduled tasks, provider capture services, customer report outputs, browser tests +``` + +**Structure Decision**: Keep implementation in the existing TenantConfiguration/Coverage v2 source-contract path. Add only a narrow Exchange PowerShell contract boundary if current code has no equivalent. + +**File Path Decision**: T003 must verify sibling conventions and record the exact support/config paths before any runtime source edit. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +| --- | --- | --- | +| New or extended command-contract boundary | Prevents raw PowerShell, command injection, mutation commands, and provider calls before capture exists | Resolver-only exceptions would not prove command safety or fake-runner behavior | +| Source-contract metadata for three Exchange types | Required to unblock later capture while preserving no-claim state | Broad Cohort 1 implementation would be too large and mix Exchange/Teams/Admin API concerns | + +## Proportionality Review + +- **Current operator problem**: release reviewers need proof that Exchange capture will later use safe, allowlisted contracts rather than arbitrary PowerShell or guessed endpoints. +- **Existing structure is insufficient because**: current resolver can fail closed, but not represent the Exchange PowerShell command safety boundary. +- **Narrowest correct implementation**: three concrete command contracts and a fake-runner boundary; no live execution and no broader adapter framework. +- **Ownership cost created**: metadata and tests must evolve with Exchange source behavior. +- **Alternative intentionally rejected**: raw strings, direct shell execution, Graph endpoint guesses, one-off resolver promotions, or all Cohort 1 at once. +- **Release truth**: future-release preparation required immediately to unblock Spec 431. + +## Implementation Phases + +`tasks.md` intentionally expands these plan phases into finer-grained execution checkpoints. Dependency order is authoritative; numeric phase labels do not need a one-to-one match. + +### Phase 0 - Preflight + +- Confirm branch, HEAD, clean state, Spec 429 completion, selected target types, excluded types, and no-implementation boundaries. +- Verify current repo classes and tests before choosing exact file names. + +### Phase 1 - Contract Shape + +- Add or extend a structured Exchange PowerShell command contract shape. +- Add allowlist and parameter policy. +- Add fake command runner boundary and structured success/failure result shape. +- Ensure no raw shell strings are accepted. + +### Phase 2 - Target Contracts + +- Add contracts for `transportRule`, `remoteDomain`, and `inboundConnector`. +- Include permission, response-shape, identity handoff, redaction, normalization handoff, claim boundary, and restore-tier metadata. + +### Phase 3 - Resolver Integration + +- Integrate with `CoverageSourceContractResolver` or repo-equivalent path. +- Promote included types only to verified pending-capture / adapter-contract-available. +- Preserve blocked/deferred states for excluded types. + +### Phase 4 - Safety And No-Promotion Proof + +- Add command allowlist and fake-runner tests. +- Add metadata tests. +- Add no evidence, no OperationRun, no provider call, no compare/render/certification/restore/customer claim tests. +- Add no `tenant_id`, no fallback reader, no legacy shim, no mini-platform checks. + +### Phase 5 - Regression And Report + +- Run focused tests and selected regressions. +- Run Pint and `git diff --check`. +- Complete `implementation-report.md` with required proof matrices. + +## Rollout Considerations + +- Staging validation is required before production promotion for any runtime code, but this slice should not require migrations, env vars, queues, scheduler, storage, or assets. +- If live Exchange execution, credentials, app-only auth, queueing, consent, or provider permission checks become necessary, stop and split to Spec 431 or an execution-hardening spec. + +## Risk Controls + +- Command allowlist is static. +- Unknown commands/parameters fail closed. +- Mutation command families fail closed. +- Fake runner only in tests. +- Production runner, if present, remains disabled/inert for this spec. +- Resolver states remain no-evidence/no-claim. +- Permission model is marked pending runtime validation unless proven. +- Redaction metadata is required before capture. +- Exchange cmdlets and response fields remain provider-owned source details, not platform-core ownership truth, customer vocabulary, or customer-safe labels. +- Future execution handoff scope remains workspace, managed-environment, and provider-connection based; provider-native tenant identifiers remain metadata only. +- Excluded types are explicitly tested. + +## Analyze Result + +Preparation analyze is recorded in this package by static cross-artifact review after artifact creation. Any findings must be fixed only in `spec.md`, `plan.md`, `tasks.md`, or `checklists/requirements.md`; application code is out of scope for preparation. diff --git a/specs/430-exchange-powershell-adapter-contract-slice-1/spec.md b/specs/430-exchange-powershell-adapter-contract-slice-1/spec.md new file mode 100644 index 00000000..a9663888 --- /dev/null +++ b/specs/430-exchange-powershell-adapter-contract-slice-1/spec.md @@ -0,0 +1,437 @@ +# Feature Specification: Exchange PowerShell Adapter Contract Slice 1 + +**Feature Branch**: `430-exchange-powershell-adapter-contract-slice-1` +**Created**: 2026-07-04 +**Status**: Draft +**Input**: User-provided draft "Spec 430 - Exchange PowerShell Adapter Contract Slice 1" plus repo evidence from Spec 429. + +## Activated Skills And Gate Preflight + +- **Activated skill**: `spec-kit-next-best-prep` because the user requested preparation of the next Spec Kit package without implementation. +- **Repo hard-gate context consulted**: `workflows/spec-readiness-gate`, `repo-contracts/provider-freshness-semantics`, `repo-contracts/evidence-anchor-contract`, `repo-contracts/workspace-scope-safety`, `repo-contracts/operation-run-truth`, and `temporary-migrations/tcm-cutover-guard`. +- **Current branch before Spec Kit execution**: `platform-dev`. +- **HEAD before Spec Kit execution**: `0e2cea30 spec: add Exchange Teams source-surface catalog adapter strategy (#496)`. +- **Dirty state before Spec Kit execution**: clean. +- **Current branch after Spec Kit execution**: `430-exchange-powershell-adapter-contract-slice-1`. +- **Hard-gate stop conditions**: none for preparation. Runtime stop conditions are carried into this spec: no live provider execution, no evidence, no OperationRun, no UI, no customer claims, no `tenant_id`, no fallback readers, no legacy adapters. + +## Candidate Selection Result + +- **Selected candidate**: Spec 430 - Exchange PowerShell Adapter Contract Slice 1. +- **Source location**: user-provided draft attachment and `specs/429-exchange-teams-source-surface-catalog-adapter-strategy/implementation-report.md`, which names `new_exchange_powershell_adapter` as the recommended first Spec 430 runtime path and lists `transportRule`, `remoteDomain`, and `inboundConnector` in Cohort 1. +- **Why selected**: Spec 429 completed the source-surface catalog and left a narrow runtime follow-up: create an Exchange PowerShell adapter contract boundary before any content-backed evidence capture. Current repo search found no existing `specs/430-*` package. +- **Why close alternatives were deferred**: + - `acceptedDomain` and `organizationConfig` are deferred because Spec 429 identifies possible Exchange Admin API involvement and preview/RBAC concerns. + - `outboundConnector` is deferred so the first connector proof covers one connector direction only. + - Teams PowerShell targets are deferred to a later Teams-specific adapter contract slice. + - Content-backed capture, compare/render, certification, restore, and customer output are deferred to Specs 431+ because this slice must not create product-readiness claims. +- **Completed-spec guardrail result**: Specs 426-429 are completed or implementation-closed context and were not modified. No existing Spec 430 package was found. +- **Smallest viable implementation slice**: exactly `transportRule`, `remoteDomain`, and `inboundConnector` command contracts using `exchange_online_powershell_rest` and `new_exchange_powershell_adapter`, with fake-runner testability and no live execution. +- **Feature description fed into Spec Kit**: Prepare a safe, allowlisted Exchange PowerShell adapter contract boundary for `transportRule`, `remoteDomain`, and `inboundConnector` using `exchange_online_powershell_rest`, without live provider execution, evidence, OperationRuns, UI, compare/render, certification, restore, customer claims, or `tenant_id`. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: Exchange/Teams Coverage v2 remains blocked because selected Exchange target types have no repo-safe source adapter contract boundary. +- **Today's failure**: The platform correctly reports missing contracts, but cannot safely proceed to content-backed Exchange capture without risking one-off PowerShell execution, over-broad target selection, command injection, permission overclaiming, or customer readiness claims. +- **User-visible improvement**: Reviewers and later implementers get a narrow, testable contract proving which Exchange cmdlets are allowed and which claims remain blocked before any provider capture occurs. +- **Smallest enterprise-capable version**: Three read-only Exchange target types only: `transportRule`, `remoteDomain`, and `inboundConnector`; structured command contracts; static allowlist; fake runner; resolver state; metadata for permission, identity, response shape, redaction, normalization, and claim boundaries. +- **Explicit non-goals**: No live Exchange connection, provider call, PowerShell shell execution, evidence persistence, OperationRun, UI, routes, navigation, compare/render, certification, restore/apply, customer report, Teams adapter, Exchange Admin API adapter, `acceptedDomain`, `organizationConfig`, `outboundConnector`, or `tenant_id`. +- **Permanent complexity imported**: A narrow adapter contract shape, command-contract metadata, fake runner boundary, resolver integration, and focused tests. No new persisted entity/table and no product UI taxonomy. +- **Why now**: Spec 429 explicitly makes this the next prerequisite before Spec 431 can perform OperationRun-backed content-backed evidence capture. +- **Why not local**: Local hardcoded resolver exceptions would not prove command allowlisting, parameter rejection, fake-runner safety, permission metadata, or no-promotion behavior. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: New adapter abstraction and source-contract metadata. Defense: three real command contracts plus command-injection and no-provider-call safety require a structured boundary; scope is intentionally narrower than the full Cohort 1. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**. +- **Decision**: approve for preparation; implementation must remain bounded to the three target types. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace + managed environment + provider connection semantics, without new persisted ownership. +- **Primary Routes**: none. +- **Data Ownership**: no new persistence is planned. Any runtime metadata must remain attached to Coverage v2 source-contract/provider-boundary configuration or support classes; provider-native tenant identifiers remain metadata only. +- **RBAC**: no new user action is exposed. Permission metadata must not widen consent or claim least privilege without runtime validation. + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: N/A - no rendered or queryable UI view. +- **Explicit entitlement checks preventing cross-tenant leakage**: No runtime surface is introduced. Any future provider execution is deferred and must re-resolve workspace, managed environment, and provider connection scope before execution. + +## No Legacy / No Backward Compatibility Constraint *(mandatory)* + +TenantPilot is pre-production unless this spec explicitly records a compatibility exception. + +- **Compatibility posture**: canonical narrow addition to Coverage v2 source-contract behavior. +- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no. +- **Why clean replacement is safe now**: current repo truth expects missing Exchange source contracts to fail closed until a verified adapter contract exists; no production compatibility path is required. + +## UI Surface Impact *(mandatory - UI-COV-001)* + +Does this spec add, remove, rename, or materially change any reachable UI surface? + +- [x] No UI surface impact +- [ ] Existing page changed +- [ ] New page/route added +- [ ] Navigation changed +- [ ] Filament panel/provider surface changed +- [ ] New modal/drawer/wizard/action added +- [ ] New table/form/state added +- [ ] Customer-facing surface changed +- [ ] Dangerous action changed +- [ ] Status/evidence/review presentation changed +- [ ] Workspace/environment context presentation changed + +No-impact rationale: Spec 430 defines backend/source-contract behavior only. It must not edit runtime UI files, routes, Filament resources/pages/widgets, navigation, reports, downloads, customer outputs, or browser-rendered diagnostics. + +## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)* + +N/A - no reachable UI surface impact. The implementation must stop and amend this spec if UI changes become necessary. + +## Product Surface Impact *(mandatory for UI-affecting specs; otherwise write `N/A - no rendered product surface changed` plus rationale)* + +Reference: `docs/product/standards/product-surface-contract.md`. + +- **Product Surface Contract applies?**: no - no rendered product surface changes. +- **Page archetype**: N/A. +- **Primary user question**: N/A. +- **Primary action**: N/A. +- **Surface budget result**: N/A. +- **Technical Annex / deep-link demotion**: N/A for runtime; the implementation must not render OperationRun, evidence, raw payload, IDs, source keys, detectors, fingerprints, logs, or provider diagnostics. +- **Canonical status vocabulary**: internal resolver states only; no product-facing status label changes. +- **Visible complexity impact**: neutral. +- **Product Surface exceptions**: none. + +## Browser Verification Plan *(mandatory)* + +- **Browser proof required?**: no. +- **No-browser rationale**: `N/A - no rendered UI surface changed`. +- **Focused path when required**: N/A. +- **Primary interaction to execute**: N/A. +- **Console, Livewire, Filament, network, and 500-error checks**: N/A. +- **Full-suite failure triage**: N/A unless UI changes are introduced after spec amendment. + +## Human Product Sanity Check *(mandatory)* + +- **Required?**: no. +- **No-human-sanity rationale**: N/A - no product surface changed. +- **Reviewer questions**: N/A. +- **Planned result location**: implementation report should record `N/A - no rendered UI surface changed`. + +## Product Surface Merge Gate Checklist *(mandatory)* + +- [x] No-legacy posture or approved exception recorded. +- [x] Product Surface Impact is completed or `N/A` is justified. +- [x] Browser proof is completed or `N/A - no rendered UI surface changed` is justified. +- [x] Human Product Sanity is completed or not applicable with rationale. +- [x] Product Surface exceptions are documented or `none`. +- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, and visible complexity outcome. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: no. +- **Interaction class(es)**: N/A. +- **Systems touched**: N/A. +- **Existing pattern(s) to extend**: N/A. +- **Shared contract / presenter / builder / renderer to reuse**: N/A. +- **Why the existing shared path is sufficient or insufficient**: N/A. +- **Allowed deviation and why**: none. +- **Consistency impact**: no operator-facing interaction language changes. +- **Review focus**: verify no notifications, action links, navigation, reports, or evidence viewers are added. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no. +- **Shared OperationRun UX contract/layer reused**: N/A. +- **Delegated start/completion UX behaviors**: N/A. +- **Local surface-owned behavior that remains**: none. +- **Queued DB-notification policy**: N/A. +- **Terminal notification path**: N/A. +- **Exception required?**: none. + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes. +- **Boundary classification**: mixed. Coverage v2 source-contract state, resolver behavior, evidence gates, claim guards, workspace/managed-environment/provider-connection scope, and no-promotion semantics are platform-core. Exchange PowerShell cmdlets, source response shapes, permission names, and provider-native identifiers are provider-owned source details. +- **Seams affected**: Coverage source contract resolver, resource type registry metadata where required, source contract metadata, command allowlist, fake command runner test boundary, Claim Guard/no-promotion tests, identity handoff metadata, redaction metadata. +- **Neutral platform terms preserved or introduced**: workspace, managed environment, provider connection, target type, source surface, source contract, adapter contract, command contract, evidence state, claim boundary, restore tier. +- **Provider-specific semantics retained and why**: `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector` are retained because the source surface is Exchange Online PowerShell and command identity is the safety boundary. +- **Why this does not deepen provider coupling accidentally**: provider-specific cmdlet details remain metadata behind Coverage v2 source-contract boundaries; no provider-native tenant ID becomes platform ownership truth; no UI/customer vocabulary is introduced. +- **Follow-up path**: document-in-feature for this slice; live execution and content-backed evidence are deferred to Spec 431 or a dedicated execution-hardening spec. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +N/A - no operator-facing surface change. + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change. + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change. + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change. + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change. + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no persisted source of truth. The source contract remains derived/configured runtime behavior. +- **New persisted entity/table/artifact?**: no. +- **New abstraction?**: yes - a narrow Exchange PowerShell adapter command-contract/runner boundary may be introduced or extended. +- **New enum/state/reason family?**: no new broad status family. Reuse repo-equivalent resolver states and add only local structured failure reasons if needed for command-contract validation. +- **New cross-domain UI framework/taxonomy?**: no. +- **Current operator problem**: reviewers cannot safely approve Exchange evidence capture while the repo lacks a contract that prevents arbitrary PowerShell, mutation commands, provider calls, and customer claims. +- **Existing structure is insufficient because**: Coverage v2 can fail closed for missing contracts, but it cannot represent allowlisted Exchange PowerShell commands, response-shape metadata, or fake-runner safety for this source surface. +- **Narrowest correct implementation**: three concrete command contracts plus a structured runner boundary; no live runner, no UI, no persistence, and no full Exchange mini-platform. +- **Ownership cost**: adapter metadata and focused tests must be maintained as Exchange command contracts evolve; future live capture must honor this boundary. +- **Alternative intentionally rejected**: raw command strings, direct shell execution, Graph endpoint guesses, Exchange Admin API substitution, one-off resolver exceptions, and implementing the entire Cohort 1. +- **Release truth**: future-release preparation that is an immediate prerequisite for the next content-backed evidence slice. + +### Compatibility posture + +This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, historical fixtures, fallback readers, and compatibility-specific tests are out of scope unless explicitly required by an amended spec. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit and focused Feature tests. Browser is N/A. +- **Validation lane(s)**: fast-feedback for adapter contracts, resolver state, fake runner, metadata, and no-promotion tests; confidence lane for selected regressions if existing focused files require it. +- **Why this classification and these lanes are sufficient**: this slice is backend/source-contract only. The key risks are command safety, resolver state, metadata completeness, and no evidence/OperationRun/product promotion. +- **New or expanded test families**: focused Spec 430 unit/feature tests under existing TenantConfiguration/Coverage v2 test families. No browser family. +- **Fixture / helper cost impact**: response-shape fixtures and fake command runner only; no provider setup, no shell setup, no workspace membership browser state. +- **Heavy-family visibility / justification**: none expected. If regression filters become broad or costly, document command, result, and direct-file fallback. +- **Special surface test profile**: N/A. +- **Standard-native relief or required special coverage**: N/A - no Filament/UI surface. +- **Reviewer handoff**: verify tests prove allowlist, rejected mutation/raw/unknown commands, resolver states, metadata presence, no provider calls, no evidence, no OperationRun, no claims, no `tenant_id`, and no mini-platform. +- **Budget / baseline / trend impact**: expected neutral; no browser or heavy-governance expansion planned. +- **Escalation needed**: none if scope stays bounded; split if live execution, UI, evidence, or broader target types appear. +- **Active feature PR close-out entry**: implementation report. +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec430 --compact` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec426 --compact` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec427 --compact` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec428 --compact` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec417 --compact` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec420 --compact` + - `git diff --check` + - `git status --short` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Verify Exchange PowerShell Command Contracts (Priority: P1) + +As a release reviewer, I need the first Exchange PowerShell adapter slice to expose only structured, allowlisted read-only command contracts for `transportRule`, `remoteDomain`, and `inboundConnector`. + +**Why this priority**: This is the command-safety gate. Without it, later capture could drift into arbitrary shell execution or mutation commands. + +**Independent Test**: Run focused command-contract and allowlist tests proving only `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector` are representable, while raw command strings, mutation families, and unknown parameters are rejected. + +**Acceptance Scenarios**: + +1. **Given** the Exchange PowerShell adapter contract registry, **When** `transportRule` is resolved, **Then** the contract names `Get-TransportRule` and marks it as read-only, collection-shaped, fake-runner-testable, and pending capture. +2. **Given** a mutation command such as `Set-TransportRule`, **When** a command contract is requested, **Then** the adapter rejects it before runner execution. +3. **Given** a raw command string or unknown parameter, **When** the runner boundary receives it, **Then** it returns a structured rejection without shell execution. + +--- + +### User Story 2 - Resolve Included Types Without Promoting Excluded Types (Priority: P2) + +As a future capture implementer, I need the Coverage v2 resolver to return a verified pending-capture source-contract state only for the three included target types. + +**Why this priority**: Spec 431 must know exactly which Exchange types are eligible for later capture and which remain blocked or deferred. + +**Independent Test**: Run resolver tests proving `transportRule`, `remoteDomain`, and `inboundConnector` return repo-equivalent `contract_verified_pending_capture` and `adapter_contract_available`, while excluded Exchange/Teams types remain blocked or deferred. + +**Acceptance Scenarios**: + +1. **Given** the resolver receives `remoteDomain`, **When** it evaluates source-contract availability, **Then** it returns a verified pending-capture contract with Exchange PowerShell metadata and no evidence state. +2. **Given** the resolver receives `outboundConnector`, `acceptedDomain`, `organizationConfig`, or a Teams type, **When** it evaluates source-contract availability, **Then** those types remain excluded, blocked, or deferred. + +--- + +### User Story 3 - Preserve No-Evidence And No-Claim Boundaries (Priority: P3) + +As a product reviewer, I need this adapter-contract slice to avoid any customer, restore, compare/render, certification, or evidence claim. + +**Why this priority**: The adapter contract is a prerequisite, not product readiness. Overclaiming here would weaken the safety guarantees from Specs 426-429. + +**Independent Test**: Run no-promotion tests proving no evidence rows, OperationRuns, provider calls, compare/render states, certification states, restore-ready states, customer claims, UI changes, `tenant_id`, or mini-platform artifacts appear. + +**Acceptance Scenarios**: + +1. **Given** the three included contracts are available, **When** claim guard behavior is evaluated, **Then** only an internal/operator-safe statement that adapter contracts exist is allowed. +2. **Given** the implementation finishes, **When** the repository diff is reviewed, **Then** no migrations, routes, Filament resources, Livewire components, jobs, provider calls, evidence rows, OperationRuns, or customer report outputs were added. + +### Edge Cases + +- Valid empty collection must differ from permission denied, command unavailable, adapter unavailable, malformed response, and unexpected object shape. +- Display name alone must never produce stable identity. +- Least-privilege requirements must not be marked fully proven without runtime evidence. +- Command output must be structured; human-formatted console text, transcripts, stdout, and stderr are not authoritative evidence. +- Sensitive fields and provider error text must be redacted before logs, failures, or diagnostics. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-430-001**: The implementation MUST include exactly `transportRule`, `remoteDomain`, and `inboundConnector`. +- **FR-430-002**: The implementation MUST use source surface `exchange_online_powershell_rest` and adapter pattern `new_exchange_powershell_adapter` or repo-equivalent names. +- **FR-430-003**: The implementation MUST provide structured command contracts for `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector`. +- **FR-430-004**: The command boundary MUST reject raw command strings, unknown command names, unknown parameters, script blocks, pipeline fragments, semicolon-separated commands, redirection, file writes, module installation, and arbitrary operator-supplied command text. +- **FR-430-005**: The command boundary MUST reject write or mutation command families including `Set-*`, `New-*`, `Remove-*`, `Enable-*`, `Disable-*`, `Update-*`, `Start-*`, `Stop-*`, `Invoke-*`, `Search-*`, `Export-*`, and `Import-*`. +- **FR-430-006**: Tests MUST use a fake command runner and MUST NOT execute a real shell, call Microsoft services, or require Exchange Online connectivity. +- **FR-430-007**: Each target type MUST include source contract metadata for canonical type, workload, source surface, adapter pattern, command name, command contract version, collection/singleton shape, expected response shape, identity fields, permission model, permission failure modes, redaction rules, volatile fields, normalization handoff, capture eligibility, claim state, and restore tier. +- **FR-430-008**: The resolver MUST return repo-equivalent `contract_verified_pending_capture` and `adapter_contract_available` for the three included types. +- **FR-430-009**: The resolver MUST preserve blocked or deferred states for non-included Exchange/Teams types, including `acceptedDomain`, `organizationConfig`, `mailboxPlan`, `outboundConnector`, `sharingPolicy`, Teams policy types, and `externalAccessPolicy`. +- **FR-430-010**: Permission metadata MUST distinguish known documentation notes from runtime-proven least privilege and MUST mark runtime validation as pending when proof is incomplete. +- **FR-430-011**: Identity handoff metadata MUST use `stable_candidate`, `derived_candidate`, `identity_unsafe`, or `unknown` repo-equivalent semantics and MUST prevent display-name-only stable identity. +- **FR-430-012**: Redaction metadata MUST forbid tokens, secrets, authorization headers, cookies, certificate private material, passwords, raw transcripts, raw shell stdout/stderr, mail body/content, mailbox content, file content, and Teams transcript/content from logs, diagnostics, or evidence. +- **FR-430-013**: Target-specific protected configuration such as email addresses, domains, IP ranges, certificate names, header names, rule patterns, and connector routing metadata MUST be classified as protected configuration. +- **FR-430-014**: The implementation MUST NOT create content-backed evidence, raw provider payload persistence, normalized evidence persistence, provider capture, OperationRuns, jobs, scheduled tasks, routes, Filament pages, Livewire components, database `tenant_id`, Exchange-specific evidence tables, legacy shims, fallback readers, compare/render promotion, certification, restore-ready state, customer-ready state, or customer claims. +- **FR-430-015**: Claim Guard or repo-equivalent tests MUST allow only the internal/operator-safe statement: "Exchange PowerShell adapter contracts exist for transportRule, remoteDomain, and inboundConnector." +- **FR-430-016**: Browser verification MUST remain `N/A - no rendered UI surface changed` unless the spec is amended before UI work. + +### Non-Functional Requirements + +- **NFR-430-001**: Security posture MUST fail closed for unknown command names, unknown parameters, raw command text, mutation command families, and unsupported response shapes. +- **NFR-430-002**: Test execution MUST be deterministic and offline; no test may require Exchange Online connectivity, Microsoft credentials, installed PowerShell modules, shell execution, or network access. +- **NFR-430-003**: Redaction posture MUST prevent secrets, tokens, raw transcripts, raw shell stdout/stderr, mail/message body content, mailbox content, file content, and Teams transcript/content from entering logs, diagnostics, OperationRun context, or evidence payloads. +- **NFR-430-004**: Provider boundary posture MUST keep Exchange-specific cmdlets and response fields as provider-owned source details, not platform-core ownership truth or customer vocabulary. +- **NFR-430-005**: Workspace/managed-environment/provider-connection scope MUST be preserved for any future execution handoff, and provider-native tenant identifiers MUST remain metadata only. +- **NFR-430-006**: Observability posture MUST remain explicit: this slice creates no OperationRun, while any later live execution must be OperationRun-backed in a separate spec. +- **NFR-430-007**: Product surface posture MUST remain `N/A - no rendered UI surface changed`; any UI, route, report, download, readiness badge, restore action, or customer output requires spec amendment before implementation. +- **NFR-430-008**: Test governance MUST keep focused Spec 430 tests in the narrowest honest lane and record any broadened regression cost in the implementation report. + +## UI Action Matrix *(mandatory when Filament is changed)* + +N/A - no Filament Resource, RelationManager, Page, action, table, form, navigation, global search, or panel provider changes are in scope. + +### Key Entities *(include if feature involves data)* + +- **Exchange PowerShell command contract**: Structured, allowlisted representation of one read-only Exchange PowerShell cmdlet, its allowed parameters, response shape, permission metadata, identity handoff, redaction rules, and normalization handoff. +- **Exchange PowerShell fake runner**: Test-only runner boundary that accepts structured contracts and returns structured success/failure results without shell execution or provider calls. +- **Coverage source contract resolver decision**: Existing repo-equivalent decision result that changes only the included types from missing adapter/contract blockers to verified pending capture while preserving no-promotion states. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-430-001**: Focused tests prove `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector` are the only allowlisted command contracts in this slice. +- **SC-430-002**: Focused tests prove raw command strings, unknown parameters, and mutation command families are rejected before execution. +- **SC-430-003**: Resolver tests prove the three included types return verified pending-capture states and all explicitly excluded types remain blocked or deferred. +- **SC-430-004**: Metadata tests prove permission, response-shape, identity, redaction, normalization, claim boundary, and restore-tier metadata exists for each included type. +- **SC-430-005**: No-promotion tests prove no evidence, OperationRun, provider call, compare/render, certification, restore, customer claim, UI, `tenant_id`, legacy shim, fallback reader, or Exchange mini-platform appears. +- **SC-430-006**: Validation report records focused tests, selected regressions, Pint, `git diff --check`, and `git status --short`. + +## Runtime / Data Impact + +Allowed runtime changes: + +- source contract descriptors +- adapter contract value objects or narrow support classes +- resolver integration +- fake command runner +- command allowlist +- response-shape fixtures +- redaction metadata +- claim guard tests + +Preferred data impact: no migrations. + +Forbidden runtime/data changes: + +- evidence rows +- OperationRuns +- provider calls +- jobs or scheduled tasks +- routes +- Filament pages/resources/widgets +- Livewire components +- database `tenant_id` +- Exchange-specific evidence tables +- legacy snapshot adapters +- fallback readers + +## Target Type Contract Notes + +### `transportRule` + +- **Command**: `Get-TransportRule`. +- **Shape**: collection. +- **Identity handoff**: prefer stable rule identity fields from source output such as GUID-like identifiers; display name alone is unsafe. +- **Protected configuration**: rules can include conditions, exceptions, actions, state, priority/order, mode, domains, addresses, words, patterns, and header names. Message body/content must never be captured. + +### `remoteDomain` + +- **Command**: `Get-RemoteDomain`. +- **Shape**: collection. +- **Identity handoff**: distinguish default remote domain from custom remote domains; domain/name alone is a candidate only if source contract proves stability. +- **Protected configuration**: remote domains are Exchange configuration objects and can expose external communication posture. + +### `inboundConnector` + +- **Command**: `Get-InboundConnector`. +- **Shape**: collection. +- **Identity handoff**: connector identity must not rely on display name alone if stable source identifiers are available. +- **Protected configuration**: domains, IPs, TLS settings, certificate names, and partner/on-premises routing metadata are protected configuration data. + +## Related Specs And Prerequisites + +This spec may proceed only after these historical packages remain complete and unmodified as context: + +- `specs/414-tcm-first-coverage-core-cutover` +- `specs/415-generic-content-backed-capture` +- `specs/417-canonical-identity-engine` +- `specs/419-m365-tcm-workload-registry-expansion` +- `specs/420-m365-generic-evidence-coverage-pack` +- `specs/426-exchange-teams-core-evidence-identity-readiness` +- `specs/427-exchange-teams-verified-source-contract-enablement` +- `specs/428-exchange-teams-content-backed-evidence-promotion` +- `specs/429-exchange-teams-source-surface-catalog-adapter-strategy` + +## Risks + +| Risk | Severity | Mitigation | +| --- | ---: | --- | +| Adapter contract mistaken for live capture | High | No-evidence/no-OperationRun/no-provider-call tests | +| PowerShell command injection | High | Structured command contracts and strict allowlist | +| Mutation command accidentally allowed | High | Reject mutation family tests | +| Real shell execution in tests | High | Fake runner only | +| Permission model overclaimed | High | Runtime-validation-pending metadata | +| Identity overclaimed | High | Identity handoff only; display-name-only rejection | +| Redaction too weak | High | Redaction metadata tests | +| Excluded types accidentally included | Medium | Explicit exclusion tests | +| Exchange mini-platform appears | Medium | Coverage v2 architecture/no-mini-platform tests | +| Customer claim appears | High | Claim Guard tests | +| `tenant_id` returns | High | Static/no-tenant ownership tests | + +## Assumptions + +- Spec 429 remains the authoritative current source-surface catalog for the first Exchange adapter path. +- Exchange PowerShell connection and authentication details are intentionally deferred to Spec 431 or a dedicated execution-hardening spec. +- Current repo terminology may differ slightly; implementation should use repo-canonical class, enum, and state names rather than literal draft names when equivalents exist. +- No application implementation was performed during preparation. + +## Open Questions + +None block implementation. The exact class names and file placement must be selected from current repo conventions during implementation. + +## Follow-Up Spec Candidates + +- Spec 431 - Exchange content-backed evidence promotion slice using OperationRun-backed capture. +- Exchange comparable/renderable promotion after content-backed evidence exists. +- Teams PowerShell adapter contract slice. +- Exchange Admin API contract slice for `acceptedDomain` and `organizationConfig` if preview/RBAC constraints are acceptable. +- Outbound connector slice after inbound connector contract proof. + +## Candidate Selection Gate Result + +PASS. The selected candidate is directly provided by the user, aligns with Spec 429, is not already covered by an active/completed Spec 430, is narrow enough for a bounded implementation loop, and keeps adjacent concerns as follow-up specs. + +## Spec Readiness Gate Result + +PASS for preparation. `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md` exist and define a bounded, testable, no-implementation-ready package. diff --git a/specs/430-exchange-powershell-adapter-contract-slice-1/tasks.md b/specs/430-exchange-powershell-adapter-contract-slice-1/tasks.md new file mode 100644 index 00000000..b23d93e8 --- /dev/null +++ b/specs/430-exchange-powershell-adapter-contract-slice-1/tasks.md @@ -0,0 +1,180 @@ +# Tasks: Exchange PowerShell Adapter Contract Slice 1 + +**Input**: `specs/430-exchange-powershell-adapter-contract-slice-1/spec.md` and `plan.md` +**Prerequisites**: Spec 429 completed and current repo truth confirms no existing Spec 430 package. +**Tests**: Pest tests are required because the implementation changes runtime source-contract behavior. + +## Test Governance Checklist + +- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in the smallest honest family; no heavy-governance or browser family is added accidentally. +- [x] Shared helpers, factories, seeds, fixtures, provider setup, workspace context, and session defaults stay cheap by default. +- [x] Planned validation commands cover the change without pulling in unrelated lane cost. +- [x] Browser proof is explicitly `N/A - no rendered UI surface changed`. +- [x] Human Product Sanity is `N/A - no product surface changed`. +- [x] Any material budget, baseline, trend, or escalation note is recorded in the implementation report. + +## Phase 1: Preflight And Repo Truth + +**Purpose**: Confirm current state and prevent implementation drift before runtime edits. + +- [x] T001 Capture current branch, HEAD, and `git status --short` in `specs/430-exchange-powershell-adapter-contract-slice-1/implementation-report.md`. +- [x] T002 Confirm Spec 429 completion and source path from `specs/429-exchange-teams-source-surface-catalog-adapter-strategy/implementation-report.md`. +- [x] T003 Inspect current Coverage v2 source-contract surfaces before editing, decide exact support/config file paths from sibling conventions, and record the choice before runtime edits: `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php`, `ResourceTypeRegistry.php`, `CoverageIdentityStrategyRegistry.php`, and nearby Spec 426-429 tests. +- [x] T004 Confirm exact included types: `transportRule`, `remoteDomain`, `inboundConnector`. +- [x] T005 Confirm excluded types: `acceptedDomain`, `organizationConfig`, `mailboxPlan`, `outboundConnector`, `sharingPolicy`, Teams policy types, `externalAccessPolicy`, and the rest of Cohort 1. +- [x] T006 Confirm no live provider execution, no evidence promotion, no OperationRun, no UI, no customer claim, no `tenant_id`, no fallback reader, no legacy shim, and no Exchange mini-platform are planned. + +**Checkpoint**: Preflight evidence exists and scope remains the three-type adapter-contract slice. + +## Phase 2: Contract Shape And Command Boundary + +**Purpose**: Introduce or reuse the narrow structured command boundary before target-specific contracts. + +- [x] T007 Add or reuse a structured Exchange PowerShell command contract shape in the repo-canonical TenantConfiguration/Coverage v2 support path. +- [x] T008 Add or reuse fields for source surface, adapter pattern, command name, command contract version, allowed parameters, response shape, identity handoff, permission model, redaction rules, and normalization handoff. +- [x] T009 Add or reuse structured result/failure objects or arrays for command-runner responses without creating a broad framework. +- [x] T010 Add a fake command runner boundary for tests that accepts structured command contracts only. +- [x] T011 Ensure any production runner implementation introduced by this slice is disabled/inert and not wired to UI, jobs, commands, capture services, or provider execution. + +**Checkpoint**: The command boundary can represent structured read-only commands without shell execution. + +## Phase 3: Command Allowlist And Safety Tests + +**Purpose**: Prove only safe commands can be represented or run through the fake boundary. + +- [x] T012 Add `Spec430ExchangePowerShellCommandAllowlistTest` covering the static allowlist. +- [x] T013 Allow `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector`. +- [x] T014 Reject `Set-*`, `New-*`, `Remove-*`, `Enable-*`, `Disable-*`, `Update-*`, `Start-*`, `Stop-*`, `Invoke-*`, `Search-*`, `Export-*`, and `Import-*`. +- [x] T015 Reject raw command strings, user-supplied arbitrary parameter names, script blocks, pipeline fragments, semicolon-separated commands, redirection, file writes, module installation, and unknown parameters. +- [x] T016 Ensure tests prove no unit test executes a real shell and no test calls Microsoft services. + +**Checkpoint**: Only the three read-only command contracts are representable in this slice. + +## Phase 4: Target Type Contracts + +**Purpose**: Add the three target contracts with complete metadata. + +- [x] T017 Add `transportRule` contract metadata for `Get-TransportRule`, collection shape, identity candidates, permission notes, response shape, redaction, normalization handoff, claim boundary, and restore tier. +- [x] T018 Add `remoteDomain` contract metadata for `Get-RemoteDomain`, collection shape, default-vs-custom identity concerns, permission notes, response shape, redaction, normalization handoff, claim boundary, and restore tier. +- [x] T019 Add `inboundConnector` contract metadata for `Get-InboundConnector`, collection shape, connector identity concerns, permission notes, response shape, redaction, normalization handoff, claim boundary, and restore tier. +- [x] T020 Add `Spec430TransportRuleCommandContractTest`. +- [x] T021 Add `Spec430RemoteDomainCommandContractTest`. +- [x] T022 Add `Spec430InboundConnectorCommandContractTest`. +- [x] T023 Ensure display name alone cannot be treated as stable identity for any included type. +- [x] T024 Ensure permission metadata is marked pending runtime validation unless repo evidence proves least privilege. + +**Checkpoint**: Each included target type has complete contract metadata and focused tests. + +## Phase 5: Resolver Integration + +**Purpose**: Make resolver state deterministic without promoting evidence/readiness. + +- [x] T025 Update `CoverageSourceContractResolver.php` or repo-equivalent resolver integration so included types no longer return missing adapter/missing contract blockers. +- [x] T026 Return repo-equivalent `contract_verified_pending_capture` and `adapter_contract_available` for `transportRule`, `remoteDomain`, and `inboundConnector`. +- [x] T027 Preserve blocked/deferred states for explicitly excluded Exchange and Teams types. +- [x] T028 Add `Spec430ExchangePowerShellResolverTest` covering included and excluded types. +- [x] T029 Add or update feature-level resolver tests only if the existing resolver path needs DB-backed proof. + +**Checkpoint**: Resolver state changes only for the three included types. + +## Phase 6: Metadata, Redaction, And Response Shape Proof + +**Purpose**: Prove capture prerequisites are explicit before any capture exists. + +- [x] T030 Add `Spec430ExchangePowerShellPermissionMetadataTest`. +- [x] T031 Add `Spec430ExchangePowerShellIdentityHandoffTest`. +- [x] T032 Add `Spec430ExchangePowerShellRedactionRulesTest`. +- [x] T033 Add response-shape tests covering collection item path, required identity candidate fields, safe fields, sensitive fields, volatile fields, unsupported fields, empty collection, permission denied, command unavailable, adapter unavailable, malformed response, and unexpected object shape. +- [x] T034 Ensure redaction metadata forbids tokens, secrets, authorization headers, cookies, certificate private material, passwords, raw transcripts, raw shell stdout/stderr, mail body/content, mailbox content, file content, and Teams transcript/content. +- [x] T035 Ensure protected configuration examples are classified for email addresses, domains, IP ranges, certificate names, header names, rule patterns, and connector routing metadata. +- [x] T036 Ensure provider-owned source-detail metadata keeps Exchange cmdlets and response fields inside adapter/source-contract internals, not platform-core ownership truth, customer vocabulary, customer-safe labels, or product surface copy. +- [x] T037 Ensure future execution handoff metadata preserves workspace, managed-environment, and provider-connection scope and treats provider-native tenant identifiers as metadata only. + +**Checkpoint**: Metadata is sufficient for later capture validation, preserves provider/scope boundaries, and does not create capture. + +## Phase 7: No-Promotion And Architecture Guard Tests + +**Purpose**: Keep the adapter contract from becoming evidence, readiness, or customer truth. + +- [x] T038 Add `Spec430ExchangePowerShellNoEvidencePromotionTest`. +- [x] T039 Add `Spec430ExchangePowerShellNoClaimPromotionTest`. +- [x] T040 Add no-OperationRun proof that no run record is created or required by this slice. +- [x] T041 Add no-provider-call proof that no Exchange, Graph, PowerShell, or Microsoft service call happens. +- [x] T042 Add no-compare/render/certification/restore/customer-ready proof for the included types. +- [x] T043 Add no-UI/no-route/no-navigation proof or static assertions if current repo guard patterns support it. +- [x] T044 Add no-`tenant_id`, no provider-native tenant ownership, no Exchange-specific evidence table, no legacy shim, no fallback reader, and no mini-platform proof. + +**Checkpoint**: Spec 430 remains an adapter-contract slice with no product-readiness promotion. + +## Phase 8: Regression Coverage + +**Purpose**: Preserve fail-safe behavior from prerequisite specs. + +- [x] T045 Run or update focused Spec 426 fail-safe/source-contract regression tests. +- [x] T046 Run or update focused Spec 427 source contract state regression tests. +- [x] T047 Run or document Spec 428 no-op/no-evidence proof if test-backed. +- [x] T048 Run focused Spec 417 identity regression tests relevant to Exchange target types. +- [x] T049 Run focused Spec 420 generic evidence/capture eligibility regression tests. +- [x] T050 Document exact commands, pass counts, assertions, and any unrelated failures in the implementation report. + +**Checkpoint**: Existing Exchange/Teams fail-safe guarantees remain intact. + +## Phase 9: Product Surface And Filament Close-Out + +**Purpose**: Record explicit N/A results required by repo gates. + +- [x] T051 Confirm no rendered UI surface changed. +- [x] T052 Record browser status as `N/A - no rendered UI surface changed`. +- [x] T053 Record Livewire v4 compliance as N/A/no UI change while preserving repo baseline. +- [x] T054 Record panel provider registration as no panel change; providers remain in `apps/platform/bootstrap/providers.php`. +- [x] T055 Record global search posture as no resource changed. +- [x] T056 Record destructive/high-impact action posture as none. +- [x] T057 Record asset strategy as no assets and no `filament:assets` requirement. +- [x] T058 Record deployment impact as no env vars, migrations, queues, scheduler, storage, or assets unless implementation evidence says otherwise. + +**Checkpoint**: Product Surface Contract close-out is explicit and not faked with browser work. + +## Phase 10: Validation And Implementation Report + +**Purpose**: Finish with reviewable proof. + +- [x] T059 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T060 Run focused Spec 430 tests with `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec430 --compact`. +- [x] T061 If Spec 430 tests are split across files that the filter misses, run each direct file and record the file list, pass count, and assertions. +- [x] T062 Run selected Spec 426/427/428 regressions. +- [x] T063 Run selected Spec 417/420 regressions. +- [x] T064 Run `git diff --check`. +- [x] T065 Run `git status --short`. +- [x] T066 Create `specs/430-exchange-powershell-adapter-contract-slice-1/implementation-report.md` with candidate gate, branch/head, dirty state before/after, files changed, target types, excluded types, command allowlist proof, resolver proof, permission/response/identity/redaction proof, provider-owned source-detail proof, workspace/managed-environment/provider-connection scope proof, provider-native tenant metadata-only proof, no shell/provider/evidence/OperationRun/compare/render/certification/customer/restore/tenant_id/mini-platform proof, Product Surface result, tests run, and deferred work. + +**Checkpoint**: Implementation report is complete and ready for review. + +## Dependencies & Execution Order + +- Phase 1 blocks all runtime edits. +- Phase 2 blocks Phases 3-6. +- Phase 3 command safety should be test-first before target contract implementation where practical. +- Phase 4 target contracts can be implemented in parallel after the shared contract shape exists. +- Phase 5 depends on target contracts. +- Phases 6 and 7 can run alongside resolver work after metadata exists. +- Phase 8 depends on resolver/no-promotion behavior. +- Phase 9 and Phase 10 close out after code and tests are complete. + +## Stop Conditions + +Stop and amend/split the spec if implementation requires: + +- live Exchange connection or provider execution +- production PowerShell runner wiring +- OperationRun creation +- content-backed evidence +- compare/render/certification/restore/customer claims +- UI, routes, navigation, reports, downloads, or customer output +- Teams adapter +- Exchange Admin API adapter +- more than `transportRule`, `remoteDomain`, and `inboundConnector` +- database migration or `tenant_id` +- legacy shim, fallback reader, dual truth, or Exchange mini-platform +- Exchange cmdlets or response fields promoted to platform-core ownership truth, customer vocabulary, customer-safe labels, or product surface copy +- provider-native tenant identifiers promoted to ownership truth or used to bypass workspace/managed-environment/provider-connection scope