From 2cd512915aa9a73cbac9c714dc3dcbd2619d9368 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 1 Jul 2026 14:41:24 +0000 Subject: [PATCH] feat: complete spec 424 security defaults content-backed comparable support (#491) Implements spec 424 with comparable renderable capture/readiness changes and supporting tests/spec artifacts. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/491 --- .../Console/Commands/GraphContractCheck.php | 40 +- .../TenantConfigurationSyncDefaults.php | 28 + .../Services/Graph/GraphClientInterface.php | 2 +- .../Services/Graph/MicrosoftGraphClient.php | 61 +- .../TenantConfiguration/ClaimGuard.php | 4 +- .../CoverageIdentityStrategyRegistry.php | 14 + .../CoverageSourceContractResolver.php | 1 + .../EntraComparablePayloadNormalizer.php | 81 ++- .../EntraCoverageComparator.php | 2 +- .../EntraRenderableSummaryBuilder.php | 71 +++ .../GenericContentEvidenceCaptureService.php | 20 +- .../ResourceTypeRegistry.php | 99 +++- apps/platform/config/graph_contracts.php | 19 + ..._expand_tenant_configuration_workloads.php | 91 ++- ...ableRenderableOperatorSurfaceSmokeTest.php | 292 ++++++++++ .../Spec419M365RegistryExpansionTest.php | 23 + ...Spec421EntraCoverageLevelPromotionTest.php | 23 +- ...24SecurityDefaultsCaptureReadinessTest.php | 523 ++++++++++++++++++ .../Spec419M365WorkloadRegistryTest.php | 25 +- ...c424SecurityDefaultsSourceContractTest.php | 213 +++++++ ...c424SecurityDefaultsTypedSemanticsTest.php | 136 +++++ .../checklists/requirements.md | 61 ++ .../implementation-report.md | 132 +++++ .../plan.md | 222 ++++++++ .../spec.md | 358 ++++++++++++ .../tasks.md | 131 +++++ 26 files changed, 2615 insertions(+), 57 deletions(-) create mode 100644 apps/platform/app/Console/Commands/TenantConfigurationSyncDefaults.php create mode 100644 apps/platform/tests/Browser/Spec424SecurityDefaultsComparableRenderableOperatorSurfaceSmokeTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec424SecurityDefaultsCaptureReadinessTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsSourceContractTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsTypedSemanticsTest.php create mode 100644 specs/424-security-defaults-content-backed-comparable-support/checklists/requirements.md create mode 100644 specs/424-security-defaults-content-backed-comparable-support/implementation-report.md create mode 100644 specs/424-security-defaults-content-backed-comparable-support/plan.md create mode 100644 specs/424-security-defaults-content-backed-comparable-support/spec.md create mode 100644 specs/424-security-defaults-content-backed-comparable-support/tasks.md diff --git a/apps/platform/app/Console/Commands/GraphContractCheck.php b/apps/platform/app/Console/Commands/GraphContractCheck.php index 84f59b80..c1af1644 100644 --- a/apps/platform/app/Console/Commands/GraphContractCheck.php +++ b/apps/platform/app/Console/Commands/GraphContractCheck.php @@ -38,17 +38,25 @@ public function handle(GraphClientInterface $graph, GraphContractRegistry $regis } $queryInput = array_filter([ - '$top' => 1, + '$top' => $this->isSingletonContract($contract) ? null : 1, '$select' => $select, '$expand' => $expand, ], static fn ($value): bool => $value !== null && $value !== '' && $value !== []); $query = $registry->sanitizeQuery($type, $queryInput)['query']; - $response = $graph->request('GET', $resource, [ + $requestOptions = [ 'query' => $query, 'tenant' => $tenant, - ]); + ]; + + $graphVersion = $this->contractGraphVersion($contract); + + if ($graphVersion !== null) { + $requestOptions['graph_version'] = $graphVersion; + } + + $response = $graph->request('GET', $resource, $requestOptions); if ($response->failed()) { $code = $response->meta['error_code'] ?? $response->status; @@ -68,4 +76,30 @@ public function handle(GraphClientInterface $graph, GraphContractRegistry $regis return $failures > 0 ? self::FAILURE : self::SUCCESS; } + + /** + * @param array $contract + */ + private function isSingletonContract(array $contract): bool + { + return ($contract['response_shape'] ?? null) === 'singleton'; + } + + /** + * @param array $contract + */ + private function contractGraphVersion(array $contract): ?string + { + $version = $contract['graph_version'] ?? null; + + if (! is_string($version) || trim($version) === '') { + return null; + } + + $version = trim($version); + + return preg_match('/^(?:beta|v\d+(?:\.\d+)?)$/', $version) === 1 + ? $version + : null; + } } diff --git a/apps/platform/app/Console/Commands/TenantConfigurationSyncDefaults.php b/apps/platform/app/Console/Commands/TenantConfigurationSyncDefaults.php new file mode 100644 index 00000000..78edef21 --- /dev/null +++ b/apps/platform/app/Console/Commands/TenantConfigurationSyncDefaults.php @@ -0,0 +1,28 @@ +syncDefaults(); + $supportedScopes->syncDefaults(); + + $this->info('Tenant configuration defaults synchronized.'); + $this->line(sprintf('Active resource types: %d', $resourceTypes->active()->count())); + $this->line(sprintf('Active supported scopes: %d', $supportedScopes->activeScopes()->count())); + + return self::SUCCESS; + } +} diff --git a/apps/platform/app/Services/Graph/GraphClientInterface.php b/apps/platform/app/Services/Graph/GraphClientInterface.php index 6efab40f..c7d35b0d 100644 --- a/apps/platform/app/Services/Graph/GraphClientInterface.php +++ b/apps/platform/app/Services/Graph/GraphClientInterface.php @@ -46,7 +46,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon /** * Execute an arbitrary Graph request (used for specialized operations like RBAC setup). * - * Supported options: `query`, `json`, `tenant`, `client_id`, `client_secret`, `scope`, `token_url`, `access_token`. + * Supported options: `query`, `json`, `tenant`, `client_id`, `client_secret`, `scope`, `token_url`, `access_token`, `graph_version`. */ public function request(string $method, string $path, array $options = []): GraphResponse; } diff --git a/apps/platform/app/Services/Graph/MicrosoftGraphClient.php b/apps/platform/app/Services/Graph/MicrosoftGraphClient.php index 05093756..513825b7 100644 --- a/apps/platform/app/Services/Graph/MicrosoftGraphClient.php +++ b/apps/platform/app/Services/Graph/MicrosoftGraphClient.php @@ -54,11 +54,13 @@ public function listPolicies(string $policyType, array $options = []): GraphResp { $endpoint = $this->endpointFor($policyType); $contract = $this->contracts->get($policyType); + $graphVersion = $this->contractGraphVersion($contract); + $expectsSingleton = $this->contractExpectsSingletonResponse($contract); $allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : []; $defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null); $queryInput = array_filter([ - '$top' => $options['top'] ?? null, + '$top' => $expectsSingleton ? null : ($options['top'] ?? null), '$filter' => $options['filter'] ?? null, '$select' => $defaultSelect, '$expand' => $options['expand'] ?? null, @@ -85,6 +87,10 @@ public function listPolicies(string $policyType, array $options = []): GraphResp $sendOptions = ['query' => $query, 'client_request_id' => $clientRequestId]; + if ($graphVersion !== null) { + $sendOptions['graph_version'] = $graphVersion; + } + if (isset($options['access_token'])) { $sendOptions['access_token'] = $options['access_token']; } @@ -138,6 +144,10 @@ public function listPolicies(string $policyType, array $options = []): GraphResp $fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery); $fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId]; + if ($graphVersion !== null) { + $fallbackSendOptions['graph_version'] = $graphVersion; + } + if (isset($options['access_token'])) { $fallbackSendOptions['access_token'] = $options['access_token']; } @@ -217,6 +227,10 @@ public function listPolicies(string $policyType, array $options = []): GraphResp $pageOptions = ['client_request_id' => $clientRequestId]; + if ($graphVersion !== null) { + $pageOptions['graph_version'] = $graphVersion; + } + if (isset($options['access_token'])) { $pageOptions['access_token'] = $options['access_token']; } @@ -728,7 +742,7 @@ private function send(string $method, string $path, array $options = [], array $ $token = $this->getAccessToken($context); } - $pending = Http::baseUrl($this->baseUrl) + $pending = Http::baseUrl($this->baseUrlForOptions($options)) ->acceptJson() ->timeout($this->timeout) ->retry( @@ -789,6 +803,49 @@ function (Throwable $exception): bool { return $response; } + private function baseUrlForOptions(array $options): string + { + $version = $options['graph_version'] ?? null; + + if (! is_string($version) || trim($version) === '') { + return $this->baseUrl; + } + + $version = trim($version, " \t\n\r\0\x0B/"); + + if (! preg_match('/^(?:beta|v\d+(?:\.\d+)?)$/', $version)) { + return $this->baseUrl; + } + + return rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/').'/'.$version; + } + + /** + * @param array $contract + */ + private function contractGraphVersion(array $contract): ?string + { + $version = $contract['graph_version'] ?? null; + + if (! is_string($version) || trim($version) === '') { + return null; + } + + $version = trim($version); + + return preg_match('/^(?:beta|v\d+(?:\.\d+)?)$/', $version) === 1 + ? $version + : null; + } + + /** + * @param array $contract + */ + private function contractExpectsSingletonResponse(array $contract): bool + { + return ($contract['response_shape'] ?? null) === 'singleton'; + } + private function retryDelayMs(int $attempt): int { $baseMs = max(0, $this->retrySleepMs); diff --git a/apps/platform/app/Services/TenantConfiguration/ClaimGuard.php b/apps/platform/app/Services/TenantConfiguration/ClaimGuard.php index 32fb22e3..49a0f9c9 100644 --- a/apps/platform/app/Services/TenantConfiguration/ClaimGuard.php +++ b/apps/platform/app/Services/TenantConfiguration/ClaimGuard.php @@ -185,7 +185,8 @@ private function hasUnsafeBroadCoverageClaim(array $tokens, bool $registryScoped 'labels', 'compliance', ]) - || ($this->hasToken($tokens, 'security') && $this->hasToken($tokens, 'compliance')); + || ($this->hasToken($tokens, 'security') && $this->hasToken($tokens, 'compliance')) + || ($this->hasToken($tokens, 'security') && $this->hasToken($tokens, 'defaults')); if ($this->hasHundredPercent($tokens) && ! $registryScoped) { return true; @@ -257,6 +258,7 @@ private function hasScopedWorkloadReference(array $tokens): bool { return $this->hasAnyToken($tokens, ['entra', 'exchange', 'teams']) || ($this->hasToken($tokens, 'security') && $this->hasToken($tokens, 'compliance')) + || ($this->hasToken($tokens, 'security') && $this->hasToken($tokens, 'defaults')) || ($this->hasToken($tokens, 'retention') && $this->hasToken($tokens, 'compliance')) || ($this->hasToken($tokens, 'dlp') && $this->hasToken($tokens, 'compliance')) || $this->hasAnyToken($tokens, ['label', 'labels']); diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php b/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php index c83a7867..f874a40e 100644 --- a/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php +++ b/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php @@ -105,6 +105,20 @@ final class CoverageIdentityStrategyRegistry 'derived_claims_allowed' => false, 'stable_key_kind' => 'graph_object_id', ], + 'securityDefaults' => [ + 'strategy_identifier' => 'graph.security_defaults.v1', + 'preferred_identity_fields' => ['id'], + 'fallback_identity_fields' => [], + 'source_composite_fields' => [], + 'derived_composite_fields' => [], + 'display_fields' => ['displayName', 'name'], + 'secondary_fields' => ['isEnabled', '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'], diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php index 56096886..68055253 100644 --- a/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php +++ b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php @@ -19,6 +19,7 @@ final class CoverageSourceContractResolver */ private const CONTRACT_KEYS = [ 'conditionalAccessPolicy' => 'conditionalAccessPolicy', + 'securityDefaults' => 'securityDefaults', 'deviceAndAppManagementAssignmentFilter' => 'assignmentFilter', 'notificationMessageTemplate' => 'notificationMessageTemplate', 'roleScopeTag' => 'roleScopeTag', diff --git a/apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php b/apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php index 5e9113e8..465041b2 100644 --- a/apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php +++ b/apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php @@ -11,6 +11,7 @@ final class EntraComparablePayloadNormalizer */ private const SUPPORTED_TYPES = [ 'conditionalAccessPolicy', + 'securityDefaults', ]; /** @@ -33,6 +34,20 @@ final class EntraComparablePayloadNormalizer 'templateId', ]; + /** + * @var list + */ + private const SECURITY_DEFAULTS_ROOT_FIELDS = [ + '@odata.context', + '@odata.etag', + '@odata.type', + 'description', + 'displayName', + 'id', + 'isEnabled', + 'name', + ]; + /** * @var list */ @@ -70,6 +85,10 @@ public function normalize(string $canonicalType, array $payload): array ]; } + if ($canonicalType === 'securityDefaults') { + return $this->normalizeSecurityDefaults($payload); + } + return $this->normalizeConditionalAccessPolicy($payload); } @@ -132,7 +151,38 @@ private function normalizeConditionalAccessPolicy(array $payload): array ], 'session_controls' => $this->normalizeNested(data_get($redacted, 'sessionControls', [])), 'diagnostics' => [ - 'unsupported_fields' => $this->unsupportedRootFields($redacted), + 'unsupported_fields' => $this->unsupportedRootFields($redacted, 'conditionalAccessPolicy'), + 'redacted_fields' => $this->redactedPaths($redacted), + 'volatile_fields' => $this->presentVolatileFields($payload), + ], + ]); + } + + /** + * @param array $payload + * @return array + */ + private function normalizeSecurityDefaults(array $payload): array + { + $redacted = $this->redactor->redact($payload); + $redacted = is_array($redacted) ? $redacted : []; + $enabled = $this->boolValue($redacted['isEnabled'] ?? null); + + return $this->sortAssociative([ + 'canonical_type' => 'securityDefaults', + 'supported' => true, + 'display_name' => $this->stringValue($redacted['displayName'] ?? $redacted['name'] ?? null) ?? 'Security Defaults', + 'enabled' => $enabled, + 'enabled_state' => match ($enabled) { + true => 'enabled', + false => 'disabled', + default => 'unknown', + }, + 'source_identity' => [ + 'id' => $this->stringValue($redacted['id'] ?? null), + ], + 'diagnostics' => [ + 'unsupported_fields' => $this->unsupportedRootFields($redacted, 'securityDefaults'), 'redacted_fields' => $this->redactedPaths($redacted), 'volatile_fields' => $this->presentVolatileFields($payload), ], @@ -185,6 +235,27 @@ private function stringValue(mixed $value): ?string return $value !== '' ? $value : null; } + private function boolValue(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + + if (is_int($value)) { + return $value === 1 ? true : ($value === 0 ? false : null); + } + + if (! is_string($value)) { + return null; + } + + return match (strtolower(trim($value))) { + '1', 'true', 'yes', 'enabled' => true, + '0', 'false', 'no', 'disabled' => false, + default => null, + }; + } + private function normalizeNested(mixed $value): mixed { if (! is_array($value)) { @@ -242,11 +313,15 @@ private function allScalar(array $items): bool * @param array $payload * @return list */ - private function unsupportedRootFields(array $payload): array + private function unsupportedRootFields(array $payload, string $canonicalType): array { + $allowedFields = $canonicalType === 'securityDefaults' + ? self::SECURITY_DEFAULTS_ROOT_FIELDS + : self::CONDITIONAL_ACCESS_ROOT_FIELDS; + $fields = array_values(array_filter( array_map('strval', array_keys($payload)), - static fn (string $key): bool => ! in_array($key, self::CONDITIONAL_ACCESS_ROOT_FIELDS, true), + static fn (string $key): bool => ! in_array($key, $allowedFields, true), )); sort($fields, SORT_NATURAL | SORT_FLAG_CASE); diff --git a/apps/platform/app/Services/TenantConfiguration/EntraCoverageComparator.php b/apps/platform/app/Services/TenantConfiguration/EntraCoverageComparator.php index b105ae1f..6a667c7f 100644 --- a/apps/platform/app/Services/TenantConfiguration/EntraCoverageComparator.php +++ b/apps/platform/app/Services/TenantConfiguration/EntraCoverageComparator.php @@ -133,7 +133,7 @@ private function isEmptyValue(mixed $value): bool private function importance(string $field): string { - if ($field === 'state') { + if (in_array($field, ['state', 'enabled', 'enabled_state'], true)) { return 'critical'; } diff --git a/apps/platform/app/Services/TenantConfiguration/EntraRenderableSummaryBuilder.php b/apps/platform/app/Services/TenantConfiguration/EntraRenderableSummaryBuilder.php index 2ce5fe15..1ff3344a 100644 --- a/apps/platform/app/Services/TenantConfiguration/EntraRenderableSummaryBuilder.php +++ b/apps/platform/app/Services/TenantConfiguration/EntraRenderableSummaryBuilder.php @@ -46,6 +46,10 @@ public function build(string $canonicalType, array $payload, array $context = [] return null; } + if ($canonicalType === 'securityDefaults') { + return $this->securityDefaultsSummary($normalized, $context); + } + return [ 'resource_type' => 'Conditional Access policy', 'display_name' => $normalized['display_name'] ?? 'Unnamed Conditional Access policy', @@ -97,6 +101,43 @@ public function build(string $canonicalType, array $payload, array $context = [] ]; } + /** + * @param array $normalized + * @param array $context + * @return array + */ + private function securityDefaultsSummary(array $normalized, array $context): array + { + $state = $this->humanValue($normalized['enabled_state'] ?? null) ?? 'Unknown'; + $claimState = $this->humanContext($context, 'claim_state'); + $identityState = $this->humanContext($context, 'identity_state'); + $evidenceState = $this->humanContext($context, 'evidence_state'); + $lastCaptured = $this->stringContext($context, 'last_captured'); + + return [ + 'resource_type' => 'Security Defaults', + 'display_name' => $normalized['display_name'] ?? 'Security Defaults', + 'state' => $state, + 'summary_fields' => [ + ['label' => 'Display name', 'value' => $normalized['display_name'] ?? 'Security Defaults'], + ['label' => 'Enabled state', 'value' => $state], + ['label' => 'Evidence state', 'value' => $evidenceState], + ['label' => 'Identity state', 'value' => $identityState], + ['label' => 'Claim state', 'value' => $claimState], + ['label' => 'Last captured', 'value' => $lastCaptured], + ], + 'targets' => [], + 'conditions' => [], + 'grant_controls' => 'Not applicable', + 'session_controls' => 'Not applicable', + 'claim_state' => $this->stringContext($context, 'claim_state'), + 'identity_state' => $this->stringContext($context, 'identity_state'), + 'last_captured' => $lastCaptured, + 'unsupported_fields' => data_get($normalized, 'diagnostics.unsupported_fields', []), + 'redacted_fields' => data_get($normalized, 'diagnostics.redacted_fields', []), + ]; + } + /** * @param list $include * @param list $exclude @@ -207,4 +248,34 @@ private function stringContext(array $context, string $key): ?string return $value !== '' ? $value : null; } + + /** + * @param array $context + */ + private function humanContext(array $context, string $key): ?string + { + return $this->humanValue($context[$key] ?? null); + } + + private function humanValue(mixed $value): ?string + { + if ($value instanceof \BackedEnum) { + $value = $value->value; + } + + if (! is_scalar($value)) { + return null; + } + + $value = trim((string) $value); + + if ($value === '') { + return null; + } + + return str($value) + ->replace('_', ' ') + ->headline() + ->toString(); + } } diff --git a/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php b/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php index a4b1145c..cc929a34 100644 --- a/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php +++ b/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php @@ -10,8 +10,8 @@ use App\Models\TenantConfigurationResourceType; use App\Services\Graph\GraphResponse; use App\Services\Providers\ProviderGateway; -use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OperationRunType; +use App\Support\OpsUx\RunFailureSanitizer; use App\Support\TenantConfiguration\CaptureOutcome; use Illuminate\Support\Collection; use InvalidArgumentException; @@ -63,7 +63,7 @@ public function capture( $response = $this->providerGateway->listPolicies( $providerConnection, (string) $decision->contractKey, - $this->graphListOptions($operationRun), + $this->graphListOptions($operationRun, $decision), ); } catch (Throwable $e) { $outcomes[] = $this->outcomeRow($resourceType, CaptureOutcome::Failed, $this->exceptionReasonCode($e), 0, $decision->contractKey); @@ -121,12 +121,22 @@ private function selectedResourceTypes(?array $canonicalTypes): Collection /** * @return array */ - private function graphListOptions(OperationRun $operationRun): array + private function graphListOptions(OperationRun $operationRun, CoverageSourceContractDecision $decision): array { - return [ + $options = [ 'client_request_id' => sprintf('tenant-config-capture-%d', (int) $operationRun->getKey()), - 'top' => 999, ]; + + if (! $this->expectsSingletonResponse($decision)) { + $options['top'] = 999; + } + + return $options; + } + + private function expectsSingletonResponse(CoverageSourceContractDecision $decision): bool + { + return ($decision->contract['response_shape'] ?? null) === 'singleton'; } private function assertScopedExecution( diff --git a/apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php b/apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php index f11b14e9..f4ba22ba 100644 --- a/apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php +++ b/apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php @@ -158,6 +158,8 @@ public function syncDefaults(): void 'updated_at', ], ); + + $this->deactivateSupersededSecurityDefaultsPlanningRow(); } /** @@ -250,39 +252,102 @@ private static function m365RepresentativeDefinitions(): array private static function m365ResourceDefinitions(Workload $workload, array $entries): array { return array_map( - static fn (array $entry): array => [ - 'canonical_type' => $entry[0], + static fn (array $entry): array => self::m365ResourceDefinition($workload, $entry), + $entries, + ); + } + + /** + * @param array{0: string, 1: string, 2: RestoreTier, 3: string, 4: list} $entry + * @return array + */ + private static function m365ResourceDefinition(Workload $workload, array $entry): array + { + if ($workload === Workload::Entra && $entry[0] === 'securityDefaults') { + return [ + 'canonical_type' => 'securityDefaults', 'display_name' => $entry[1], - 'description' => sprintf('Registry-only Microsoft 365 %s planning entry.', str($workload->value)->replace('_', ' ')->headline()), - 'source_class' => SourceClass::Tcm->value, + 'description' => 'Graph v1 fallback Microsoft Entra Security Defaults policy.', + 'source_class' => SourceClass::GraphV1Fallback->value, 'workload' => $workload->value, 'resource_class' => ResourceClass::Configuration->value, - 'support_state' => SupportState::OutOfScope->value, - 'default_coverage_level' => CoverageLevel::Detected->value, + 'support_state' => SupportState::FallbackSupported->value, + 'default_coverage_level' => CoverageLevel::Renderable->value, 'default_evidence_state' => EvidenceState::NotCaptured->value, - 'default_identity_state' => IdentityState::Derived->value, + 'default_identity_state' => IdentityState::Stable->value, 'default_claim_state' => ClaimState::InternalOnly->value, - 'restore_tier' => $entry[2]->value, + 'restore_tier' => RestoreTier::NotRestorable->value, 'allows_beta_claims' => false, - 'allows_graph_fallback_claims' => false, + 'allows_graph_fallback_claims' => true, 'allows_certified_claims' => false, 'is_active' => true, 'metadata' => [ 'kernel' => 'coverage_v2', 'provider_owned_source' => true, - 'registry_only' => true, - 'documentation_status' => 'documented_resource_catalog', - 'catalog_source' => 'm365_tcm_registry_seed', - 'catalog_last_reviewed_at' => '2026-06-26', + 'registry_only' => false, + 'documentation_status' => 'documented_graph_v1_contract', + 'catalog_source' => 'microsoft_graph_security_defaults_contract', + 'catalog_last_reviewed_at' => '2026-06-30', 'source_aliases' => $entry[4], 'risk_tier' => $entry[3], - 'default_restore_posture' => $entry[2]->value, + 'default_restore_posture' => RestoreTier::NotRestorable->value, 'is_full_catalog' => false, - 'catalog_import_batch' => 'spec_419_seeded_representative_manifest', + 'catalog_import_batch' => 'spec_424_security_defaults_graph_v1_contract', + 'source_contract_key' => 'securityDefaults', + 'source_endpoint' => '/policies/identitySecurityDefaultsEnforcementPolicy', + 'source_version' => 'v1.0', + 'operator_support' => 'internal_comparable_renderable', 'customer_claims_allowed' => false, + 'certification_allowed' => false, + 'restore_allowed' => false, + 'read_permissions' => ['Policy.Read.All'], ], + ]; + } + + return [ + 'canonical_type' => $entry[0], + 'display_name' => $entry[1], + 'description' => sprintf('Registry-only Microsoft 365 %s planning entry.', str($workload->value)->replace('_', ' ')->headline()), + 'source_class' => SourceClass::Tcm->value, + 'workload' => $workload->value, + 'resource_class' => ResourceClass::Configuration->value, + 'support_state' => SupportState::OutOfScope->value, + 'default_coverage_level' => CoverageLevel::Detected->value, + 'default_evidence_state' => EvidenceState::NotCaptured->value, + 'default_identity_state' => IdentityState::Derived->value, + 'default_claim_state' => ClaimState::InternalOnly->value, + 'restore_tier' => $entry[2]->value, + 'allows_beta_claims' => false, + 'allows_graph_fallback_claims' => false, + 'allows_certified_claims' => false, + 'is_active' => true, + 'metadata' => [ + 'kernel' => 'coverage_v2', + 'provider_owned_source' => true, + 'registry_only' => true, + 'documentation_status' => 'documented_resource_catalog', + 'catalog_source' => 'm365_tcm_registry_seed', + 'catalog_last_reviewed_at' => '2026-06-26', + 'source_aliases' => $entry[4], + 'risk_tier' => $entry[3], + 'default_restore_posture' => $entry[2]->value, + 'is_full_catalog' => false, + 'catalog_import_batch' => 'spec_419_seeded_representative_manifest', + 'customer_claims_allowed' => false, ], - $entries, - ); + ]; + } + + private function deactivateSupersededSecurityDefaultsPlanningRow(): void + { + DB::table('tenant_configuration_resource_types') + ->where('canonical_type', 'securityDefaults') + ->where('source_class', SourceClass::Tcm->value) + ->where('workload', Workload::Entra->value) + ->update([ + 'is_active' => false, + 'updated_at' => now(), + ]); } } diff --git a/apps/platform/config/graph_contracts.php b/apps/platform/config/graph_contracts.php index afff1011..5bf3d173 100644 --- a/apps/platform/config/graph_contracts.php +++ b/apps/platform/config/graph_contracts.php @@ -526,6 +526,25 @@ 'id_field' => 'id', 'hydration' => 'properties', ], + 'securityDefaults' => [ + 'resource' => 'policies/identitySecurityDefaultsEnforcementPolicy', + 'graph_version' => 'v1.0', + 'response_shape' => 'singleton', + 'allowed_select' => ['id', 'displayName', 'description', 'isEnabled'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.identitySecurityDefaultsEnforcementPolicy', + ], + 'id_field' => 'id', + 'hydration' => 'properties', + 'volatile_fields' => [ + '@odata.context', + '@odata.etag', + ], + 'read_permissions' => [ + 'Policy.Read.All', + ], + ], 'deviceComplianceScript' => [ 'resource' => 'deviceManagement/deviceComplianceScripts', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], diff --git a/apps/platform/database/migrations/2026_06_26_000419_expand_tenant_configuration_workloads.php b/apps/platform/database/migrations/2026_06_26_000419_expand_tenant_configuration_workloads.php index 51f347ac..76f9609e 100644 --- a/apps/platform/database/migrations/2026_06_26_000419_expand_tenant_configuration_workloads.php +++ b/apps/platform/database/migrations/2026_06_26_000419_expand_tenant_configuration_workloads.php @@ -117,6 +117,12 @@ public function down(): void ->where('metadata->catalog_import_batch', self::SPEC_419_IMPORT_BATCH) ->delete(); + DB::table('tenant_configuration_resource_types') + ->where('source_class', 'graph_v1_fallback') + ->where('canonical_type', 'securityDefaults') + ->where('metadata->catalog_import_batch', 'spec_424_security_defaults_graph_v1_contract') + ->delete(); + $this->restoreWorkloadConstraintAfterSpec419Rollback(); } @@ -202,40 +208,91 @@ private function resourceTypeDefinitions(): array private function m365ResourceDefinitions(string $workload, array $entries): array { return array_map( - static fn (array $entry): array => [ - 'canonical_type' => $entry[0], + fn (array $entry): array => $this->m365ResourceDefinition($workload, $entry), + $entries, + ); + } + + /** + * @param array{0: string, 1: string, 2: string, 3: string, 4: list} $entry + * @return array + */ + private function m365ResourceDefinition(string $workload, array $entry): array + { + if ($workload === 'entra' && $entry[0] === 'securityDefaults') { + return [ + 'canonical_type' => 'securityDefaults', 'display_name' => $entry[1], - 'description' => 'Registry-only Microsoft 365 planning entry.', - 'source_class' => 'tcm', + 'description' => 'Graph v1 fallback Microsoft Entra Security Defaults policy.', + 'source_class' => 'graph_v1_fallback', 'workload' => $workload, 'resource_class' => 'configuration', - 'support_state' => 'out_of_scope', - 'default_coverage_level' => 'detected', + 'support_state' => 'fallback_supported', + 'default_coverage_level' => 'renderable', 'default_evidence_state' => 'not_captured', - 'default_identity_state' => 'derived', + 'default_identity_state' => 'stable', 'default_claim_state' => 'internal_only', - 'restore_tier' => $entry[2], + 'restore_tier' => 'not_restorable', 'allows_beta_claims' => false, - 'allows_graph_fallback_claims' => false, + 'allows_graph_fallback_claims' => true, 'allows_certified_claims' => false, 'is_active' => true, 'metadata' => [ 'kernel' => 'coverage_v2', 'provider_owned_source' => true, - 'registry_only' => true, - 'documentation_status' => 'documented_resource_catalog', - 'catalog_source' => 'm365_tcm_registry_seed', - 'catalog_last_reviewed_at' => '2026-06-26', + 'registry_only' => false, + 'documentation_status' => 'documented_graph_v1_contract', + 'catalog_source' => 'microsoft_graph_security_defaults_contract', + 'catalog_last_reviewed_at' => '2026-06-30', 'source_aliases' => $entry[4], 'risk_tier' => $entry[3], - 'default_restore_posture' => $entry[2], + 'default_restore_posture' => 'not_restorable', 'is_full_catalog' => false, - 'catalog_import_batch' => self::SPEC_419_IMPORT_BATCH, + 'catalog_import_batch' => 'spec_424_security_defaults_graph_v1_contract', + 'source_contract_key' => 'securityDefaults', + 'source_endpoint' => '/policies/identitySecurityDefaultsEnforcementPolicy', + 'source_version' => 'v1.0', + 'operator_support' => 'internal_comparable_renderable', 'customer_claims_allowed' => false, + 'certification_allowed' => false, + 'restore_allowed' => false, + 'read_permissions' => ['Policy.Read.All'], ], + ]; + } + + return [ + 'canonical_type' => $entry[0], + 'display_name' => $entry[1], + 'description' => 'Registry-only Microsoft 365 planning entry.', + 'source_class' => 'tcm', + 'workload' => $workload, + 'resource_class' => 'configuration', + 'support_state' => 'out_of_scope', + 'default_coverage_level' => 'detected', + 'default_evidence_state' => 'not_captured', + 'default_identity_state' => 'derived', + 'default_claim_state' => 'internal_only', + 'restore_tier' => $entry[2], + 'allows_beta_claims' => false, + 'allows_graph_fallback_claims' => false, + 'allows_certified_claims' => false, + 'is_active' => true, + 'metadata' => [ + 'kernel' => 'coverage_v2', + 'provider_owned_source' => true, + 'registry_only' => true, + 'documentation_status' => 'documented_resource_catalog', + 'catalog_source' => 'm365_tcm_registry_seed', + 'catalog_last_reviewed_at' => '2026-06-26', + 'source_aliases' => $entry[4], + 'risk_tier' => $entry[3], + 'default_restore_posture' => $entry[2], + 'is_full_catalog' => false, + 'catalog_import_batch' => self::SPEC_419_IMPORT_BATCH, + 'customer_claims_allowed' => false, ], - $entries, - ); + ]; } /** diff --git a/apps/platform/tests/Browser/Spec424SecurityDefaultsComparableRenderableOperatorSurfaceSmokeTest.php b/apps/platform/tests/Browser/Spec424SecurityDefaultsComparableRenderableOperatorSurfaceSmokeTest.php new file mode 100644 index 00000000..83755a19 --- /dev/null +++ b/apps/platform/tests/Browser/Spec424SecurityDefaultsComparableRenderableOperatorSurfaceSmokeTest.php @@ -0,0 +1,292 @@ +browser()->timeout(60_000); + +it('Spec424 smokes the Coverage v2 inspect surface for Security Defaults renderable evidence', function (): void { + [$user, $environment] = spec424CoverageV2BrowserFixture(); + spec424AuthenticateCoverageV2Browser($this, $user, $environment); + + $page = visit(CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin')) + ->resize(768, 1100) + ->waitForText('Coverage v2 Readiness') + ->waitForText('Spec424 Browser Security Defaults') + ->assertSee('Resource type registry') + ->assertSee('Resource instances') + ->assertSee('Security defaults') + ->assertSee('Coverage level') + ->assertSee('Renderable') + ->assertSee('Internal only') + ->assertDontSee('Security Defaults covered') + ->assertDontSee('certified') + ->assertDontSee('restore-ready') + ->assertDontSee('customer-ready') + ->assertDontSee('100%') + ->assertDontSee('spec424-raw-secret') + ->assertDontSee('spec424-normalized-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('Spec424 Browser Security Defaults')); + const inspect = Array.from(row?.querySelectorAll('button, a') ?? []) + .find((element) => element.textContent.includes('Spec424 Browser Security Defaults')); + + inspect?.click(); + })() + JS); + + $page + ->waitForText('Coverage: Renderable') + ->assertSee('Security Defaults') + ->assertSee('Display name') + ->assertSee('Spec424 Browser Security Defaults') + ->assertSee('Enabled state') + ->assertSee('Enabled') + ->assertSee('Compare summary') + ->assertSee('Material changes detected') + ->assertSee('Previous comparable evidence') + ->assertSee('Enabled State') + ->assertSee('Redacted fields') + ->assertSee('clientSecret') + ->assertSee('Evidence: Content backed') + ->assertSee('Identity: Stable') + ->assertSee('Claim: Internal only') + ->assertDontSee('Security Defaults covered') + ->assertDontSee('certified') + ->assertDontSee('restore-ready') + ->assertDontSee('customer-ready') + ->assertDontSee('100%') + ->assertDontSee('identitySecurityDefaultsEnforcementPolicy') + ->assertDontSee('spec424-raw-secret') + ->assertDontSee('spec424-normalized-secret') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->screenshot(true, 'spec424-security-defaults-comparable-renderable-operator-surface'); +}); + +/** + * @return array{0: User, 1: ManagedEnvironment} + */ +function spec424CoverageV2BrowserFixture(): array +{ + app(ResourceTypeRegistry::class)->syncDefaults(); + + $environment = ManagedEnvironment::factory()->active()->create([ + 'name' => 'Spec424 Browser Environment', + 'external_id' => 'spec424-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' => 'Spec424 Browser Microsoft provider', + ]); + + $resourceType = TenantConfigurationResourceType::query() + ->where('canonical_type', 'securityDefaults') + ->where('source_class', SourceClass::GraphV1Fallback->value) + ->firstOrFail(); + + TenantConfigurationSupportedScope::factory()->create([ + 'scope_key' => 'spec424_browser_internal_security_defaults_scope', + 'display_name' => 'Spec424 Browser internal Security Defaults scope', + 'minimum_coverage_level' => CoverageLevel::ContentBacked->value, + 'included_resource_types' => ['securityDefaults'], + 'allow_graph_fallback' => true, + 'allow_beta' => false, + 'customer_claims_allowed' => false, + ]); + + $previousRun = spec424BrowserRun($environment, $user, now()->subMinutes(6), now()->subMinutes(5)); + $run = spec424BrowserRun($environment, $user, now()->subMinute(), 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' => 'securityDefaults', + 'canonical_resource_key' => 'securityDefaults:graph_object_id:spec424-browser-security-defaults', + 'canonical_key_kind' => CanonicalKeyKind::GraphObjectId->value, + 'source_resource_id' => 'securityDefaults', + 'source_display_name' => 'Spec424 Browser Security Defaults', + 'source_class' => SourceClass::GraphV1Fallback->value, + 'source_metadata' => [ + 'source_contract_key' => 'securityDefaults', + 'source_endpoint' => '/policies/identitySecurityDefaultsEnforcementPolicy', + 'source_version' => 'v1.0', + 'registry_source_class' => SourceClass::GraphV1Fallback->value, + 'registry_support_state' => 'fallback_supported', + ], + 'identity_strategy' => 'graph.security_defaults.v1', + 'source_identity' => [ + 'primary_field' => 'id', + 'primary_value' => 'securityDefaults', + ], + 'identity_diagnostics' => [ + 'reason_code' => 'stable_identity_resolved', + ], + '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(), + ]); + + 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) $previousRun->getKey(), + 'source_contract_key' => 'securityDefaults', + 'source_endpoint' => '/policies/identitySecurityDefaultsEnforcementPolicy', + 'source_version' => 'v1.0', + 'source_schema_hash' => 'spec424-browser-previous-schema-hash', + 'source_metadata' => [ + 'registry_source_class' => SourceClass::GraphV1Fallback->value, + 'registry_support_state' => 'fallback_supported', + ], + 'raw_payload' => ['id' => 'securityDefaults'], + 'normalized_payload' => [ + 'id' => 'securityDefaults', + 'displayName' => 'Spec424 Browser Security Defaults', + 'description' => 'Tenant-wide Security Defaults policy.', + 'isEnabled' => false, + ], + 'payload_hash' => str_repeat('a', 64), + 'permission_context' => ['scopes_granted' => ['Policy.Read.All']], + 'evidence_state' => EvidenceState::ContentBacked->value, + 'coverage_level' => CoverageLevel::Comparable->value, + 'capture_outcome' => CaptureOutcome::Captured->value, + 'captured_at' => now()->subMinutes(5), + ]); + + $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' => 'securityDefaults', + 'source_endpoint' => '/policies/identitySecurityDefaultsEnforcementPolicy', + 'source_version' => 'v1.0', + 'source_schema_hash' => 'spec424-browser-schema-hash', + 'source_metadata' => [ + 'registry_source_class' => SourceClass::GraphV1Fallback->value, + 'registry_support_state' => 'fallback_supported', + ], + 'raw_payload' => ['id' => 'securityDefaults', 'secret' => 'spec424-raw-secret'], + 'normalized_payload' => [ + 'id' => 'securityDefaults', + 'displayName' => 'Spec424 Browser Security Defaults', + 'description' => 'Tenant-wide Security Defaults policy.', + 'isEnabled' => true, + 'clientSecret' => '[redacted]', + ], + 'payload_hash' => str_repeat('b', 64), + 'permission_context' => ['scopes_granted' => ['Policy.Read.All']], + 'evidence_state' => EvidenceState::ContentBacked->value, + 'coverage_level' => CoverageLevel::Renderable->value, + 'capture_outcome' => CaptureOutcome::Captured->value, + 'captured_at' => now(), + ]); + + $resource->forceFill([ + 'latest_evidence_id' => (int) $evidence->getKey(), + 'latest_payload_hash' => str_repeat('b', 64), + ])->save(); + + return [$user, $environment->refresh()]; +} + +function spec424BrowserRun(ManagedEnvironment $environment, User $user, $startedAt, $completedAt): OperationRun +{ + return 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' => 1, + 'processed' => 1, + 'succeeded' => 1, + 'skipped' => 0, + 'failed' => 0, + 'errors_recorded' => 0, + ], + 'context' => [ + 'requested_resource_types' => ['securityDefaults'], + 'outcomes' => [ + ['canonical_type' => 'securityDefaults', 'outcome' => CaptureOutcome::Captured->value], + ], + ], + 'started_at' => $startedAt, + 'completed_at' => $completedAt, + ]); +} + +function spec424AuthenticateCoverageV2Browser( + 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/Spec419M365RegistryExpansionTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec419M365RegistryExpansionTest.php index 215ac101..eef5f734 100644 --- a/apps/platform/tests/Feature/TenantConfiguration/Spec419M365RegistryExpansionTest.php +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec419M365RegistryExpansionTest.php @@ -13,6 +13,7 @@ use App\Support\TenantConfiguration\EvidenceState; use App\Support\TenantConfiguration\IdentityState; use App\Support\TenantConfiguration\RestoreTier; +use App\Support\TenantConfiguration\SourceClass; use App\Support\TenantConfiguration\SupportState; use App\Support\TenantConfiguration\Workload; use Illuminate\Support\Facades\DB; @@ -52,6 +53,28 @@ ->get(); foreach ($m365Rows as $row) { + if ($row->canonical_type === 'securityDefaults') { + expect($row->source_class)->toBe(SourceClass::GraphV1Fallback) + ->and($row->support_state)->toBe(SupportState::FallbackSupported) + ->and($row->default_coverage_level)->toBe(CoverageLevel::Renderable) + ->and($row->default_evidence_state)->toBe(EvidenceState::NotCaptured) + ->and($row->default_identity_state)->toBe(IdentityState::Stable) + ->and($row->default_claim_state)->toBe(ClaimState::InternalOnly) + ->and($row->allows_beta_claims)->toBeFalse() + ->and($row->allows_graph_fallback_claims)->toBeTrue() + ->and($row->allows_certified_claims)->toBeFalse() + ->and($row->metadata['registry_only'])->toBeFalse() + ->and($row->metadata['source_contract_key'])->toBe('securityDefaults') + ->and($row->metadata['source_version'])->toBe('v1.0') + ->and($row->metadata['is_full_catalog'])->toBeFalse() + ->and($row->metadata['customer_claims_allowed'])->toBeFalse() + ->and($row->metadata['certification_allowed'])->toBeFalse() + ->and($row->metadata['restore_allowed'])->toBeFalse() + ->and($row->restore_tier)->toBe(RestoreTier::NotRestorable); + + continue; + } + expect($row->support_state)->toBe(SupportState::OutOfScope) ->and($row->default_coverage_level)->toBe(CoverageLevel::Detected) ->and($row->default_evidence_state)->toBe(EvidenceState::NotCaptured) diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php index e70862ce..5dbcb54a 100644 --- a/apps/platform/tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php @@ -16,7 +16,7 @@ use App\Support\TenantConfiguration\CaptureOutcome; use App\Support\TenantConfiguration\CoverageLevel; -it('Spec421 promotes only content-backed Conditional Access evidence to renderable coverage', function (): void { +it('Spec421 promotes current content-backed Entra evidence and keeps planning types blocked', function (): void { app(ResourceTypeRegistry::class)->syncDefaults(); config()->set('graph_contracts.types.conditionalAccessPolicy.volatile_fields', ['modifiedDateTime']); @@ -49,13 +49,16 @@ ], ); $outcomes = collect($result['outcomes'])->keyBy('canonical_type'); + $resources = TenantConfigurationResource::query()->get()->keyBy('canonical_type'); + $evidenceByResourceId = TenantConfigurationResourceEvidence::query()->get()->keyBy('resource_id'); - expect($graph->calls)->toBe(['conditionalAccessPolicy']) - ->and(TenantConfigurationResource::query()->count())->toBe(1) - ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(1) - ->and(TenantConfigurationResourceEvidence::query()->sole()->coverage_level)->toBe(CoverageLevel::Renderable) + expect($graph->calls)->toBe(['conditionalAccessPolicy', 'securityDefaults']) + ->and($resources)->toHaveCount(2) + ->and($evidenceByResourceId)->toHaveCount(2) + ->and($evidenceByResourceId[$resources['conditionalAccessPolicy']->getKey()]->coverage_level)->toBe(CoverageLevel::Renderable) + ->and($evidenceByResourceId[$resources['securityDefaults']->getKey()]->coverage_level)->toBe(CoverageLevel::Renderable) ->and($outcomes['conditionalAccessPolicy']['outcome'])->toBe(CaptureOutcome::Captured->value) - ->and($outcomes['securityDefaults']['outcome'])->toBe(CaptureOutcome::BlockedUnsupported->value) + ->and($outcomes['securityDefaults']['outcome'])->toBe(CaptureOutcome::Captured->value) ->and($outcomes['application']['outcome'])->toBe(CaptureOutcome::BlockedUnsupported->value) ->and($outcomes['servicePrincipal']['outcome'])->toBe(CaptureOutcome::BlockedUnsupported->value) ->and($outcomes['roleDefinition']['outcome'])->toBe(CaptureOutcome::BlockedUnsupported->value) @@ -90,6 +93,14 @@ public function listPolicies(string $policyType, array $options = []): GraphResp { $this->calls[] = $policyType; + if ($policyType === 'securityDefaults') { + return new GraphResponse(true, [ + 'id' => 'securityDefaults', + 'displayName' => 'Security Defaults', + 'isEnabled' => true, + ]); + } + return new GraphResponse(true, [[ 'id' => 'cap-1', 'displayName' => 'Require MFA', diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec424SecurityDefaultsCaptureReadinessTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec424SecurityDefaultsCaptureReadinessTest.php new file mode 100644 index 00000000..eb061eb9 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec424SecurityDefaultsCaptureReadinessTest.php @@ -0,0 +1,523 @@ +syncDefaults(); + + $rows = TenantConfigurationResourceType::query() + ->active() + ->where('canonical_type', 'securityDefaults') + ->get(); + $type = $rows->sole(); + + expect($rows)->toHaveCount(1) + ->and($type->source_class)->toBe(SourceClass::GraphV1Fallback) + ->and($type->support_state)->toBe(SupportState::FallbackSupported) + ->and($type->default_coverage_level)->toBe(CoverageLevel::Renderable) + ->and($type->default_evidence_state)->toBe(EvidenceState::NotCaptured) + ->and($type->default_identity_state)->toBe(IdentityState::Stable) + ->and($type->default_claim_state)->toBe(ClaimState::InternalOnly) + ->and($type->restore_tier)->toBe(RestoreTier::NotRestorable) + ->and((bool) $type->allows_beta_claims)->toBeFalse() + ->and((bool) $type->allows_graph_fallback_claims)->toBeTrue() + ->and((bool) $type->allows_certified_claims)->toBeFalse() + ->and($type->metadata['registry_only'])->toBeFalse() + ->and($type->metadata['source_contract_key'])->toBe('securityDefaults') + ->and($type->metadata['source_version'])->toBe('v1.0') + ->and($type->metadata['customer_claims_allowed'])->toBeFalse() + ->and($type->metadata['certification_allowed'])->toBeFalse() + ->and($type->metadata['restore_allowed'])->toBeFalse(); + + expect(TenantConfigurationResourceType::query() + ->active() + ->where('canonical_type', 'securityDefaults') + ->where('source_class', SourceClass::Tcm->value) + ->count())->toBe(0); +}); + +it('Spec424 default sync command deactivates stale Security Defaults TCM planning rows', function (): void { + TenantConfigurationResourceType::query()->updateOrCreate( + [ + 'canonical_type' => 'securityDefaults', + 'source_class' => SourceClass::Tcm->value, + ], + [ + 'display_name' => 'Security defaults legacy planning row', + 'description' => 'Legacy Security Defaults planning row.', + 'workload' => 'entra', + 'resource_class' => 'configuration', + 'support_state' => SupportState::OutOfScope->value, + 'default_coverage_level' => CoverageLevel::Detected->value, + 'default_evidence_state' => EvidenceState::NotCaptured->value, + 'default_identity_state' => IdentityState::Derived->value, + 'default_claim_state' => ClaimState::InternalOnly->value, + 'restore_tier' => RestoreTier::NotRestorable->value, + 'allows_beta_claims' => false, + 'allows_graph_fallback_claims' => false, + 'allows_certified_claims' => false, + 'is_active' => true, + 'metadata' => ['catalog_import_batch' => 'spec_419_seeded_representative_manifest'], + ], + ); + + $this->artisan('tenant-configuration:sync-defaults') + ->assertSuccessful(); + + $activeRows = TenantConfigurationResourceType::query() + ->active() + ->where('canonical_type', 'securityDefaults') + ->get(); + $activeType = $activeRows->sole(); + + expect($activeRows)->toHaveCount(1) + ->and($activeType->source_class)->toBe(SourceClass::GraphV1Fallback) + ->and(TenantConfigurationResourceType::query() + ->where('canonical_type', 'securityDefaults') + ->where('source_class', SourceClass::Tcm->value) + ->where('is_active', true) + ->exists())->toBeFalse(); +}); + +it('Spec424 captures singleton Security Defaults evidence with internal-only renderable posture', function (): void { + app(ResourceTypeRegistry::class)->syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'scopes_granted' => ['Policy.Read.All'], + ]); + $graph = spec424CaptureGraphClient([ + spec424CaptureSecurityDefaultsPayload([ + '@odata.context' => 'https://graph.microsoft.com/v1.0/$metadata#policies/identitySecurityDefaultsEnforcementPolicy/$entity', + 'clientSecret' => 'spec424-provider-secret', + ]), + ]); + app()->instance(GraphClientInterface::class, $graph); + + $result = app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: spec424CaptureRun($user, $environment, $connection), + canonicalTypes: ['securityDefaults'], + ); + + expect($graph->calls)->toHaveCount(1) + ->and($graph->calls[0]['policy_type'])->toBe('securityDefaults') + ->and($graph->calls[0]['options'])->toHaveKey('client_request_id') + ->and($graph->calls[0]['options'])->not->toHaveKey('top') + ->and($result['summary_counts'])->toMatchArray([ + 'total' => 1, + 'processed' => 1, + 'succeeded' => 1, + 'skipped' => 0, + 'failed' => 0, + ]) + ->and($result['outcomes'][0]['outcome'])->toBe(CaptureOutcome::Captured->value) + ->and($result['outcomes'][0]['item_count'])->toBe(1) + ->and($result['outcomes'][0]['source_contract_key'])->toBe('securityDefaults'); + + $resource = TenantConfigurationResource::query()->sole(); + $evidence = TenantConfigurationResourceEvidence::query()->sole(); + + expect($resource->canonical_type)->toBe('securityDefaults') + ->and($resource->source_class)->toBe(SourceClass::GraphV1Fallback) + ->and($resource->canonical_key_kind)->toBe(CanonicalKeyKind::GraphObjectId) + ->and($resource->source_resource_id)->toBe('securityDefaults') + ->and($resource->source_display_name)->toBe('Security Defaults') + ->and($resource->latest_identity_state)->toBe(IdentityState::Stable) + ->and($resource->latest_claim_state)->toBe(ClaimState::InternalOnly) + ->and($resource->source_identity['strategy_identifier'])->toBe('graph.security_defaults.v1') + ->and($resource->source_metadata['source_contract_key'])->toBe('securityDefaults') + ->and($resource->source_metadata['registry_source_class'])->toBe('graph_v1_fallback') + ->and($resource->source_metadata['registry_support_state'])->toBe('fallback_supported'); + + expect($evidence->source_contract_key)->toBe('securityDefaults') + ->and($evidence->source_endpoint)->toBe('/policies/identitySecurityDefaultsEnforcementPolicy') + ->and($evidence->source_version)->toBe('v1.0') + ->and($evidence->coverage_level)->toBe(CoverageLevel::Renderable) + ->and($evidence->evidence_state)->toBe(EvidenceState::ContentBacked) + ->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured) + ->and($evidence->raw_payload['clientSecret'])->toBe('spec424-provider-secret') + ->and($evidence->normalized_payload)->not->toHaveKey('@odata.context') + ->and($evidence->normalized_payload['clientSecret'])->toBe('[redacted]') + ->and($evidence->permission_context['scopes_granted'])->toBe(['Policy.Read.All']) + ->and($evidence->payload_hash)->toBeString()->toHaveLength(64); +}); + +it('Spec424 creates no fake evidence when Security Defaults capture is permission blocked', function (): void { + app(ResourceTypeRegistry::class)->syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'scopes_granted' => [], + ]); + $graph = spec424CaptureGraphClient([ + new GraphResponse(false, [], 403, [['message' => 'Forbidden']]), + ]); + app()->instance(GraphClientInterface::class, $graph); + + $result = app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: spec424CaptureRun($user, $environment, $connection), + canonicalTypes: ['securityDefaults'], + ); + + expect($graph->calls)->toHaveCount(1) + ->and($result['outcomes'][0]['outcome'])->toBe(CaptureOutcome::BlockedPermission->value) + ->and($result['outcomes'][0]['reason_code'])->toBe('graph_permission_blocked') + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec424 blocks mismatched operation run scope before provider work', 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 = spec424CaptureGraphClient([ + spec424CaptureSecurityDefaultsPayload(), + ]); + app()->instance(GraphClientInterface::class, $graph); + + $run = spec424CaptureRun($user, $environment, $connection, [ + 'securityDefaults', + ], [ + 'provider_connection_id' => (int) $connection->getKey() + 1000, + ]); + + expect(fn () => app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalTypes: ['securityDefaults'], + ))->toThrow(InvalidArgumentException::class, 'target scope'); + + expect($graph->calls)->toBe([]) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec424 renders Security Defaults inspect summaries and compare details from DB only', function (): void { + app(ResourceTypeRegistry::class)->syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'scopes_granted' => ['Policy.Read.All'], + ]); + app()->instance(GraphClientInterface::class, spec424CaptureGraphClient([ + spec424CaptureSecurityDefaultsPayload(['isEnabled' => false]), + spec424CaptureSecurityDefaultsPayload(['isEnabled' => true, 'clientSecret' => 'spec424-render-secret']), + ])); + + app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: spec424CaptureRun($user, $environment, $connection), + canonicalTypes: ['securityDefaults'], + ); + app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: spec424CaptureRun($user, $environment, $connection), + canonicalTypes: ['securityDefaults'], + ); + + $resource = TenantConfigurationResource::query()->sole(); + app()->instance(GraphClientInterface::class, spec424FailingGraphClient()); + + $details = assertNoOutboundHttp(fn (): array => app(CoverageV2ReadinessReadModel::class) + ->inspectDetails($resource, $environment, $user)); + $summary = $details['typed_render_summary'] ?? null; + $encodedSummary = json_encode($summary, JSON_THROW_ON_ERROR); + + expect($summary)->toBeArray() + ->and($summary['resource_type'])->toBe('Security Defaults') + ->and($summary['state'])->toBe('Enabled') + ->and($summary['compare_summary']['status'])->toBe('Material changes detected') + ->and($summary['compare_summary']['changed'])->toBeTrue() + ->and(collect($summary['compare_summary']['changes'])->pluck('label')->all())->toContain('Enabled State') + ->and($encodedSummary)->not->toContain('spec424-render-secret') + ->and($encodedSummary)->not->toContain('source_endpoint') + ->and($encodedSummary)->not->toContain('identitySecurityDefaultsEnforcementPolicy'); +}); + +it('Spec424 denies Security Defaults inspect and page access outside the actor scope', function (): void { + app(ResourceTypeRegistry::class)->syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + [, $foreignEnvironment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + app()->instance(GraphClientInterface::class, spec424CaptureGraphClient([ + spec424CaptureSecurityDefaultsPayload(), + ])); + + app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: spec424CaptureRun($user, $environment, $connection), + canonicalTypes: ['securityDefaults'], + ); + + $resource = TenantConfigurationResource::query()->sole(); + $outsider = User::factory()->create(); + $outsiderDetails = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $environment, $outsider); + + expect(app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $foreignEnvironment, $user))->toBe([]) + ->and($outsiderDetails['operation_run_url'] ?? null)->toBeNull(); + + $this->actingAs($outsider) + ->get(CoverageV2Readiness::getUrl(tenant: $environment)) + ->assertNotFound(); +}); + +it('Spec424 returns forbidden for Security Defaults readiness when view capability is denied', function (): void { + app(ResourceTypeRegistry::class)->syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + spec424CoverageActingAs($user, $environment); + + app()->instance(ManagedEnvironmentAccessScopeResolver::class, new class + { + public function decision(User $user, $environment, ?string $requiredCapability = null): ManagedEnvironmentAccessDecision + { + return new ManagedEnvironmentAccessDecision( + workspaceId: (int) $environment->workspace_id, + managedEnvironmentId: (int) $environment->getKey(), + userId: (int) $user->getKey(), + workspaceMember: true, + workspaceRole: 'owner', + explicitScopeRowsPresent: false, + managedEnvironmentAllowed: true, + failedBoundary: 'capability', + requiredCapability: $requiredCapability, + capabilityAllowed: false, + denialHttpStatus: 403, + ); + } + }); + + try { + $this->get(CoverageV2Readiness::getUrl(tenant: $environment)) + ->assertForbidden(); + } finally { + app()->forgetInstance(ManagedEnvironmentAccessScopeResolver::class); + } +}); + +it('Spec424 readonly users cannot start Security Defaults capture', 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, [ + 'securityDefaults', + ]))->toThrow(AuthorizationException::class); + + Queue::assertNothingPushed(); +}); + +it('Spec424 adds no tenant id ownership, mini-platform, route, Filament resource, or customer output file', function (): void { + $newPlatformFiles = collect([ + 'apps/platform/app/Filament', + 'apps/platform/app/Models', + 'apps/platform/routes', + 'apps/platform/database/migrations', + ]) + ->flatMap(fn (string $path): array => glob(repo_path($path).'/**/*424*') ?: []) + ->map(fn (string $path): string => str_replace(repo_path().DIRECTORY_SEPARATOR, '', $path)) + ->values() + ->all(); + + $runtimeFiles = [ + app_path('Services/TenantConfiguration/ClaimGuard.php'), + app_path('Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php'), + app_path('Services/TenantConfiguration/CoverageSourceContractResolver.php'), + app_path('Services/TenantConfiguration/EntraComparablePayloadNormalizer.php'), + app_path('Services/TenantConfiguration/EntraCoverageComparator.php'), + app_path('Services/TenantConfiguration/EntraRenderableSummaryBuilder.php'), + app_path('Services/TenantConfiguration/ResourceTypeRegistry.php'), + app_path('Services/Graph/MicrosoftGraphClient.php'), + config_path('graph_contracts.php'), + ]; + $joinedRuntime = implode("\n", array_map(static fn (string $path): string => file_get_contents($path) ?: '', $runtimeFiles)); + + expect($newPlatformFiles)->toBe([]) + ->and($joinedRuntime)->not->toContain('tenant_id') + ->and($joinedRuntime)->not->toContain('ReviewPack') + ->and($joinedRuntime)->not->toContain('customer-ready') + ->and($joinedRuntime)->not->toContain('certification-ready') + ->and($joinedRuntime)->not->toContain('restore-ready'); +}); + +function spec424CaptureRun( + $user, + $environment, + ProviderConnection $connection, + array $resourceTypes = ['securityDefaults'], + array $targetScopeOverrides = [], +): OperationRun { + return OperationRun::factory()->withUser($user)->forTenant($environment)->create([ + 'type' => OperationRunType::TenantConfigurationCapture->value, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'target_scope' => array_replace([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + ], $targetScopeOverrides), + 'resource_types' => $resourceTypes, + 'required_capability' => 'evidence.manage', + ], + ]); +} + +function spec424CaptureGraphClient(array $responses): GraphClientInterface +{ + return new class($responses) implements GraphClientInterface + { + public array $calls = []; + + public function __construct(private array $responses) {} + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->calls[] = ['policy_type' => $policyType, 'options' => $options]; + $response = array_shift($this->responses); + + if ($response instanceof GraphResponse) { + return $response; + } + + return new GraphResponse(true, is_array($response) ? $response : []); + } + + 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 spec424CoverageActingAs(User $user, $environment): void +{ + test()->actingAs($user); + $environment->makeCurrent(); + Filament::setTenant($environment, true); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id); +} + +function spec424CaptureSecurityDefaultsPayload(array $overrides = []): array +{ + return array_replace([ + 'id' => 'securityDefaults', + 'displayName' => 'Security Defaults', + 'description' => 'Tenant-wide Security Defaults policy.', + 'isEnabled' => true, + ], $overrides); +} + +function spec424FailingGraphClient(): GraphClientInterface +{ + return new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + throw new RuntimeException('Spec424 render path must not call provider clients.'); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + throw new RuntimeException('Spec424 render path must not call provider clients.'); + } + + public function getOrganization(array $options = []): GraphResponse + { + throw new RuntimeException('Spec424 render path must not call provider clients.'); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + throw new RuntimeException('Spec424 render path must not call provider clients.'); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + throw new RuntimeException('Spec424 render path must not call provider clients.'); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + throw new RuntimeException('Spec424 render path must not call provider clients.'); + } + }; +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec419M365WorkloadRegistryTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec419M365WorkloadRegistryTest.php index 40253ba0..60969f68 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec419M365WorkloadRegistryTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec419M365WorkloadRegistryTest.php @@ -38,7 +38,7 @@ ]); }); -it('Spec419 seeds representative M365 resource types as registry-only detected entries', function (): void { +it('Spec419 seeds representative M365 resource types as registry-only detected entries except Security Defaults graph support', function (): void { $definitions = collect(ResourceTypeRegistry::defaultDefinitions()); $expectedByWorkload = [ Workload::Entra->value => [ @@ -84,6 +84,29 @@ expect($workloadDefinitions->pluck('canonical_type')->all())->toBe($canonicalTypes); foreach ($workloadDefinitions as $definition) { + if ($definition['canonical_type'] === 'securityDefaults') { + expect($definition['source_class'])->toBe(SourceClass::GraphV1Fallback->value) + ->and($definition['support_state'])->toBe(SupportState::FallbackSupported->value) + ->and($definition['default_coverage_level'])->toBe(CoverageLevel::Renderable->value) + ->and($definition['default_evidence_state'])->toBe(EvidenceState::NotCaptured->value) + ->and($definition['default_identity_state'])->toBe(IdentityState::Stable->value) + ->and($definition['default_claim_state'])->toBe(ClaimState::InternalOnly->value) + ->and($definition['allows_beta_claims'])->toBeFalse() + ->and($definition['allows_graph_fallback_claims'])->toBeTrue() + ->and($definition['allows_certified_claims'])->toBeFalse() + ->and($definition['metadata']['registry_only'])->toBeFalse() + ->and($definition['metadata']['source_contract_key'])->toBe('securityDefaults') + ->and($definition['metadata']['source_version'])->toBe('v1.0') + ->and($definition['metadata']['is_full_catalog'])->toBeFalse() + ->and($definition['metadata']['customer_claims_allowed'])->toBeFalse() + ->and($definition['metadata']['certification_allowed'])->toBeFalse() + ->and($definition['metadata']['restore_allowed'])->toBeFalse() + ->and($definition['metadata']['documentation_status'])->toBe('documented_graph_v1_contract') + ->and($definition['restore_tier'])->toBe(RestoreTier::NotRestorable->value); + + continue; + } + expect($definition['source_class'])->toBe(SourceClass::Tcm->value) ->and($definition['support_state'])->toBe(SupportState::OutOfScope->value) ->and($definition['default_coverage_level'])->toBe(CoverageLevel::Detected->value) diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsSourceContractTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsSourceContractTest.php new file mode 100644 index 00000000..762cbe36 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsSourceContractTest.php @@ -0,0 +1,213 @@ +resolve(spec424SecurityDefaultsUnitResourceType()); + + expect($decision->outcome)->toBe(CaptureOutcome::Captured) + ->and($decision->contractKey)->toBe('securityDefaults') + ->and($decision->sourceEndpoint)->toBe('/policies/identitySecurityDefaultsEnforcementPolicy') + ->and($decision->sourceVersion)->toBe('v1.0') + ->and($decision->sourceSchemaHash)->toBeString()->not->toBe('') + ->and($decision->sourceMetadata['source_contract_key'])->toBe('securityDefaults') + ->and($decision->sourceMetadata['registry_source_class'])->toBe('graph_v1_fallback') + ->and($decision->sourceMetadata['registry_support_state'])->toBe('fallback_supported'); +}); + +it('Spec424 blocks Security Defaults capture when the graph contract resource is missing', function (): void { + config()->set('graph_contracts.types.securityDefaults', []); + + $decision = (new CoverageSourceContractResolver(new GraphContractRegistry)) + ->resolve(spec424SecurityDefaultsUnitResourceType()); + + expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract) + ->and($decision->reasonCode)->toBe('missing_graph_contract_resource') + ->and($decision->contractKey)->toBeNull() + ->and($decision->sourceEndpoint)->toBeNull() + ->and($decision->sourceMetadata['reason_code'])->toBe('missing_graph_contract_resource'); +}); + +it('Spec424 declares a bounded Security Defaults graph contract', function (): void { + $contract = config('graph_contracts.types.securityDefaults'); + + expect($contract['resource'])->toBe('policies/identitySecurityDefaultsEnforcementPolicy') + ->and($contract['graph_version'])->toBe('v1.0') + ->and($contract['response_shape'])->toBe('singleton') + ->and($contract['allowed_select'])->toBe(['id', 'displayName', 'description', 'isEnabled']) + ->and($contract['allowed_expand'])->toBe([]) + ->and($contract['volatile_fields'])->toBe(['@odata.context', '@odata.etag']) + ->and($contract['read_permissions'])->toBe(['Policy.Read.All']) + ->and($contract)->not->toHaveKey('create_method') + ->and($contract)->not->toHaveKey('update_method'); +}); + +it('Spec424 list policies calls the Security Defaults v1.0 endpoint without using beta', function (): void { + config()->set('graph.base_url', 'https://graph.microsoft.com'); + config()->set('graph.version', 'beta'); + + Http::fake([ + 'https://graph.microsoft.com/*' => Http::response([ + 'id' => 'securityDefaults', + 'displayName' => 'Security Defaults', + 'description' => 'Tenant-wide defaults', + 'isEnabled' => true, + ], 200), + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + $response = (new MicrosoftGraphClient( + logger: $logger, + contracts: app(GraphContractRegistry::class), + ))->listPolicies('securityDefaults', [ + 'access_token' => 'spec424-test-token', + 'top' => 999, + ]); + + expect($response->successful())->toBeTrue() + ->and($response->data['id'])->toBe('securityDefaults'); + + Http::assertSent(function (Request $request): bool { + $url = $request->url(); + + if (! str_contains($url, '/v1.0/policies/identitySecurityDefaultsEnforcementPolicy')) { + return false; + } + + if (str_contains($url, '/beta/')) { + return false; + } + + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + expect($query['$select'] ?? null)->toBe('id,displayName,description,isEnabled'); + expect($query)->not->toHaveKey('$top'); + + return true; + }); +}); + +it('Spec424 live graph contract check probes the Security Defaults singleton without top', function (): void { + $client = new class implements GraphClientInterface + { + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + 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 + { + $this->requests[] = [ + 'method' => $method, + 'path' => $path, + 'options' => $options, + ]; + + return new GraphResponse(true, []); + } + }; + app()->instance(GraphClientInterface::class, $client); + + $this->artisan('graph:contract:check')->assertSuccessful(); + + $request = collect($client->requests) + ->firstWhere('path', 'policies/identitySecurityDefaultsEnforcementPolicy'); + + expect($request)->not->toBeNull() + ->and($request['options']['query'])->toHaveKey('$select', 'id,displayName,description,isEnabled') + ->and($request['options']['query'])->not->toHaveKey('$top') + ->and($request['options']['graph_version'] ?? null)->toBe('v1.0'); +}); + +it('Spec424 resolves Security Defaults identity only from a stable Graph id', function (): void { + $resourceType = spec424SecurityDefaultsUnitResourceType(); + + $stable = app(CanonicalIdentityResolver::class)->resolve($resourceType, [ + 'id' => 'securityDefaults', + 'displayName' => 'Security Defaults', + 'isEnabled' => true, + ], [ + 'source_contract_key' => 'securityDefaults', + 'source_version' => 'v1.0', + ]); + + $missing = app(CanonicalIdentityResolver::class)->resolve($resourceType, [ + 'displayName' => 'Security Defaults', + 'isEnabled' => true, + ], [ + 'source_contract_key' => 'securityDefaults', + 'source_version' => 'v1.0', + ]); + $sourceIdOnly = app(CanonicalIdentityResolver::class)->resolve($resourceType, [ + 'sourceId' => 'securityDefaults-provider-alias', + 'displayName' => 'Security Defaults', + 'isEnabled' => true, + ], [ + 'source_contract_key' => 'securityDefaults', + 'source_version' => 'v1.0', + ]); + + expect($stable->identityState)->toBe(IdentityState::Stable) + ->and($stable->keyKind)->toBe(CanonicalKeyKind::GraphObjectId) + ->and($stable->sourceResourceId)->toBe('securityDefaults') + ->and($stable->strategyIdentifier)->toBe('graph.security_defaults.v1') + ->and($missing->identityState)->toBe(IdentityState::MissingExternalId) + ->and($missing->keyKind)->toBe(CanonicalKeyKind::Unsupported) + ->and($missing->diagnostics['reason_code'])->toBe('missing_external_id') + ->and($missing->canonicalResourceKey)->not->toContain('displayName') + ->and($sourceIdOnly->identityState)->toBe(IdentityState::MissingExternalId) + ->and($sourceIdOnly->keyKind)->toBe(CanonicalKeyKind::Unsupported) + ->and($sourceIdOnly->diagnostics['reason_code'])->toBe('missing_external_id') + ->and($sourceIdOnly->sourceResourceId)->toStartWith('missing:'); +}); + +function spec424SecurityDefaultsUnitResourceType(): TenantConfigurationResourceType +{ + $definition = collect(ResourceTypeRegistry::defaultDefinitions()) + ->firstWhere('canonical_type', 'securityDefaults'); + + expect($definition)->not->toBeNull('Missing default resource type definition for securityDefaults.'); + + return new TenantConfigurationResourceType($definition); +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsTypedSemanticsTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsTypedSemanticsTest.php new file mode 100644 index 00000000..e7f5fcdd --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsTypedSemanticsTest.php @@ -0,0 +1,136 @@ +normalize('securityDefaults', spec424SecurityDefaultsPayload([ + 'isEnabled' => $enabled, + '@odata.context' => 'https://graph.microsoft.com/v1.0/$metadata#policies/identitySecurityDefaultsEnforcementPolicy/$entity', + 'clientSecret' => 'spec424-secret-value', + ])); + $second = $normalizer->normalize('securityDefaults', spec424SecurityDefaultsPayload([ + 'clientSecret' => 'spec424-secret-value', + '@odata.context' => 'changed-volatile-context', + 'isEnabled' => $enabled, + ])); + $encoded = json_encode($first, JSON_THROW_ON_ERROR); + + expect($first['canonical_type'])->toBe('securityDefaults') + ->and($first['supported'])->toBeTrue() + ->and($first['enabled_state'])->toBe($state) + ->and($first['source_identity']['id'])->toBe('securityDefaults') + ->and($first['diagnostics']['volatile_fields'])->toContain('@odata.context') + ->and($first['diagnostics']['unsupported_fields'])->toContain('clientSecret') + ->and($first['diagnostics']['redacted_fields'])->toContain('clientSecret') + ->and($encoded)->not->toContain('spec424-secret-value') + ->and($first)->toBe($second); +})->with([ + 'enabled boolean' => [true, 'enabled'], + 'disabled string' => ['false', 'disabled'], + 'unknown null' => [null, 'unknown'], +]); + +it('Spec424 treats Security Defaults enabled-state changes as critical material changes', function (): void { + $result = app(EntraCoverageComparator::class)->compare( + 'securityDefaults', + spec424SecurityDefaultsPayload(['isEnabled' => false]), + spec424SecurityDefaultsPayload(['isEnabled' => true]), + ); + + $fields = collect($result['changes'])->keyBy('field'); + + expect($result['changed'])->toBeTrue() + ->and($result['classification'])->toBe('changed') + ->and($fields['enabled']['classification'])->toBe('changed') + ->and($fields['enabled']['importance'])->toBe('critical') + ->and($fields['enabled_state']['classification'])->toBe('changed') + ->and($fields['enabled_state']['importance'])->toBe('critical'); +}); + +it('Spec424 ignores volatile-only Security Defaults changes and keeps redacted fields diagnostic', function (): void { + $volatile = app(EntraCoverageComparator::class)->compare( + 'securityDefaults', + spec424SecurityDefaultsPayload(['@odata.etag' => 'W/"first"']), + spec424SecurityDefaultsPayload(['@odata.etag' => 'W/"second"']), + ); + $redacted = app(EntraCoverageComparator::class)->compare( + 'securityDefaults', + spec424SecurityDefaultsPayload(), + spec424SecurityDefaultsPayload(['clientSecret' => 'spec424-secret-value']), + ); + + expect($volatile['changed'])->toBeFalse() + ->and(collect($volatile['changes'])->pluck('classification'))->toContain('ignored_volatile') + ->and($redacted['changed'])->toBeFalse() + ->and(collect($redacted['changes'])->pluck('classification'))->toContain('redacted', 'unsupported_field') + ->and(json_encode($redacted, JSON_THROW_ON_ERROR))->not->toContain('spec424-secret-value'); +}); + +it('Spec424 renders a compact operator-safe Security Defaults summary', function (): void { + $summary = app(EntraRenderableSummaryBuilder::class)->build('securityDefaults', spec424SecurityDefaultsPayload([ + 'isEnabled' => true, + 'clientSecret' => 'spec424-secret-value', + ]), [ + 'claim_state' => 'internal_only', + 'identity_state' => 'stable', + 'evidence_state' => 'content_backed', + 'last_captured' => 'Jun 30, 2026 7:00 PM', + 'source_endpoint' => '/policies/identitySecurityDefaultsEnforcementPolicy', + ]); + $encoded = json_encode($summary, JSON_THROW_ON_ERROR); + $fields = collect($summary['summary_fields'])->pluck('value', 'label'); + + expect($summary)->not->toBeNull() + ->and($summary['resource_type'])->toBe('Security Defaults') + ->and($summary['display_name'])->toBe('Security Defaults') + ->and($summary['state'])->toBe('Enabled') + ->and($fields['Enabled state'])->toBe('Enabled') + ->and($fields['Evidence state'])->toBe('Content Backed') + ->and($fields['Identity state'])->toBe('Stable') + ->and($fields['Claim state'])->toBe('Internal Only') + ->and($summary['unsupported_fields'])->toContain('clientSecret') + ->and($summary['redacted_fields'])->toContain('clientSecret') + ->and($encoded)->not->toContain('spec424-secret-value') + ->and($encoded)->not->toContain('source_endpoint') + ->and($encoded)->not->toContain('identitySecurityDefaultsEnforcementPolicy'); +}); + +it('Spec424 allows only scoped internal Security Defaults comparable/renderable wording', function (string $claim): void { + expect(app(ClaimGuard::class)->evaluateStatement($claim, internalOperatorOnly: true)) + ->toBe(ClaimState::InternalOnly); +})->with([ + 'Selected Security Defaults is comparable for internal operator review', + 'Selected Security Defaults is renderable for internal review', + 'Selected Security Defaults is ready for operator review', +]); + +it('Spec424 blocks unsafe Security Defaults overclaims', function (string $claim): void { + expect(app(ClaimGuard::class)->evaluateStatement($claim, internalOperatorOnly: true)) + ->toBe(ClaimState::ClaimBlocked); +})->with([ + 'Security Defaults certified coverage', + 'Security Defaults restore-ready coverage', + 'Security Defaults customer-ready proof', + 'Security Defaults Review Pack output', + 'All Security Defaults resources are supported', + '100 percent Security Defaults coverage', + 'Microsoft 365 Security Defaults certification', +]); + +function spec424SecurityDefaultsPayload(array $overrides = []): array +{ + return array_replace([ + 'id' => 'securityDefaults', + 'displayName' => 'Security Defaults', + 'description' => 'Tenant-wide Security Defaults policy.', + 'isEnabled' => true, + ], $overrides); +} diff --git a/specs/424-security-defaults-content-backed-comparable-support/checklists/requirements.md b/specs/424-security-defaults-content-backed-comparable-support/checklists/requirements.md new file mode 100644 index 00000000..b5060dd3 --- /dev/null +++ b/specs/424-security-defaults-content-backed-comparable-support/checklists/requirements.md @@ -0,0 +1,61 @@ +# Requirements Checklist: Spec 424 - Security Defaults Content-Backed Comparable Support + +**Purpose**: Preparation-readiness checklist for Spec 424 before implementation. +**Created**: 2026-06-30 +**Feature**: [Spec 424](../spec.md) + +## Candidate and Scope + +- [x] CHK001 The selected candidate is directly user-provided and not auto-selected from the empty active queue. +- [x] CHK002 Related completed specs are marked read-only context and are not reopened. +- [x] CHK003 Scope is limited to `securityDefaults`. +- [x] CHK004 Certification, restore/apply, customer output, Review Pack/report/export, dashboards, routes, and additional Entra types are explicitly out of scope. +- [x] CHK005 Candidate Selection Gate passes with repo-truth deviations documented. + +## Repo Truth Alignment + +- [x] CHK006 Current registry-only/out-of-scope Security Defaults state is documented. +- [x] CHK007 Missing source-contract mapping is documented. +- [x] CHK008 Missing graph contract entry is documented. +- [x] CHK009 Missing identity strategy is documented. +- [x] CHK010 Existing Entra helper support for Conditional Access only is documented. +- [x] CHK011 Draft-to-repo deviations for restore tier, resource class, capture outcomes, and source class are documented. + +## Constitution and Product Surface + +- [x] CHK012 No `tenant_id` ownership truth is allowed. +- [x] CHK013 Workspace, managed-environment, and provider-connection ownership is required. +- [x] CHK014 Graph calls must go through the repo graph contract and `GraphClientInterface`. +- [x] CHK015 Proportionality review is complete. +- [x] CHK016 Product Surface Contract handling is complete for existing rendered Coverage v2 output. +- [x] CHK017 Browser proof and Human Product Sanity are required if rendered output changes, or exact N/A proof is required. +- [x] CHK018 No new UI route/navigation/action/customer surface is allowed without amending artifacts. + +## Requirements Coverage + +- [x] CHK019 Source contract and missing-contract behavior are specified. +- [x] CHK020 Capture/evidence persistence requirements are specified. +- [x] CHK021 Identity requirements are specified. +- [x] CHK022 Typed normalization requirements are specified. +- [x] CHK023 Compare requirements are specified. +- [x] CHK024 Render requirements are specified. +- [x] CHK025 Claim Guard requirements are specified. +- [x] CHK026 RBAC/scope requirements are specified. +- [x] CHK027 Redaction/no-raw-payload requirements are specified. +- [x] CHK028 No restore/certification/customer claim requirements are specified. +- [x] CHK029 Supported-scope restrictions are specified. + +## Task Readiness + +- [x] CHK030 Tasks include preflight before runtime implementation. +- [x] CHK031 Tasks are ordered by dependency. +- [x] CHK032 Tasks include tests before or alongside implementation. +- [x] CHK033 Tasks include validation and implementation-report close-out. +- [x] CHK034 Tasks include browser/no-browser and Human Product Sanity handling. +- [x] CHK035 Tasks include no completed-spec rewrite proof. + +## Review Outcome + +- [x] CHK036 Review outcome class: acceptable-special-case for preparation. +- [x] CHK037 Workflow outcome: keep. +- [x] CHK038 No blocking open question remains before implementation; source-contract viability is an implementation preflight gate with safe blocked behavior. diff --git a/specs/424-security-defaults-content-backed-comparable-support/implementation-report.md b/specs/424-security-defaults-content-backed-comparable-support/implementation-report.md new file mode 100644 index 00000000..346db912 --- /dev/null +++ b/specs/424-security-defaults-content-backed-comparable-support/implementation-report.md @@ -0,0 +1,132 @@ +# Implementation Report: Spec 424 - Security Defaults Content-Backed Comparable Support + +## Preflight + +- **Active spec**: `specs/424-security-defaults-content-backed-comparable-support/` +- **Implementation start**: 2026-06-30 19:22:46 CEST +- **Branch**: `424-security-defaults-content-backed-comparable-support` +- **HEAD**: `c49784b3 feat: complete spec 423 security compliance readiness pack (#490)` +- **Initial dirty state**: untracked active spec directory only. +- **Activated skills**: `spec-kit-implementation-loop`, `pest-testing`, `.agent/workflows/spec-readiness-gate`, `.agent/repo-contracts/workspace-scope-safety`, `.agent/repo-contracts/rbac-action-safety`, `.agent/repo-contracts/operation-run-truth`, `.agent/repo-contracts/evidence-anchor-contract`, `.agent/repo-contracts/provider-freshness-semantics`, `.agent/repo-contracts/product-surface-gate`. +- **Hard-gate stop conditions checked**: no unrelated dirty files; no completed-spec rewrite; no new schema/table/persisted enum/status family; no new route/navigation/dashboard/action/customer output; no restore/apply/certification/Review Pack/report/export scope; no direct HTTP, Graph SDK bypass, runtime docs fetch, or broad singleton framework; no `tenant_id` ownership path. Existing fresh-install seed alignment is allowed only for the already-identified one-row `securityDefaults` entry in `2026_06_26_000419_expand_tenant_configuration_workloads.php`. + +## Completed-Spec Guardrail + +Specs 414, 415, 417, 418, 419, 420, 421, and 423 are read-only dependency context. No completed historical spec files are edited. + +## Preflight Evidence + +- `securityDefaults` currently exists as a registry-only/out-of-scope TCM planning row in `ResourceTypeRegistry` and the fresh-install workload expansion migration. +- `CoverageSourceContractResolver` has no `securityDefaults` mapping before this spec. +- `config/graph_contracts.php` has no Security Defaults contract before this spec. +- `CoverageIdentityStrategyRegistry` has no `securityDefaults` identity strategy before this spec. +- `EntraComparablePayloadNormalizer` supports Conditional Access before this spec and not Security Defaults. +- `GenericContentEvidenceCaptureService` already accepts singleton Graph payloads that contain an `id`, so no broad singleton capture framework is planned. +- Official Microsoft Graph documentation confirms the Security Defaults read endpoint as `GET /policies/identitySecurityDefaultsEnforcementPolicy`, the resource fields `id`, `displayName`, `description`, and `isEnabled`, and least-privileged read permission `Policy.Read.All`. + +## Implementation Summary + +- Added `securityDefaults` as an explicit Coverage v2 source contract in `apps/platform/config/graph_contracts.php` using `/policies/identitySecurityDefaultsEnforcementPolicy`, `graph_version = v1.0`, `response_shape = singleton`, safe select fields, singleton-safe volatile fields, and `Policy.Read.All`. +- Added `securityDefaults` to `CoverageSourceContractResolver` explicit mappings; missing contract resource still blocks as `capture_blocked_missing_contract`. +- Added request-local Graph version handling in `MicrosoftGraphClient::listPolicies()` so this contract calls the v1.0 endpoint without changing global Graph defaults or bypassing `GraphClientInterface`. +- Updated `MicrosoftGraphClient`, Security Defaults capture, and `graph:contract:check` singleton probes so they use the contract-local v1.0 endpoint and do not send `$top` to `/policies/identitySecurityDefaultsEnforcementPolicy`; the endpoint remains constrained to the contract `$select`. +- Promoted only the `securityDefaults` registry row to active `graph_v1_fallback` / `fallback_supported` / internal-only / non-restorable support in `ResourceTypeRegistry`, and aligned the one existing fresh-install migration seed row allowed by the amended plan. +- Added the idempotent `tenant-configuration:sync-defaults` deploy command to run the existing resource-type and supported-scope default sync paths on already-migrated environments. +- Added a bounded identity strategy requiring a stable Graph `id`; display-name-only and `sourceId`-only Security Defaults payloads remain `missing_external_id`. +- Extended existing Entra typed helpers for Security Defaults normalization, critical enabled-state compare, and safe render summaries. No Blade, Filament Resource/Page/Widget, route, navigation, dashboard, action, export, report, Review Pack, restore, certify, or customer output was added. +- Hardened `ClaimGuard` so Security Defaults-specific claims are treated as workload claims: selected internal/operator comparable/renderable wording is allowed, while certification, restore, customer-ready, full, 100 percent, M365 certified, and Review Pack wording is blocked. + +## Source Contract And Capture Matrix + +| Area | Result | +| --- | --- | +| Contract key | `securityDefaults` | +| Endpoint | `/policies/identitySecurityDefaultsEnforcementPolicy` | +| Version | `v1.0` via contract-local `graph_version` | +| Response shape | singleton; `GraphClientInterface` list calls, capture, and live contract probes use v1.0 and omit `$top` | +| Permission proof | `Policy.Read.All` recorded as the least-privileged read permission | +| Capture path | Existing `GenericContentEvidenceCaptureService`; singleton payloads with `id` are already captured without new framework code | +| Missing contract | Blocks as `capture_blocked_missing_contract` / `missing_graph_contract_resource`; no fake evidence | +| Permission block | 403 response maps to `capture_blocked_permission`; no fake evidence | +| Scope safety | Provider connection and OperationRun target-scope mismatches fail before provider work | +| Registry posture | one active `graph_v1_fallback` row; old TCM planning row is inactive after `ResourceTypeRegistry::syncDefaults()` | +| Fresh-install seed | Existing migration `2026_06_26_000419_expand_tenant_configuration_workloads.php` aligned for only the `securityDefaults` row | +| Existing DB sync | run `cd apps/platform && php artisan tenant-configuration:sync-defaults` after deployment and before Security Defaults capture | + +## Identity, Normalize, Compare, And Render Matrix + +| Area | Result | +| --- | --- | +| Identity | `graph.security_defaults.v1`, stable `graph_object_id` only when `id` is present | +| Missing identity | display-name-only and `sourceId`-only payloads resolve to `missing_external_id`; no stable claim | +| Normalized fields | `display_name`, `enabled`, `enabled_state`, `source_identity.id`, diagnostics | +| Volatile fields | `@odata.context` and `@odata.etag` ignored as volatile | +| Compare | enabled and enabled-state changes are critical material changes; redacted/unsupported diagnostics are informational | +| Render | existing Coverage v2 inspector shows Security Defaults display name, enabled state, evidence state, identity state, claim state, last captured, compare summary, and redaction diagnostics | +| Read model | Existing Entra dispatch path supports Security Defaults; no new generic registry/framework | + +## Claim Guard, Redaction, And Scope Proof + +- Claim Guard allows only selected internal/operator Security Defaults comparable/renderable/ready-for-operator-review wording. +- Claim Guard blocks Security Defaults certified, restore-ready, customer-ready, Review Pack, all/full, 100 percent, and M365 certification wording. +- `CoveragePayloadRedactor` was reused; no redactor extension was required. Tests prove secret values are redacted from normalized payloads, render summaries, and compare results. +- Existing Coverage v2 owner/workspace/managed-environment/provider connection scoping remains authoritative; no `tenant_id` ownership path was added. +- `SupportedScopeResolver` was not changed. No certified, restore-ready, customer-ready, full Entra, or broad M365 scope names were added. + +## Product Surface Close-Out + +- **No-legacy posture**: no legacy path or compatibility exception; this is a canonical Coverage v2 extension. +- **Product Surface Impact**: existing Coverage v2 readiness/inspect surface can render Security Defaults summaries when renderable content-backed evidence exists. +- **UI Surface Impact**: no route, navigation item, dashboard, action, table, Filament Resource/Page/Widget, provider registration, or Blade file changed. Existing inspector content changes only for Security Defaults renderable evidence. +- **Page archetype**: Technical Annex / internal operator evidence inspection. +- **Surface budgets**: decision-first status remains Coverage/Evidence/Identity/Claim badges; Security Defaults summary adds compact `summary_fields`; diagnostics remain secondary; raw/support details remain collapsed under existing technical details. +- **Technical Annex / deep-link demotion**: source endpoint, contract key, schema hash, canonical key, operation link, and source class remain in existing technical details, not default-visible summary fields. +- **Canonical status vocabulary**: existing Coverage v2 states only (`renderable`, `content_backed`, `stable`, `internal_only`, blockers where applicable). +- **Product Surface exceptions**: none. +- **Focused browser proof**: `php artisan test --compact tests/Browser/Spec424SecurityDefaultsComparableRenderableOperatorSurfaceSmokeTest.php` passed, proving rendered Security Defaults inspect output, compare summary, redaction badges, no raw/secrets/source endpoint default exposure, no restore/certified/customer-ready wording, no new high-impact action, no remote Graph/TCM/provider resource calls, and no JavaScript/console errors. +- **Human Product Sanity result**: pass. An internal operator can see that Security Defaults is enabled and materially changed versus prior evidence without seeing raw payloads, secrets, source endpoints, or overclaim wording. +- **Visible complexity outcome**: neutral. The existing inspector hierarchy is reused; no nested cards, new actions, new navigation, or new page structure. + +## Filament v5 Output Contract + +- **Livewire v4.0+ compliance**: unchanged; platform remains Filament v5 on Livewire v4 and the focused browser smoke exercises the existing Filament surface. +- **Provider registration location**: unchanged; Laravel 12 provider registration remains in `apps/platform/bootstrap/providers.php`. +- **Global search**: unchanged; no Filament Resource was added or made globally searchable. +- **Destructive/high-impact actions**: none added or changed. No restore/apply/capture-start/export/certify action was introduced. +- **Asset strategy**: no assets registered and no new frontend bundle or `filament:assets` deployment requirement introduced. +- **Testing plan coverage**: unit tests cover contract, graph version URL, singleton no-`$top` probes, identity, normalization, compare, render, and claims; feature tests cover registry, deploy sync command, capture, permission/scope/RBAC/no-remote/no-mini-platform; browser smoke covers the existing rendered inspector. + +## Validation + +- Sail attempts: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec424` -> interrupted after about two minutes with no output because Sail/Docker exec did not progress in this session. + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsSourceContractTest.php tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsTypedSemanticsTest.php tests/Feature/TenantConfiguration/Spec424SecurityDefaultsCaptureReadinessTest.php` -> interrupted after about one minute with no output for the same reason. +- Local fallback validation: + - `cd apps/platform && php artisan list --raw | rg '^tenant-configuration:sync-defaults'` -> passed; command is registered. + - `cd apps/platform && php artisan test --compact tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsSourceContractTest.php tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsTypedSemanticsTest.php tests/Feature/TenantConfiguration/Spec424SecurityDefaultsCaptureReadinessTest.php` -> passed, 32 tests / 206 assertions. + - `cd apps/platform && php artisan test --compact tests/Browser/Spec424SecurityDefaultsComparableRenderableOperatorSurfaceSmokeTest.php` -> passed, 1 test / 46 assertions. + - `cd apps/platform && php artisan test --compact tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php tests/Feature/TenantConfiguration/Spec419M365RegistryExpansionTest.php tests/Unit/Support/TenantConfiguration/Spec419M365WorkloadRegistryTest.php` -> passed, 11 tests / 814 assertions, 1 skipped. + - `cd apps/platform && php artisan test --compact --filter=ClaimGuard` -> passed, 110 tests / 123 assertions. + - `cd apps/platform && php artisan test --compact tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php tests/Feature/TenantConfiguration/Spec421EntraComparableRenderableTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraConditionalAccessNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraComparableDiffTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraRenderableSummaryTest.php` -> passed, 14 tests / 120 assertions. + - `cd apps/platform && php artisan test --compact tests/Feature/TenantConfiguration/Spec420M365NoLegacyTest.php tests/Feature/TenantConfiguration/Spec415NoLegacyNoUiActivationTest.php tests/Feature/TenantConfiguration/TenantConfigurationKernelSchemaTest.php` -> passed, 5 tests / 41 assertions, 1 skipped. + - `cd apps/platform && ./vendor/bin/pint --dirty --format agent` -> passed and formatted `apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php`. + - `git diff --check` -> passed. + +## Deployment Impact + +- **Schema migrations**: none added. +- **Migration seed alignment**: one existing fresh-install seed row was aligned for `securityDefaults` only, as allowed by the amended plan. No other existing migration seed was changed. +- **Existing databases**: already-migrated staging/production databases must run `cd apps/platform && php artisan tenant-configuration:sync-defaults` after deployment and before Security Defaults capture is attempted. The command is idempotent, syncs Coverage v2 resource-type and supported-scope defaults, and deactivates any stale active Security Defaults TCM planning row. +- **Environment variables**: none. +- **Queues / scheduler / workers**: no new jobs, queues, scheduler entries, or worker requirements. Existing capture queue behavior applies. +- **Storage / volumes**: none. +- **Runtime assets**: none. +- **Provider registration**: none. +- **External services**: no new live Microsoft Graph, TCM, provider, Microsoft docs, or remote network calls in render/compare paths. Capture uses the existing provider gateway and Graph client abstraction. +- **Staging / production**: validate registry sync/default state and the focused Coverage v2 inspect path in Staging before Production promotion. + +## Residual Risks / Follow-Up Candidates + +- This spec supports only `securityDefaults`. Other Entra types remain deferred. +- This is internal/operator content-backed comparable/renderable support only. It is not restore readiness, certification, legal/regulatory attestation, customer proof, Review Pack output, or full Entra/M365 coverage. +- Existing long-lived environments need the documented `tenant-configuration:sync-defaults` deployment step before Security Defaults capture; no schema migration handles that automatically. diff --git a/specs/424-security-defaults-content-backed-comparable-support/plan.md b/specs/424-security-defaults-content-backed-comparable-support/plan.md new file mode 100644 index 00000000..d2f35c27 --- /dev/null +++ b/specs/424-security-defaults-content-backed-comparable-support/plan.md @@ -0,0 +1,222 @@ +# Implementation Plan: Spec 424 - Security Defaults Content-Backed Comparable Support + +**Branch**: `424-security-defaults-content-backed-comparable-support` | **Date**: 2026-06-30 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/424-security-defaults-content-backed-comparable-support/spec.md` + +## Summary + +Close the precise Coverage v2 gap for `securityDefaults`: it is registry-only/out-of-scope today and cannot participate in later Entra certification. The implementation should either prove and promote Security Defaults through the existing Coverage v2 path as content-backed, comparable, and renderable internal/operator evidence, or keep it safely blocked with no fake support. + +The path is intentionally narrow: source contract, capture eligibility, evidence persistence, canonical identity, typed normalization, deterministic compare, operator-safe render summary, Claim Guard, redaction, RBAC/scope, Product Surface proof if rendered, and focused tests. No certification, restore/apply, customer output, new dashboard, new route, new table, or additional Entra type is in scope. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4, Pest v4. +**Primary Dependencies**: Existing TenantConfiguration Coverage v2 services (`ResourceTypeRegistry`, `CoverageSourceContractResolver`, `GenericContentEvidenceCaptureService`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `CoverageIdentityStrategyRegistry`, `CanonicalIdentityResolver`, `CoveragePayloadRedactor`, `ClaimGuard`, `CoverageV2ReadinessReadModel`, Entra comparable/renderable helpers). +**Storage**: Existing PostgreSQL-backed Coverage v2 tables only. No migration is planned. +**Tests**: Focused Pest unit/feature tests; browser test only if rendered output changes; PostgreSQL lane only if implementation unexpectedly changes schema/index/JSONB behavior. +**Constraints**: No restore/apply/certification/customer claims; no new route/navigation/dashboard/action/report/export; no `tenant_id`; no completed-spec rewrites; no direct HTTP/Graph SDK bypass; no live docs/network calls in render/compare/tests. + +## Current Repo Evidence + +- `ResourceTypeRegistry::m365RepresentativeDefinitions()` seeds `securityDefaults` as an Entra representative row with `source_class = tcm`, `support_state = out_of_scope`, `default_coverage_level = detected`, `default_evidence_state = not_captured`, `default_claim_state = internal_only`, `restore_tier = not_restorable`, and registry-only metadata. +- `CoverageSourceContractResolver::CONTRACT_KEYS` maps `conditionalAccessPolicy`, `assignmentFilter`, `notificationMessageTemplate`, and `roleScopeTag`, but not `securityDefaults`. +- `config/graph_contracts.php` contains `conditionalAccessPolicy` but not `securityDefaults`. +- `CoverageIdentityStrategyRegistry` contains `conditionalAccessPolicy` but not `securityDefaults`. +- `SupportedScopeResolver` includes `securityDefaults` in the Entra planning list, but no internal comparable scope or certified scope is active for this slice. +- `EntraComparablePayloadNormalizer`, `EntraCoverageComparator`, and `EntraRenderableSummaryBuilder` currently support Conditional Access only. +- `GenericContentEvidenceCaptureService::responseItems()` can handle list responses and single-object payloads with an `id`, but implementation must prove the Security Defaults source shape works safely before promotion. +- Existing capture outcomes do not include `capture_blocked_source_unavailable`; use existing outcomes and stable reason codes unless the spec is amended. + +## Constitution Check + +- **Inventory/snapshots**: PASS. This works over Coverage v2 observed evidence only; no snapshot/backup semantics are changed. +- **Read/write separation**: PASS. The feature is read/capture/compare/render only; no write or restore behavior. +- **Single Graph contract path**: REQUIRED. Any source contract must live in the repo graph contract registry and be invoked through `GraphClientInterface` via existing provider/capture services. +- **Proportionality**: PASS with a bounded exception. One source mapping, one identity strategy, and one typed mapping are justified by a concrete denominator blocker. +- **No premature abstraction**: PASS if implementation extends existing concrete helpers. Stop if a broad singleton framework or Entra mini-platform appears necessary. +- **Workspace/managed-environment/provider ownership**: REQUIRED. Existing `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id` remain the only internal ownership truth. +- **OperationRun**: PASS if existing tenant-configuration capture OperationRun is reused and no new start UX is introduced. +- **Product Surface Contract**: APPLIES only if rendered Coverage v2 output changes. Focused browser proof and Human Product Sanity are required in that case. +- **Test governance**: REQUIRED. Focused unit/feature tests must prove source contract, evidence, identity, compare/render, redaction, RBAC, and overclaim boundaries. + +## Affected Repository Surfaces + +Expected runtime surfaces if implementation proceeds: + +```text +apps/platform/config/graph_contracts.php +apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php +apps/platform/database/migrations/2026_06_26_000419_expand_tenant_configuration_workloads.php (one-row fresh-install seed alignment for `securityDefaults` only) +apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php +apps/platform/app/Services/TenantConfiguration/SupportedScopeResolver.php +apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php +apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php +apps/platform/app/Services/TenantConfiguration/EntraCoverageComparator.php +apps/platform/app/Services/TenantConfiguration/EntraRenderableSummaryBuilder.php +apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php +apps/platform/app/Services/TenantConfiguration/ClaimGuard.php +apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php +apps/platform/tests/Unit/Support/TenantConfiguration/ +apps/platform/tests/Feature/TenantConfiguration/ +apps/platform/tests/Browser/ only if rendered output changes +``` + +Potential no-change surfaces: + +- No new schema migration expected. Existing migration seed data may be edited only for the already-identified `securityDefaults` row in `2026_06_26_000419_expand_tenant_configuration_workloads.php` so fresh installs match `ResourceTypeRegistry::syncDefaults()` after real source support is proven. +- No Filament Resource/Page/Widget class expected unless rendered output cannot be exposed through the existing read model/data path; amend this plan and tasks before editing runtime UI files. +- No route, navigation, dashboard, customer output, report/export, action, or asset registration expected. + +## Domain / Model Implications + +- `TenantConfigurationResourceType` continues to represent the resource type definition. +- `TenantConfigurationResource` continues to represent the observed resource identity within a workspace, managed environment, and provider connection. +- `TenantConfigurationResourceEvidence` continues to represent append-only content-backed evidence and payload hashes. +- `securityDefaults` should move out of registry-only/out-of-scope posture only if source contract and capture proof exist. +- `RestoreTier::NotRestorable` remains the no-restore posture for this slice. +- No new persisted compare result or readiness table is justified. + +## Data / Truth Flow + +1. Registry defines `securityDefaults` conservatively. +2. Source contract resolver either finds an explicit approved contract or returns a blocked missing-contract/unsupported outcome. +3. Capture runs through existing provider gateway and `GraphClientInterface`; no direct HTTP or render-time provider calls. +4. Generic capture persists raw and normalized evidence only after successful response handling. +5. Canonical identity resolver produces stable/derived/blocked identity state. +6. Entra typed normalizer reduces Security Defaults to deterministic enabled-state semantics and diagnostics. +7. Comparator classifies enabled-state changes as critical and volatile-only changes as ignored. +8. Render summary exposes operator-safe enabled/evidence/identity/claim/blocker fields. +9. Claim Guard allows only scoped internal/operator content-backed/comparable/renderable statements and blocks broader claims. +10. Supported-scope behavior stays internal/operator-only if touched and never advertises certified, restore-ready, customer-ready, full Entra, or broad M365 scope. + +## UI / Filament / Livewire Implications + +- Livewire v4 compliance remains required; no Livewire v3 API may be introduced. +- Panel providers remain registered in `apps/platform/bootstrap/providers.php`; no provider registration change is expected. +- No globally searchable Filament Resource is added or changed. Global search posture should remain unchanged. +- No destructive/high-impact action is added. If implementation unexpectedly adds any action, stop and amend the spec first. +- Asset strategy: no new Filament assets expected; no new `filament:assets` requirement beyond existing deploy practice. +- Existing Coverage v2 surface may render data-driven Security Defaults summary if implementation wires the read model. If rendered output changes, browser smoke and Human Product Sanity are required. Blade, Livewire, Filament Resource/Page/Widget, route, navigation, or action edits are outside the current plan until the affected files and proof criteria are added here. + +## RBAC / Policy Implications + +- Non-member or wrong workspace/environment access must remain 404. +- Established member without the required evidence/view capability must receive 403. +- Readonly users must not start tenant-configuration capture. +- Provider connection, OperationRun, resource, and evidence rows must belong to the same workspace and managed environment. +- UI visibility is not authorization; server-side policies/gates/read model scoping must enforce boundaries. + +## Audit / Observability / OperationRun Implications + +- Capture is remote/provider work and must remain OperationRun-backed through existing tenant-configuration capture behavior. +- No new OperationRun type, toast, DB notification, start UX, or terminal notification path is expected. +- OperationRun context and summaries must stay sanitized: numeric summary counts only, no raw payloads, no tokens/secrets, no provider response bodies. +- Render/compare paths must be DB-only and must not trigger provider calls. + +## Test Strategy + +### Unit Tests + +- Source contract resolver allows Security Defaults only with explicit mapping and blocks missing/unsupported/beta-only source safely. +- Registry defaults are conservative: no restore, no certification, no customer claim, no `tenant_id`. +- Identity strategy never uses display name alone and blocks unsafe states. +- Normalizer emits deterministic enabled-state representation and drops/demotes volatile fields. +- Comparator detects enabled true/false/null, added/removed, unchanged, ignored volatile, redacted, and unsupported-field cases. +- Render summary hides raw payload/normalized JSON/permission context and exposes only operator-safe fields. +- Claim Guard allows scoped internal/operator content-backed/comparable/renderable wording and blocks certification/restore/customer/full claims. +- Redaction covers tokens, secrets, authorization headers, cookies, raw payload markers, and unsafe diagnostic context. + +### Feature Tests + +- Fake provider response persists content-backed Security Defaults evidence with raw/normalized payloads, payload hash, source metadata, operation run, and same-scope provider connection. +- Missing contract/permission/unsupported source does not create evidence and does not promote coverage levels. +- Promotion to comparable/renderable happens only after content-backed evidence and typed helpers exist. +- RBAC: non-member/wrong scope 404, member without capability 403, readonly cannot start capture, provider connection scope enforced. +- No certification, no restore, no customer claim, no `tenant_id`, no route/navigation/dashboard/report/export, no mini-platform. +- Render/compare performs no remote/provider/docs calls. + +### Browser Tests + +Required only if rendered output changes. Focused smoke should prove: + +- Existing Coverage v2 page loads for an authorized operator. +- Security Defaults state is visible as enabled/disabled/unknown. +- Comparable/renderable state and blockers are readable. +- Raw payload, normalized JSON, secrets, source keys, certified/restore/customer-ready claims are absent. +- No console, Livewire, Filament, network, or 500 errors. + +## Implementation Phases + +### Phase 0 - Preflight + +Record branch, HEAD, dirty state, activated skills, completed-spec guardrail, current registry row, contract mapping absence, identity strategy absence, typed helper support absence, and no duplicate Spec 424 package. + +### Phase 1 - Tests First: Contract, Capture, and Identity + +Add failing tests for source-contract resolver behavior, graph contract shape, registry promotion, singleton-safe capture, evidence persistence, provider scope, OperationRun scope, and canonical identity. + +### Phase 2 - Tests First: Typed Semantics and Claim Safety + +Add failing tests for normalizer, comparator, render summary, redaction, Claim Guard allowed/blocked wording, no restore/certification/customer claims, no `tenant_id`, and no mini-platform. + +### Phase 3 - Source Contract and Registry Closure + +Add the minimal approved `securityDefaults` graph contract and resolver mapping if safe. Update registry defaults only when capture is real; otherwise leave blocked/out-of-scope and document the blocker. + +### Phase 4 - Capture and Evidence Integration + +Ensure existing generic capture handles the Security Defaults source response shape. Add bounded handling only inside existing source/capture machinery if singleton shape requires it. Persist evidence only for real captured payloads. + +### Phase 5 - Identity, Normalization, Compare, Render + +Add Security Defaults identity strategy and typed Entra semantics. Normalize enabled state, compare deterministic changes, and render a concise operator-safe summary. + +### Phase 6 - Claim Guard, Read Model, Product Surface + +Harden Claim Guard. Wire the summary into the existing read model only if rendered output is intended. Keep diagnostics demoted and customer/restore/certification claims blocked. If `SupportedScopeResolver` is touched, keep any Security Defaults scope internal/operator-only and forbid certified or restore-ready scope names. + +### Phase 7 - Browser Proof If Rendered + +Run focused browser smoke and Human Product Sanity if the existing Coverage v2 surface renders new Security Defaults output. Otherwise record exact no-rendered-change proof. + +### Phase 8 - Validation and Implementation Report + +Run focused tests, formatting, `git diff --check`, and complete an implementation report with source/capture/identity/compare/render/claim matrices and deployment impact. + +## Stop Conditions + +- Only a beta or undocumented source is available. +- Source contract requires direct HTTP, Graph SDK bypass, runtime docs fetch, or endpoint guessing. +- Existing capture cannot support singleton payload safely without a broader framework. +- Implementation needs a schema migration, a migration-seed edit beyond the one-row `securityDefaults` alignment in `2026_06_26_000419_expand_tenant_configuration_workloads.php`, a new table, new persisted enum/status family, new route/navigation/dashboard, or customer output. +- Restore/apply, certification, customer-ready, Review Pack/report/export, full Entra, 100 percent, or broad M365 claims appear. +- Raw payload, normalized JSON, permission context, tokens, secrets, source keys, or provider response bodies must be default-visible. +- `tenant_id` appears as Coverage v2 ownership truth. +- Completed specs 414, 415, 417, 418, 419, 420, 421, or 423 would need to be rewritten. + +## Rollout / Deployment Considerations + +- **Migrations**: no new schema migration expected. Align only the existing `securityDefaults` fresh-install seed row in `2026_06_26_000419_expand_tenant_configuration_workloads.php` if real source support is proven; any other existing migration seed edit or new migration remains a stop condition. +- **Existing database default sync**: after deployment and before Security Defaults capture, run `cd apps/platform && php artisan tenant-configuration:sync-defaults` to synchronize Coverage v2 resource-type and supported-scope defaults and deactivate any stale active Security Defaults TCM planning row. +- **Environment variables**: none expected. +- **Queues/workers**: existing tenant-configuration capture queue/worker behavior applies. +- **Scheduler**: no change expected. +- **Storage/volumes**: no change expected. +- **Assets**: no new assets expected; no new `filament:assets` requirement beyond current deploy practice. +- **Staging/Dokploy**: validate capture and rendered existing Coverage v2 surface on staging before any production promotion if this support later participates in certification work. + +## Complexity Tracking + +| Risk | Why Needed | Simpler Alternative Rejected Because | +|---|---|---| +| Security Defaults source contract mapping | Real content-backed evidence is required before later certification inclusion | Registry-only support would fake evidence | +| Security Defaults typed helper support | Enabled-state compare/render cannot be safely derived from generic raw payload display | Raw JSON display leaks technical payloads and forces manual interpretation | +| Singleton source handling if needed | Security Defaults may be one object rather than a collection | A broad singleton framework would be premature; keep handling bounded inside existing capture path | + +## Draft-To-Repo Deviation Handling + +- Use `not_restorable` instead of draft `compare_only`. +- Use existing `configuration` resource class unless the spec is amended. +- Use existing capture outcomes and stable reason codes. +- Treat `securityDefaults` TCM registry row as planning truth only until a real source contract is proven. diff --git a/specs/424-security-defaults-content-backed-comparable-support/spec.md b/specs/424-security-defaults-content-backed-comparable-support/spec.md new file mode 100644 index 00000000..63a91d4b --- /dev/null +++ b/specs/424-security-defaults-content-backed-comparable-support/spec.md @@ -0,0 +1,358 @@ +# Feature Specification: Spec 424 - Security Defaults Content-Backed Comparable Support + +**Feature Branch**: `424-security-defaults-content-backed-comparable-support` +**Created**: 2026-06-30 +**Status**: Draft +**Input**: User-provided draft "Spec 424 - Security Defaults Content-Backed Comparable Support", prepared through `spec-kit-next-best-prep`. + +## Preparation Metadata + +- **Selected candidate**: Spec 424 - Security Defaults Content-Backed Comparable Support. +- **Source location**: User attachment `/Users/ahmeddarrazi/.codex/attachments/5bd37778-05ff-4b65-b5fc-407deb4d5000/pasted-text.txt`. +- **Why selected**: The active auto-prep queue in `docs/product/spec-candidates.md` remains empty, but the user explicitly promoted this bounded Coverage v2 follow-up. Spec 421 proved Conditional Access comparable/renderable support and deferred Security Defaults because it was not content-backed. Spec 423 is now completed, leaving Security Defaults as the precise Entra denominator blocker before any later certified Entra compare pack can be credible. +- **Roadmap relationship**: Aligns with the roadmap's Microsoft governance expansion, evidence/coverage hardening, provider-boundary discipline, and security posture direction. This package is not auto-selected from the candidate queue; it is a user-promoted P0 Coverage v2 safety slice. +- **Close alternatives deferred**: Management-report runtime validation, governance artifact lifecycle retention, provider readiness productization, cross-domain indicator follow-through, system-panel browser fixture work, and first governed AI consumer remain manual-promotion backlog items. Entra certification, restore/apply, customer reports, Review Pack output, full Entra coverage, and additional Entra resource types remain later explicit specs. +- **Related completed-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/`, `specs/419-m365-tcm-workload-registry-expansion/`, `specs/420-m365-generic-evidence-coverage-pack/`, `specs/421-entra-core-comparable-renderable-pack/`, and `specs/423-security-compliance-readiness-pack/` contain completed/validated signals and are read-only dependency context. Do not rewrite them, normalize their close-out history, or strip task/browser/review evidence. +- **Repo-truth preflight result**: Current code has a `securityDefaults` registry row in `ResourceTypeRegistry::m365RepresentativeDefinitions()`, but it is registry-only/out-of-scope, uses existing `source_class = tcm`, has `default_coverage_level = detected`, `default_evidence_state = not_captured`, `default_claim_state = internal_only`, and `restore_tier = not_restorable`. `CoverageSourceContractResolver` has no `securityDefaults` mapping, `config/graph_contracts.php` has no Security Defaults contract, `CoverageIdentityStrategyRegistry` has no `securityDefaults` strategy, and `EntraComparablePayloadNormalizer` supports only `conditionalAccessPolicy`. `SupportedScopeResolver` includes `securityDefaults` in Entra planning scope, but no certified or restore-ready Security Defaults scope may be activated in this spec. +- **Smallest viable implementation slice**: Add the minimum repo-conventional source contract, capture eligibility, singleton-safe evidence capture proof, identity strategy, typed normalizer, comparator, render summary, Claim Guard hardening, redaction proof, RBAC/scope proof, and tests needed to promote only `securityDefaults` to internal/operator content-backed, comparable, and renderable support. If the source contract cannot be proven safely, the implementation must leave Security Defaults blocked with a stable missing-contract/unsupported result and must not promote support. +- **Candidate Selection Gate**: PASS for a direct user-provided P0 candidate with repo-truth deviations documented below. + +## Draft-To-Repo Deviations + +- The draft uses `restore_tier = compare_only`; current repo truth has `not_restorable`, `preview_only`, and `restorable`. This spec uses `not_restorable` as the repo-equivalent no-restore posture and forbids adding a new restore tier unless the proportionality review is amended. +- The draft allows `resource_class = security_setting` or `tenant_setting`; current repo truth has `ResourceClass::Configuration`. This spec keeps `configuration` unless implementation proves a new resource class has a current-release behavioral consequence and amends the proportionality review first. +- The draft lists `capture_blocked_source_unavailable`; current repo outcomes are `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 the constitution proportionality gate. +- The draft allows source class `tcm` or `graph_v1_fallback`. Current repo truth has `securityDefaults` seeded as a TCM planning row but no real contract. Implementation may switch the row to `graph_v1_fallback` only if the canonical graph contract and capture path prove a real v1 content source. It must not promote a beta-only source to later-certifiable support. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: Security Defaults is part of the later Entra certified compare denominator, but TenantPilot currently treats `securityDefaults` as registry-only/out-of-scope and cannot capture, compare, or render it as content-backed evidence. +- **Today's failure**: A later Entra compare claim would either omit a core security setting or imply Security Defaults support that repo truth does not prove. Operators cannot answer whether Security Defaults is enabled without raw provider inspection or unsafe overclaim. +- **User-visible improvement**: Authorized internal operators can see Security Defaults as content-backed, comparable, and renderable on the existing Coverage v2 path once real evidence exists, while certification, restore, customer-ready, and full Entra claims remain blocked. +- **Smallest enterprise-capable version**: Support exactly `securityDefaults` through existing Coverage v2 capture/evidence/identity/redaction/Claim Guard/read-model paths. No new Entra dashboard, no restore, no certification, no customer output, no additional Entra types. +- **Explicit non-goals**: No Entra certification, no restore/apply, no Review Pack/report/PDF/export output, no customer-facing Entra claim, no Application/Service Principal/Role Definition/Administrative Unit support, no new Entra table family, no separate Security Defaults engine, no live docs fetch, no `tenant_id`, no v1 compatibility. +- **Permanent complexity imported**: One focused source contract mapping if safe, one bounded identity strategy, Security Defaults typed normalization/compare/render helper extensions or sibling helpers, Claim Guard phrase coverage, redaction tests, and focused feature/browser-if-rendered tests. No new persisted entity, broad status family, route, dashboard, or cross-domain framework is planned. +- **Why now**: Spec 421 proved Conditional Access comparable/renderable support and deferred Security Defaults. Security Defaults is the next concrete blocker before any later Entra denominator can be certified or described honestly. +- **Why not local**: A local Security Defaults parser or ad-hoc Graph call would bypass Coverage v2 source contracts, evidence persistence, provider scope, identity, redaction, OperationRun, and Claim Guard truth. The existing Coverage v2 path is the narrowest correct implementation. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: Adds typed support and source-contract mapping for one provider-specific singleton. Defense: the slice is limited to one concrete security setting, plugs into existing Coverage v2 machinery, and prevents a concrete future overclaim. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve as a narrow, evidence-gated support closure. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace plus managed-environment scoped Coverage v2 evidence for one Entra resource type, `securityDefaults`. +- **Primary Routes**: Existing internal/operator Coverage v2 readiness/inspect route only if rendered data changes. No new route, navigation entry, customer route, report, download, dashboard, wizard, or action is in scope. +- **Data Ownership**: Existing Coverage v2 `TenantConfigurationResourceType`, `TenantConfigurationResource`, and `TenantConfigurationResourceEvidence` rows remain owned by `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id` for provider-sourced records. Provider-native Microsoft tenant IDs remain metadata only. +- **RBAC**: Existing Coverage v2 read authorization applies. Non-member or wrong workspace/environment access is deny-as-not-found (404). A member missing the required view capability receives 403. Capture start permissions remain existing tenant-configuration capture permissions; readonly users must not start capture. + +## No Legacy / No Backward Compatibility Constraint *(mandatory)* + +TenantPilot is pre-production for this feature. + +- **Compatibility posture**: canonical Coverage v2 extension. +- **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 support closure over Coverage v2. No customer contract requires Coverage v1 adapters, dual writes, fallback readers, legacy claim labels, or old fixture preservation. + +## 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 + +Expected impact is data-driven presentation on the existing Coverage v2 internal/operator readiness and inspect surface when Security Defaults comparable/renderable summaries become visible through existing read-model output. No new UI files are required by this spec. If implementation needs Blade, Livewire, Filament Resource/Page/Widget, route, navigation, action, or other runtime UI file edits, the plan/tasks must be amended first with the exact affected files, browser proof, and Human Product Sanity criteria. + +## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")* + +- **Route/page/surface**: Existing Coverage v2 readiness/resource/evidence inspection surface and inspect detail disclosure. +- **Current or new page archetype**: Technical Annex / read-only evidence inspection. +- **Design depth**: Internal/Hidden with Product Surface proof if rendered output changes. +- **Repo-truth level**: repo-verified existing surface from Spec 418. +- **Existing pattern reused**: Existing Coverage v2 read model, internal/operator inspect flow, badge/status patterns, and Spec 421 Entra render/compare family. +- **New pattern required**: none. +- **Screenshot required**: no standalone screenshot artifact required by prep; focused browser proof required if rendered output changes. +- **Page audit required**: no full page audit; focused existing-surface browser smoke is sufficient unless implementation changes runtime UI files, routes, navigation, page structure, actions, or panel/provider surface. +- **Customer-safe review required**: yes for wording boundaries; no customer route or output may be activated. +- **Dangerous-action review required**: no mutating/destructive action is in scope. +- **Coverage files updated or explicitly not needed**: + - [ ] `docs/ui-ux-enterprise-audit/route-inventory.md` + - [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` + - [ ] `docs/ui-ux-enterprise-audit/page-reports/...` + - [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md` + - [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md` + - [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md` + - [x] `N/A - existing internal surface only; update coverage artifacts only if runtime UI files/routes/navigation change` +- **No-impact rationale when applicable**: N/A. + +## Product Surface Impact *(mandatory for UI-affecting specs)* + +Reference: `docs/product/standards/product-surface-contract.md`. + +- **Product Surface Contract applies?**: yes, for data-driven rendered status/evidence/readiness changes on the existing Coverage v2 surface. +- **Page archetype**: Technical Annex. +- **Primary user question**: Is Security Defaults content-backed, comparable, renderable, and safe for later certification inclusion? +- **Primary action**: Inspect. +- **Surface budget result**: pass if limited to the existing read-only surface and inspect disclosure; exception required if implementation adds a new page, dashboard, action, customer output, or more than one primary decision flow. +- **Technical Annex / deep-link demotion**: Raw payloads, normalized JSON, source IDs, provider IDs, evidence hashes, OperationRun links, permission context, source contract keys, and unsupported fields remain hidden, demoted, or diagnostics-only. +- **Canonical status vocabulary**: Product-facing labels must use existing canonical states such as `Ready`, `Needs attention`, `Blocked`, and `Unknown`, or existing internal Coverage v2 labels. Do not expose `certified`, `restore-ready`, `customer-ready`, `full Entra coverage`, or `M365 certified`. +- **Visible complexity impact**: neutral or decreased if the existing inspect surface gains a concise summary; increased complexity requires a documented exception. +- **Product Surface exceptions**: none planned. + +## Browser Verification Plan *(mandatory)* + +- **Browser proof required?**: yes if Security Defaults summaries, comparable/renderable state, or readiness text render on the existing Coverage v2 surface; otherwise no. +- **No-browser rationale**: `N/A - no rendered UI surface changed` only if implementation proves no rendered output changes. +- **Focused path when required**: Existing Coverage v2 readiness/operator route with a seeded workspace, managed environment, provider connection, and Security Defaults content-backed evidence row. +- **Primary interaction to execute**: Load the existing route as an authorized operator, inspect the Security Defaults row, verify enabled/disabled/unknown summary, comparable/renderable state, no raw payload, no secrets, no certified/restore/customer claim, and no console/Livewire/Filament errors. +- **Console, Livewire, Filament, network, and 500-error checks**: planned when browser proof is required. +- **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 rendered output changes; no if no rendered output changes. +- **No-human-sanity rationale**: N/A only with exact no-rendered-change proof. +- **Reviewer questions**: Can an operator tell whether Security Defaults is enabled? Is it clear this is internal comparable/renderable support, not certification or restore readiness? Are technical details demoted? Is there one dominant inspect action? Is visible complexity not worse? +- **Planned result location**: implementation report / PR close-out. + +## Product Surface Merge Gate Checklist *(mandatory)* + +- [x] No-legacy posture or approved exception recorded. +- [x] Product Surface Impact is completed. +- [x] Browser proof is required if rendered output changes, or `N/A - no rendered UI surface changed` must be justified. +- [x] Human Product Sanity is required if rendered output changes, or N/A must be justified. +- [x] Product Surface exceptions are documented or `none`. +- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, visible complexity outcome, and no completed-spec rewrite assertion. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory)* + +- **Cross-cutting feature?**: yes. +- **Interaction class(es)**: source contract, evidence capture, typed compare/render, status/evidence presentation, claim safety. +- **Systems touched**: `ResourceTypeRegistry`, `CoverageSourceContractResolver`, `config/graph_contracts.php`, `GenericContentEvidenceCaptureService`, `CoverageIdentityStrategyRegistry`, `CanonicalIdentityResolver`, `EntraComparablePayloadNormalizer`, `EntraCoverageComparator`, `EntraRenderableSummaryBuilder`, `CoverageV2ReadinessReadModel`, `CoveragePayloadRedactor`, and `ClaimGuard`. +- **Existing pattern(s) to extend**: Spec 415 generic content-backed capture, Spec 417 canonical identity, Spec 421 Entra comparable/renderable helpers, existing Coverage v2 read model, existing Claim Guard. +- **Shared contract / presenter / builder / renderer to reuse**: Coverage v2 source/evidence/identity/read-model path, redactor, Claim Guard, and Entra comparable/renderable family. +- **Why the existing shared path is sufficient or insufficient**: Sufficient as the integration boundary. It lacks only Security Defaults source mapping, identity strategy, typed semantics, and claim phrase coverage. +- **Allowed deviation and why**: A focused singleton handling path is allowed only if the existing list capture path cannot safely capture the source payload; it must remain inside the existing Coverage v2 capture/source contract machinery. +- **Consistency impact**: Security Defaults support must align with existing coverage levels, evidence states, identity states, capture outcomes, claim states, redaction, RBAC behavior, and Product Surface demotion. +- **Review focus**: Verify no endpoint guessing, no direct HTTP/Graph SDK bypass, no raw payload default display, no customer claims, no restore/certify action, no mini-platform, no new tables, and no `tenant_id`. + +## OperationRun UX Impact *(mandatory when touched)* + +- **Touches OperationRun start/completion/link UX?**: no new start/completion/link UX. +- **Shared OperationRun UX contract/layer reused**: Existing tenant-configuration capture OperationRun behavior remains the execution path if capture is run. +- **Delegated start/completion UX behaviors**: N/A - no new capture start action, toast, DB notification, or run detail link is introduced. +- **Local surface-owned behavior that remains**: Existing diagnostic OperationRun references, if present, remain secondary/internal and authorized. +- **Queued DB-notification policy**: N/A - no new queued notification. +- **Terminal notification path**: Existing OperationRun lifecycle mechanism only. +- **Exception required?**: none. + +## Provider Boundary / Platform Core Check *(mandatory)* + +- **Shared provider/platform boundary touched?**: yes. +- **Boundary classification**: mixed. Coverage v2 evidence, identity, claim, redaction, and read-model semantics are platform-core; Security Defaults and Graph field names are provider-owned typed adapter details. +- **Seams affected**: source contract mapping, source class, resource type defaults, identity strategy, typed payload field mapping, compare/render interpretation, claim wording. +- **Neutral platform terms preserved or introduced**: workspace, managed environment, provider connection, resource type, evidence state, coverage level, identity state, claim state, compare result, render summary. +- **Provider-specific semantics retained and why**: `securityDefaults` and enabled-state semantics are necessary for this Microsoft Entra support slice. They must stay bounded to source metadata and typed helper code, not platform ownership truth. +- **Why this does not deepen provider coupling accidentally**: No provider-native tenant ID ownership, no Entra table family, no Entra dashboard, no provider framework, no customer claim activation, and no restore/certify support. +- **Follow-up path**: Later Entra certified compare pack, restore/apply, customer reporting, and additional Entra resource types require separate specs. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Existing Coverage v2 readiness / inspect surface data | yes, data-driven if summaries render | Native Filament existing surface | Coverage v2 evidence/readiness family | page/detail | no | No new route/action/navigation planned | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Existing Coverage v2 readiness / inspect surface | Tertiary Evidence / Diagnostics | Release/operator review of Security Defaults support | resource name, enabled/disabled/unknown summary, coverage/evidence/identity/claim state, blockers | raw/normalized payload, source metadata, unsupported fields, evidence hash, OperationRun link | Not primary; it supports internal evidence inspection and release review | Follows existing Coverage v2 internal review flow | Reduces raw-payload reading for Security Defaults | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Coverage v2 readiness / inspect | operator-MSP, support-platform | Security Defaults enabled state, evidence/identity/claim state, blocker summary | source contract, unsupported fields, source version, permission summary | raw payload, normalized JSON, permission context, source keys | Inspect | raw/support detail and OperationRun diagnostics | Security Defaults state appears once; no duplicate certified/readiness claim | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Coverage v2 readiness | List / Table / Report | Read-only registry/evidence inspection | Inspect Security Defaults evidence | Existing inspect affordance | existing behavior | not required | none | existing route | inspect disclosure | workspace + managed environment | Security Defaults | enabled state, coverage level, evidence state, identity state, claim state | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Coverage v2 readiness / inspect | internal operator / release reviewer | Verify Security Defaults can be interpreted safely | Technical Annex | Is Security Defaults content-backed and comparable/renderable? | enabled state, coverage/evidence/identity/claim state, blockers | raw payload, normalized JSON, source metadata, unsupported fields, OperationRun | coverage level, evidence state, identity state, claim state | read-only | Inspect | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no. Security Defaults remains Coverage v2 evidence truth on existing resource/evidence rows. +- **New persisted entity/table/artifact?**: no planned. +- **New abstraction?**: no new broad abstraction planned; a focused source contract mapping, identity strategy entry, and typed helper extension are expected. +- **New enum/state/reason family?**: no planned. Existing `CaptureOutcome`, `CoverageLevel`, `EvidenceState`, `IdentityState`, `ClaimState`, `RestoreTier`, and compare labels should be reused. New reason codes may be stable strings only when they change blocker explanation. +- **New cross-domain UI framework/taxonomy?**: no. +- **Current operator problem**: Security Defaults is a core Entra setting required for later denominator truth, but current repo truth cannot capture or explain it. +- **Existing structure is insufficient because**: The existing Coverage v2 path is correct, but lacks the one source contract, identity strategy, and typed Security Defaults semantics needed to avoid raw-payload inspection and overclaim. +- **Narrowest correct implementation**: Extend only existing Coverage v2 source, capture, identity, Entra typed compare/render, read-model, redaction, and Claim Guard paths for `securityDefaults`. +- **Ownership cost**: Focused tests and maintenance of one source contract, one identity strategy, and one typed mapping. +- **Alternative intentionally rejected**: New Security Defaults engine/dashboard or broad Entra pack, because that would import routes, persistence, and product claims outside the current blocker. +- **Release truth**: Current-release internal support closure, not customer/certification/restore readiness. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit for source contract, normalizer, comparator, render summary, Claim Guard, redaction; Feature for capture/evidence, RBAC/scope, no certification/restore/customer claim, no `tenant_id`, no mini-platform; Browser only if rendered output changes. +- **Validation lane(s)**: focused Sail/Pest fast-feedback and confidence lanes; browser lane only when rendered output changes; PostgreSQL lane only if implementation touches JSONB/schema/index behavior, which is not planned. +- **Why this classification and these lanes are sufficient**: The behavior is service-level and existing-surface rendering over existing persistence. Unit/feature tests prove business truth; browser proof proves rendered Product Surface safety if needed. +- **New or expanded test families**: focused Spec 424 TenantConfiguration tests; no broad heavy-governance family. +- **Fixture / helper cost impact**: Minimal workspace, managed environment, provider connection, OperationRun, and fake Security Defaults payload fixtures. No live Graph, TCM, Microsoft docs, or network calls. +- **Heavy-family visibility / justification**: none planned. +- **Special surface test profile**: Technical Annex / existing Coverage v2 surface if rendered output changes. +- **Standard-native relief or required special coverage**: Existing internal surface only; focused browser proof instead of full page audit unless UI files/routes/navigation change. +- **Reviewer handoff**: Confirm lane fit, fake payload minimality, no live provider calls, no broad Entra scope, and exact proof commands. +- **Budget / baseline / trend impact**: none expected. +- **Escalation needed**: none if scope stays limited to Security Defaults support closure. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage. +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec424` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=ClaimGuard` + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `git diff --check` + - focused browser test command if rendered output changes + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Capture Real Security Defaults Evidence (Priority: P1) + +As an internal operator preparing an Entra compare baseline, I need Security Defaults captured through the existing Coverage v2 source-contract path so later comparison is based on real content, not a registry placeholder. + +**Independent Test**: Given a valid source contract and fake provider response for Security Defaults, capture persists raw and normalized payloads, payload hash, provider scope, OperationRun link, and content-backed evidence. Given no valid contract, capture produces a blocked outcome and no fake evidence. + +**Acceptance Scenarios**: + +1. Given no approved source contract mapping exists, when capture requests `securityDefaults`, then the result is `capture_blocked_missing_contract` or the repo-equivalent blocked outcome with a stable reason code and no evidence row. +2. Given an approved v1 source contract and successful fake provider response, when capture runs through the existing tenant-configuration capture operation, then one Security Defaults evidence row is persisted as content-backed. +3. Given provider permission is missing, when capture runs, then the result is blocked or failed safely with sanitized context and no fake content-backed evidence. + +### User Story 2 - Compare Enabled-State Changes Deterministically (Priority: P1) + +As an operator reviewing two captured states, I need Security Defaults enabled-state changes to be classified consistently so a material security posture change is never hidden as metadata noise. + +**Independent Test**: Compare output detects false-to-true, true-to-false, null-to-known, added, removed, unchanged, volatile-only, redacted, and unsupported-field cases with stable ordering and critical importance for enabled-state changes. + +### User Story 3 - Render an Operator-Safe Security Defaults Summary (Priority: P1) + +As an operator, I need a concise Security Defaults summary that answers whether it is enabled, content-backed, comparable, renderable, and safe for later certification inclusion without exposing raw Graph payloads. + +**Independent Test**: Render output shows enabled/disabled/unknown, evidence state, identity state, claim state, last captured time, and blockers while hiding raw payload, normalized JSON, permission context, tokens, secrets, provider response bodies, and source keys by default. + +### User Story 4 - Prevent Overclaim, Restore, and Customer Output (Priority: P1) + +As a release reviewer, I need Claim Guard, RBAC, Product Surface, and no-mini-platform proof so Security Defaults support cannot be mistaken for Entra certification, restore readiness, full coverage, or customer-ready proof. + +**Independent Test**: Claim Guard allows only scoped internal/operator content-backed/comparable/renderable claims and blocks certified, restore-ready, customer-ready, full Entra, 100 percent, broad M365, report/review-pack, and restore/apply claims. Feature tests prove no customer output, no restore action, no new route/dashboard, and no `tenant_id`. + +## Functional Requirements *(mandatory)* + +- **FR-424-001**: The implementation MUST use existing Coverage v2 source contract, capture, resource/evidence, identity, redaction, read-model, and Claim Guard boundaries. +- **FR-424-002**: `securityDefaults` MUST remain the only resource type in scope. +- **FR-424-003**: The resource type registry MUST either keep Security Defaults safely blocked or promote it only when source contract, capture, identity, normalize, compare, render, redaction, and Claim Guard tests pass. +- **FR-424-004**: Any source contract MUST be explicit in the repo graph contract registry and routed through `GraphClientInterface` via existing provider gateway/capture paths. +- **FR-424-005**: Missing contract, unsupported source, missing permission, beta-only source, or failed capture MUST not create fake raw payloads, normalized payloads, content-backed evidence, comparable support, or renderable support. +- **FR-424-006**: Captured evidence MUST persist raw payload, normalized payload, payload hash, source metadata, redacted permission context, `operation_run_id`, `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id` through existing tables. +- **FR-424-007**: Provider connection scope MUST be validated against the same workspace and managed environment as the resource/evidence and OperationRun. +- **FR-424-008**: Security Defaults identity MUST use `CanonicalIdentityResolver` and MUST never treat display name alone as stable identity. +- **FR-424-009**: Typed normalization MUST produce deterministic Security Defaults fields including resource type, enabled state, source identity, source class, source version/schema when known, capture timestamp context, and unsupported-field diagnostics. +- **FR-424-010**: Volatile metadata such as OData context/etag and timestamp/request/correlation fields MUST be ignored or diagnostics-only for compare purposes. +- **FR-424-011**: Compare output MUST classify added, removed, changed, unchanged, ignored volatile, unsupported field, and redacted cases. +- **FR-424-012**: Enabled-state changes MUST be classified as critical compare changes. +- **FR-424-013**: Render output MUST be operator-safe and must not require raw payload display to answer enabled/disabled/unknown, evidence state, identity state, claim state, last captured, and blocker questions. +- **FR-424-014**: Claim Guard MUST allow only scoped internal/operator statements for Security Defaults content-backed/comparable/renderable support. +- **FR-424-015**: Claim Guard MUST block Security Defaults certified, restore-ready, customer-ready, full Entra, 100 percent Entra, M365 certified, report/review-pack, legal/regulatory, and restore/apply claims. +- **FR-424-016**: No restore/apply action, restore claim, customer output, report, Review Pack inclusion, export, dashboard, or new route/navigation may be introduced. +- **FR-424-017**: No `tenant_id` may be introduced as Coverage v2 ownership truth, compatibility alias, dual-write target, fallback reader, or parallel scope key. +- **FR-424-018**: Runtime render/compare/readiness paths MUST perform no Graph, TCM, HTTP, Microsoft docs, or provider calls. +- **FR-424-019**: Existing Coverage v2 rendered output MAY show Security Defaults summary on the internal/operator surface; any rendered change requires focused browser proof and Human Product Sanity. +- **FR-424-020**: Any supported-scope entry or resolver behavior added for Security Defaults MUST be internal/operator-only and MUST NOT use forbidden scope names such as `entra_certified`, `entra_full_coverage`, `entra_restore_ready`, or `m365_certified`. +- **FR-424-021**: Implementation close-out MUST record source contract result, evidence/capture matrix, identity strategy, supported-scope posture, normalizer/compare/render matrices, Claim Guard proof, redaction proof, no restore/certification/customer proof, no `tenant_id`, no mini-platform, Product Surface result, tests/browser/no-browser, deployment impact, and deferred work. + +## Non-Functional Requirements + +- **NFR-424-001**: Tests and runtime code MUST use fake provider payloads and must not require live Graph, TCM, Microsoft documentation, or network access. +- **NFR-424-002**: The implementation MUST preserve existing workspace, managed-environment, provider-connection, RBAC, OperationRun, and redaction contracts. +- **NFR-424-003**: Any new Graph contract or registry default MUST be covered by unit/feature tests so support cannot silently drift. +- **NFR-424-004**: The implementation MUST remain small enough for one bounded implementation loop and must stop if a singleton capture framework, broad Entra platform, migration, or new UI surface becomes necessary. + +## Data / Truth Source Requirements + +- **Execution truth**: Existing `OperationRun` for tenant-configuration capture. +- **Artifact/evidence truth**: Existing `TenantConfigurationResource` and `TenantConfigurationResourceEvidence` rows. +- **Source truth**: Microsoft Graph through explicit repo graph contract and `GraphClientInterface`, or safe blocked state if no contract is approved. +- **Claim truth**: Existing `ClaimGuard` output, not UI wording alone. +- **Display truth**: Derived render summary from redacted normalized evidence, not raw payload display. + +## Out Of Scope + +- Entra certification or certified denominator activation. +- Restore/apply, preview restore, assisted restore, or restore readiness. +- Customer-facing Entra claims, reports, Review Pack output, PDF/export/download output. +- Full Entra, full M365, Application, Service Principal, Role Definition, Administrative Unit, Authentication Methods, Identity Protection, Authorization Policy, Cross-Tenant Access, Access Review, or PIM support. +- New Entra dashboard, route, navigation entry, mini-platform, table family, persisted compare table, or broad source framework. +- Live Microsoft docs fetch, direct HTTP client, Graph SDK bypass, endpoint guessing from resource type name. + +## Acceptance Criteria + +- **AC-424-001**: Security Defaults has either a valid source contract and content-backed evidence path or a safe blocked missing-contract/unsupported state with no fake evidence. +- **AC-424-002**: Captured Security Defaults evidence stores raw payload, normalized payload, payload hash, source metadata, redacted permission context, OperationRun link, and same-scope ownership fields through existing tables. +- **AC-424-003**: Canonical identity resolution is used and unsafe identity states block or limit claims. +- **AC-424-004**: Deterministic compare detects enabled-state changes as critical and ignores volatile-only changes. +- **AC-424-005**: Operator-safe render summary answers enabled/disabled/unknown, content-backed, comparable, renderable, blocker, and last captured questions without raw payload display. +- **AC-424-006**: Claim Guard blocks certification, restore, customer-ready, report/review-pack, full Entra, 100 percent, broad M365, and legal/regulatory claims. +- **AC-424-007**: RBAC and scope tests prove 404 for non-member/wrong scope, 403 for established member without capability, readonly cannot start capture, and provider connections are same-scope. +- **AC-424-008**: No `tenant_id`, new route/navigation/dashboard, restore action, customer output, or Security Defaults mini-platform is introduced. +- **AC-424-009**: Browser proof and Human Product Sanity are recorded if rendered output changes; otherwise exact no-rendered-change proof is recorded. + +## Success Criteria + +- Security Defaults is eligible for later certified Entra denominator work because it is content-backed, comparable, renderable, and claim-limited internally. +- Later Entra certification work can depend on real Security Defaults evidence instead of a registry-only placeholder. +- The Coverage v2 path remains conservative: blocked means blocked, and no customer/restore/certification claims are activated. + +## Risks + +| Risk | Severity | Mitigation | +|---|---|---| +| Source contract cannot capture singleton payload through existing list path | High | Prove through focused tests; add only bounded singleton handling inside existing source/capture path or leave blocked | +| Security Defaults support is mistaken for certification | High | Claim Guard, wording restrictions, Product Surface proof, implementation report | +| Beta-only or unsupported source is promoted | High | Source-class tests, beta guard, certified-claim block | +| Raw payload or permission context leaks | High | Redaction tests, render summary tests, browser proof if rendered | +| Broad Entra scope creeps in | Medium | Explicit non-goals, stop conditions, tasks limited to `securityDefaults` | + +## Assumptions + +- Existing Coverage v2 tables can represent Security Defaults evidence without migration. +- `ResourceTypeRegistry::syncDefaults()` is expected to be the canonical runtime default-sync path; implementation preflight found the existing fresh-install migration seed also lists `securityDefaults`, so this spec permits only that one-row seed alignment after real source support is proven. Any other migration-seed edit or new migration remains out of scope. +- Existing capture OperationRun type is sufficient for remote/provider work. +- Existing Entra comparable/renderable helper family can be extended or mirrored without a new registry/framework. +- Current repo pre-production posture allows clean replacement of the registry-only Security Defaults row when support is proven. + +## Open Questions + +None block preparation. Implementation preflight must verify whether the repo-approved source contract can capture the singleton Security Defaults payload safely. If not, the implementation must keep Security Defaults blocked and record the blocker instead of widening scope. + +## Follow-Up Spec Candidates + +- Entra Certified Core Compare Pack including Conditional Access and Security Defaults. +- Customer-safe Entra reporting / Review Pack claim guard. +- Entra restore/apply safety exploration. +- Additional Entra resource packs for Applications, Service Principals, Role Definitions, Administrative Units, authentication methods, and related identity governance domains. diff --git a/specs/424-security-defaults-content-backed-comparable-support/tasks.md b/specs/424-security-defaults-content-backed-comparable-support/tasks.md new file mode 100644 index 00000000..47ad3902 --- /dev/null +++ b/specs/424-security-defaults-content-backed-comparable-support/tasks.md @@ -0,0 +1,131 @@ +# Tasks: Spec 424 - Security Defaults Content-Backed Comparable Support + +**Input**: [spec.md](./spec.md), [plan.md](./plan.md), user-provided Spec 424 draft +**Prerequisites**: Completed read-only Specs 414, 415, 417, 418, 419, 420, 421, and 423; existing Coverage v2 registry/capture/read-model; existing Sail/Pest platform test workflow. + +**Scope Reminder**: Implement support for `securityDefaults` only. Do not add certification, restore/apply, customer-facing output, Review Pack/report/export output, new dashboard, route, navigation, migration, table, live docs fetch, direct HTTP/Graph SDK bypass, or additional Entra resource types. The only allowed migration-file edit is the one-row fresh-install seed alignment for `securityDefaults` in `2026_06_26_000419_expand_tenant_configuration_workloads.php`. + +## Test Governance Checklist + +- [x] Lane assignment is named and is the narrowest sufficient proof for Security Defaults source, capture, identity, compare/render, redaction, claims, scope, and Product Surface behavior. +- [x] New or changed tests stay in the smallest honest family; browser coverage is explicit only if rendered output changes. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default. +- [x] Planned validation commands cover the change without pulling unrelated lane cost. +- [x] Browser proof is explicitly `N/A - no rendered UI surface changed` if no rendered output changes, and required if rendered output changes. +- [x] Human Product Sanity and Product Surface implementation-report close-out are planned where applicable. + +## Phase 1: Preflight and Evidence Gate + +- [x] T001 Record branch, HEAD, dirty state, activated skills, hard-gate status, and implementation start timestamp in `specs/424-security-defaults-content-backed-comparable-support/implementation-report.md`. +- [x] T002 Verify completed Specs 414, 415, 417, 418, 419, 420, 421, and 423 are read-only dependency context and record no completed-spec rewrites in `implementation-report.md`. +- [x] T003 Inspect current `securityDefaults` registry row and default-seeding sources in `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php` and `apps/platform/database/migrations/2026_06_26_000419_expand_tenant_configuration_workloads.php`; record source class, support state, coverage level, evidence state, claim state, restore tier, metadata, and whether fresh-install seed alignment is needed. +- [x] T004 Inspect `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php`, `apps/platform/config/graph_contracts.php`, `apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php`, and `apps/platform/app/Services/TenantConfiguration/SupportedScopeResolver.php` to confirm current contract, identity, and supported-scope gaps. +- [x] T005 Inspect `apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php` and fake Graph response patterns to determine whether Security Defaults singleton payloads can be captured through the existing path. +- [x] T006 Stop and amend `spec.md`/`plan.md` before runtime implementation if support requires a schema migration, a migration-seed edit beyond the one-row `securityDefaults` alignment in `2026_06_26_000419_expand_tenant_configuration_workloads.php`, new table, new capture start action, new route/navigation/dashboard, restore/apply, certification, customer output, direct HTTP, live docs fetch, or a broad singleton framework. + +## Phase 2: Tests First - Source Contract, Capture, and Identity + +- [x] T007 [P] Add source-contract resolver tests proving `securityDefaults` blocks as `capture_blocked_missing_contract` before mapping and resolves only through an explicit approved contract after mapping. +- [x] T008 [P] Add graph contract tests proving the Security Defaults contract has a stable resource path, v1/default source version, safe select/volatile-field behavior, and no beta/certification default. +- [x] T009 [P] Add registry tests proving `securityDefaults` remains non-restorable, claim-limited/internal-only, not customer-facing, and not certified. +- [x] T010 [P] Add capture eligibility tests proving missing contract, missing permission, unsupported source, and beta-disabled source do not create fake evidence or promote coverage. +- [x] T011 Add feature tests proving a successful fake provider response persists one Security Defaults resource/evidence row with raw payload, normalized payload, payload hash, source metadata, redacted permission context, `operation_run_id`, `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id`. +- [x] T012 Add feature tests proving provider connection and OperationRun scope mismatches fail before capture. +- [x] T013 [P] Add identity strategy tests proving Security Defaults uses canonical identity, never display-name-only stable identity, and blocks/limits unsafe identity states. +- [x] T014 [P] Add supported-scope tests proving any Security Defaults scope is internal/operator-only and forbidden scope names such as `entra_certified`, `entra_full_coverage`, `entra_restore_ready`, or `m365_certified` are absent. + +## Phase 3: Tests First - Typed Semantics and Safety + +- [x] T015 [P] Add Security Defaults typed normalizer tests covering `enabled: true`, `enabled: false`, `enabled: null`, source identity, source class, source version/schema metadata, unsupported fields, and volatile-field dropping. +- [x] T016 [P] Add deterministic compare tests covering false-to-true, true-to-false, null-to-known, added, removed, unchanged, volatile-only ignored, redacted, and unsupported-field cases. +- [x] T017 [P] Add compare importance tests proving enabled-state changes are `critical` and no broad risk-scoring framework is introduced. +- [x] T018 [P] Add render summary tests proving default-visible output shows Security Defaults enabled/disabled/unknown, evidence state, identity state, claim state, last captured, and blockers. +- [x] T019 [P] Add redaction tests proving raw payload, normalized JSON, permission context, raw Graph response, tokens, credentials, authorization headers, cookies, private keys, certificates, and unsafe OperationRun/audit metadata do not appear in summaries. +- [x] T020 [P] Add Claim Guard tests allowing only scoped internal/operator Security Defaults content-backed/comparable/renderable wording. +- [x] T021 [P] Add Claim Guard tests blocking certified, restore-ready, customer-ready, full Entra, 100 percent Entra, M365 certified, legal/regulatory, Review Pack/report/export, and restore/apply claims. + +## Phase 4: Tests First - RBAC, Product Surface, and No Mini-Platform + +- [x] T022 Add feature tests proving wrong-workspace/non-member access is deny-as-not-found and member missing view capability is 403 for Security Defaults evidence/render access. +- [x] T023 Add feature tests proving readonly users cannot start capture and provider connections must belong to the same workspace/managed environment. +- [x] T024 Add no-remote-render tests proving compare/render/readiness performs no Graph, TCM, HTTP, provider, Microsoft docs, or network calls. +- [x] T025 Add no-tenant-id/no-mini-platform guard tests proving no `tenant_id` ownership, no new Entra/Security Defaults table family, no route/navigation/dashboard, no restore action, no customer output, and no report/export path. + +## Phase 5: Implement Source Contract and Capture Closure + +- [x] T026 Add the minimal approved Security Defaults contract to `apps/platform/config/graph_contracts.php` only if the contract can be represented safely by existing graph contract conventions. +- [x] T027 Add `securityDefaults` to `CoverageSourceContractResolver` explicit mappings only after the source-contract tests prove the contract is real and safe. +- [x] T028 Update `ResourceTypeRegistry` defaults for `securityDefaults` only as far as real capture support justifies; keep `not_restorable`, no certification, no customer claims, and conservative claim state. Align the existing fresh-install seed row in `2026_06_26_000419_expand_tenant_configuration_workloads.php` only after source support is proven; stop and amend `spec.md`/`plan.md` before editing any other existing migration seed or adding a migration. +- [x] T029 Update `SupportedScopeResolver` only if implementation needs an explicit internal/operator comparable scope; do not add certified, restore-ready, customer-ready, full Entra, or broad M365 scope names. +- [x] T030 If singleton response handling needs code, add the smallest bounded change inside existing Coverage v2 source/capture machinery; do not introduce a broad singleton framework. +- [x] T031 Ensure missing contract or failed capture still creates no raw payload, no normalized payload, no content-backed evidence, and no comparable/renderable promotion. + +## Phase 6: Implement Identity, Normalization, Compare, and Render + +- [x] T032 Add a bounded Security Defaults identity strategy in `CoverageIdentityStrategyRegistry` or repo-equivalent strategy path. +- [x] T033 Extend `EntraComparablePayloadNormalizer` or create the smallest sibling helper needed for `securityDefaults`; normalize enabled state and diagnostics deterministically. +- [x] T034 Extend `EntraCoverageComparator` or create the smallest sibling helper needed for Security Defaults compare behavior. +- [x] T035 Extend `EntraRenderableSummaryBuilder` or create the smallest sibling helper needed for operator-safe Security Defaults summaries. +- [x] T036 Reuse `CoveragePayloadRedactor`; extend it only if focused tests prove Security Defaults-sensitive values are not already covered. + +## Phase 7: Integrate Read Model, Claim Guard, and Product Surface + +- [x] T037 Wire Security Defaults typed support into `CoverageV2ReadinessReadModel` or existing dispatch path without adding a generic registry/framework unless implementation evidence proves existing dispatch cannot remain bounded. +- [x] T038 Update `ClaimGuard` so scoped internal/operator Security Defaults content-backed/comparable/renderable claims are allowed and prohibited claims are blocked. +- [x] T039 If rendered output changes through existing read-model data, update only the Coverage v2 read model/dispatch path needed to show Security Defaults summary with native/shared Filament semantics; stop and amend `spec.md`/`plan.md`/`tasks.md` before editing Blade, Livewire, Filament Resource/Page/Widget, route, navigation, action, or other runtime UI files. +- [x] T040 Confirm no new action, route, navigation entry, dashboard, export, report, Review Pack, restore, certify, capture-start UI, or customer output was added. +- [x] T041 Confirm global search posture is unchanged because no Filament Resource is added or changed for global search. +- [x] T042 Confirm no new assets are registered and no new `filament:assets` deployment requirement is introduced. + +## Phase 8: Browser Proof If Rendered Output Changes + +- [x] T043 Add focused browser smoke coverage only if rendered Coverage v2 output changes. +- [x] T044 In the browser smoke, seed a workspace, managed environment, provider connection, OperationRun, and Security Defaults content-backed evidence row. +- [x] T045 Load the existing Coverage v2 readiness route, inspect Security Defaults, and assert enabled/disabled/unknown summary, comparable/renderable state, no raw payload, no secrets, no restore/certified/customer-ready claim, no new high-impact action, and no console/Livewire/Filament errors. +- [x] T046 N/A because rendered Coverage v2 output changes; focused browser proof is recorded instead. +- [x] T047 Record Human Product Sanity if rendered output changes, confirming the page is clearer or neutral and does not overclaim. + +## Phase 9: Validation and Close-Out + +- [x] T048 Run focused Spec 424 validation and record the result in `implementation-report.md`; Sail execution was attempted but did not progress, so local `php artisan test` fallback results are recorded. +- [x] T049 Run focused Claim Guard validation and record the result. +- [x] T050 Run any existing narrow Coverage v2 affected tests identified during implementation and record commands/results. +- [x] T051 Run formatter validation; Sail execution did not progress earlier, so local `./vendor/bin/pint --dirty --format agent` fallback result is recorded. +- [x] T052 Run `git diff --check`. +- [x] T053 Confirm no schema migration, migration-seed edit beyond the allowed one-row `securityDefaults` fresh-install seed alignment, env var, queue, scheduler, storage, or asset deployment step was introduced; record the default-seeding alignment decision. If any other migration impact was introduced, amend `plan.md` before close-out. +- [x] T054 Complete `specs/424-security-defaults-content-backed-comparable-support/implementation-report.md` with source contract result, capture/evidence matrix, identity strategy, supported-scope posture, normalizer/compare/render matrices, Claim Guard proof, redaction proof, no restore/certification/customer proof, no `tenant_id`, no mini-platform, Product Surface result, tests/browser/no-browser, deployment impact, Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, and deferred work. +- [x] T055 Confirm no completed historical spec was rewritten, normalized, reopened, or stripped of validation/task/browser/review history. + +## Dependencies and Ordering + +- T001-T006 block runtime implementation. +- T007-T025 should be written before or alongside implementation. +- T026-T031 depend on source/capture tests. +- T032-T036 depend on typed semantic tests. +- T037-T042 depend on helper behavior and Claim Guard tests. +- T043-T047 depend on whether rendered output changes. +- T048-T055 close out after implementation and validation. + +## Parallel Work Opportunities + +- T007-T014 can be split by contract/capture/identity/supported scope. +- T015-T021 can be split by normalizer/compare/render/claim/redaction. +- T022-T025 can run in parallel with service helper implementation. +- T033-T036 can proceed in parallel after the normalizer contract is clear, but one reviewer should keep Claim Guard wording aligned. + +## Implementation Guardrails + +- Keep fake payload fixtures minimal and local to Spec 424 tests. +- Use existing service/test naming conventions from sibling TenantConfiguration code. +- Prefer direct concrete helper extensions over a new registry, factory, interface, or orchestration pipeline. +- Do not introduce persisted states, enums, tables, migrations, routes, navigation entries, dashboards, actions, assets, or customer outputs without stopping to amend the spec/plan. +- Treat existing migration seed data as fresh-install alignment evidence, not as an edit target unless the plan has been amended first. +- Do not rewrite completed specs to retrofit close-out wording. +- Do not use live Microsoft Graph, TCM, Microsoft docs, or HTTP calls in tests or runtime render/compare paths. + +## Completion Definition + +- Spec, plan, tasks, checklist, implementation report, and implementation agree on Security Defaults support or blocked status. +- Source contract, capture, evidence, identity, normalization, compare, render, redaction, Claim Guard, RBAC, no-remote, no-tenant-id, and no-mini-platform proof exist. +- Product Surface proof or exact N/A proof is recorded. +- Deployment impact is assessed as none or amended before merge.