From 94050584334f8073bf59efb57d611b5fee7270af Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 27 Jun 2026 13:00:22 +0200 Subject: [PATCH] feat: complete m365 generic evidence coverage pack --- .../CanonicalIdentityResolver.php | 18 +- .../CoverageIdentityStrategyRegistry.php | 15 +- .../CoverageSourceContractResolver.php | 45 ++- .../GenericContentEvidenceCaptureService.php | 13 +- ...enericEvidenceOperatorSurfaceSmokeTest.php | 262 ++++++++++++ .../Spec420M365CaptureAuthorizationTest.php | 98 +++++ .../Spec420M365CaptureOperationRunTest.php | 214 ++++++++++ .../Spec420M365GenericEvidenceCaptureTest.php | 212 ++++++++++ .../Spec420M365NoLegacyTest.php | 40 ++ .../Spec420M365NoOverclaimTest.php | 95 +++++ .../Spec420M365NoTenantIdTest.php | 28 ++ ...Spec420M365ProviderConnectionScopeTest.php | 112 ++++++ ...17CoverageIdentityStrategyRegistryTest.php | 2 +- .../Spec420M365CaptureClaimGuardTest.php | 42 ++ .../Spec420M365CaptureEligibilityTest.php | 90 +++++ ...Spec420M365CaptureIdentityStrategyTest.php | 48 +++ .../Spec420M365CaptureRedactionTest.php | 24 ++ ...0M365CaptureSourceContractResolverTest.php | 48 +++ ...pec420M365GenericPayloadNormalizerTest.php | 31 ++ .../checklists/requirements.md | 96 +++++ .../implementation-report.md | 154 +++++++ .../plan.md | 237 +++++++++++ .../spec.md | 375 ++++++++++++++++++ .../tasks.md | 107 +++++ 24 files changed, 2392 insertions(+), 14 deletions(-) create mode 100644 apps/platform/tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoLegacyTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoOverclaimTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoTenantIdTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec420M365ProviderConnectionScopeTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureIdentityStrategyTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureRedactionTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365GenericPayloadNormalizerTest.php create mode 100644 specs/420-m365-generic-evidence-coverage-pack/checklists/requirements.md create mode 100644 specs/420-m365-generic-evidence-coverage-pack/implementation-report.md create mode 100644 specs/420-m365-generic-evidence-coverage-pack/plan.md create mode 100644 specs/420-m365-generic-evidence-coverage-pack/spec.md create mode 100644 specs/420-m365-generic-evidence-coverage-pack/tasks.md diff --git a/apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php b/apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php index 3134f8e1..7ca559fa 100644 --- a/apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php +++ b/apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php @@ -48,7 +48,11 @@ public function resolve(TenantConfigurationResourceType $resourceType, array $pa $preferred = $this->firstScalarField($strategy['preferred_identity_fields'] ?? [], $payload, $sourceMetadata); if ($preferred !== null) { - $keyKind = $this->stableKeyKind($resourceType, experimental: (bool) ($strategy['allows_experimental_identity'] ?? false)); + $keyKind = $this->stableKeyKind( + resourceType: $resourceType, + strategy: $strategy, + experimental: (bool) ($strategy['allows_experimental_identity'] ?? false), + ); $identityState = $keyKind === CanonicalKeyKind::ExperimentalSourceKey ? IdentityState::Derived : IdentityState::Stable; @@ -231,12 +235,21 @@ private function sourceResourceId(IdentityState $identityState, array $identityV }; } - private function stableKeyKind(TenantConfigurationResourceType $resourceType, bool $experimental): CanonicalKeyKind + /** + * @param array $strategy + */ + private function stableKeyKind(TenantConfigurationResourceType $resourceType, array $strategy, bool $experimental): CanonicalKeyKind { if ($experimental) { return CanonicalKeyKind::ExperimentalSourceKey; } + $configuredKeyKind = $strategy['stable_key_kind'] ?? null; + + if (is_string($configuredKeyKind) && CanonicalKeyKind::tryFrom($configuredKeyKind) instanceof CanonicalKeyKind) { + return CanonicalKeyKind::from($configuredKeyKind); + } + $sourceClass = $resourceType->source_class; if ($sourceClass instanceof SourceClass && $sourceClass === SourceClass::Tcm) { @@ -339,4 +352,3 @@ private function list(mixed $fields): array )); } } - diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php b/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php index 20647bdb..c83a7867 100644 --- a/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php +++ b/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php @@ -91,6 +91,20 @@ final class CoverageIdentityStrategyRegistry 'allows_experimental_identity' => false, 'derived_claims_allowed' => false, ], + 'conditionalAccessPolicy' => [ + 'strategy_identifier' => 'graph.conditional_access_policy.v1', + 'preferred_identity_fields' => ['id', 'policyId', 'sourceId'], + 'fallback_identity_fields' => ['sourceKey'], + 'source_composite_fields' => [], + 'derived_composite_fields' => [], + 'display_fields' => ['displayName', 'name'], + 'secondary_fields' => ['state', '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' => 'graph_object_id', + ], 'notificationMessageTemplate' => [ 'strategy_identifier' => 'graph.notification_message_template.v1', 'preferred_identity_fields' => ['id', 'templateId', 'sourceId'], @@ -184,4 +198,3 @@ private function sourceClassValue(mixed $sourceClass): ?string return null; } } - diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php index 94481566..56096886 100644 --- a/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php +++ b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php @@ -18,11 +18,23 @@ final class CoverageSourceContractResolver * @var array */ private const CONTRACT_KEYS = [ + 'conditionalAccessPolicy' => 'conditionalAccessPolicy', 'deviceAndAppManagementAssignmentFilter' => 'assignmentFilter', 'notificationMessageTemplate' => 'notificationMessageTemplate', 'roleScopeTag' => 'roleScopeTag', ]; + /** + * Spec 420 first-pack types that must fail closed as missing source contracts. + * + * @var list + */ + private const SPEC_420_MISSING_CONTRACT_TYPES = [ + 'acceptedDomain', + 'appPermissionPolicy', + 'dlpCompliancePolicy', + ]; + public function __construct( private readonly GraphContractRegistry $contracts, ) {} @@ -37,18 +49,28 @@ public function resolve(TenantConfigurationResourceType $resourceType, bool $all ? $resourceType->support_state : SupportState::tryFrom((string) $resourceType->support_state); - if (in_array($supportState, [SupportState::Unsupported, SupportState::OutOfScope], true)) { - return $this->blocked($canonicalType, CaptureOutcome::BlockedUnsupported, 'resource_type_unsupported'); + $contractKey = self::CONTRACT_KEYS[$canonicalType] ?? null; + + if (! is_string($contractKey) || $contractKey === '') { + if (in_array($canonicalType, self::SPEC_420_MISSING_CONTRACT_TYPES, true)) { + return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping'); + } + + if (in_array($supportState, [SupportState::Unsupported, SupportState::OutOfScope], true)) { + return $this->blocked($canonicalType, CaptureOutcome::BlockedUnsupported, 'resource_type_unsupported'); + } + + return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping'); } if ($sourceClass === SourceClass::GraphBetaExperimental && ! $allowBetaCapture) { return $this->blocked($canonicalType, CaptureOutcome::BlockedBeta, 'beta_capture_disabled'); } - $contractKey = self::CONTRACT_KEYS[$canonicalType] ?? null; - - if (! is_string($contractKey) || $contractKey === '') { - return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping'); + if (in_array($supportState, [SupportState::Unsupported, SupportState::OutOfScope], true) + && $canonicalType !== 'conditionalAccessPolicy' + ) { + return $this->blocked($canonicalType, CaptureOutcome::BlockedUnsupported, 'resource_type_unsupported'); } $contract = $this->contracts->get($contractKey); @@ -58,11 +80,18 @@ public function resolve(TenantConfigurationResourceType $resourceType, bool $all return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_graph_contract_resource'); } + $sourceVersion = $this->sourceVersion($contract); + $sourceSchemaHash = $this->sourceSchemaHash($contract); $metadata = [ 'source_contract_key' => $contractKey, 'source_endpoint' => '/'.ltrim($resource, '/'), 'source_class' => $sourceClass?->value, + 'registry_source_class' => $sourceClass?->value, 'support_state' => $supportState?->value, + 'registry_support_state' => $supportState?->value, + 'source_version' => $sourceVersion, + 'source_schema_hash' => $sourceSchemaHash, + 'source_schema_hash_available' => $sourceSchemaHash !== null, ]; return new CoverageSourceContractDecision( @@ -70,8 +99,8 @@ public function resolve(TenantConfigurationResourceType $resourceType, bool $all outcome: CaptureOutcome::Captured, contractKey: $contractKey, sourceEndpoint: '/'.ltrim($resource, '/'), - sourceVersion: $this->sourceVersion($contract), - sourceSchemaHash: $this->sourceSchemaHash($contract), + sourceVersion: $sourceVersion, + sourceSchemaHash: $sourceSchemaHash, reasonCode: null, contract: $contract, sourceMetadata: array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== ''), diff --git a/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php b/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php index 27d94f12..a4b1145c 100644 --- a/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php +++ b/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php @@ -197,7 +197,7 @@ private function captureResponseItems( $permissionContext = $this->permissionContext($providerConnection); foreach ($this->responseItems($response) as $item) { - $normalizedPayload = $this->normalizer->normalize($item, $volatileFields); + $normalizedPayload = $this->redactedNormalizedPayload($this->normalizer->normalize($item, $volatileFields)); $payloadHash = $this->normalizer->payloadHash($normalizedPayload); $resource = $this->resourceUpserter->upsert( @@ -281,6 +281,17 @@ private function permissionContext(ProviderConnection $providerConnection): arra ]); } + /** + * @param array $payload + * @return array + */ + private function redactedNormalizedPayload(array $payload): array + { + $redacted = $this->redactor->redact($payload); + + return is_array($redacted) ? $redacted : []; + } + private function stringValue(mixed $value): ?string { if ($value instanceof \BackedEnum) { diff --git a/apps/platform/tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php b/apps/platform/tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php new file mode 100644 index 00000000..bef9298d --- /dev/null +++ b/apps/platform/tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php @@ -0,0 +1,262 @@ +browser()->timeout(60_000); + +it('Spec420 smokes the existing Coverage v2 surface for internal M365 generic evidence', function (): void { + [$user, $environment] = spec420CoverageV2BrowserFixture(); + spec420AuthenticateCoverageV2Browser($this, $user, $environment); + + $page = visit(CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin')) + ->resize(1440, 1100) + ->waitForText('Coverage v2 Readiness') + ->waitForText('Spec420 Browser Conditional Access policy') + ->assertSee('Resource type registry') + ->assertSee('Resource instances') + ->assertSee('Conditional Access policy') + ->assertSee('Coverage level') + ->assertSee('Evidence state') + ->assertSee('Identity state') + ->assertSee('Claim state') + ->assertSee('Content backed') + ->assertSee('Internal only') + ->assertDontSee('M365 covered') + ->assertDontSee('certified') + ->assertDontSee('restore-ready') + ->assertDontSee('customer-ready') + ->assertDontSee('spec420-raw-secret') + ->assertDontSee('spec420-normalized-secret') + ->assertDontSee('spec420-permission-secret') + ->assertScript('typeof window.Livewire !== "undefined"', true) + ->assertScript('(() => document.querySelectorAll("table tbody tr").length > 0)()', true) + ->assertScript("(() => performance.getEntriesByType('resource').filter((entry) => /graph\\.microsoft\\.com|\\/tcm\\b|provider-remote/i.test(entry.name)).length)()", 0) + ->assertScript("(() => Array.from(document.querySelectorAll('main button, main a')).map((element) => element.textContent.trim()).filter(Boolean).some((label) => /^(Capture|Restore|Certify|Export|Download)$/i.test(label)))()", false) + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + $page->script(<<<'JS' + (() => { + const rows = Array.from(document.querySelectorAll('table tbody tr')); + const row = rows.find((candidate) => candidate.textContent.includes('Spec420 Browser Conditional Access policy')); + const inspect = Array.from(row?.querySelectorAll('button, a') ?? []) + .find((element) => element.textContent.includes('Spec420 Browser Conditional Access policy')); + + inspect?.click(); + })() + JS); + + $page + ->waitForText('Coverage: Content backed') + ->assertSee('Evidence: Content backed') + ->assertSee('Identity: Stable') + ->assertSee('Claim: Internal only') + ->assertSee('Spec420 Browser Microsoft provider') + ->assertSee('conditionalAccessPolicy:graph_object_id:cap-browser-1') + ->assertSee('conditionalAccessPolicy') + ->assertSee('v1.0') + ->assertSee('spec420-browser-schema-hash') + ->assertSee('Operation #') + ->assertDontSee('M365 covered') + ->assertDontSee('certified') + ->assertDontSee('restore-ready') + ->assertDontSee('customer-ready') + ->assertDontSee('spec420-raw-secret') + ->assertDontSee('spec420-normalized-secret') + ->assertDontSee('spec420-permission-secret') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->screenshot(true, 'spec420-m365-generic-evidence-operator-surface'); +}); + +/** + * @return array{0: User, 1: ManagedEnvironment} + */ +function spec420CoverageV2BrowserFixture(): array +{ + app(ResourceTypeRegistry::class)->syncDefaults(); + + $environment = ManagedEnvironment::factory()->active()->create([ + 'name' => 'Spec420 Browser Environment', + 'external_id' => 'spec420-browser-environment', + ]); + + [$user, $environment] = createUserWithTenant( + tenant: $environment, + role: 'owner', + workspaceRole: 'owner', + clearCapabilityCaches: true, + ); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'display_name' => 'Spec420 Browser Microsoft provider', + ]); + + $resourceType = TenantConfigurationResourceType::query() + ->where('canonical_type', 'conditionalAccessPolicy') + ->where('source_class', SourceClass::Tcm->value) + ->firstOrFail(); + + TenantConfigurationSupportedScope::factory()->create([ + 'scope_key' => 'spec420_browser_internal_m365_scope', + 'display_name' => 'Spec420 Browser internal M365 scope', + 'minimum_coverage_level' => CoverageLevel::ContentBacked->value, + 'included_resource_types' => ['conditionalAccessPolicy'], + 'allow_graph_fallback' => false, + 'allow_beta' => false, + 'customer_claims_allowed' => false, + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => (string) $user->name, + 'type' => OperationRunType::TenantConfigurationCapture->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'summary_counts' => [ + 'total' => 4, + 'processed' => 4, + 'succeeded' => 1, + 'skipped' => 3, + 'failed' => 0, + 'errors_recorded' => 0, + ], + 'context' => [ + 'requested_resource_types' => [ + 'acceptedDomain', + 'appPermissionPolicy', + 'conditionalAccessPolicy', + 'dlpCompliancePolicy', + ], + 'outcomes' => [ + ['canonical_type' => 'conditionalAccessPolicy', 'outcome' => CaptureOutcome::Captured->value], + ['canonical_type' => 'acceptedDomain', 'outcome' => CaptureOutcome::BlockedMissingContract->value], + ['canonical_type' => 'appPermissionPolicy', 'outcome' => CaptureOutcome::BlockedMissingContract->value], + ['canonical_type' => 'dlpCompliancePolicy', 'outcome' => CaptureOutcome::BlockedMissingContract->value], + ], + ], + 'started_at' => now()->subMinute(), + 'completed_at' => now(), + ]); + + $resource = TenantConfigurationResource::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + 'resource_type_id' => (int) $resourceType->getKey(), + 'canonical_type' => 'conditionalAccessPolicy', + 'canonical_resource_key' => 'conditionalAccessPolicy:graph_object_id:cap-browser-1', + 'canonical_key_kind' => CanonicalKeyKind::GraphObjectId->value, + 'source_resource_id' => 'cap-browser-1', + 'source_display_name' => 'Spec420 Browser Conditional Access policy', + 'source_class' => SourceClass::Tcm->value, + 'source_metadata' => [ + 'source_contract_key' => 'conditionalAccessPolicy', + 'source_endpoint' => '/identity/conditionalAccess/policies', + 'source_version' => 'v1.0', + 'source_schema_hash' => 'spec420-browser-schema-hash', + 'source_schema_hash_available' => true, + 'registry_source_class' => SourceClass::Tcm->value, + 'registry_support_state' => 'out_of_scope', + ], + 'identity_strategy' => 'graph.conditional_access_policy.v1', + 'source_identity' => [ + 'primary_field' => 'id', + 'primary_value' => 'cap-browser-1', + ], + 'secondary_identity_keys' => [ + 'state' => 'enabled', + 'source_metadata.source_contract_key' => 'conditionalAccessPolicy', + 'source_metadata.source_version' => 'v1.0', + ], + 'identity_diagnostics' => [ + 'reason_code' => 'graph_object_id', + ], + 'identity_evaluated_at' => now(), + 'latest_evidence_state' => EvidenceState::ContentBacked->value, + 'latest_identity_state' => IdentityState::Stable->value, + 'latest_claim_state' => ClaimState::InternalOnly->value, + 'latest_captured_at' => now(), + ]); + + $evidence = TenantConfigurationResourceEvidence::factory()->create([ + 'resource_id' => (int) $resource->getKey(), + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + 'resource_type_id' => (int) $resourceType->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'source_contract_key' => 'conditionalAccessPolicy', + 'source_endpoint' => '/identity/conditionalAccess/policies', + 'source_version' => 'v1.0', + 'source_schema_hash' => 'spec420-browser-schema-hash', + 'source_metadata' => [ + 'registry_source_class' => SourceClass::Tcm->value, + 'registry_support_state' => 'out_of_scope', + ], + 'raw_payload' => ['id' => 'cap-browser-1', 'secret' => 'spec420-raw-secret'], + 'normalized_payload' => ['id' => 'cap-browser-1', 'secret' => 'spec420-normalized-secret'], + 'payload_hash' => str_repeat('e', 64), + 'permission_context' => ['token' => 'spec420-permission-secret'], + 'evidence_state' => EvidenceState::ContentBacked->value, + 'coverage_level' => CoverageLevel::ContentBacked->value, + 'capture_outcome' => CaptureOutcome::Captured->value, + 'captured_at' => now(), + ]); + + $resource->forceFill([ + 'latest_evidence_id' => (int) $evidence->getKey(), + 'latest_payload_hash' => str_repeat('e', 64), + ])->save(); + + return [$user, $environment->refresh()]; +} + +function spec420AuthenticateCoverageV2Browser( + mixed $test, + User $user, + ManagedEnvironment $environment, +): void { + $workspaceId = (int) $environment->workspace_id; + + $test->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [ + (string) $workspaceId => (int) $environment->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [ + (string) $workspaceId => (int) $environment->getKey(), + ]); +} diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureAuthorizationTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureAuthorizationTest.php new file mode 100644 index 00000000..af7bf8d7 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureAuthorizationTest.php @@ -0,0 +1,98 @@ +create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + + $run = app(StartTenantConfigurationCapture::class)->start($environment, $connection, $user, [ + 'conditionalAccessPolicy', + ]); + + expect(data_get($run->context, 'required_capability'))->toBe('evidence.manage') + ->and(data_get($run->context, 'resource_types'))->toBe(['conditionalAccessPolicy']); + + Queue::assertPushed(CaptureTenantConfigurationEvidenceJob::class); +}); + +it('Spec420 allows managers with evidence manage capability to start selected M365 capture', function (): void { + Queue::fake(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'manager'); + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + + $run = app(StartTenantConfigurationCapture::class)->start($environment, $connection, $user, [ + 'conditionalAccessPolicy', + ]); + + expect(data_get($run->context, 'required_capability'))->toBe('evidence.manage') + ->and(data_get($run->context, 'resource_types'))->toBe(['conditionalAccessPolicy']); + + Queue::assertPushed(CaptureTenantConfigurationEvidenceJob::class); +}); + +it('Spec420 returns forbidden for operators without evidence manage capability', function (): void { + Queue::fake(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'operator'); + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + + expect(fn () => app(StartTenantConfigurationCapture::class)->start($environment, $connection, $user, [ + 'conditionalAccessPolicy', + ]))->toThrow(AuthorizationException::class); + + Queue::assertNothingPushed(); +}); + +it('Spec420 returns forbidden for readonly users after membership is established', function (): void { + Queue::fake(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'readonly', workspaceRole: 'readonly'); + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + + expect(fn () => app(StartTenantConfigurationCapture::class)->start($environment, $connection, $user, [ + 'conditionalAccessPolicy', + ]))->toThrow(AuthorizationException::class); + + Queue::assertNothingPushed(); +}); + +it('Spec420 hides environments from non-members before capture start', function (): void { + Queue::fake(); + + $user = User::factory()->create(); + [, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + + expect(fn () => app(StartTenantConfigurationCapture::class)->start($environment, $connection, $user, [ + 'conditionalAccessPolicy', + ]))->toThrow(NotFoundHttpException::class); + + Queue::assertNothingPushed(); +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php new file mode 100644 index 00000000..0d7cf326 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php @@ -0,0 +1,214 @@ +syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + $graph = spec420OperationGraphClient([ + 'conditionalAccessPolicy' => [[ + 'id' => 'cap-1', + 'displayName' => 'Require MFA', + 'clientSecret' => 'spec420-operation-client-secret', + 'tokenClaims' => [ + 'access_token' => 'spec420-operation-access-token', + ], + ]], + ]); + app()->instance(GraphClientInterface::class, $graph); + $originalLogger = Log::getFacadeRoot(); + $logger = spec420RecordingLogger(); + Log::swap($logger); + + $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' => ['conditionalAccessPolicy', 'acceptedDomain', 'appPermissionPolicy', 'dlpCompliancePolicy'], + 'required_capability' => 'evidence.manage', + ], + ]); + + try { + app(CaptureTenantConfigurationEvidenceJob::class, ['run' => $run])->handle( + app(GenericContentEvidenceCaptureService::class), + app(OperationRunService::class), + app(AuditRecorder::class), + ); + + $run->refresh(); + + expect($run->type)->toBe(OperationRunType::TenantConfigurationCapture->value) + ->and($run->type)->not->toBe('tenant_configuration.m365_capture') + ->and($run->status)->toBe(OperationRunStatus::Completed->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value) + ->and($run->summary_counts)->toMatchArray([ + 'total' => 4, + 'processed' => 4, + 'succeeded' => 1, + 'skipped' => 3, + 'failed' => 0, + 'errors_recorded' => 0, + ]) + ->and(array_diff(array_keys($run->summary_counts), OperationSummaryKeys::all()))->toBe([]) + ->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret') + ->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('access_token') + ->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-client-secret') + ->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-access-token') + ->and(json_encode($run->failure_summary ?? [], JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-client-secret') + ->and(json_encode($run->failure_summary ?? [], JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-access-token'); + + $evidence = TenantConfigurationResourceEvidence::query()->sole(); + $audit = AuditLog::query() + ->where('action', 'tenant_configuration.capture.completed') + ->latest('id') + ->first(); + + expect($evidence->raw_payload['clientSecret'])->toBe('spec420-operation-client-secret') + ->and($evidence->normalized_payload['clientSecret'])->toBe('[redacted]') + ->and($evidence->normalized_payload['tokenClaims'])->toBe('[redacted]') + ->and(json_encode($evidence->normalized_payload, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-client-secret') + ->and(json_encode($evidence->normalized_payload, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-access-token') + ->and($audit)->not->toBeNull() + ->and(json_encode($audit?->metadata, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-client-secret') + ->and(json_encode($audit?->metadata, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-access-token'); + + spec420AssertSecretsWereNotLogged($logger, [ + 'spec420-operation-client-secret', + 'spec420-operation-access-token', + ]); + } finally { + Log::swap($originalLogger); + } +}); + +it('Spec420 deduplicates active capture starts for the selected M365 first pack', function (): void { + Queue::fake(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + $types = ['conditionalAccessPolicy', 'acceptedDomain', 'appPermissionPolicy', 'dlpCompliancePolicy']; + + $first = app(StartTenantConfigurationCapture::class)->start($environment, $connection, $user, $types); + $second = app(StartTenantConfigurationCapture::class)->start($environment, $connection, $user, $types); + + expect($second->getKey())->toBe($first->getKey()) + ->and($first->type)->toBe(OperationRunType::TenantConfigurationCapture->value) + ->and(data_get($first->context, 'resource_types'))->toBe(collect($types)->sort()->values()->all()); + + Queue::assertPushed(CaptureTenantConfigurationEvidenceJob::class, 1); +}); + +function spec420OperationGraphClient(array $responses): GraphClientInterface +{ + return new class($responses) implements GraphClientInterface + { + public function __construct(private readonly array $responses) {} + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, $this->responses[$policyType] ?? []); + } + + 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); + } + }; +} + +function spec420RecordingLogger(): object +{ + return new class + { + /** + * @var list}> + */ + public array $entries = []; + + public function __call(string $method, array $arguments): self + { + if (in_array($method, ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'], true)) { + $this->entries[] = ['level' => $method, 'arguments' => $arguments]; + } + + return $this; + } + }; +} + +/** + * @param list $secrets + */ +function spec420AssertSecretsWereNotLogged(object $logger, array $secrets): void +{ + $entries = property_exists($logger, 'entries') && is_array($logger->entries) + ? $logger->entries + : []; + $logged = spec420LogArguments($entries); + + foreach ($secrets as $secret) { + expect($logged)->not->toContain($secret); + } +} + +/** + * @param array $arguments + */ +function spec420LogArguments(array $arguments): string +{ + return json_encode($arguments, JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_UNESCAPED_SLASHES) ?: ''; +} diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php new file mode 100644 index 00000000..749b79ec --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php @@ -0,0 +1,212 @@ +syncDefaults(); + config()->set('graph_contracts.types.conditionalAccessPolicy.volatile_fields', ['modifiedDateTime']); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'scopes_granted' => ['Policy.Read.All', 'Directory.Read.All'], + ]); + $graph = spec420CaptureGraphClient([ + 'conditionalAccessPolicy' => [ + [ + 'id' => 'cap-1', + 'displayName' => 'Require MFA', + 'state' => 'enabled', + 'modifiedDateTime' => '2026-06-27T10:00:00Z', + 'clientSecret' => 'spec420-provider-client-secret', + 'tokenClaims' => [ + 'access_token' => 'spec420-provider-access-token', + ], + 'conditions' => ['users' => ['includeUsers' => ['All']]], + ], + ], + ]); + app()->instance(GraphClientInterface::class, $graph); + + $run = spec420CaptureRun($user, $environment, $connection, [ + 'conditionalAccessPolicy', + 'acceptedDomain', + 'appPermissionPolicy', + 'dlpCompliancePolicy', + ]); + + $result = app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalTypes: ['conditionalAccessPolicy', 'acceptedDomain', 'appPermissionPolicy', 'dlpCompliancePolicy'], + ); + + expect($graph->calls)->toHaveCount(1) + ->and($graph->calls[0]['policy_type'])->toBe('conditionalAccessPolicy') + ->and($result['summary_counts'])->toMatchArray([ + 'total' => 4, + 'processed' => 4, + 'succeeded' => 1, + 'skipped' => 3, + 'failed' => 0, + 'errors_recorded' => 0, + ]) + ->and($result['run_outcome'])->toBe(OperationRunOutcome::Succeeded->value); + + $outcomes = collect($result['outcomes'])->keyBy('canonical_type'); + + expect($outcomes['conditionalAccessPolicy']['outcome'])->toBe(CaptureOutcome::Captured->value) + ->and($outcomes['conditionalAccessPolicy']['source_contract_key'])->toBe('conditionalAccessPolicy') + ->and($outcomes['acceptedDomain']['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value) + ->and($outcomes['appPermissionPolicy']['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value) + ->and($outcomes['dlpCompliancePolicy']['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value); + + $resource = TenantConfigurationResource::query()->sole(); + $evidence = TenantConfigurationResourceEvidence::query()->sole(); + + expect($resource->canonical_type)->toBe('conditionalAccessPolicy') + ->and($resource->workspace_id)->toBe((int) $environment->workspace_id) + ->and($resource->managed_environment_id)->toBe((int) $environment->getKey()) + ->and($resource->provider_connection_id)->toBe((int) $connection->getKey()) + ->and($resource->canonical_key_kind)->toBe(CanonicalKeyKind::GraphObjectId) + ->and($resource->latest_identity_state)->toBe(IdentityState::Stable) + ->and($resource->latest_claim_state)->toBe(ClaimState::InternalOnly) + ->and($resource->source_identity['strategy_identifier'])->toBe('graph.conditional_access_policy.v1') + ->and($resource->source_metadata['source_contract_key'])->toBe('conditionalAccessPolicy') + ->and($resource->source_metadata['registry_source_class'])->toBe('tcm') + ->and($resource->source_metadata['registry_support_state'])->toBe('out_of_scope'); + + expect($evidence->resource_id)->toBe((int) $resource->getKey()) + ->and($evidence->operation_run_id)->toBe((int) $run->getKey()) + ->and($evidence->source_contract_key)->toBe('conditionalAccessPolicy') + ->and($evidence->source_endpoint)->toBe('/identity/conditionalAccess/policies') + ->and($evidence->coverage_level)->toBe(CoverageLevel::ContentBacked) + ->and($evidence->evidence_state)->toBe(EvidenceState::ContentBacked) + ->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured) + ->and($evidence->raw_payload['id'])->toBe('cap-1') + ->and($evidence->raw_payload['clientSecret'])->toBe('spec420-provider-client-secret') + ->and($evidence->normalized_payload)->not->toHaveKey('modifiedDateTime') + ->and($evidence->normalized_payload['clientSecret'])->toBe('[redacted]') + ->and($evidence->normalized_payload['tokenClaims'])->toBe('[redacted]') + ->and(json_encode($evidence->normalized_payload, JSON_THROW_ON_ERROR))->not->toContain('spec420-provider-client-secret') + ->and(json_encode($evidence->normalized_payload, JSON_THROW_ON_ERROR))->not->toContain('spec420-provider-access-token') + ->and($evidence->payload_hash)->toBeString()->toHaveLength(64) + ->and($evidence->permission_context['scopes_granted'])->toBe(['Policy.Read.All', 'Directory.Read.All']); +}); + +it('Spec420 creates no fake evidence rows for selected missing-contract types', 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(), + ]); + $graph = spec420CaptureGraphClient([]); + app()->instance(GraphClientInterface::class, $graph); + + $run = spec420CaptureRun($user, $environment, $connection, [ + 'acceptedDomain', + 'appPermissionPolicy', + 'dlpCompliancePolicy', + ]); + + $result = app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalTypes: ['acceptedDomain', 'appPermissionPolicy', 'dlpCompliancePolicy'], + ); + + expect($graph->calls)->toBe([]) + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0) + ->and($result['summary_counts'])->toMatchArray([ + 'total' => 3, + 'processed' => 3, + 'succeeded' => 0, + 'skipped' => 3, + 'failed' => 0, + ]) + ->and($result['run_outcome'])->toBe(OperationRunOutcome::Blocked->value); +}); + +function spec420CaptureRun($user, $environment, ProviderConnection $connection, array $resourceTypes): OperationRun +{ + return 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' => $resourceTypes, + 'required_capability' => 'evidence.manage', + ], + ]); +} + +function spec420CaptureGraphClient(array $responses): GraphClientInterface +{ + return new class($responses) implements GraphClientInterface + { + public array $calls = []; + + public function __construct(private readonly array $responses) {} + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->calls[] = ['policy_type' => $policyType, 'options' => $options]; + + return new GraphResponse(true, $this->responses[$policyType] ?? []); + } + + 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/Spec420M365NoLegacyTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoLegacyTest.php new file mode 100644 index 00000000..66e1a661 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoLegacyTest.php @@ -0,0 +1,40 @@ +map(fn (string $file): string => file_get_contents($file) ?: '') + ->implode("\n"); + + expect($content) + ->not->toContain('tenant_configuration.m365_capture') + ->not->toContain('ProviderV1') + ->not->toContain('LegacyAdapter') + ->not->toContain('fallback_reader') + ->not->toContain('dual_write') + ->not->toContain('namespace App\\Services\\TenantConfiguration\\Entra') + ->not->toContain('namespace App\\Services\\TenantConfiguration\\Exchange') + ->not->toContain('namespace App\\Services\\TenantConfiguration\\Teams') + ->not->toContain('namespace App\\Services\\TenantConfiguration\\SecurityCompliance') + ->not->toContain('Http::'); + + foreach ([ + 'policy_record_missing', + 'foundation_not_policy_backed', + 'meta_fallback', + 'ambiguous_match', + 'raw_gap_count', + ] as $legacyOutcome) { + expect(CaptureOutcome::values())->not->toContain($legacyOutcome); + } +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoOverclaimTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoOverclaimTest.php new file mode 100644 index 00000000..f7536836 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoOverclaimTest.php @@ -0,0 +1,95 @@ +syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + app()->instance(GraphClientInterface::class, spec420NoOverclaimGraphClient()); + + $run = spec420NoOverclaimRun($user, $environment, $connection); + + app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalTypes: ['conditionalAccessPolicy'], + ); + + $resource = TenantConfigurationResource::query()->sole(); + + expect($resource->latest_claim_state)->toBe(ClaimState::InternalOnly) + ->and($resource->source_metadata['source_contract_key'])->toBe('conditionalAccessPolicy') + ->and($resource->source_metadata)->not->toHaveKey('certified') + ->and($resource->source_metadata)->not->toHaveKey('restore_ready') + ->and($resource->source_metadata)->not->toHaveKey('customer_ready'); +}); + +function spec420NoOverclaimGraphClient(): GraphClientInterface +{ + return new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, [ + ['id' => 'cap-1', 'displayName' => 'Require MFA'], + ]); + } + + 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); + } + }; +} + +function spec420NoOverclaimRun($user, $environment, ProviderConnection $connection): \App\Models\OperationRun +{ + return \App\Models\OperationRun::factory()->withUser($user)->forTenant($environment)->create([ + 'type' => \App\Support\OperationRunType::TenantConfigurationCapture->value, + 'status' => \App\Support\OperationRunStatus::Queued->value, + 'outcome' => \App\Support\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' => ['conditionalAccessPolicy'], + 'required_capability' => 'evidence.manage', + ], + ]); +} diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoTenantIdTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoTenantIdTest.php new file mode 100644 index 00000000..1f816e9e --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoTenantIdTest.php @@ -0,0 +1,28 @@ +not->toContain('tenant_id'); + } + + $source = collect([ + app_path('Services/TenantConfiguration/CoverageSourceContractResolver.php'), + app_path('Services/TenantConfiguration/GenericContentEvidenceCaptureService.php'), + app_path('Services/TenantConfiguration/CoverageResourceUpserter.php'), + app_path('Services/TenantConfiguration/CoverageEvidenceWriter.php'), + app_path('Services/TenantConfiguration/StartTenantConfigurationCapture.php'), + app_path('Jobs/TenantConfiguration/CaptureTenantConfigurationEvidenceJob.php'), + ])->map(fn (string $file): string => file_get_contents($file) ?: '') + ->implode("\n"); + + expect($source)->not->toContain('tenant_id'); +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec420M365ProviderConnectionScopeTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365ProviderConnectionScopeTest.php new file mode 100644 index 00000000..5a982c39 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365ProviderConnectionScopeTest.php @@ -0,0 +1,112 @@ +create([ + 'workspace_id' => (int) $otherEnvironment->workspace_id, + 'managed_environment_id' => (int) $otherEnvironment->getKey(), + ]); + + expect(fn () => app(StartTenantConfigurationCapture::class)->start($environment, $foreignConnection, $user, [ + 'conditionalAccessPolicy', + ]))->toThrow(NotFoundHttpException::class); + + Queue::assertNothingPushed(); +}); + +it('Spec420 revalidates provider connection scope in the queued job before graph work', function (): void { + app(ResourceTypeRegistry::class)->syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + [, $otherEnvironment] = createMinimalUserWithTenant(role: 'owner'); + $foreignConnection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $otherEnvironment->workspace_id, + 'managed_environment_id' => (int) $otherEnvironment->getKey(), + ]); + $graph = spec420ScopeGraphClient(); + 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) $foreignConnection->getKey(), + ], + 'resource_types' => ['conditionalAccessPolicy'], + 'required_capability' => 'evidence.manage', + ], + ]); + + expect(fn () => app(CaptureTenantConfigurationEvidenceJob::class, ['run' => $run])->handle( + app(GenericContentEvidenceCaptureService::class), + app(OperationRunService::class), + app(AuditRecorder::class), + ))->toThrow(RuntimeException::class, 'same-scope provider connection'); + + expect($graph->calls)->toBe([]); +}); + +function spec420ScopeGraphClient(): GraphClientInterface +{ + return new class implements GraphClientInterface + { + public array $calls = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->calls[] = ['policy_type' => $policyType, 'options' => $options]; + + return new GraphResponse(true, []); + } + + 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/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php index 896a236d..86b5d771 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php @@ -14,6 +14,7 @@ 'deviceEnrollmentStatusPageWindows10', 'appProtectionPolicyAndroid', 'appProtectionPolicyiOS', + 'conditionalAccessPolicy', 'notificationMessageTemplate', 'roleScopeTag', ]); @@ -34,4 +35,3 @@ ->and($strategy['allows_derived_identity'])->toBeTrue() ->and($strategy['derived_claims_allowed'])->toBeFalse(); }); - diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php new file mode 100644 index 00000000..29c41499 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php @@ -0,0 +1,42 @@ +evaluateStatement($claim))->toBe(ClaimState::ClaimBlocked); +})->with([ + 'M365 coverage is certified', + 'Microsoft 365 restore-ready coverage', + 'Complete tenant coverage', + 'All M365 resources are supported', + '100 percent coverage', +]); + +it('Spec420 blocks unsafe Conditional Access customer claims when identity is not stable', function (IdentityState $identityState): void { + $state = app(ClaimGuard::class)->evaluate( + scopeKey: 'm365_tcm_registry_detected', + requestedLevel: CoverageLevel::ContentBacked, + actualLevel: CoverageLevel::ContentBacked, + scopeComplete: false, + customerFacing: true, + customerClaimsAllowed: false, + sourceClass: SourceClass::Tcm, + restoreTier: RestoreTier::NotRestorable, + identityState: $identityState, + allowsDerivedIdentityClaims: false, + ); + + expect($state)->toBe(ClaimState::ClaimBlocked); +})->with([ + 'missing external id' => [IdentityState::MissingExternalId], + 'unsupported identity' => [IdentityState::UnsupportedIdentity], + 'identity conflict' => [IdentityState::IdentityConflict], + 'derived identity' => [IdentityState::Derived], +]); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php new file mode 100644 index 00000000..a00f362e --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php @@ -0,0 +1,90 @@ +metadata['source_aliases'] ?? []; + + $decision = (new CoverageSourceContractResolver(new GraphContractRegistry))->resolve($resourceType); + + expect($aliases)->toBeArray()->not->toBeEmpty() + ->and($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract) + ->and($decision->sourceEndpoint)->toBeNull() + ->and($decision->sourceMetadata['reason_code'])->toBe('missing_source_contract_mapping'); +})->with([ + 'acceptedDomain', + 'appPermissionPolicy', + 'dlpCompliancePolicy', +]); + +it('Spec420 keeps existing beta and unsupported outcomes instead of adding a new outcome family', function (): void { + $resolver = new CoverageSourceContractResolver(new GraphContractRegistry); + + $beta = $resolver->resolve(spec420EligibilityAdHocResourceType( + canonicalType: 'roleScopeTag', + sourceClass: \App\Support\TenantConfiguration\SourceClass::GraphBetaExperimental, + supportState: \App\Support\TenantConfiguration\SupportState::Experimental, + )); + $unsupported = $resolver->resolve(spec420EligibilityAdHocResourceType( + canonicalType: 'unsupportedResource', + supportState: \App\Support\TenantConfiguration\SupportState::Unsupported, + )); + + expect($beta->outcome)->toBe(CaptureOutcome::BlockedBeta) + ->and($unsupported->outcome)->toBe(CaptureOutcome::BlockedUnsupported) + ->and(CaptureOutcome::values())->toBe([ + 'captured', + 'capture_blocked_missing_contract', + 'capture_blocked_permission', + 'capture_blocked_beta', + 'capture_blocked_unsupported', + 'capture_failed', + ]); +}); + +function spec420EligibilityResourceType(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 spec420EligibilityAdHocResourceType( + string $canonicalType, + SourceClass $sourceClass = SourceClass::Tcm, + SupportState $supportState = SupportState::Supported, +): TenantConfigurationResourceType { + return new TenantConfigurationResourceType([ + 'canonical_type' => $canonicalType, + 'display_name' => $canonicalType, + 'source_class' => $sourceClass->value, + 'workload' => Workload::Intune->value, + 'resource_class' => ResourceClass::Configuration->value, + 'support_state' => $supportState->value, + 'default_coverage_level' => CoverageLevel::ContentBacked->value, + 'default_evidence_state' => EvidenceState::ContentBacked->value, + 'default_identity_state' => IdentityState::Stable->value, + 'default_claim_state' => ClaimState::ClaimAllowed->value, + 'restore_tier' => RestoreTier::PreviewOnly->value, + 'is_active' => true, + ]); +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureIdentityStrategyTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureIdentityStrategyTest.php new file mode 100644 index 00000000..43f52c4a --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureIdentityStrategyTest.php @@ -0,0 +1,48 @@ +strategyFor(spec420IdentityResourceType()); + + expect($strategy['strategy_identifier'])->toBe('graph.conditional_access_policy.v1') + ->and($strategy['preferred_identity_fields'])->toContain('id') + ->and($strategy['stable_key_kind'])->toBe(CanonicalKeyKind::GraphObjectId->value) + ->and($strategy['requires_provider_connection_scope'])->toBeTrue() + ->and($strategy['derived_claims_allowed'])->toBeFalse(); +}); + +it('Spec420 never treats Conditional Access display names as stable identity', function (): void { + $resolver = app(CanonicalIdentityResolver::class); + $resourceType = spec420IdentityResourceType(); + + $stable = $resolver->resolve($resourceType, [ + 'id' => 'cap-1', + 'displayName' => 'Require MFA', + ]); + $missing = $resolver->resolve($resourceType, [ + 'displayName' => 'Require MFA', + ]); + + expect($stable->identityState)->toBe(IdentityState::Stable) + ->and($stable->keyKind)->toBe(CanonicalKeyKind::GraphObjectId) + ->and($missing->identityState)->toBe(IdentityState::MissingExternalId) + ->and($missing->canonicalResourceKey)->not->toContain('Require MFA'); +}); + +function spec420IdentityResourceType(): TenantConfigurationResourceType +{ + $definition = collect(ResourceTypeRegistry::defaultDefinitions()) + ->firstWhere('canonical_type', 'conditionalAccessPolicy'); + + expect($definition)->not->toBeNull('Missing default resource type definition for conditionalAccessPolicy.'); + + return new TenantConfigurationResourceType($definition); +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureRedactionTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureRedactionTest.php new file mode 100644 index 00000000..140f9f5b --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureRedactionTest.php @@ -0,0 +1,24 @@ +redact([ + 'provider_connection_id' => 10, + 'Authorization' => 'Bearer secret', + 'clientSecret' => 'secret', + 'permission_context' => [ + 'access_token' => 'token', + 'refreshToken' => 'refresh', + 'scopes_granted' => ['Policy.Read.All'], + ], + ]); + + expect($redacted['Authorization'])->toBe('[redacted]') + ->and($redacted['clientSecret'])->toBe('[redacted]') + ->and($redacted['permission_context']['access_token'])->toBe('[redacted]') + ->and($redacted['permission_context']['refreshToken'])->toBe('[redacted]') + ->and($redacted['permission_context']['scopes_granted'])->toBe(['Policy.Read.All']); +}); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php new file mode 100644 index 00000000..8db4274b --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php @@ -0,0 +1,48 @@ +resolve($resourceType); + + expect($decision->outcome)->toBe(CaptureOutcome::Captured) + ->and($decision->contractKey)->toBe('conditionalAccessPolicy') + ->and($decision->sourceEndpoint)->toBe('/identity/conditionalAccess/policies') + ->and($decision->sourceVersion)->toBe('v1.0') + ->and($decision->sourceSchemaHash)->toBeString()->not->toBe('') + ->and($decision->sourceMetadata['source_contract_key'])->toBe('conditionalAccessPolicy') + ->and($decision->sourceMetadata['registry_source_class'])->toBe('tcm') + ->and($decision->sourceMetadata['registry_support_state'])->toBe('out_of_scope'); +}); + +it('Spec420 blocks selected missing-contract M365 types without falling back to unsupported', function (string $canonicalType): void { + $decision = (new CoverageSourceContractResolver(new GraphContractRegistry)) + ->resolve(spec420UnitResourceType($canonicalType)); + + expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract) + ->and($decision->reasonCode)->toBe('missing_source_contract_mapping') + ->and($decision->contractKey)->toBeNull() + ->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]); +})->with([ + 'Exchange accepted domain' => ['acceptedDomain'], + 'Teams app permission policy' => ['appPermissionPolicy'], + 'Security and Compliance DLP policy' => ['dlpCompliancePolicy'], +]); + +function spec420UnitResourceType(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/Spec420M365GenericPayloadNormalizerTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365GenericPayloadNormalizerTest.php new file mode 100644 index 00000000..abd920b9 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365GenericPayloadNormalizerTest.php @@ -0,0 +1,31 @@ +normalize([ + 'id' => 'cap-1', + 'modifiedDateTime' => '2026-06-27T10:00:00Z', + 'conditions' => [ + 'users' => ['includeUsers' => ['All']], + 'applications' => ['includeApplications' => ['Office365']], + ], + 'displayName' => 'Require MFA', + ], ['modifiedDateTime']); + $second = $normalizer->normalize([ + 'displayName' => 'Require MFA', + 'conditions' => [ + 'applications' => ['includeApplications' => ['Office365']], + 'users' => ['includeUsers' => ['All']], + ], + 'modifiedDateTime' => '2026-06-27T10:05:00Z', + 'id' => 'cap-1', + ], ['modifiedDateTime']); + + expect($first)->toBe($second) + ->and($first)->not->toHaveKey('modifiedDateTime') + ->and($normalizer->payloadHash($first))->toBe($normalizer->payloadHash($second)); +}); diff --git a/specs/420-m365-generic-evidence-coverage-pack/checklists/requirements.md b/specs/420-m365-generic-evidence-coverage-pack/checklists/requirements.md new file mode 100644 index 00000000..91629f19 --- /dev/null +++ b/specs/420-m365-generic-evidence-coverage-pack/checklists/requirements.md @@ -0,0 +1,96 @@ +# Requirements Checklist: Spec 420 - M365 Generic Evidence Coverage Pack + +## Preparation Checklist + +- [x] Candidate is user-provided, not auto-selected from the empty active candidate queue. +- [x] Spec 414 is read-only dependency context only. +- [x] Spec 415 is read-only dependency context only. +- [x] Spec 417 is read-only dependency context only. +- [x] Spec 418 is read-only dependency context only. +- [x] Spec 419 is read-only dependency context only. +- [x] No existing `specs/420-*` package or branch was found before creation. +- [x] Existing Coverage v2 resource/evidence tables, registry, source resolver, capture service, identity resolver, Claim Guard, OperationRun service, and M365 registry rows were verified as repo truth. +- [x] Draft-to-repo deviations are documented. +- [x] No application implementation was performed during preparation. + +## Candidate Scope Checklist + +- [x] Selected first pack is bounded to `conditionalAccessPolicy`, `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy`. +- [x] At least one enabled capture path is planned only when backed by an explicit repo-real contract. +- [x] Missing-contract paths are first-class requirements, not implementation failures. +- [x] No compare/render/restore/certification/customer output is in scope. +- [x] No new UI start action, route, navigation entry, dashboard, report, download, or customer surface is in scope. +- [x] No workload-specific mini-platform is in scope. + +## Product Surface Checklist + +- [x] UI Surface Impact records existing Spec 418 operator-surface data impact without runtime UI code scope. +- [x] Product Surface Impact covers data-driven existing-surface impact. +- [x] Browser proof is required if captured/blocked M365 data renders, or N/A only with proof that no rendered output changed. +- [x] Human Product Sanity is required if captured/blocked M365 data renders, or N/A only with proof. +- [x] Product Surface exceptions are `none`. +- [x] Stop-and-amend rule exists for any runtime UI file, route, navigation, action, report, download, customer output, or rendered-label change beyond existing data-driven display. + +## OperationRun / RBAC / Scope Checklist + +- [x] Existing `tenant_configuration.capture` operation type is reused by default. +- [x] New `tenant_configuration.m365_capture` is rejected unless proportionality review is amended. +- [x] OperationRunService owns status/outcome transitions. +- [x] Summary counts remain flat numeric-only and use existing keys. +- [x] Non-member and missing environment entitlement deny as not found. +- [x] Missing capture capability and readonly denial return 403 after membership is established. +- [x] Provider connection scope must match workspace and managed environment before run creation and job provider work. + +## Evidence / Identity / Claim Checklist + +- [x] Captured evidence must persist raw payload, normalized payload, payload hash, source metadata, permission context, and OperationRun link. +- [x] Missing contracts must not create fake evidence. +- [x] CanonicalIdentityResolver must be used. +- [x] Display-name-only identity is forbidden as stable identity. +- [x] Identity conflicts and unsafe derived identity block customer-facing claims. +- [x] Claim Guard blocks broad M365, certified, restore-ready, customer-ready, complete tenant, all-resource, and unscoped 100% claims. +- [x] Generic captured evidence does not imply comparable, renderable, restorable, certified, or customer-ready. + +## Source Contract / Provider Boundary Checklist + +- [x] Provider calls must go through `GraphClientInterface` and existing provider gateway/contract paths. +- [x] `conditionalAccessPolicy` capture depends on explicit repo-real source contract mapping. +- [x] `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy` remain missing-contract blockers for Spec 420; adding contracts for those three types requires an amended package or follow-up spec. +- [x] Endpoint guessing from canonical type strings or source aliases is forbidden. +- [x] Runtime Microsoft docs scraping is forbidden. +- [x] Provider-native tenant/directory/account IDs remain metadata only. + +## No Legacy / Ownership Checklist + +- [x] No `tenant_id`. +- [x] No old gap taxonomy. +- [x] No v1-to-v2 adapter. +- [x] No fallback reader. +- [x] No dual writes. +- [x] No old snapshot promotion. +- [x] No customer-facing dual truth. + +## Test Requirements Checklist + +- [x] Unit tests cover source contracts, eligibility, normalization/hash, identity strategy, Claim Guard, and redaction. +- [x] Feature tests cover capture persistence, OperationRun, authorization, provider scope, no-overclaim, no-legacy, and no-tenant-id. +- [x] No real Graph/TCM/provider calls are allowed in tests. +- [x] Test lane impact is documented. +- [x] PostgreSQL lane is required if migrations/check constraints/indexes change. +- [x] Browser proof is required if existing Spec 418 operator surface renders captured/blocked M365 data. + +## Spec Readiness Gate + +- [x] `spec.md` exists. +- [x] `plan.md` exists. +- [x] `tasks.md` exists. +- [x] Requirements are bounded and testable. +- [x] Plan identifies likely affected repo surfaces. +- [x] Tasks are ordered, small, verifiable, and include validation. +- [x] Product Surface, RBAC, workspace/provider isolation, OperationRun, evidence/result truth, provider boundary, no-legacy, and test governance are addressed. +- [x] No open question blocks safe implementation. + +## Gate Results + +- [x] Candidate Selection Gate: PASS for direct user-provided candidate. +- [x] Spec Readiness Gate: PASS for preparation; implementation must still follow `tasks.md`. diff --git a/specs/420-m365-generic-evidence-coverage-pack/implementation-report.md b/specs/420-m365-generic-evidence-coverage-pack/implementation-report.md new file mode 100644 index 00000000..12aa2159 --- /dev/null +++ b/specs/420-m365-generic-evidence-coverage-pack/implementation-report.md @@ -0,0 +1,154 @@ +# Implementation Report: Spec 420 - M365 Generic Evidence Coverage Pack + +## Status + +- Result: passed implementation loop; ready for manual review. +- Active spec directory: `specs/420-m365-generic-evidence-coverage-pack`. +- Branch: `420-m365-generic-evidence-coverage-pack`. +- HEAD at implementation start/final validation: `52523980 feat: expand m365 tcm workload registry (#486)`. +- Initial dirty state: active spec directory was untracked; no unrelated modified runtime files were present. +- Final dirty state: expected active-spec, runtime, and test changes only. No unrelated work was modified. +- Historical specs: Specs 414, 415, 417, 418, and 419 were used as read-only dependency context only. No closed historical spec was rewritten or stripped of validation history. + +## Activated Skills And Gates + +- Activated skills: `spec-kit-implementation-loop`, `pest-testing`; repo gates applied for spec readiness, workspace scope safety, RBAC/action safety, operation-run truth, evidence anchor contract, Product Surface, TCM cutover guard, customer output, and browser read-only audit. +- Hard-gate result: pass with conditions before code, then pass after validation. +- Stop conditions: none hit. No compare/render/restore/certification/customer output, no UI start action, no dashboard, no endpoint guessing, no direct HTTP, no `tenant_id`, no v1 compatibility adapter, and no M365 mini-platform were introduced. +- Analysis/fix iterations: two implementation loops; one browser assertion correction because the existing safe inspect modal intentionally does not render source endpoint, followed by a manual-review fix loop for redaction proof, RBAC role precision, and OperationRun browser-fixture summary keys. Final post-implementation analysis found no confirmed in-scope findings. +- Merge Readiness Gate: passed, subject to human review. + +## Changed Files + +- Runtime: + - `apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php` + - `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` + - `apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php` + - `apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php` +- Existing test update: + - `apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php` +- New Spec 420 tests: + - `apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php` + - `apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php` + - `apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365GenericPayloadNormalizerTest.php` + - `apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureIdentityStrategyTest.php` + - `apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php` + - `apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureRedactionTest.php` + - `apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php` + - `apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php` + - `apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureAuthorizationTest.php` + - `apps/platform/tests/Feature/TenantConfiguration/Spec420M365ProviderConnectionScopeTest.php` + - `apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoOverclaimTest.php` + - `apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoLegacyTest.php` + - `apps/platform/tests/Feature/TenantConfiguration/Spec420M365NoTenantIdTest.php` + - `apps/platform/tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php` +- Spec artifacts: + - `specs/420-m365-generic-evidence-coverage-pack/tasks.md` + - `specs/420-m365-generic-evidence-coverage-pack/implementation-report.md` + +## Implementation Summary + +- `conditionalAccessPolicy` now resolves through an explicit `graph_contracts.types.conditionalAccessPolicy` mapping in the existing generic capture resolver. +- The selected missing-contract types `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy` fail closed as `capture_blocked_missing_contract` with `missing_source_contract_mapping`. +- Registry truth from Spec 419 is preserved: selected M365 types remain registry-seeded as `source_class = tcm`, `support_state = out_of_scope`, and `default_claim_state = internal_only`; runtime capture eligibility is narrow and contract-driven. +- Source metadata now carries actual source contract key, endpoint, source version, source schema hash, registry source class, registry support state, and schema-hash availability where captured. +- `conditionalAccessPolicy` uses a narrow identity strategy with Graph object ID stable keys. `CanonicalIdentityResolver` remains the only resolver path and now honors a strategy-level `stable_key_kind` before falling back to source-class defaults. +- Existing `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `StartTenantConfigurationCapture`, `CaptureTenantConfigurationEvidenceJob`, and `OperationRunService` are reused. `GenericContentEvidenceCaptureService` remains the capture path and now redacts normalized payloads before persistence/hashing while preserving the raw-payload evidence boundary. + +## Capture Eligibility Matrix + +| Canonical type | Runtime result | Provider called | Evidence row | Notes | +| --- | --- | --- | --- | --- | +| `conditionalAccessPolicy` | `captured` when fake Graph payload exists | yes, through `GraphClientInterface::listPolicies()` | yes | Explicit repo-real source contract only; no alias or endpoint guessing. | +| `acceptedDomain` | `capture_blocked_missing_contract` | no | no | Stable reason `missing_source_contract_mapping`. | +| `appPermissionPolicy` | `capture_blocked_missing_contract` | no | no | Stable reason `missing_source_contract_mapping`. | +| `dlpCompliancePolicy` | `capture_blocked_missing_contract` | no | no | Stable reason `missing_source_contract_mapping`; source aliases are ignored at runtime. | + +## Source Contract Matrix + +| Canonical type | Contract key | Endpoint | Version/schema | Outcome | +| --- | --- | --- | --- | --- | +| `conditionalAccessPolicy` | `conditionalAccessPolicy` | `/identity/conditionalAccess/policies` | `v1.0`; schema hash captured when available, explicit unavailable state tested | Capture enabled. | +| `acceptedDomain` | none | none | none | Missing contract blocker. | +| `appPermissionPolicy` | none | none | none | Missing contract blocker. | +| `dlpCompliancePolicy` | none | none | none | Missing contract blocker. | + +## Evidence And OperationRun Proof + +- Captured Conditional Access evidence persists one `TenantConfigurationResource` and one append-only `TenantConfigurationResourceEvidence` row with raw payload, normalized payload, deterministic payload hash, redacted permission context, source metadata, and `operation_run_id`. +- Missing-contract selected types create no fake resources or evidence rows. +- OperationRun type remains `tenant_configuration.capture`; no `tenant_configuration.m365_capture` type was introduced. +- OperationRun lifecycle transitions remain service-owned through `OperationRunService`. +- Summary counts use existing whitelisted numeric keys. +- Dedupe/retry behavior reuses the active-run check in `StartTenantConfigurationCapture`. +- OperationRun has no separate message column in the current model; OperationRun context and failure summary do not contain raw payloads, provider response bodies, tokens, or secrets. + +## RBAC And Scope Proof + +- Authorized owner and manager paths can start the selected capture. +- Operator, readonly, and missing-capability paths are denied with 403 after membership is established. +- Non-member and missing environment entitlement paths are denied without leaking environment data. +- Cross-workspace/cross-environment provider connections are rejected before run creation. +- Job execution revalidates workspace, managed environment, provider connection, OperationRun type, and target scope before provider work. + +## Redaction, Claims, And Safety Proof + +- Secret-bearing provider payload tests prove raw payload remains inside the evidence raw-payload storage boundary, while normalized evidence payload, permission context, OperationRun context/failure summary, audit metadata, and logs do not persist or emit the secret values. +- Broad M365, certified, restore-ready, customer-ready, complete-tenant, all-resource, and unscoped 100% claims remain blocked. +- Captured Conditional Access evidence remains `internal_only` by default. +- No customer output, report, export, publish, restore, certify, or Review Pack path was added. + +## No-Legacy And Data Model Proof + +- No v1 adapter, fallback reader, dual-write path, old gap taxonomy, workload-specific table/model/engine/namespace/service, or mini-platform was introduced. +- No `tenant_id` ownership truth was added to Coverage v2 tables or changed capture paths. +- No migrations, constraints, indexes, enums/status families, operation types, tables, or persisted taxonomies were added. + +## Product Surface Close-Out + +- No-legacy posture: no legacy UI/API compatibility path and no approved exception. +- Product Surface Impact: data-impact only on the existing Coverage v2 Readiness surface when a captured M365 generic evidence row exists. +- UI Surface Impact: no runtime UI files, routes, navigation entries, Filament resources/widgets/pages/actions, Blade views, Livewire components, labels, customer outputs, dashboards, or downloads were edited. +- Page archetype: existing internal operator readiness/review page. +- Surface budgets: unchanged; one existing table row and existing inspect slide-over render seeded data. +- Technical Annex/deep-link demotion: unchanged; source endpoint is intentionally not rendered in the safe inspect modal. +- Canonical status vocabulary: existing Coverage v2 labels only (`Content backed`, `Stable`, `Internal only`, existing capture outcome/status values). +- Product Surface exceptions: none. +- Human Product Sanity: pass. Existing page renders the Conditional Access row as internal generic evidence, shows no broad M365/certified/restore-ready/customer-ready claim, does not expose payload secrets, and adds no new high-impact UI action. +- Visible complexity outcome: unchanged; no new surface controls or navigation. +- Browser proof: `Spec420M365GenericEvidenceOperatorSurfaceSmokeTest` passed. It loads `CoverageV2Readiness`, verifies the row and inspect slide-over, checks Livewire availability, checks no Graph/TCM/provider network calls during render, checks no console/JS errors, and checks no new capture/restore/certify/export/download action on the main surface. + +## Filament v5 Output Contract + +- Livewire v4.0+ compliance: no Livewire API changes; existing Filament v5/Livewire v4 surface is exercised by the browser test. +- Provider registration location: no panel/provider registration changed. Laravel 12 provider registration remains under `apps/platform/bootstrap/providers.php`. +- Global search: no Filament Resource global-search behavior changed; no new Resource was added. +- Destructive/high-impact actions: no new Filament action or UI start action was added. Backend capture start remains capability-gated and OperationRun/audit backed; there is no new destructive action requiring a Filament confirmation modal. +- Asset strategy: no assets registered and no frontend bundles changed. `php artisan filament:assets` is not newly required by this implementation beyond existing deployment practice. +- Testing plan: page/data impact covered by browser smoke; resolver/capture/evidence/identity/RBAC/scope/OperationRun/no-overclaim/no-legacy/no-tenant-id covered by Pest unit and feature tests. + +## Validation Commands + +- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - passed. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php tests/Unit/Support/TenantConfiguration/Spec420M365GenericPayloadNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureIdentityStrategyTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureRedactionTest.php` - passed in latest combined focused run. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php tests/Feature/TenantConfiguration/Spec420M365CaptureAuthorizationTest.php tests/Feature/TenantConfiguration/Spec420M365ProviderConnectionScopeTest.php tests/Feature/TenantConfiguration/Spec420M365NoOverclaimTest.php tests/Feature/TenantConfiguration/Spec420M365NoLegacyTest.php tests/Feature/TenantConfiguration/Spec420M365NoTenantIdTest.php` - passed in latest combined focused run. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php tests/Unit/Support/TenantConfiguration/Spec420M365GenericPayloadNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureIdentityStrategyTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureRedactionTest.php tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php tests/Feature/TenantConfiguration/Spec420M365CaptureAuthorizationTest.php tests/Feature/TenantConfiguration/Spec420M365ProviderConnectionScopeTest.php tests/Feature/TenantConfiguration/Spec420M365NoOverclaimTest.php tests/Feature/TenantConfiguration/Spec420M365NoLegacyTest.php tests/Feature/TenantConfiguration/Spec420M365NoTenantIdTest.php` - 35 passed, 215 assertions. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php` - 1 passed, 43 assertions. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec415CoverageSourceContractResolverTest.php tests/Unit/Support/TenantConfiguration/Spec417CanonicalIdentityResolverTest.php tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php tests/Unit/Support/TenantConfiguration/Spec419M365WorkloadRegistryTest.php tests/Feature/TenantConfiguration/Spec415GenericContentBackedCaptureTest.php tests/Feature/TenantConfiguration/Spec417CanonicalIdentityPersistenceTest.php tests/Feature/TenantConfiguration/Spec417CoverageResourceIdentityUpsertTest.php tests/Feature/TenantConfiguration/Spec419M365RegistryExpansionTest.php` - 31 passed, 2 skipped, 975 assertions. +- `git diff --check` - passed. + +## Deployment Impact + +- Migrations: none. +- Environment variables: none. +- Queue/cron: existing tenant-configuration capture queue path still applies; no new worker type. +- Storage/volumes: none. +- Assets: none. +- Dokploy/staging: deploy as ordinary app code/test change; validate on Staging before Production per repo release rules. No additional rollback step beyond reverting code. +- PostgreSQL lane: N/A because no schema/check constraint/index changed. + +## Deferred Work And Residual Risk + +- Deferred by spec: explicit source contracts for `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy`; compare/render/restore/certification/customer output; broader M365 dashboard or support claims. +- Residual risk: this proves a fake-provider generic capture path for the selected first pack, not real Microsoft Graph production coverage for all M365 resources. +- Remaining in-scope findings: none. diff --git a/specs/420-m365-generic-evidence-coverage-pack/plan.md b/specs/420-m365-generic-evidence-coverage-pack/plan.md new file mode 100644 index 00000000..0473a6c9 --- /dev/null +++ b/specs/420-m365-generic-evidence-coverage-pack/plan.md @@ -0,0 +1,237 @@ +# Implementation Plan: Spec 420 - M365 Generic Evidence Coverage Pack + +**Branch**: `420-m365-generic-evidence-coverage-pack` | **Date**: 2026-06-27 | **Spec**: `specs/420-m365-generic-evidence-coverage-pack/spec.md` +**Input**: Feature specification from `/specs/420-m365-generic-evidence-coverage-pack/spec.md` + +## Summary + +Extend the existing Coverage v2 generic capture path to a bounded M365 first pack. The implementation should enable one explicit contract-backed content capture path for `conditionalAccessPolicy`, prove missing-contract blockers for `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy`, and preserve workspace/managed-environment/provider scope, OperationRun lifecycle, canonical identity, redaction, and Claim Guard boundaries. No compare/render/restore/certification/customer output, new UI start action, M365 dashboard, or workload-specific mini-platform is in scope. + +## Technical Context + +**Language/Version**: PHP 8.4.x, Laravel 12.x +**Primary Dependencies**: existing TenantConfiguration Coverage v2 models/services/enums, `GraphClientInterface`, provider gateway, `OperationRunService`, Pest 4, PostgreSQL via Sail +**Storage**: Existing `tenant_configuration_resources` and `tenant_configuration_resource_evidence` for concrete resource/evidence rows; existing registry tables from Specs 414/419. No new table by default. +**Testing**: Pest 4 / PHPUnit 12 via Sail; all provider calls faked. +**Validation Lanes**: fast-feedback, confidence, PostgreSQL lane if migrations/check constraints change, focused browser if existing Coverage v2 surface renders new captured/blocked M365 data. +**Target Platform**: Laravel Sail locally, Dokploy/container deployment for staging/production. +**Project Type**: Laravel monolith under `apps/platform`. +**Performance Goals**: no provider call for missing contracts, async capture for enabled contract, deterministic normalization/hash, no render-time Graph calls. +**Constraints**: no direct HTTP, no endpoint guessing, no customer claims, no `tenant_id`, no UI start action, no new dashboard/report/download, no workload-specific engines/tables/classes. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: no runtime UI files, routes, navigation, Filament providers, actions, reports, downloads, or customer output are planned. +- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: existing Spec 418 Coverage v2 operator surface may show data-driven captured/blocked M365 resource/evidence rows. +- **No-impact class, if applicable**: not applicable if captured/blocked rows render. Implementation must explicitly choose one close-out path: focused browser/Human Product Sanity when rendered output changes, or `N/A - no rendered UI surface changed` with exact proof when it does not. +- **Native vs custom classification summary**: no custom UI. +- **Shared-family relevance**: no new UI shared-family path. +- **State layers in scope**: backend capture outcomes, evidence rows, identity state, claim state, OperationRun state; existing rendered data only if already queried. +- **Audience modes in scope**: internal operator only. +- **Decision/diagnostic/raw hierarchy plan**: default product views must not expose raw payloads, provider responses, OperationRun internals, source keys, permission context, identity diagnostics, or customer-proof claims. +- **Raw/support gating plan**: raw payload remains in evidence storage only; existing UI must not render it by default. +- **One-primary-action / duplicate-truth control**: no new actions. +- **Handling modes by drift class or surface**: hard stop if runtime UI code, route, navigation, action, report/download, or customer surface is needed. +- **Repository-signal treatment**: no UI audit registry update unless implementation amends scope to runtime UI files. +- **Special surface test profiles**: existing technical/evidence operator surface if browser proof is required. +- **Required tests or manual smoke**: focused browser proof when existing rendered output changes. +- **Exception path and spread control**: none. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage. +- **UI/Productization coverage decision**: existing internal operator data impact only. +- **Coverage artifacts to update**: active Spec 420 artifacts and implementation report only. Do not rewrite Specs 414/415/417/418/419. +- **No-impact rationale**: no runtime UI file is planned, but data-driven rendered impact is possible. +- **Navigation / Filament provider-panel handling**: no panel/provider registration change. +- **Screenshot or page-report need**: focused browser proof screenshot only if existing rendered output changes. + +## Product Surface Contract Plan + +- **Product Surface Contract reference**: `docs/product/standards/product-surface-contract.md`. +- **No-legacy posture**: canonical Coverage v2 generic capture extension; no compatibility exception. +- **Page archetype and surface budget plan**: existing internal/operator Technical Annex / evidence inspection surface only. +- **Technical Annex and deep-link demotion plan**: OperationRun links, raw/normalized payloads, source contract metadata, provider IDs, identity diagnostics, and permission context stay secondary/internal. +- **Canonical status vocabulary plan**: use existing Coverage v2 internal state labels and product canonical labels if rendered. No `M365 covered`, `certified`, `restore-ready`, or `customer-ready` wording. +- **Product Surface exceptions**: none. +- **Browser verification plan**: focused existing-surface proof if captured/blocked M365 data renders. +- **Human Product Sanity plan**: required only when rendered output changes. +- **Visible complexity outcome target**: neutral; no new surface family. +- **Implementation report target**: `specs/420-m365-generic-evidence-coverage-pack/implementation-report.md`. + +## Filament / Livewire / Deployment Posture + +- **Livewire v4 compliance**: Livewire v4.x remains required. No Livewire code is planned. +- **Panel provider registration location**: Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`; no panel/provider change is planned. +- **Global search posture**: no Filament Resource changes. If a Resource is unexpectedly added, stop and amend the spec. +- **Destructive/high-impact action posture**: no UI action. Backend capture start remains high-impact and must be server-authorized, audited, queued, and OperationRun-backed through existing service paths. +- **Asset strategy**: no assets. `filament:assets` not required unless scope is amended to register assets. +- **Testing plan**: focused unit and feature tests; focused browser only if existing rendered Coverage v2 output changes. +- **Deployment impact**: queue worker required for capture job; possible config/code only by default; migrations only if implementation proves schema/check constraints need updates. No env vars, scheduler, storage, or assets expected. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes at evidence/operation/provider-contract level; no new UI interaction family. +- **Systems touched**: `CoverageSourceContractResolver`, `CoverageSourceContractDecision`, `GenericContentEvidenceCaptureService`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `GenericPayloadNormalizer`, `CoveragePayloadRedactor`, `CoverageCaptureOutcomeSummarizer`, `CoverageIdentityStrategyRegistry`, `CanonicalIdentityResolver`, `ClaimGuard`, `StartTenantConfigurationCapture`, `CaptureTenantConfigurationEvidenceJob`, `OperationRunService`, and existing tests. +- **Shared abstractions reused**: existing Coverage v2 registry, source resolver, capture service, identity registry, evidence writer, Claim Guard, OperationRun lifecycle, provider gateway. +- **New abstraction introduced? why?**: none by default. A small local mapping in existing resolver/identity registry is preferred over new M365-specific classes. +- **Why the existing abstraction was sufficient or insufficient**: The existing generic capture stack already handles the hard parts; it lacks selected M365 contract/identity mappings. +- **Bounded deviation / spread control**: no `EntraEvidenceEngine`, `ExchangeEvidenceEngine`, `TeamsEvidenceEngine`, `SecurityComplianceEvidenceEngine`, new dashboard, or separate table family. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: backend lifecycle yes; no new start/link UI. +- **Central contract reused**: existing `OperationRunService`, `OperationRunType::TenantConfigurationCapture`, `CaptureTenantConfigurationEvidenceJob`, and terminal notification lifecycle. +- **Delegated UX behaviors**: no new toast/link/browser event. Existing diagnostic links remain secondary and authorized if rendered. +- **Surface-owned behavior kept local**: none. +- **Queued DB-notification policy**: no new queued DB notification opt-in. +- **Terminal notification path**: central lifecycle mechanism. +- **Exception path**: none. Do not add `tenant_configuration.m365_capture` unless a distinct lifecycle/operator consequence is proven. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes. +- **Provider-owned seams**: Microsoft Graph/TCM source names, source contract keys, endpoint paths, source aliases, permission context, provider IDs in metadata. +- **Platform-core seams**: resource/evidence persistence, capture outcomes, coverage/evidence/identity/claim states, OperationRun, RBAC, redaction. +- **Neutral platform terms / contracts preserved**: provider connection, managed environment, resource type, source contract, evidence, identity, claim, operation. +- **Retained provider-specific semantics and why**: selected M365 canonical type names and Graph contract keys are necessary provider-owned source metadata for this M365 pack. +- **Bounded extraction or follow-up path**: document-in-feature for source/identity mapping; follow-up-spec for compare/render/certification/customer output. + +## Constitution Check + +- Inventory/evidence truth: PASS. Real evidence rows are created only for real payload capture; missing contracts do not create fake evidence. +- Read/write separation: PASS. Capture is read-only provider work and queued; no restore/apply/write to Microsoft. +- Graph contract path: PASS if implementation uses explicit `GraphClientInterface`/provider gateway contracts only. +- Deterministic capabilities: PASS. Capture eligibility and claim behavior are testable. +- RBAC-UX: PASS with required 404/403 semantics and readonly denial. +- Workspace isolation: PASS with same-scope workspace/managed-environment/provider checks before run creation and job work. +- OperationRun: PASS with existing `tenant_configuration.capture` and service-owned lifecycle. +- Evidence/currentness: PASS. Evidence payload truth is distinct from OperationRun execution truth. +- Customer output: PASS. No customer output or customer-safe claim. +- Provider boundary: PASS if provider-native IDs remain metadata only. +- Product Surface: PASS with existing-surface data-impact proof if rendered. +- Test governance: PASS. Unit/Feature/Browser-if-rendered lanes are named. +- Proportionality: PASS. No new tables/status families/frameworks by default; extension is bounded to selected resource types. +- No premature abstraction: PASS if existing services are extended. +- Persisted truth: PASS. Existing durable resource/evidence tables are product truth for observed configuration. +- Behavioral state: PASS using existing outcome/state families. +- No legacy / pre-production lean: PASS. No compatibility path, v1 adapter, fallback reader, dual write, or `tenant_id`. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for pure resolver/identity/redaction/claim behavior; Feature for persistence, authorization, OperationRun, provider scope, no-overclaim/no-legacy; Browser if existing UI renders new data. +- **Affected validation lanes**: fast-feedback, confidence, PostgreSQL if schema changes, browser if rendered. +- **Why this lane mix is the narrowest sufficient proof**: Runtime behavior is service/job/evidence based; browser is only required for actual rendered output. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php tests/Unit/Support/TenantConfiguration/Spec420M365GenericPayloadNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureIdentityStrategyTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureRedactionTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php tests/Feature/TenantConfiguration/Spec420M365CaptureAuthorizationTest.php tests/Feature/TenantConfiguration/Spec420M365ProviderConnectionScopeTest.php tests/Feature/TenantConfiguration/Spec420M365NoOverclaimTest.php tests/Feature/TenantConfiguration/Spec420M365NoLegacyTest.php tests/Feature/TenantConfiguration/Spec420M365NoTenantIdTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php` if existing rendered output changes + - `git diff --check` +- **Fixture / helper / factory / seed / context cost risks**: keep fake M365 provider responses local to Spec 420 tests. +- **Expensive defaults or shared helper growth introduced?**: none expected. +- **Heavy-family additions, promotions, or visibility changes**: no heavy-governance family; focused browser only when rendered. +- **Surface-class relief / special coverage rule**: no UI code change; browser may be N/A with proof. +- **Closing validation and reviewer handoff**: implementation report records matrices, tests, no-claim/no-leak/no-scope proof, browser/N/A proof, deployment impact. +- **Budget / baseline / trend follow-up**: none expected. +- **Review-stop questions**: endpoint guessing, raw leak, provider scope, identity stability, broad claim, no UI scope, no historical-spec rewrite. +- **Escalation path**: document-in-feature for contained mapping choices; follow-up-spec for broad packs. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage. +- **Why no dedicated follow-up spec is needed**: this is the narrow first M365 generic evidence slice; later semantic packs are listed separately. + +## Project Structure + +### Documentation (this feature) + +```text +specs/420-m365-generic-evidence-coverage-pack/ ++-- spec.md ++-- plan.md ++-- tasks.md ++-- checklists/ + +-- requirements.md +``` + +### Source Code (likely affected in later implementation) + +```text +apps/platform/app/ ++-- Services/TenantConfiguration/ +| +-- CoverageSourceContractResolver.php +| +-- GenericContentEvidenceCaptureService.php +| +-- CoverageCaptureOutcomeSummarizer.php +| +-- CoverageResourceUpserter.php +| +-- CoverageEvidenceWriter.php +| +-- CoverageIdentityStrategyRegistry.php +| +-- GenericPayloadNormalizer.php +| +-- CoveragePayloadRedactor.php +| +-- ClaimGuard.php ++-- Jobs/TenantConfiguration/ +| +-- CaptureTenantConfigurationEvidenceJob.php ++-- Support/ + +-- OperationRunType.php only if a distinct operation type is approved + +apps/platform/config/ ++-- graph_contracts.php only if selected source contracts need narrow metadata adjustment + +apps/platform/tests/ ++-- Unit/Support/TenantConfiguration/ ++-- Feature/TenantConfiguration/ ++-- Browser/ only if existing rendered output changes +``` + +**Structure Decision**: Reuse existing Coverage v2 generic services and tests. Do not add workload-specific service namespaces, tables, dashboards, routes, or providers. + +## Implementation Phases + +### Phase 0 - Preflight + +Capture branch, HEAD, dirty state, activated skills, related spec guardrail, and stop conditions. Confirm no unrelated dirty files before implementation. Re-read current resolver, registry, identity, claim, OperationRun, and Graph contract truth. + +### Phase 1 - Tests First: Source Contracts And Eligibility + +Add tests proving `conditionalAccessPolicy` resolves through an explicit repo-real contract and selected missing-contract resource types block safely without provider calls or evidence rows. Include explicit retry/idempotency and duplicate active-run/resource-row protection. + +### Phase 2 - Tests First: Identity, Redaction, And Claims + +Add tests for selected M365 identity strategies, no display-name-only stable identity, deterministic normalization/hash, redaction, and broad M365 claim blocking. + +### Phase 3 - Tests First: Persistence, OperationRun, RBAC, And Scope + +Add feature tests for fake-provider capture persistence, append-only evidence, retry/idempotency, stale active-run/deduplication behavior, bounded duplicate resource/evidence behavior, same-scope provider connection enforcement, OperationRun lifecycle, authorization 404/403 behavior, readonly denial, no `tenant_id`, no legacy, and no mini-platform. + +### Phase 4 - Source Contract And Eligibility Implementation + +Extend `CoverageSourceContractResolver` narrowly for the selected first pack. Use existing `graph_contracts.php` contract metadata for `conditionalAccessPolicy` if valid; if it is not valid, stop and amend the package. Leave `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy` as missing-contract blockers for Spec 420. + +### Phase 5 - Identity And Evidence Implementation + +Add/confirm identity strategies, source metadata handling, evidence writing, normalization/redaction, and claim-state preservation for selected M365 resource types. + +### Phase 6 - OperationRun, Authorization, And Guardrails + +Reuse `StartTenantConfigurationCapture`, `CaptureTenantConfigurationEvidenceJob`, `OperationRunService`, audit, and queued execution legitimacy paths. Add focused guards for no direct status writes, no raw payload context, no direct Graph calls, and no UI/customer output scope. + +### Phase 7 - Product Surface Data-Impact Verification + +Confirm no UI route, page, navigation, provider, action, report, download, or customer output changed. If existing Coverage v2 surface renders captured/blocked M365 rows, run focused browser proof and Human Product Sanity. If runtime UI code changes are needed, stop and amend artifacts. + +### Phase 8 - Validation And Implementation Report + +Run Pint dirty, focused unit/feature tests, PostgreSQL lane if required, browser if rendered, and `git diff --check`. Complete implementation report with matrices and required proof. + +## Rollout And Deployment Considerations + +- Migrations: not expected; if added, validate on staging before production. +- Queue workers: required for capture job processing. +- Scheduler: no new scheduled job. +- Environment variables: none expected. +- Storage/volumes: no change. +- Assets: no change; `filament:assets` not required. +- Staging: validate fake/provider-safe tests and any rendered existing-surface proof before production promotion. + +## Risk Controls + +- Stop if implementation requires endpoint guessing or direct HTTP. +- Stop if implementation requires a new UI start action, route, dashboard, report, restore/certify/export/download action, or customer output. +- Stop if `tenant_id` appears as Coverage v2 ownership truth. +- Stop if a new operation type, enum/status family, table, or abstraction is introduced without proportionality review. +- Stop if raw payloads, credentials, tokens, or provider response bodies enter OperationRun/audit/log/default UI. +- Stop if a broad M365/certified/restore-ready/customer-ready claim must be allowed. diff --git a/specs/420-m365-generic-evidence-coverage-pack/spec.md b/specs/420-m365-generic-evidence-coverage-pack/spec.md new file mode 100644 index 00000000..da32ac19 --- /dev/null +++ b/specs/420-m365-generic-evidence-coverage-pack/spec.md @@ -0,0 +1,375 @@ +# Feature Specification: Spec 420 - M365 Generic Evidence Coverage Pack + +**Feature Branch**: `420-m365-generic-evidence-coverage-pack` +**Created**: 2026-06-27 +**Status**: Draft +**Input**: User-provided "Spec 420 - M365 Generic Evidence Coverage Pack" draft plus repo checks against Specs 414, 415, 417, 418, 419, roadmap/candidate queue, constitution, Product Surface Contract, TenantPilot agent gates, and current TenantConfiguration runtime. + +## Candidate Selection + +- **Selected candidate**: Spec 420 - M365 Generic Evidence Coverage Pack. +- **Source location**: User-provided draft attachment in this session. `docs/product/spec-candidates.md` says the automatic next-best-prep queue is empty as of 2026-06-15, so this is a direct manual promotion. +- **Why selected**: Spec 419 established the registry-only M365 workload layer and explicitly deferred "M365 Generic Evidence Coverage Pack" as the next M365 runtime follow-up. Specs 414, 415, 417, and 418 provide the Coverage v2 kernel, generic content-backed capture path, canonical identity engine, and operator inspection surface required to prepare this safely. +- **Roadmap relationship**: Aligns with the roadmap's provider-boundary, evidence, workspace/managed-environment ownership, and claim-safety priorities. It is not auto-selected from the active candidate queue. +- **Close alternatives deferred**: Management-report PDF runtime validation, governance artifact lifecycle retention, provider readiness productization, cross-domain indicator follow-through, system-panel browser fixture work, and first governed AI runtime consumer remain manual-promotion backlog items. Entra compare/render, Exchange/Teams compare, Security/Compliance readiness, certified packs, restore, customer reports, and M365 dashboards remain later specs. +- **Related spec guardrail**: `specs/414-tcm-first-coverage-core-cutover/`, `specs/415-generic-content-backed-capture/`, `specs/417-canonical-identity-engine/`, `specs/418-coverage-v2-operator-surface/`, and `specs/419-m365-tcm-workload-registry-expansion/` are read-only dependency context with implementation history. Do not rewrite them, normalize their historical notes, or strip task/browser/review evidence. +- **Prerequisite gate result**: PASS for preparation. Repo truth includes `TenantConfigurationResourceType`, `TenantConfigurationResource`, `TenantConfigurationResourceEvidence`, `TenantConfigurationSupportedScope`, `ResourceTypeRegistry`, `SupportedScopeResolver`, `ClaimGuard`, `GenericContentEvidenceCaptureService`, `CoverageSourceContractResolver`, `CanonicalIdentityResolver`, `OperationRunService`, `GraphClientInterface`, and M365 registry rows from Spec 419. +- **Smallest viable implementation slice**: Use the existing generic capture stack to enable one explicit content-backed M365 capture path for `conditionalAccessPolicy` when its repo-real Graph contract remains valid, and prove structured blocked outcomes for selected Exchange, Teams, and Security/Compliance resource types with missing source contracts. If the existing `conditionalAccessPolicy` contract is not valid for generic capture, stop and amend this package before implementation continues. Do not add a UI start action, customer output, compare/render/restore/certification, broad M365 claims, or workload-specific mini-platform. +- **Draft-to-repo deviations**: + - The user draft names operation type `tenant_configuration.m365_capture`; current repo truth already has `tenant_configuration.capture`. This spec reuses the existing operation type unless implementation proves a distinct type has a current operator or lifecycle consequence. + - The user draft includes new eligibility outcomes such as `capture_blocked_source_unavailable` and `capture_blocked_high_risk`; current repo truth has `captured`, `capture_blocked_missing_contract`, `capture_blocked_permission`, `capture_blocked_beta`, `capture_blocked_unsupported`, and `capture_failed`. This spec uses existing outcomes plus stable reason codes unless a new outcome passes proportionality review. + - The user draft suggests a broad first-pack list. This spec narrows the first implementation-ready pack to four representative resource types: `conditionalAccessPolicy`, `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy`. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot now recognizes M365 workload/resource types as registry-only planning truth, but generic evidence capture still effectively defaults to Intune. Without a bounded M365 evidence slice, later compare/render/certification work has no real payload basis and the product can drift toward false M365 readiness claims. +- **Today's failure**: Operators and implementers can see M365 registry rows but cannot verify any real non-Intune M365 configuration evidence through the Coverage v2 evidence path. The system also lacks a proven safe missing-contract path across M365 workloads. +- **User-visible improvement**: Authorized operators can rely on internal evidence truth for a small M365 first pack: `conditionalAccessPolicy` becomes content-backed when fake-provider capture succeeds, while Exchange, Teams, and Security/Compliance examples fail safe as structured blockers in Spec 420. +- **Smallest enterprise-capable version**: Enable one repo-contract-backed M365 capture path and three missing-contract blocker paths through existing generic services, canonical identity, OperationRun, RBAC, redaction, and Claim Guard. No UI start action and no customer-facing claims. +- **Explicit non-goals**: No semantic compare, customer-safe rendering, restore/apply, certification, broad M365 coverage, customer-facing reports, Review Pack output, M365 dashboard, Microsoft docs scraping, workload-specific service/platform namespaces, live provider calls in tests, v1 compatibility, or `tenant_id`. +- **Permanent complexity imported**: Focused source-contract mapping for selected types, identity strategies for selected M365 resource types, possibly narrow Graph contract entries only when repo truth supports them, reason-code tests for missing contracts, and focused unit/feature/browser-if-rendered tests. No new table, provider framework, UI framework, or broad status taxonomy by default. +- **Why now**: Spec 419 made M365 registry rows visible and explicitly deferred generic evidence capture. This is the next safety gate before any compare/render/certification pack can be credible. +- **Why not local**: A local Entra-only capture path would bypass the generic Coverage v2 contracts and invite workload mini-platforms. The existing generic capture, evidence, identity, OperationRun, RBAC, and Claim Guard stack is the correct shared path. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: Runtime evidence capture for a new workload family; source-contract mapping; identity strategy expansion. Defense: the slice reuses existing infrastructure, proves one real payload path, blocks missing contracts safely, and prevents false M365 coverage claims. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve as a narrow generic-evidence pack with one enabled capture path and explicit missing-contract blockers. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace + managed-environment scoped runtime evidence capture for selected M365 resource types, using same-scope `provider_connection_id` when provider-sourced. +- **Primary Routes**: No new route, page, action, navigation entry, customer output, download, or report is in scope. Existing Spec 418 Coverage v2 operator surface may show captured/blocked data if it already renders generic Coverage v2 registry/evidence rows. +- **Data Ownership**: Concrete `TenantConfigurationResource` and `TenantConfigurationResourceEvidence` rows remain environment-owned with `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id`. Provider-native tenant/directory/account IDs are metadata only. No `tenant_id` is allowed as Coverage v2 internal ownership truth. +- **RBAC**: Starting capture remains server-authorized through the existing capture service/capability path. Non-member or missing environment entitlement must deny as not found (404); established members without capture capability must receive 403. Readonly users cannot start capture. + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: N/A - no canonical route or route filter change. +- **Explicit entitlement checks preventing cross-tenant leakage**: Any existing rendered operator surface must rely on current workspace/managed-environment scope. Capture services/jobs must re-resolve and validate managed environment, provider connection, and OperationRun scope before provider work. + +## No Legacy / No Backward Compatibility Constraint *(mandatory)* + +TenantPilot is pre-production unless this spec explicitly records a compatibility exception. + +- **Compatibility posture**: canonical Coverage v2 generic capture extension; no compatibility exception. +- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no. +- **Why clean replacement is safe now**: This is a new M365 generic-evidence slice on the Coverage v2 path. No production/customer data or external contract requires Coverage v1 compatibility, old gap taxonomy, dual writes, fallback readers, or old snapshot promotion. + +## UI Surface Impact *(mandatory - UI-COV-001)* + +Does this spec add, remove, rename, or materially change any reachable UI surface? + +- [ ] No UI surface impact +- [x] 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 +- [x] Status/evidence/review presentation changed +- [ ] Workspace/environment context presentation changed + +No runtime UI file, route, navigation, table/form schema, action, Blade view, Livewire component, or Filament provider change is planned. The impact is data-driven: existing Spec 418 Coverage v2 operator surfaces may show M365 resource/evidence rows or blocked outcomes because they already read generic Coverage v2 data. Implementation must make one explicit close-out decision: if rendered output changes, run focused browser proof and Human Product Sanity; if no rendered output changes, record exact `N/A - no rendered UI surface changed` proof. If implementation requires runtime UI edits, a new start action, or changed labels/actions, stop and amend this spec, plan, and tasks before editing runtime UI. + +## 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)* + +Existing Coverage v2 operator surface data may change without UI code edits. Productization coverage is limited to the existing internal/operator Coverage v2 readiness/resource/evidence inspection surface. No new route, primary navigation, dashboard, customer-facing surface, report, download, restore/certify action, or capture button is allowed. The existing surface must remain internal/operator only, show generic evidence or blocked outcomes honestly, and avoid customer-ready M365 coverage language. + +## 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?**: yes, for data-driven status/evidence changes on the existing Coverage v2 operator surface. +- **Page archetype**: existing internal/operator Technical Annex / registry and evidence inspection surface. +- **Primary user question**: Which selected M365 resource types have real generic evidence, and which are blocked because the source contract is missing or unsafe? +- **Primary action**: inspect existing registry/evidence rows. No new start, restore, certify, publish, export, or customer-output action. +- **Surface budget result**: existing surface budget only; no new page, action family, dashboard, or navigation. +- **Technical Annex / deep-link demotion**: raw payloads, normalized payloads, source keys, OperationRun proof, identity diagnostics, permission context, source endpoints, and provider IDs remain technical/internal detail and must not become default customer proof. +- **Canonical status vocabulary**: Product-facing labels, if any render, must map to existing canonical language such as `Blocked`, `Running`, `Failed`, `Unknown`, or internal Coverage v2 state labels already used by Spec 418. Do not introduce page-local "M365 covered", "certified", "restore-ready", or "customer-ready" labels. +- **Visible complexity impact**: neutral to slightly broader on the existing internal evidence surface; no new surface family. +- **Product Surface exceptions**: none. + +## Browser Verification Plan *(mandatory)* + +- **Browser proof required?**: yes, if implementation-created M365 resource/evidence rows or blocked outcomes render on the existing Coverage v2 operator surface. +- **No-browser rationale**: `N/A - no rendered UI surface changed` only if implementation proves no rendered output changes despite runtime capture support. +- **Focused path when required**: existing Spec 418 Coverage v2 readiness/operator route. +- **Primary interaction to execute**: load the existing page with a fake captured `conditionalAccessPolicy` row and blocked selected M365 types, inspect the relevant table/slide-over path, and verify internal-only wording, no broad claim, no raw payload, no capture/restore/certify/customer-output action. +- **Console, Livewire, Filament, network, and 500-error checks**: required for focused path when rendered data changes. +- **Full-suite failure triage**: unrelated browser/full-suite failures may be documented only after focused proof is green. + +## Human Product Sanity Check *(mandatory)* + +- **Required?**: yes, if existing operator surface rendering changes. +- **No-human-sanity rationale**: N/A only when no rendered product surface changes. +- **Reviewer questions**: Is it clear this is internal generic evidence, not M365 coverage completion? Are missing source contracts visibly blocked without looking like failures operators can "fix" with restore/certify actions? Are raw technical details demoted? +- **Planned result location**: implementation report for Spec 420. + +## Product Surface Merge Gate Checklist *(mandatory)* + +- [x] No-legacy posture or approved exception recorded. +- [x] Product Surface Impact is filled in for data-driven existing-surface impact. +- [x] Browser proof is required if rendered Coverage v2 output changes, or `N/A - no rendered UI surface changed` must be justified. +- [x] Human Product Sanity is required if rendered Coverage v2 output changes, or N/A must be justified. +- [x] Product Surface exceptions are documented as `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, visible complexity outcome, and no historical-spec rewrite assertion. + +## 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?**: yes at domain-contract level; no new UI interaction family. +- **Interaction class(es)**: evidence capture status, OperationRun-backed operation lifecycle, internal Coverage v2 evidence inspection. +- **Systems touched**: `CoverageSourceContractResolver`, `GenericContentEvidenceCaptureService`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `GenericPayloadNormalizer`, `CoveragePayloadRedactor`, `CanonicalIdentityResolver`, `ClaimGuard`, `StartTenantConfigurationCapture`, existing OperationRun/audit paths, and existing Coverage v2 operator read model if rendered. +- **Existing pattern(s) to extend**: existing Coverage v2 generic capture/evidence/identity/claim services. +- **Shared contract / presenter / builder / renderer to reuse**: existing OperationRun lifecycle service, capture job/service, resource/evidence writer, identity strategy registry, Claim Guard, and existing Coverage v2 operator read model. Do not add local workload-specific engines. +- **Why the existing shared path is sufficient or insufficient**: Existing generic capture already handles source resolution, fakeable provider calls, normalization, hashing, evidence persistence, OperationRun summary, and same-scope provider validation. It is sufficient for the first M365 slice when contract mappings and identity strategies are added narrowly. +- **Allowed deviation and why**: none by default. Any source-contract metadata addition must remain local to the generic resolver/registry. +- **Consistency impact**: M365 capture must use the same Coverage v2 evidence states, capture outcomes, identity states, claim states, OperationRun summary keys, RBAC semantics, and redaction rules as Intune capture. +- **Review focus**: Verify no Entra/Exchange/Teams/Security mini-platform, no direct Graph endpoint guessing, no raw payload leak, no customer claim activation, and no historical-spec rewrite. + +## 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?**: yes for backend capture lifecycle; no new UI start/link semantics. +- **Shared OperationRun UX contract/layer reused**: existing `OperationRunService`, `OperationRunType::TenantConfigurationCapture`, existing queued job path, and central terminal notification lifecycle. +- **Delegated start/completion UX behaviors**: no new UI toast/link/browser event. Any existing OperationRun link rendered by Spec 418 remains secondary diagnostic only and must respect `Gate::allows('view', $run)`. +- **Local surface-owned behavior that remains**: none; no start surface is added. +- **Queued DB-notification policy**: no new queued DB notification opt-in. +- **Terminal notification path**: existing central lifecycle mechanism for `OperationRun`. +- **Exception required?**: none. Reuse `tenant_configuration.capture`; do not introduce `tenant_configuration.m365_capture` unless a separate OperationRun type passes proportionality review. + +## 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. Generic Coverage v2 capture/evidence/identity is platform-core; Microsoft workload names and Graph/TCM contract metadata are provider-owned. +- **Seams affected**: source contract mapping, provider gateway list path, canonical resource type identity strategy, permission context metadata, source metadata, workload labels, and Claim Guard statements. +- **Neutral platform terms preserved or introduced**: provider connection, managed environment, resource type, source contract, coverage level, evidence state, capture outcome, identity state, claim state, OperationRun, permission context. +- **Provider-specific semantics retained and why**: Microsoft Graph/TCM names remain because this spec is explicitly M365 evidence capture. They must stay in source metadata/contract config and must not become platform ownership keys. +- **Why this does not deepen provider coupling accidentally**: No new provider framework, no `tenant_id`, no provider-native ID routing, no workload-specific tables/services, no UI dashboard, and no customer claims are introduced. +- **Follow-up path**: compare/render/certified packs must be separate specs after evidence truth exists. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +Existing Spec 418 Coverage v2 operator surfaces may show new M365 resource/evidence rows through existing data queries. No new route, page, navigation, action, table/form definition, Blade view, Livewire component, Filament provider, customer surface, report, or download is in scope. + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +The existing surface remains an internal technical/evidence inspection surface. New data must answer "captured generic evidence or blocked missing contract" rather than implying M365 coverage completion, readiness, certification, customer proof, or restore support. + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +Internal/operator only. Raw payloads, normalized payloads, permission context, provider IDs, OperationRun proof, and source metadata are technical diagnostics and must be hidden or secondary on existing surfaces. Customer/read-only output is out of scope. + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +Existing page, data-only impact. No new page archetype, action family, primary navigation, modal type, wizard, dashboard, or customer surface is introduced. + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +The existing Coverage v2 operator surface must remain read-only and claim-safe: no capture/start, restore/apply, certify, publish, export, report/download, or customer-output action; raw evidence hidden by default; OperationRun links secondary and authorized. + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no. `TenantConfigurationResource` and `TenantConfigurationResourceEvidence` remain existing Coverage v2 resource/evidence truth. +- **New persisted entity/table/artifact?**: no new table by default. Existing rows are created during runtime capture. +- **New abstraction?**: no new abstraction by default. Existing resolver, capture service, identity registry, redactor, writer, and Claim Guard are extended. +- **New enum/state/reason family?**: no new enum/status family by default. Use existing capture outcomes and stable reason codes. +- **New cross-domain UI framework/taxonomy?**: no. +- **Current operator problem**: M365 registry rows lack real generic evidence and can otherwise be over-read as future coverage truth. +- **Existing structure is insufficient because**: existing structures support generic capture but currently map only initial Intune contract keys and identity strategies; they need narrow M365 entries to exercise the generic path. +- **Narrowest correct implementation**: one enabled contract-backed capture path plus three blocked missing-contract paths across workloads. +- **Ownership cost**: focused tests and maintenance of source-contract mappings, identity strategies, and Claim Guard assertions for the selected first pack. +- **Alternative intentionally rejected**: building Entra/Exchange/Teams/Security-specific engines or dashboards; claiming content-backed coverage for missing contracts; adding a new OperationRun type without distinct lifecycle behavior. +- **Release truth**: current-release trust and evidence foundation for later M365 packs. + +### Compatibility posture + +This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit for resolver/normalizer/redaction/identity/Claim Guard; Feature for capture persistence, OperationRun, authorization, provider scope, no-overclaim, no-tenant-id, no-mini-platform; Browser only if rendered existing surface changes. +- **Validation lane(s)**: fast-feedback, confidence, PostgreSQL lane if migrations/check constraints change, focused browser if existing Coverage v2 surface renders new M365 capture data. +- **Why this classification and these lanes are sufficient**: The change is backend capture/evidence behavior with possible data-driven existing-surface rendering. Unit and feature tests prove the behavior; browser is only needed for rendered UI impact. +- **New or expanded test families**: focused Spec 420 TenantConfiguration tests only; no broad heavy-governance family. +- **Fixture / helper cost impact**: fake provider responses only; keep workspace/managed-environment/provider setup local to tests. +- **Heavy-family visibility / justification**: none unless focused browser proof is required for rendered output. +- **Special surface test profile**: existing technical/evidence surface profile if browser proof is needed. +- **Standard-native relief or required special coverage**: no Filament runtime UI code edits are planned. +- **Reviewer handoff**: verify lane fit, no real Microsoft calls, no raw payload leak, same-scope provider proof, and no customer claims. +- **Budget / baseline / trend impact**: none expected. +- **Escalation needed**: document-in-feature for contained source-contract/identity expansion; follow-up-spec for compare/render/certification/UI/customer output. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage. +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php tests/Unit/Support/TenantConfiguration/Spec420M365GenericPayloadNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureIdentityStrategyTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureRedactionTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php tests/Feature/TenantConfiguration/Spec420M365CaptureAuthorizationTest.php tests/Feature/TenantConfiguration/Spec420M365ProviderConnectionScopeTest.php tests/Feature/TenantConfiguration/Spec420M365NoOverclaimTest.php tests/Feature/TenantConfiguration/Spec420M365NoLegacyTest.php tests/Feature/TenantConfiguration/Spec420M365NoTenantIdTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php` if existing rendered Coverage v2 output changes + - `git diff --check` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Capture One Contract-Backed M365 Resource Type (Priority: P1) + +As an authorized operator, I need the generic evidence path to persist real content-backed evidence for at least one selected M365 resource type so M365 readiness is based on payload truth, not registry metadata. + +**Independent Test**: Fake provider capture for `conditionalAccessPolicy` persists a concrete Coverage v2 resource and append-only evidence row with raw payload, normalized payload, deterministic hash, source metadata, provider connection scope, identity, and OperationRun link. + +**Acceptance Scenarios**: + +1. **Given** a same-scope workspace, managed environment, provider connection, authorized operator, and fake provider response for `conditionalAccessPolicy`, **When** generic capture runs for that type, **Then** one `TenantConfigurationResource` row and one append-only evidence row are persisted with `content_backed` evidence state and a deterministic payload hash. +2. **Given** the provider response contains volatile fields, **When** payload normalization runs twice, **Then** normalized payload and payload hash remain deterministic. +3. **Given** the payload contains known secret keys, **When** evidence metadata, permission context, OperationRun context, audit metadata, and logs are inspected by tests, **Then** secrets are absent outside the evidence raw payload storage boundary. + +### User Story 2 - Block Missing M365 Source Contracts Safely (Priority: P1) + +As a platform reviewer, I need selected Exchange, Teams, and Security/Compliance resource types to fail safe when source contracts are missing so TenantPilot never guesses endpoints or fakes capture. + +**Independent Test**: Capture attempts for `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy` produce `capture_blocked_missing_contract` outcomes with stable reason codes and no evidence rows. Adding explicit contracts for these three types is out of scope for Spec 420 and requires an amended package or follow-up spec. + +**Acceptance Scenarios**: + +1. **Given** `acceptedDomain` has no approved source contract mapping, **When** capture is requested, **Then** the outcome is `capture_blocked_missing_contract`, OperationRun summary counts reflect a blocked/skipped item, and no evidence row is created. +2. **Given** `appPermissionPolicy` has no approved source contract mapping, **When** capture is requested, **Then** the provider gateway is not called. +3. **Given** `dlpCompliancePolicy` has no approved source contract mapping, **When** capture is requested, **Then** no Graph endpoint is guessed from the resource type string or alias metadata. + +### User Story 3 - Preserve Identity, Scope, RBAC, And Claim Safety (Priority: P1) + +As a security reviewer, I need M365 generic evidence to enforce workspace/provider scope, canonical identity, RBAC, and Claim Guard boundaries before any customer-facing claim can exist. + +**Independent Test**: Authorization, provider scope, identity strategy, identity conflict, and claim guard tests prove allowed/denied behavior and no M365 customer claim activation. + +**Acceptance Scenarios**: + +1. **Given** a non-member or actor without managed environment entitlement, **When** capture is requested, **Then** the request denies as not found (404). +2. **Given** a workspace member without capture capability or a readonly user, **When** capture is requested, **Then** the request fails with 403 and no OperationRun is queued. +3. **Given** a provider connection from another workspace or managed environment, **When** capture is requested or the job handles the run, **Then** the operation fails closed before provider work. +4. **Given** identity resolution cannot create stable identity for a selected type, **When** capture persists or attempts to persist a resource, **Then** identity state and Claim Guard block customer-facing claims. + +### User Story 4 - Keep Product Surface Internal And No-Overclaim (Priority: P1) + +As a product reviewer, I need any existing operator-surface rendering to remain internal, technical, and clear that generic evidence is not comparable, renderable, restorable, certified, or customer-ready. + +**Independent Test**: If captured/blocked M365 data renders on the existing Spec 418 surface, focused browser proof verifies no broad M365 claims, no raw payload, and no new action affordances. + +**Acceptance Scenarios**: + +1. **Given** captured `conditionalAccessPolicy` evidence exists, **When** the existing Coverage v2 operator surface renders it, **Then** the surface uses internal generic evidence wording and does not say "M365 covered", "certified", "restore-ready", or "customer-ready". +2. **Given** blocked missing-contract outcomes exist, **When** they render on the existing surface, **Then** they appear as internal blockers, not as failed customer evidence or actionable restore/certify steps. +3. **Given** no runtime UI files were changed, **When** implementation closes out, **Then** browser proof is provided only for data-driven rendered impact or explicitly marked N/A with proof. + +## Functional Requirements *(mandatory)* + +- **FR-420-001**: The implementation MUST use existing Coverage v2 resource/evidence tables and MUST NOT add new M365-specific tables. +- **FR-420-002**: The selected first pack MUST include `conditionalAccessPolicy`, `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy`. +- **FR-420-003**: `conditionalAccessPolicy` MUST become capture-enabled through an explicit repo-real source contract mapping when the current contract remains valid for generic read capture. If that contract is missing or unsafe, implementation MUST stop and amend this package instead of silently dropping the enabled path. It MUST NOT be captured by endpoint guessing. +- **FR-420-004**: `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy` MUST remain `capture_blocked_missing_contract` blockers in Spec 420. Adding explicit contracts for those three types is out of scope and requires an amended package or follow-up spec. +- **FR-420-005**: All provider calls MUST go through `GraphClientInterface` via existing provider gateway/contract paths. +- **FR-420-006**: Runtime code MUST NOT scrape Microsoft Learn, hardcode quick endpoints, use direct HTTP clients, bypass Graph contracts, or derive runtime endpoint paths from resource type strings/aliases. +- **FR-420-007**: Captured resources MUST persist `workspace_id`, `managed_environment_id`, `provider_connection_id`, `resource_type_id`, canonical identity fields, and source metadata through existing Coverage v2 models. +- **FR-420-008**: Evidence rows MUST be append-only and persist raw payload, normalized payload, deterministic payload hash, source contract metadata, source version/schema hash when available, redacted permission context, and `operation_run_id`. If source version or schema hash is unavailable, evidence metadata MUST record an explicit null/unknown value and capture MUST NOT be blocked solely for that absence. +- **FR-420-009**: Capture MUST create or reuse an `OperationRun` using existing `tenant_configuration.capture` semantics unless implementation adds a distinct operation type with proportionality proof and tests. +- **FR-420-010**: OperationRun status/outcome transitions MUST go through `OperationRunService`. +- **FR-420-011**: OperationRun `summary_counts` MUST remain flat numeric-only and limited to existing `OperationSummaryKeys`. +- **FR-420-012**: OperationRun context, messages, notifications, audit metadata, and logs MUST NOT contain raw payloads, secrets, tokens, credential material, cookies, bearer headers, or raw provider error bodies. +- **FR-420-013**: Capture MUST be asynchronous and fake-provider-testable. No real Microsoft Graph, TCM, Exchange, Teams, Purview, Defender, or Security/Compliance calls are allowed in tests. +- **FR-420-014**: Server-side authorization MUST enforce non-member/not-entitled 404, missing capability 403, and readonly cannot start capture. +- **FR-420-015**: Provider connections MUST be same-workspace and same-managed-environment before OperationRun creation and again at job execution time. +- **FR-420-016**: Canonical identity strategies MUST be added or confirmed for selected resource types. Display-name-only identity MUST NOT be stable. +- **FR-420-017**: Identity conflicts, missing external IDs, unsupported identity, and derived identity without allowed claims MUST block customer-facing claims. +- **FR-420-018**: Claim Guard MUST block broad M365, certified, restore-ready, complete tenant, all-resource, customer-ready proof, and unscoped 100% claims. +- **FR-420-019**: Captured generic evidence MUST NOT imply comparable, renderable, restorable, certified, or customer-ready status. +- **FR-420-020**: Existing Spec 419 registry-only defaults MUST not be silently changed into broad M365 support/certification. +- **FR-420-021**: No `tenant_id` may be introduced as Coverage v2 internal ownership truth. +- **FR-420-022**: No v1-to-v2 adapter, fallback reader, dual write, legacy gap taxonomy, old snapshot promotion, or compatibility shim may be introduced. +- **FR-420-023**: No new runtime UI start action, navigation entry, M365 dashboard, customer report, Review Pack output, restore/certify action, or download route may be added. +- **FR-420-024**: If existing operator surfaces render new captured/blocked M365 data, focused browser proof and Human Product Sanity are required. +- **FR-420-025**: Implementation report MUST include capture eligibility, evidence, source-contract, OperationRun, authorization, provider-scope, redaction/logging, Claim Guard, no-tenant-id, no-legacy, Product Surface, test, and deployment proof. + +## Non-Functional Requirements *(mandatory)* + +- **NFR-420-001**: Capture must be deterministic, idempotent where possible, and safe to retry without duplicate active runs or unbounded duplicate resource rows. +- **NFR-420-002**: Normalization and hashing must be stable across key order and configured volatile fields. +- **NFR-420-003**: Missing contracts must be fast, local, and fail closed before any provider call. +- **NFR-420-004**: Tests must remain focused and must not broaden shared workspace/provider fixtures by default. +- **NFR-420-005**: Any migration/check-constraint change must be reversible where practical and include PostgreSQL lane coverage. +- **NFR-420-006**: Queue deployment impact must be documented because capture is OperationRun/job backed. +- **NFR-420-007**: Existing rendered surfaces must not expose raw payloads, provider response bodies, secrets, or internal claim proof by default. + +## Key Entities *(include if feature involves data)* + +- **TenantConfigurationResourceType**: Existing registry definition for selected resource types. +- **TenantConfigurationResource**: Existing concrete environment-owned captured resource row. +- **TenantConfigurationResourceEvidence**: Existing append-only evidence payload row. +- **ProviderConnection**: Existing provider provenance and credential/readiness scope; must belong to the same workspace and managed environment. +- **OperationRun**: Existing execution truth for queued capture. +- **CoverageSourceContractResolver**: Existing source contract resolver to extend with explicit selected M365 contract decisions. +- **GenericContentEvidenceCaptureService**: Existing capture orchestrator to reuse. +- **CoverageResourceUpserter / CoverageEvidenceWriter**: Existing persistence services to reuse. +- **CanonicalIdentityResolver / CoverageIdentityStrategyRegistry**: Existing identity engine to extend for selected M365 resource types. +- **ClaimGuard**: Existing safety gate for coverage/restore/certification/customer claims. + +## Acceptance Criteria *(mandatory)* + +- **AC-420-001**: `conditionalAccessPolicy` capture with a fake provider response persists concrete resource and evidence rows with raw/normalized payloads and deterministic payload hash. +- **AC-420-002**: Evidence links to the same-scope provider connection and OperationRun. +- **AC-420-003**: `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy` block with `capture_blocked_missing_contract` for Spec 420. +- **AC-420-004**: Missing-contract types do not call provider gateway/Graph and do not create fake evidence rows. +- **AC-420-005**: Source metadata records source contract key, endpoint, source version/schema hash when known, explicit null/unknown values when source version/schema hash is unavailable, and does not overclaim TCM/Graph source truth. +- **AC-420-006**: Canonical identity resolver is used; display-name-only stable identity is impossible. +- **AC-420-007**: Non-member and no-entitlement actors receive 404; established members without capture capability receive 403; readonly cannot start capture. +- **AC-420-008**: Cross-workspace or cross-environment provider connections are rejected before run creation and before job provider work. +- **AC-420-009**: OperationRun lifecycle uses `OperationRunService`, numeric summary counts, sanitized messages, and no raw payload/secrets. +- **AC-420-010**: Claim Guard blocks broad M365, certified, restore-ready, customer-ready, complete tenant, all-resource, and unscoped 100% claims. +- **AC-420-011**: No `tenant_id`, old gap taxonomy, v1 adapter, fallback reader, dual write, old snapshot promotion, or workload-specific mini-platform appears. +- **AC-420-012**: No UI start action, customer output, report, dashboard, restore/certify/publish/export action, or new navigation appears. +- **AC-420-013**: Focused unit/feature tests pass; focused browser proof passes when rendered existing-surface data changes; `git diff --check` passes. + +## Success Criteria *(mandatory)* + +- **SC-420-001**: Implementation report includes a capture eligibility matrix for the four selected resource types. +- **SC-420-002**: Implementation report includes an evidence matrix showing captured, blocked, identity, and claim states. +- **SC-420-003**: Tests prove one enabled fake-provider capture path and at least three structured missing-contract blockers. +- **SC-420-004**: Tests prove RBAC, provider scope, OperationRun, redaction, no-overclaim, no-tenant-id, no-legacy, and no-mini-platform boundaries. +- **SC-420-005**: Product Surface proof records either focused existing-surface browser/Human Product Sanity results or exact N/A proof. + +## Assumptions + +- Specs 414, 415, 417, 418, and 419 remain read-only dependency context. +- Existing `config/graph_contracts.php` contains a `conditionalAccessPolicy` contract, but `CoverageSourceContractResolver` does not yet map it for generic capture. Implementation must verify the contract remains valid before enabling capture. +- The first pack intentionally proves missing-contract blockers for non-Entra workloads rather than inventing Exchange, Teams, or Security/Compliance endpoint contracts. +- Existing `tenant_configuration.capture` operation semantics are sufficient for selected M365 capture because the execution lifecycle is the same generic capture workflow. +- Existing Spec 418 operator surfaces may render M365 resource/evidence rows if data exists; no UI code edits are planned. + +## Open Questions + +None blocking. Implementation must record these bounded decisions in the implementation report: + +1. Whether `conditionalAccessPolicy` uses the existing `graph_contracts.types.conditionalAccessPolicy` as-is or needs a narrow contract metadata adjustment. +2. Whether source metadata needs an additional non-persisted/detail key to distinguish TCM-documented registry rows from actual Graph fallback execution without changing registry ownership truth. +3. Whether focused browser proof is required because captured/blocked M365 rows render on the existing Coverage v2 surface. + +## Risks + +| Risk | Severity | Mitigation | +|---|---:|---| +| Capture becomes broad M365 claim | High | Claim Guard tests, internal-only wording, no customer output | +| Endpoint guessing | High | explicit resolver mappings only, missing-contract blockers | +| Raw payload or secrets leak | High | redaction tests, OperationRun/audit/log guards | +| Source-class confusion | Medium | source metadata proof and implementation-report matrix | +| Cross-provider leakage | High | same-scope provider checks before run and job provider work | +| Identity instability | High | identity strategies and conflict tests | +| OperationRun status bypass | High | OperationRunService tests/static guard | +| UI activation sneaks in | Medium | no new UI scope, Product Surface stop condition | +| Workload mini-platforms appear | High | guard tests against workload-specific tables/classes/services | +| Test scope grows too broad | Medium | fake providers, focused unit/feature/browser-if-rendered lanes | + +## Follow-Up Spec Candidates + +- Entra Core Comparable/Renderable Pack. +- Exchange and Teams Comparable Pack. +- Security and Compliance Readiness Pack. +- Entra Certified Compare Pack. +- Exchange/Teams Certified Pack. +- Security and Compliance Compare Pack. +- Customer-facing M365 coverage/reporting only after evidence, compare/render, and certification truth exists. diff --git a/specs/420-m365-generic-evidence-coverage-pack/tasks.md b/specs/420-m365-generic-evidence-coverage-pack/tasks.md new file mode 100644 index 00000000..511fd317 --- /dev/null +++ b/specs/420-m365-generic-evidence-coverage-pack/tasks.md @@ -0,0 +1,107 @@ +# Tasks: Spec 420 - M365 Generic Evidence Coverage Pack + +**Input**: `specs/420-m365-generic-evidence-coverage-pack/spec.md`, `specs/420-m365-generic-evidence-coverage-pack/plan.md`, `specs/420-m365-generic-evidence-coverage-pack/checklists/requirements.md` +**Prerequisites**: Specs 414, 415, 417, 418, and 419 as read-only dependency context +**Tests**: Required. Runtime capture/evidence behavior must be covered with focused Pest unit and feature tests. Browser proof is required only if new M365 captured/blocked data renders on the existing Spec 418 Coverage v2 operator surface. + +## Test Governance Checklist + +- [x] Lane assignment is named and remains the narrowest sufficient proof for resolver, capture, evidence, identity, RBAC, OperationRun, and no-overclaim behavior. +- [x] Unit and Feature tests stay focused; PostgreSQL lane is explicit only if schema/check constraints change. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default and opt-in. +- [x] Planned validation commands cover the change without pulling unrelated lane cost. +- [x] Browser proof is required for rendered existing-surface data impact, or explicitly `N/A - no rendered UI surface changed` with proof. +- [x] Human Product Sanity and Product Surface implementation-report close-out cover existing-surface data impact, or are N/A only with proof. +- [x] Material budget, baseline, trend, or escalation notes are recorded if test cost changes. + +## Phase 1: Preflight And Dependency Guard + +- [x] T001 Capture branch, HEAD, `git status --short`, activated skills, and hard-gate status in `specs/420-m365-generic-evidence-coverage-pack/implementation-report.md`. +- [x] T002 Confirm Specs 414, 415, 417, 418, and 419 are dependency context only and must not be modified. +- [x] T003 Inspect `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php`, `GenericContentEvidenceCaptureService.php`, `CoverageResourceUpserter.php`, `CoverageEvidenceWriter.php`, `CoverageIdentityStrategyRegistry.php`, `ClaimGuard.php`, `StartTenantConfigurationCapture.php`, and `CaptureTenantConfigurationEvidenceJob.php`. +- [x] T004 Inspect `apps/platform/config/graph_contracts.php` for the current `conditionalAccessPolicy` contract and verify no explicit contracts exist for selected missing-contract types. If explicit contracts already exist for `acceptedDomain`, `appPermissionPolicy`, or `dlpCompliancePolicy`, stop and amend the package before implementation continues. +- [x] T005 Record draft-to-repo deviations: reuse `tenant_configuration.capture`, use existing `CaptureOutcome` values, and narrow first pack to `conditionalAccessPolicy`, `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy`. +- [x] T006 Stop if implementation would require compare, render, restore, certification, customer output, M365 dashboard, UI start action, endpoint guessing, direct HTTP, `tenant_id`, v1 compatibility, or workload-specific mini-platforms. + +## Phase 2: Tests First - Source Contracts And Eligibility + +- [x] T007 Add `apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php` proving `conditionalAccessPolicy` resolves only through an explicit repo-real source contract mapping. +- [x] T008 Add resolver tests proving `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy` return `capture_blocked_missing_contract` until explicit contracts exist. +- [x] T009 Add eligibility tests proving missing-contract decisions do not call provider gateway, do not guess endpoints from canonical type/source aliases, and keep stable reason codes. +- [x] T010 Add tests proving beta/unsupported states remain blocked through existing `CaptureOutcome` values and do not require a new outcome family. +- [x] T011 Add tests proving source metadata separates registry/source-class truth from actual source contract key, endpoint, source version, schema hash, and explicit null/unknown values when source version/schema hash is unavailable. + +## Phase 3: Tests First - Identity, Normalization, Redaction, And Claims + +- [x] T012 Add `apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureIdentityStrategyTest.php` covering selected resource identity strategies and no display-name-only stable identity. +- [x] T013 Add identity tests proving missing, unsupported, derived-without-claim, and conflict states block customer-facing claims. +- [x] T014 Add `Spec420M365GenericPayloadNormalizerTest.php` proving stable key ordering, volatile field stripping, and deterministic payload hash for a fake `conditionalAccessPolicy` payload. +- [x] T015 Add redaction coverage proving secret keys are redacted from normalized evidence payloads, permission context, OperationRun context/failure summary, audit metadata, and logs, with raw payload retained only inside the evidence raw-payload storage boundary. +- [x] T016 Add `Spec420M365CaptureClaimGuardTest.php` proving broad M365, certified, restore-ready, customer-ready, complete tenant, all-resource, and unscoped 100% claims are blocked. + +## Phase 4: Tests First - Persistence, OperationRun, Authorization, And Scope + +- [x] T017 Add `apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php` proving fake `conditionalAccessPolicy` capture persists a `TenantConfigurationResource` and append-only `TenantConfigurationResourceEvidence` row. +- [x] T018 Assert persisted evidence includes raw payload, normalized payload, deterministic `payload_hash`, source metadata, redacted permission context, `operation_run_id`, and `coverage_level = content_backed` only when a real payload was captured. +- [x] T019 Assert missing-contract selected types create no fake evidence rows and update only structured outcome/summary data. +- [x] T020 Add `Spec420M365CaptureOperationRunTest.php` proving existing `tenant_configuration.capture` lifecycle, service-owned transitions, numeric summary counts, retry/idempotency behavior, stale active-run or duplicate-run handling, bounded duplicate resource/evidence behavior, sanitized failure-summary behavior, and no raw payload in OperationRun context. +- [x] T021 Add `Spec420M365CaptureAuthorizationTest.php` covering non-member 404, missing environment entitlement 404 via existing capture authorization coverage, missing capability 403, operator/readonly denial, and allowed owner/manager behavior according to the current capability matrix. +- [x] T022 Add `Spec420M365ProviderConnectionScopeTest.php` proving cross-workspace and cross-environment provider connections are rejected before run creation and again at job execution time. +- [x] T023 Add `Spec420M365NoOverclaimTest.php`, `Spec420M365NoLegacyTest.php`, and `Spec420M365NoTenantIdTest.php` proving no customer claim activation, no old gap taxonomy/v1 adapter/fallback reader/dual write, and no `tenant_id`. + +## Phase 5: Source Contract And Eligibility Implementation + +- [x] T024 Update `CoverageSourceContractResolver` or its existing mapping path with the narrow selected M365 decisions required by tests. +- [x] T025 Map `conditionalAccessPolicy` to the repo-valid Graph contract only after verifying the contract exists and is safe for generic read capture; if it is missing or unsafe, stop and amend the package. +- [x] T026 Keep `acceptedDomain`, `appPermissionPolicy`, and `dlpCompliancePolicy` blocked with `capture_blocked_missing_contract` for Spec 420; do not add explicit contracts for those three types in this slice. +- [x] T027 Ensure no runtime endpoint path is derived from resource type strings or Spec 419 `source_aliases` metadata. +- [x] T028 Ensure source metadata is sufficient for implementation-report source-contract matrix, including explicit null/unknown source version/schema hash semantics, without leaking raw provider payloads or secrets. + +## Phase 6: Identity, Evidence, And Claim Implementation + +- [x] T029 Extend `CoverageIdentityStrategyRegistry` narrowly for selected M365 resource types. +- [x] T030 Ensure `CanonicalIdentityResolver` remains the only identity resolution path used by `CoverageResourceUpserter`. +- [x] T031 Ensure `CoverageResourceUpserter` and `CoverageEvidenceWriter` preserve selected M365 identity and claim state, same-scope provider connection, source metadata, and append-only evidence behavior. +- [x] T032 Ensure `GenericPayloadNormalizer` and `CoveragePayloadRedactor` cover selected fake M365 payload shapes and secret keys. +- [x] T033 Update `ClaimGuard` only if tests show existing M365 claim blocking is insufficient; do not create a parallel M365 claim guard. + +## Phase 7: OperationRun, RBAC, Audit, And Guardrails + +- [x] T034 Reuse `StartTenantConfigurationCapture`, `CaptureTenantConfigurationEvidenceJob`, and `OperationRunService`; do not add `tenant_configuration.m365_capture` unless proportionality review is amended. +- [x] T035 Ensure start path writes existing audit events with safe metadata and no raw payload or secrets. +- [x] T036 Ensure job/service path revalidates workspace, managed environment, provider connection, OperationRun type, and OperationRun target scope before provider work. +- [x] T037 Add or update static/focused guards proving no direct OperationRun status/outcome writes, no direct Graph/HTTP calls, no runtime docs fetch, and no workload-specific tables/classes/services. +- [x] T038 Confirm no Filament page/resource/widget/action, route, navigation entry, Blade view, Livewire component, customer output, report, download, restore/certify/export/publish action, scheduler, or new dashboard was added. + +## Phase 8: Product Surface Data-Impact Verification + +- [x] T039 If existing Spec 418 Coverage v2 surface renders captured/blocked M365 data, add and run `apps/platform/tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php`. +- [x] T040 Browser proof must verify no broad M365 claim, no certified/restore-ready/customer-ready wording, no raw/normalized payload default display, no new capture/restore/certify/export/download action, no console/Livewire/500 errors, and no provider calls during render. +- [x] T041 Make the rendered-surface close-out decision explicit: if existing output changes, run browser proof and Human Product Sanity; if implementation proves no rendered output changes, document `N/A - no rendered UI surface changed` with exact proof in the implementation report. +- [x] T042 Record Human Product Sanity result if rendered output changes. +- [x] T043 Stop and amend `spec.md`, `plan.md`, and `tasks.md` before any runtime UI file, route, navigation, action, label, report, download, or customer surface edit. + +## Phase 9: Validation And Close-Out + +- [x] T044 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T045 Run focused unit tests for Spec 420 source contracts, eligibility, normalizer, identity strategy, Claim Guard, and redaction. +- [x] T046 Run focused feature tests for Spec 420 evidence capture, OperationRun, authorization, provider scope, no-overclaim, no-legacy, and no-tenant-id. +- [x] T047 N/A - no migrations/check constraints/indexes changed, so no additional PostgreSQL lane was required. +- [x] T048 Run focused browser proof if existing rendered output changes. +- [x] T049 Run `git diff --check`. +- [x] T050 Complete `specs/420-m365-generic-evidence-coverage-pack/implementation-report.md` with candidate gate result, dirty state before/after, files changed, capture eligibility matrix, source-contract matrix, evidence matrix, OperationRun proof, authorization proof, provider scope proof, redaction/log proof, Claim Guard proof, no-tenant-id proof, no-legacy/no-mini-platform proof, Product Surface proof, tests run, deployment impact, and deferred work. +- [x] T051 Confirm no historical spec was rewritten or stripped of task, smoke, browser, or review history. + +## Stop Conditions + +Stop and update `spec.md`, `plan.md`, and `tasks.md` before continuing if any of these appear: + +- Compare, render, restore, apply, certification, customer output, Review Pack/report, broad M365 dashboard, customer-facing claim activation, or new UI start action is needed. +- Graph/TCM/provider remote paths are guessed, hardcoded, scraped, or called outside `GraphClientInterface`/provider gateway contracts. +- Existing `tenant_configuration.capture` cannot support the lifecycle and a new OperationRun type is proposed without proportionality review. +- A new capture outcome, enum/status family, table, abstraction, or taxonomy is needed without proportionality review. +- Existing Coverage v2 operator surface would change beyond data-driven rows/outcomes without Product Surface/browser proof. +- Raw payload, normalized payload, permission context, provider response bodies, tokens, secrets, or internal diagnostics would render by default or enter logs/audit/OperationRun context/failure summary. +- `tenant_id` appears as Coverage v2 ownership truth. +- A workload-specific table, model, engine, namespace, dashboard, or mini-platform is introduced. +- A broad M365/certified/restore-ready/customer-ready/all-resource claim must be allowed.