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 <ahmed.darrazi@live.de>
Reviewed-on: #491
This commit is contained in:
ahmido 2026-07-01 14:41:24 +00:00
parent c49784b305
commit 2cd512915a
26 changed files with 2615 additions and 57 deletions

View File

@ -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<string, mixed> $contract
*/
private function isSingletonContract(array $contract): bool
{
return ($contract['response_shape'] ?? null) === 'singleton';
}
/**
* @param array<string, mixed> $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;
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Services\TenantConfiguration\SupportedScopeResolver;
use Illuminate\Console\Command;
final class TenantConfigurationSyncDefaults extends Command
{
protected $signature = 'tenant-configuration:sync-defaults';
protected $description = 'Synchronize Coverage v2 resource type and supported scope defaults.';
public function handle(ResourceTypeRegistry $resourceTypes, SupportedScopeResolver $supportedScopes): int
{
$resourceTypes->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;
}
}

View File

@ -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;
}

View File

@ -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<string, mixed> $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<string, mixed> $contract
*/
private function contractExpectsSingletonResponse(array $contract): bool
{
return ($contract['response_shape'] ?? null) === 'singleton';
}
private function retryDelayMs(int $attempt): int
{
$baseMs = max(0, $this->retrySleepMs);

View File

@ -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']);

View File

@ -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'],

View File

@ -19,6 +19,7 @@ final class CoverageSourceContractResolver
*/
private const CONTRACT_KEYS = [
'conditionalAccessPolicy' => 'conditionalAccessPolicy',
'securityDefaults' => 'securityDefaults',
'deviceAndAppManagementAssignmentFilter' => 'assignmentFilter',
'notificationMessageTemplate' => 'notificationMessageTemplate',
'roleScopeTag' => 'roleScopeTag',

View File

@ -11,6 +11,7 @@ final class EntraComparablePayloadNormalizer
*/
private const SUPPORTED_TYPES = [
'conditionalAccessPolicy',
'securityDefaults',
];
/**
@ -33,6 +34,20 @@ final class EntraComparablePayloadNormalizer
'templateId',
];
/**
* @var list<string>
*/
private const SECURITY_DEFAULTS_ROOT_FIELDS = [
'@odata.context',
'@odata.etag',
'@odata.type',
'description',
'displayName',
'id',
'isEnabled',
'name',
];
/**
* @var list<string>
*/
@ -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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @return list<string>
*/
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);

View File

@ -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';
}

View File

@ -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<string, mixed> $normalized
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
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<string> $include
* @param list<string> $exclude
@ -207,4 +248,34 @@ private function stringContext(array $context, string $key): ?string
return $value !== '' ? $value : null;
}
/**
* @param array<string, mixed> $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();
}
}

View File

@ -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<string, mixed>
*/
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(

View File

@ -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<string>} $entry
* @return array<string, mixed>
*/
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(),
]);
}
}

View File

@ -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'],

View File

@ -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<string>} $entry
* @return array<string, mixed>
*/
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,
);
];
}
/**

View File

@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Models\TenantConfigurationSupportedScope;
use App\Models\User;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\SourceClass;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->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(),
]);
}

View File

@ -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)

View File

@ -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',

View File

@ -0,0 +1,523 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Models\User;
use App\Services\Auth\ManagedEnvironmentAccessDecision;
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
use App\Services\TenantConfiguration\GenericContentEvidenceCaptureService;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Services\TenantConfiguration\StartTenantConfigurationCapture;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
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\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Queue;
it('Spec424 syncs Security Defaults as one active graph v1 fallback registry row', function (): void {
app(ResourceTypeRegistry::class)->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.');
}
};
}

View File

@ -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)

View File

@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\MicrosoftGraphClient;
use App\Services\TenantConfiguration\CanonicalIdentityResolver;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\IdentityState;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
it('Spec424 resolves Security Defaults only through the explicit graph v1 contract', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->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);
}

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ClaimGuard;
use App\Services\TenantConfiguration\EntraComparablePayloadNormalizer;
use App\Services\TenantConfiguration\EntraCoverageComparator;
use App\Services\TenantConfiguration\EntraRenderableSummaryBuilder;
use App\Support\TenantConfiguration\ClaimState;
it('Spec424 normalizes Security Defaults enabled state and diagnostics deterministically', function (mixed $enabled, string $state): void {
$normalizer = app(EntraComparablePayloadNormalizer::class);
$first = $normalizer->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);
}

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.