feat: complete spec 423 security compliance readiness pack
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m20s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m20s
This commit is contained in:
parent
13d363c8b8
commit
c49acba7cd
@ -21,7 +21,7 @@ public function evaluateStatement(string $claim, bool $internalOperatorOnly = fa
|
||||
return ClaimState::ClaimBlocked;
|
||||
}
|
||||
|
||||
if ($internalOperatorOnly && $this->hasScopedInternalComparableRenderableClaim($tokens)) {
|
||||
if ($internalOperatorOnly && $this->hasScopedInternalComparableRenderableReadinessClaim($tokens)) {
|
||||
return ClaimState::InternalOnly;
|
||||
}
|
||||
|
||||
@ -179,6 +179,11 @@ private function hasUnsafeBroadCoverageClaim(array $tokens, bool $registryScoped
|
||||
'defender',
|
||||
'purview',
|
||||
'tcm',
|
||||
'dlp',
|
||||
'retention',
|
||||
'label',
|
||||
'labels',
|
||||
'compliance',
|
||||
])
|
||||
|| ($this->hasToken($tokens, 'security') && $this->hasToken($tokens, 'compliance'));
|
||||
|
||||
@ -194,13 +199,25 @@ private function hasUnsafeBroadCoverageClaim(array $tokens, bool $registryScoped
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasApplyReadyTerm($tokens) && ($hasWorkloadReference || $hasCoverageSurface || $registryScoped)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasCustomerReadyTerm($tokens) && ($hasWorkloadReference || $hasCoverageSurface || $registryScoped)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasLegalOrRegulatoryTerm($tokens) && ($hasWorkloadReference || $hasCoverageSurface || $this->hasToken($tokens, 'compliance'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasReviewOutputTerm($tokens) && ($hasWorkloadReference || $hasCoverageSurface || $registryScoped)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasComparableRenderableTerm($tokens)
|
||||
&& ($hasWorkloadReference || $hasCoverageSurface || $registryScoped)
|
||||
&& ! $this->hasScopedInternalComparableRenderableClaim($tokens)
|
||||
&& ! $this->hasScopedInternalComparableRenderableReadinessClaim($tokens)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@ -208,13 +225,13 @@ private function hasUnsafeBroadCoverageClaim(array $tokens, bool $registryScoped
|
||||
if ($hasWorkloadReference
|
||||
&& $hasCoverageSurface
|
||||
&& ! $registryScoped
|
||||
&& ! $this->hasScopedInternalComparableRenderableClaim($tokens)
|
||||
&& ! $this->hasScopedInternalComparableRenderableReadinessClaim($tokens)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasAnyToken($tokens, ['full', 'complete', 'all'])
|
||||
&& ($hasWorkloadReference || $registryScoped || $this->hasToken($tokens, 'tenant'))
|
||||
&& ($hasCoverageSurface || $hasWorkloadReference || $registryScoped || $this->hasToken($tokens, 'tenant'))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@ -225,14 +242,26 @@ private function hasUnsafeBroadCoverageClaim(array $tokens, bool $registryScoped
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
private function hasScopedInternalComparableRenderableClaim(array $tokens): bool
|
||||
private function hasScopedInternalComparableRenderableReadinessClaim(array $tokens): bool
|
||||
{
|
||||
return $this->hasToken($tokens, 'selected')
|
||||
&& $this->hasAnyToken($tokens, ['entra', 'exchange', 'teams'])
|
||||
&& ($this->hasToken($tokens, 'comparable') || $this->hasToken($tokens, 'renderable'))
|
||||
&& $this->hasScopedWorkloadReference($tokens)
|
||||
&& ($this->hasComparableRenderableTerm($tokens) || $this->hasReadyForOperatorReviewTerm($tokens))
|
||||
&& ($this->hasToken($tokens, 'internal') || $this->hasToken($tokens, 'operator'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
private function hasScopedWorkloadReference(array $tokens): bool
|
||||
{
|
||||
return $this->hasAnyToken($tokens, ['entra', 'exchange', 'teams'])
|
||||
|| ($this->hasToken($tokens, 'security') && $this->hasToken($tokens, 'compliance'))
|
||||
|| ($this->hasToken($tokens, 'retention') && $this->hasToken($tokens, 'compliance'))
|
||||
|| ($this->hasToken($tokens, 'dlp') && $this->hasToken($tokens, 'compliance'))
|
||||
|| $this->hasAnyToken($tokens, ['label', 'labels']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
@ -274,6 +303,15 @@ private function hasRestoreReadyTerm(array $tokens): bool
|
||||
|| ($this->hasToken($tokens, 'restore') && $this->hasToken($tokens, 'coverage'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
private function hasApplyReadyTerm(array $tokens): bool
|
||||
{
|
||||
return ($this->hasToken($tokens, 'apply') && $this->hasAnyToken($tokens, ['ready', 'readiness']))
|
||||
|| $this->hasToken($tokens, 'applyready');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
@ -283,6 +321,23 @@ private function hasCustomerReadyTerm(array $tokens): bool
|
||||
&& $this->hasAnyToken($tokens, ['ready', 'readiness', 'proof']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
private function hasLegalOrRegulatoryTerm(array $tokens): bool
|
||||
{
|
||||
return $this->hasAnyToken($tokens, ['legal', 'regulatory', 'attestation', 'verified']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
private function hasReviewOutputTerm(array $tokens): bool
|
||||
{
|
||||
return $this->hasToken($tokens, 'review')
|
||||
&& $this->hasToken($tokens, 'pack');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
@ -291,6 +346,16 @@ private function hasComparableRenderableTerm(array $tokens): bool
|
||||
return $this->hasAnyToken($tokens, ['comparable', 'renderable']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
private function hasReadyForOperatorReviewTerm(array $tokens): bool
|
||||
{
|
||||
return $this->hasToken($tokens, 'ready')
|
||||
&& $this->hasToken($tokens, 'operator')
|
||||
&& $this->hasToken($tokens, 'review');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
|
||||
@ -21,6 +21,7 @@ final class CoverageEvidenceWriter
|
||||
public function __construct(
|
||||
private readonly EntraRenderableSummaryBuilder $entraSummaryBuilder,
|
||||
private readonly ExchangeTeamsRenderableSummaryBuilder $exchangeTeamsSummaryBuilder,
|
||||
private readonly SecurityComplianceRenderableSummaryBuilder $securityComplianceSummaryBuilder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -113,6 +114,10 @@ private function coverageLevelFor(TenantConfigurationResourceType $resourceType,
|
||||
return CoverageLevel::Renderable;
|
||||
}
|
||||
|
||||
if ($this->securityComplianceSummaryBuilder->canBuild($canonicalType, $normalizedPayload)) {
|
||||
return CoverageLevel::Renderable;
|
||||
}
|
||||
|
||||
return CoverageLevel::ContentBacked;
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +32,8 @@ public function __construct(
|
||||
private readonly EntraCoverageComparator $entraCoverageComparator,
|
||||
private readonly ExchangeTeamsRenderableSummaryBuilder $exchangeTeamsSummaryBuilder,
|
||||
private readonly ExchangeTeamsCoverageComparator $exchangeTeamsCoverageComparator,
|
||||
private readonly SecurityComplianceRenderableSummaryBuilder $securityComplianceSummaryBuilder,
|
||||
private readonly SecurityComplianceCoverageComparator $securityComplianceCoverageComparator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -335,12 +337,35 @@ private function typedRenderSummary(TenantConfigurationResource $resource): ?arr
|
||||
'source_schema_hash' => $evidence->source_schema_hash,
|
||||
]);
|
||||
|
||||
$context = [
|
||||
'claim_state' => $resource->latest_claim_state,
|
||||
'identity_state' => $resource->latest_identity_state,
|
||||
'evidence_state' => $resource->latest_evidence_state,
|
||||
'coverage_level' => $evidence->coverage_level,
|
||||
'capture_outcome' => $evidence->capture_outcome,
|
||||
'last_captured' => $resource->latest_captured_at?->toDayDateTimeString(),
|
||||
'source_version' => $evidence->source_version,
|
||||
'source_schema_hash' => $evidence->source_schema_hash,
|
||||
];
|
||||
|
||||
$summary ??= $this->securityComplianceSummaryBuilder->build($canonicalType, $evidence->normalized_payload, $context);
|
||||
|
||||
if ($summary === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$summary['compare_summary'] = $this->compareSummary($resource, $evidence, $canonicalType);
|
||||
|
||||
if ($this->securityComplianceSummaryBuilder->supports($canonicalType)) {
|
||||
$summary = $this->securityComplianceSummaryBuilder->withReadiness(
|
||||
$canonicalType,
|
||||
$summary,
|
||||
$evidence->normalized_payload,
|
||||
$context,
|
||||
$summary['compare_summary'],
|
||||
);
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
@ -398,11 +423,15 @@ private function compareSummary(
|
||||
|
||||
$changed = (bool) ($result['changed'] ?? false);
|
||||
|
||||
return [
|
||||
'status' => $changed ? 'Material changes detected' : 'No material changes',
|
||||
'classification' => is_scalar($result['classification'] ?? null)
|
||||
$classification = is_scalar($result['classification'] ?? null)
|
||||
? (string) $result['classification']
|
||||
: 'unchanged',
|
||||
: 'unchanged';
|
||||
|
||||
return [
|
||||
'status' => $classification === 'manual_review_required'
|
||||
? 'Manual review required'
|
||||
: ($changed ? 'Material changes detected' : 'No material changes'),
|
||||
'classification' => $classification,
|
||||
'changed' => $changed,
|
||||
'previous_captured' => $previousEvidence->captured_at?->toDayDateTimeString(),
|
||||
'changes' => $materialChanges
|
||||
@ -433,6 +462,10 @@ private function compareEvidencePayloads(string $canonicalType, array $previousP
|
||||
return $this->exchangeTeamsCoverageComparator->compare($canonicalType, $previousPayload, $latestPayload);
|
||||
}
|
||||
|
||||
if ($this->securityComplianceSummaryBuilder->supports($canonicalType)) {
|
||||
return $this->securityComplianceCoverageComparator->compare($canonicalType, $previousPayload, $latestPayload);
|
||||
}
|
||||
|
||||
return [
|
||||
'canonical_type' => $canonicalType,
|
||||
'supported' => false,
|
||||
|
||||
@ -0,0 +1,836 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
final class SecurityComplianceComparablePayloadNormalizer
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const SUPPORTED_TYPES = [
|
||||
'retentionCompliancePolicy',
|
||||
'labelPolicy',
|
||||
'dlpCompliancePolicy',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const VOLATILE_ROOT_FIELDS = [
|
||||
'@odata.context',
|
||||
'@odata.etag',
|
||||
'createdAt',
|
||||
'createdDateTime',
|
||||
'created_at',
|
||||
'graphContext',
|
||||
'lastModifiedDateTime',
|
||||
'modifiedDateTime',
|
||||
'sourceMetadata',
|
||||
'source_metadata',
|
||||
'tcmContext',
|
||||
'updatedAt',
|
||||
'updatedDateTime',
|
||||
'updated_at',
|
||||
'version',
|
||||
'whenChanged',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private const SUPPORTED_ROOT_FIELDS = [
|
||||
'retentionCompliancePolicy' => [
|
||||
'@odata.context',
|
||||
'@odata.etag',
|
||||
'DispositionAction',
|
||||
'DisplayName',
|
||||
'Duration',
|
||||
'Enabled',
|
||||
'ExcludedGroups',
|
||||
'ExcludedLocations',
|
||||
'ExcludedUsers',
|
||||
'Identity',
|
||||
'IncludedGroups',
|
||||
'IncludedLocations',
|
||||
'IncludedUsers',
|
||||
'Name',
|
||||
'RetentionAction',
|
||||
'RetentionDuration',
|
||||
'RetentionDurationUnit',
|
||||
'State',
|
||||
'displayName',
|
||||
'duration',
|
||||
'durationUnit',
|
||||
'enabled',
|
||||
'excludedGroups',
|
||||
'excludedLocations',
|
||||
'excludedUsers',
|
||||
'id',
|
||||
'includedGroups',
|
||||
'includedLocations',
|
||||
'includedUsers',
|
||||
'name',
|
||||
'retentionAction',
|
||||
'retentionDuration',
|
||||
'retentionDurationUnit',
|
||||
'state',
|
||||
],
|
||||
'labelPolicy' => [
|
||||
'@odata.context',
|
||||
'@odata.etag',
|
||||
'DefaultLabel',
|
||||
'DisplayName',
|
||||
'ExcludedGroups',
|
||||
'ExcludedLocations',
|
||||
'ExcludedUsers',
|
||||
'Identity',
|
||||
'IncludedGroups',
|
||||
'IncludedLocations',
|
||||
'IncludedUsers',
|
||||
'Labels',
|
||||
'Mandatory',
|
||||
'Name',
|
||||
'PublishedLabels',
|
||||
'State',
|
||||
'defaultLabel',
|
||||
'displayName',
|
||||
'excludedGroups',
|
||||
'excludedLocations',
|
||||
'excludedUsers',
|
||||
'id',
|
||||
'includedGroups',
|
||||
'includedLocations',
|
||||
'includedUsers',
|
||||
'labels',
|
||||
'mandatory',
|
||||
'name',
|
||||
'publishedLabels',
|
||||
'state',
|
||||
],
|
||||
'dlpCompliancePolicy' => [
|
||||
'@odata.context',
|
||||
'@odata.etag',
|
||||
'Actions',
|
||||
'DisplayName',
|
||||
'ExcludedLocations',
|
||||
'Identity',
|
||||
'IncludedLocations',
|
||||
'Locations',
|
||||
'Mode',
|
||||
'Name',
|
||||
'Rules',
|
||||
'State',
|
||||
'Workloads',
|
||||
'actions',
|
||||
'displayName',
|
||||
'excludedLocations',
|
||||
'id',
|
||||
'includedLocations',
|
||||
'locations',
|
||||
'mode',
|
||||
'name',
|
||||
'rules',
|
||||
'state',
|
||||
'workloads',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const SENSITIVE_CONTENT_KEYS = [
|
||||
'auditmetadata.rawpayload',
|
||||
'body',
|
||||
'casecontent',
|
||||
'content',
|
||||
'dlpincidentcontent',
|
||||
'ediscoverycasecontent',
|
||||
'filecontent',
|
||||
'fingerprint',
|
||||
'mailbody',
|
||||
'mailcontent',
|
||||
'messagebody',
|
||||
'messagecontent',
|
||||
'providerresponse',
|
||||
'rawpayload',
|
||||
'sampledata',
|
||||
'securityincidentcontent',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const SENSITIVE_CONTENT_KEY_PARTS = [
|
||||
'casecontent',
|
||||
'contentcontainssensitiveinformation',
|
||||
'dlpincident',
|
||||
'ediscovery',
|
||||
'fingerprint',
|
||||
'mailcontent',
|
||||
'messagecontent',
|
||||
'providerresponse',
|
||||
'rawpayload',
|
||||
'securityincident',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
private const SUPPORTED_NESTED_FIELDS = [
|
||||
'retentionCompliancePolicy' => [
|
||||
'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'IncludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
||||
'includedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
||||
'ExcludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
||||
'excludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
||||
'IncludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
||||
'includedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
||||
'ExcludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
||||
'excludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
||||
],
|
||||
'labelPolicy' => [
|
||||
'PublishedLabels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'publishedLabels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'Labels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'labels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'DefaultLabel' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'defaultLabel' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'IncludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
||||
'includedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
||||
'ExcludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
||||
'excludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
||||
'IncludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
||||
'includedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
||||
'ExcludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
||||
'excludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
||||
],
|
||||
'dlpCompliancePolicy' => [
|
||||
'Locations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'locations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'Workloads' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'workloads' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
||||
'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
||||
'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
||||
'Rules' => ['Actions', 'DisplayName', 'Enabled', 'Mode', 'Name', 'Severity', 'State', 'actions', 'displayName', 'enabled', 'mode', 'name', 'severity', 'state'],
|
||||
'rules' => ['Actions', 'DisplayName', 'Enabled', 'Mode', 'Name', 'Severity', 'State', 'actions', 'displayName', 'enabled', 'mode', 'name', 'severity', 'state'],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string, array<string, list<string>>>>
|
||||
*/
|
||||
private const SUPPORTED_NESTED_FIELD_CHILDREN = [
|
||||
'dlpCompliancePolicy' => [
|
||||
'Rules' => [
|
||||
'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
||||
'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
||||
],
|
||||
'rules' => [
|
||||
'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
||||
'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly CoveragePayloadRedactor $redactor,
|
||||
) {}
|
||||
|
||||
public function supports(string $canonicalType): bool
|
||||
{
|
||||
return in_array($canonicalType, self::SUPPORTED_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function normalize(string $canonicalType, array $payload): array
|
||||
{
|
||||
if (! $this->supports($canonicalType)) {
|
||||
return [
|
||||
'canonical_type' => $canonicalType,
|
||||
'supported' => false,
|
||||
'diagnostics' => [
|
||||
'unsupported_fields' => [],
|
||||
'redacted_fields' => [],
|
||||
'volatile_fields' => [],
|
||||
'manual_review_fields' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$redacted = $this->redactedPayload($payload);
|
||||
|
||||
return match ($canonicalType) {
|
||||
'retentionCompliancePolicy' => $this->normalizeRetentionCompliancePolicy($payload, $redacted),
|
||||
'labelPolicy' => $this->normalizeLabelPolicy($payload, $redacted),
|
||||
'dlpCompliancePolicy' => $this->normalizeDlpCompliancePolicy($payload, $redacted),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function volatileRootFields(): array
|
||||
{
|
||||
return self::VOLATILE_ROOT_FIELDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $rawPayload
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeRetentionCompliancePolicy(array $rawPayload, array $payload): array
|
||||
{
|
||||
return $this->sortAssociative([
|
||||
'canonical_type' => 'retentionCompliancePolicy',
|
||||
'supported' => true,
|
||||
'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']),
|
||||
'enabled_state' => $this->enabledState($this->firstScalar($payload, ['Enabled', 'enabled', 'State', 'state'])),
|
||||
'retention' => [
|
||||
'duration' => $this->stringValue($this->firstScalar($payload, ['RetentionDuration', 'retentionDuration', 'Duration', 'duration'])),
|
||||
'duration_unit' => $this->firstString($payload, ['RetentionDurationUnit', 'retentionDurationUnit', 'DurationUnit', 'durationUnit']),
|
||||
'disposition_action' => $this->firstString($payload, ['DispositionAction', 'dispositionAction', 'RetentionAction', 'retentionAction']),
|
||||
],
|
||||
'scope' => [
|
||||
'included_locations' => $this->listFromFields($payload, ['IncludedLocations', 'includedLocations', 'Locations', 'locations']),
|
||||
'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']),
|
||||
'included_groups' => $this->listFromFields($payload, ['IncludedGroups', 'includedGroups']),
|
||||
'excluded_groups' => $this->listFromFields($payload, ['ExcludedGroups', 'excludedGroups']),
|
||||
'included_users' => $this->listFromFields($payload, ['IncludedUsers', 'includedUsers']),
|
||||
'excluded_users' => $this->listFromFields($payload, ['ExcludedUsers', 'excludedUsers']),
|
||||
],
|
||||
'diagnostics' => $this->diagnostics('retentionCompliancePolicy', $rawPayload, $payload),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $rawPayload
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeLabelPolicy(array $rawPayload, array $payload): array
|
||||
{
|
||||
return $this->sortAssociative([
|
||||
'canonical_type' => 'labelPolicy',
|
||||
'supported' => true,
|
||||
'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']),
|
||||
'state' => $this->firstString($payload, ['State', 'state']),
|
||||
'labeling' => [
|
||||
'published_labels' => $this->labelList($this->firstExisting($payload, ['PublishedLabels', 'publishedLabels', 'Labels', 'labels'])),
|
||||
'default_label' => $this->labelName($this->firstExisting($payload, ['DefaultLabel', 'defaultLabel'])),
|
||||
'mandatory' => $this->booleanString($this->firstScalar($payload, ['Mandatory', 'mandatory'])),
|
||||
],
|
||||
'scope' => [
|
||||
'included_locations' => $this->listFromFields($payload, ['IncludedLocations', 'includedLocations', 'Locations', 'locations']),
|
||||
'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']),
|
||||
'included_groups' => $this->listFromFields($payload, ['IncludedGroups', 'includedGroups']),
|
||||
'excluded_groups' => $this->listFromFields($payload, ['ExcludedGroups', 'excludedGroups']),
|
||||
'included_users' => $this->listFromFields($payload, ['IncludedUsers', 'includedUsers']),
|
||||
'excluded_users' => $this->listFromFields($payload, ['ExcludedUsers', 'excludedUsers']),
|
||||
],
|
||||
'diagnostics' => $this->diagnostics('labelPolicy', $rawPayload, $payload),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $rawPayload
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeDlpCompliancePolicy(array $rawPayload, array $payload): array
|
||||
{
|
||||
return $this->sortAssociative([
|
||||
'canonical_type' => 'dlpCompliancePolicy',
|
||||
'supported' => true,
|
||||
'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']),
|
||||
'state' => $this->firstString($payload, ['State', 'state']),
|
||||
'mode' => $this->firstString($payload, ['Mode', 'mode']),
|
||||
'scope' => [
|
||||
'locations' => $this->listFromFields($payload, ['Locations', 'locations', 'IncludedLocations', 'includedLocations']),
|
||||
'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']),
|
||||
'workloads' => $this->listFromFields($payload, ['Workloads', 'workloads']),
|
||||
],
|
||||
'actions' => $this->scalarList($this->firstExisting($payload, ['Actions', 'actions'])),
|
||||
'rules' => $this->ruleList($this->firstExisting($payload, ['Rules', 'rules'])),
|
||||
'diagnostics' => $this->diagnostics('dlpCompliancePolicy', $rawPayload, $payload),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function redactedPayload(array $payload): array
|
||||
{
|
||||
$redacted = $this->redactor->redact($payload);
|
||||
$redacted = is_array($redacted) ? $redacted : [];
|
||||
|
||||
return $this->redactUnsafeContent($redacted);
|
||||
}
|
||||
|
||||
private function redactUnsafeContent(mixed $value, string $path = ''): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (array_is_list($value)) {
|
||||
return array_map(fn (mixed $item): mixed => $this->redactUnsafeContent($item, $path), $value);
|
||||
}
|
||||
|
||||
$redacted = [];
|
||||
|
||||
foreach ($value as $key => $nestedValue) {
|
||||
$key = (string) $key;
|
||||
$nestedPath = $path === '' ? $key : $path.'.'.$key;
|
||||
|
||||
$redacted[$key] = $this->isUnsafeContentKey($nestedPath)
|
||||
? '[redacted]'
|
||||
: $this->redactUnsafeContent($nestedValue, $nestedPath);
|
||||
}
|
||||
|
||||
return $redacted;
|
||||
}
|
||||
|
||||
private function isUnsafeContentKey(string $path): bool
|
||||
{
|
||||
$normalized = strtolower(str_replace(['_', '-', ' '], '', $path));
|
||||
$segments = explode('.', $normalized);
|
||||
$last = end($segments);
|
||||
|
||||
foreach (self::SENSITIVE_CONTENT_KEYS as $sensitiveKey) {
|
||||
if ($normalized === $sensitiveKey || $last === $sensitiveKey) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::SENSITIVE_CONTENT_KEY_PARTS as $sensitiveKeyPart) {
|
||||
if (str_contains($normalized, $sensitiveKeyPart) || str_contains((string) $last, $sensitiveKeyPart)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
private function diagnostics(string $canonicalType, array $rawPayload, array $payload): array
|
||||
{
|
||||
$redactedFields = $this->redactedPaths($payload);
|
||||
$unsupportedFields = $this->unsupportedRootFields($canonicalType, $payload);
|
||||
|
||||
return [
|
||||
'unsupported_fields' => $unsupportedFields,
|
||||
'redacted_fields' => $redactedFields,
|
||||
'volatile_fields' => $this->presentVolatileFields($rawPayload),
|
||||
'manual_review_fields' => $this->manualReviewFields($unsupportedFields, $redactedFields),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $unsupportedFields
|
||||
* @return list<string>
|
||||
*/
|
||||
private function manualReviewFields(array $unsupportedFields, array $redactedFields): array
|
||||
{
|
||||
$fields = array_values(array_filter(
|
||||
$unsupportedFields,
|
||||
static fn (string $field): bool => $field !== '' && ! in_array($field, $redactedFields, true),
|
||||
));
|
||||
|
||||
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return list<string>
|
||||
*/
|
||||
private function unsupportedRootFields(string $canonicalType, array $payload): array
|
||||
{
|
||||
$supported = self::SUPPORTED_ROOT_FIELDS[$canonicalType] ?? [];
|
||||
$fields = array_values(array_filter(
|
||||
array_map('strval', array_keys($payload)),
|
||||
static fn (string $key): bool => ! in_array($key, $supported, true)
|
||||
&& ! in_array($key, self::VOLATILE_ROOT_FIELDS, true)
|
||||
));
|
||||
|
||||
foreach ($this->unsupportedNestedFields($canonicalType, $payload) as $field) {
|
||||
$fields[] = $field;
|
||||
}
|
||||
|
||||
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return array_values(array_unique($fields));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return list<string>
|
||||
*/
|
||||
private function unsupportedNestedFields(string $canonicalType, array $payload): array
|
||||
{
|
||||
$fields = [];
|
||||
$supportedNestedFields = self::SUPPORTED_NESTED_FIELDS[$canonicalType] ?? [];
|
||||
|
||||
foreach ($supportedNestedFields as $rootField => $supportedFields) {
|
||||
if (! array_key_exists($rootField, $payload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->unsupportedNestedFieldsForValue(
|
||||
$payload[$rootField],
|
||||
$supportedFields,
|
||||
$rootField,
|
||||
self::SUPPORTED_NESTED_FIELD_CHILDREN[$canonicalType][$rootField] ?? [],
|
||||
) as $field) {
|
||||
$fields[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $supportedFields
|
||||
* @param array<string, list<string>> $childSchemas
|
||||
* @return list<string>
|
||||
*/
|
||||
private function unsupportedNestedFieldsForValue(mixed $value, array $supportedFields, string $path, array $childSchemas = []): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
|
||||
if (array_is_list($value)) {
|
||||
foreach ($value as $index => $nestedValue) {
|
||||
foreach ($this->unsupportedNestedFieldsForValue($nestedValue, $supportedFields, $path.'.'.(string) $index, $childSchemas) as $field) {
|
||||
$fields[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
foreach ($value as $key => $nestedValue) {
|
||||
$key = (string) $key;
|
||||
$nestedPath = $path.'.'.$key;
|
||||
|
||||
if (! in_array($key, $supportedFields, true)) {
|
||||
$fields[] = $nestedPath;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$childSchema = $childSchemas[$key] ?? null;
|
||||
|
||||
if ($childSchema === null) {
|
||||
if (is_array($nestedValue)) {
|
||||
$fields[] = $nestedPath;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->unsupportedNestedFieldsForValue($nestedValue, $childSchema, $nestedPath) as $field) {
|
||||
$fields[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return list<string>
|
||||
*/
|
||||
private function presentVolatileFields(array $payload): array
|
||||
{
|
||||
$fields = array_values(array_filter(
|
||||
self::VOLATILE_ROOT_FIELDS,
|
||||
static fn (string $field): bool => array_key_exists($field, $payload),
|
||||
));
|
||||
|
||||
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function redactedPaths(mixed $value, string $prefix = ''): array
|
||||
{
|
||||
if ($value === '[redacted]') {
|
||||
return [$prefix];
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
|
||||
foreach ($value as $key => $nestedValue) {
|
||||
$path = $prefix === '' ? (string) $key : $prefix.'.'.(string) $key;
|
||||
|
||||
foreach ($this->redactedPaths($nestedValue, $path) as $nestedPath) {
|
||||
$paths[] = $nestedPath;
|
||||
}
|
||||
}
|
||||
|
||||
$paths = array_values(array_unique(array_filter($paths)));
|
||||
sort($paths, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param list<string> $fields
|
||||
* @return list<string>
|
||||
*/
|
||||
private function listFromFields(array $payload, array $fields): array
|
||||
{
|
||||
$values = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
foreach ($this->scalarList($payload[$field] ?? null) as $value) {
|
||||
$values[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$values = array_values(array_unique($values));
|
||||
sort($values, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function scalarList(mixed $value): array
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return is_scalar($value) ? [trim((string) $value)] : [];
|
||||
}
|
||||
|
||||
$values = [];
|
||||
|
||||
foreach ($value as $item) {
|
||||
if (is_array($item)) {
|
||||
$label = $this->labelName($item);
|
||||
|
||||
if ($label !== null) {
|
||||
$values[] = $label;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_scalar($item) && trim((string) $item) !== '') {
|
||||
$values[] = trim((string) $item);
|
||||
}
|
||||
}
|
||||
|
||||
$values = array_values(array_unique($values));
|
||||
sort($values, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function labelList(mixed $value): array
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = is_array($value) && array_is_list($value) ? $value : [$value];
|
||||
$labels = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$label = $this->labelName($item);
|
||||
|
||||
if ($label !== null) {
|
||||
$labels[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$labels = array_values(array_unique($labels));
|
||||
sort($labels, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
private function labelName(mixed $value): ?string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $this->firstString($value, ['displayName', 'DisplayName', 'name', 'Name']);
|
||||
}
|
||||
|
||||
return $this->stringValue($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function ruleList(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = array_is_list($value) ? $value : [$value];
|
||||
$rules = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rules[] = array_filter([
|
||||
'name' => $this->firstString($item, ['Name', 'name', 'DisplayName', 'displayName']),
|
||||
'state' => $this->firstString($item, ['State', 'state', 'Enabled', 'enabled']),
|
||||
'severity' => $this->firstString($item, ['Severity', 'severity']),
|
||||
'mode' => $this->firstString($item, ['Mode', 'mode']),
|
||||
'actions' => $this->scalarList($this->firstExisting($item, ['Actions', 'actions'])),
|
||||
], static fn (mixed $nested): bool => $nested !== null && $nested !== [] && $nested !== '');
|
||||
}
|
||||
|
||||
usort($rules, static fn (array $left, array $right): int => strcmp(
|
||||
json_encode($left, JSON_THROW_ON_ERROR),
|
||||
json_encode($right, JSON_THROW_ON_ERROR),
|
||||
));
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
private function firstExisting(array $payload, array $fields): mixed
|
||||
{
|
||||
foreach ($fields as $field) {
|
||||
if (array_key_exists($field, $payload)) {
|
||||
return $payload[$field];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function firstScalar(array $payload, array $fields): mixed
|
||||
{
|
||||
foreach ($fields as $field) {
|
||||
$value = $payload[$field] ?? null;
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function firstString(array $payload, array $fields): ?string
|
||||
{
|
||||
return $this->stringValue($this->firstScalar($payload, $fields));
|
||||
}
|
||||
|
||||
private function stringValue(mixed $value): ?string
|
||||
{
|
||||
if (! is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function enabledState(mixed $value): ?string
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'enabled' : 'disabled';
|
||||
}
|
||||
|
||||
$value = $this->stringValue($value);
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (strtolower($value)) {
|
||||
'1', 'enabled', 'true', 'yes' => 'enabled',
|
||||
'0', 'disabled', 'false', 'no' => 'disabled',
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
private function booleanString(mixed $value): ?string
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'yes' : 'no';
|
||||
}
|
||||
|
||||
$value = $this->stringValue($value);
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (strtolower($value)) {
|
||||
'1', 'true', 'yes' => 'yes',
|
||||
'0', 'false', 'no' => 'no',
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $value
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sortAssociative(array $value): array
|
||||
{
|
||||
foreach ($value as $key => $nestedValue) {
|
||||
if (is_array($nestedValue)) {
|
||||
$value[$key] = array_is_list($nestedValue)
|
||||
? $nestedValue
|
||||
: $this->sortAssociative($nestedValue);
|
||||
}
|
||||
}
|
||||
|
||||
ksort($value, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
final class SecurityComplianceCoverageComparator
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const MATERIAL_CHANGE_TYPES = [
|
||||
'added',
|
||||
'removed',
|
||||
'changed',
|
||||
'manual_review_required',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly SecurityComplianceComparablePayloadNormalizer $normalizer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $beforePayload
|
||||
* @param array<string, mixed> $afterPayload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function compare(string $canonicalType, array $beforePayload, array $afterPayload): array
|
||||
{
|
||||
if (! $this->normalizer->supports($canonicalType)) {
|
||||
return [
|
||||
'canonical_type' => $canonicalType,
|
||||
'supported' => false,
|
||||
'classification' => 'unsupported_field',
|
||||
'changed' => false,
|
||||
'changes' => [[
|
||||
'field' => 'canonical_type',
|
||||
'classification' => 'unsupported_field',
|
||||
'importance' => 'informational',
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
$before = $this->normalizer->normalize($canonicalType, $beforePayload);
|
||||
$after = $this->normalizer->normalize($canonicalType, $afterPayload);
|
||||
$changes = [
|
||||
...$this->volatileChanges($beforePayload, $afterPayload),
|
||||
...$this->diagnosticChanges($before, $after),
|
||||
...$this->materialChanges(
|
||||
$this->materialPayload($before),
|
||||
$this->materialPayload($after),
|
||||
canonicalType: $canonicalType,
|
||||
),
|
||||
];
|
||||
$hasMaterialChange = collect($changes)
|
||||
->contains(fn (array $change): bool => in_array($change['classification'] ?? null, self::MATERIAL_CHANGE_TYPES, true));
|
||||
$manualReview = collect($changes)
|
||||
->contains(fn (array $change): bool => ($change['classification'] ?? null) === 'manual_review_required'
|
||||
|| in_array($change['importance'] ?? null, ['critical', 'important'], true));
|
||||
|
||||
return [
|
||||
'canonical_type' => $canonicalType,
|
||||
'supported' => true,
|
||||
'classification' => $manualReview ? 'manual_review_required' : ($hasMaterialChange ? 'changed' : 'unchanged'),
|
||||
'changed' => $hasMaterialChange,
|
||||
'changes' => $changes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function materialPayload(array $payload): array
|
||||
{
|
||||
unset($payload['diagnostics'], $payload['supported'], $payload['canonical_type']);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $before
|
||||
* @param array<string, mixed> $after
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function materialChanges(array $before, array $after, string $path = '', string $canonicalType = ''): array
|
||||
{
|
||||
$changes = [];
|
||||
$keys = array_values(array_unique([...array_keys($before), ...array_keys($after)]));
|
||||
sort($keys, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$field = $path === '' ? (string) $key : $path.'.'.(string) $key;
|
||||
$beforeValue = $before[$key] ?? null;
|
||||
$afterValue = $after[$key] ?? null;
|
||||
|
||||
if (is_array($beforeValue) && is_array($afterValue) && ! array_is_list($beforeValue) && ! array_is_list($afterValue)) {
|
||||
foreach ($this->materialChanges($beforeValue, $afterValue, $field, $canonicalType) as $nestedChange) {
|
||||
$changes[] = $nestedChange;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->containsRedacted($beforeValue) || $this->containsRedacted($afterValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($beforeValue === $afterValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changes[] = [
|
||||
'field' => $field,
|
||||
'classification' => $this->changeClassification($beforeValue, $afterValue),
|
||||
'importance' => $this->importance($canonicalType, $field),
|
||||
'before' => $beforeValue,
|
||||
'after' => $afterValue,
|
||||
];
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function changeClassification(mixed $beforeValue, mixed $afterValue): string
|
||||
{
|
||||
if ($this->isEmptyValue($beforeValue) && ! $this->isEmptyValue($afterValue)) {
|
||||
return 'added';
|
||||
}
|
||||
|
||||
if (! $this->isEmptyValue($beforeValue) && $this->isEmptyValue($afterValue)) {
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
return 'changed';
|
||||
}
|
||||
|
||||
private function isEmptyValue(mixed $value): bool
|
||||
{
|
||||
return $value === null || $value === '' || $value === [];
|
||||
}
|
||||
|
||||
private function containsRedacted(mixed $value): bool
|
||||
{
|
||||
if ($value === '[redacted]') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($value as $nestedValue) {
|
||||
if ($this->containsRedacted($nestedValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function importance(string $canonicalType, string $field): string
|
||||
{
|
||||
if ($canonicalType === 'retentionCompliancePolicy'
|
||||
&& (
|
||||
$field === 'enabled_state'
|
||||
|| str_starts_with($field, 'retention.')
|
||||
|| str_starts_with($field, 'scope.')
|
||||
)
|
||||
) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if ($canonicalType === 'dlpCompliancePolicy'
|
||||
&& (
|
||||
in_array($field, ['mode', 'state', 'actions', 'rules'], true)
|
||||
|| str_starts_with($field, 'scope.')
|
||||
)
|
||||
) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if ($canonicalType === 'labelPolicy'
|
||||
&& (
|
||||
str_starts_with($field, 'labeling.')
|
||||
|| str_starts_with($field, 'scope.')
|
||||
)
|
||||
) {
|
||||
return 'important';
|
||||
}
|
||||
|
||||
return 'informational';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $beforePayload
|
||||
* @param array<string, mixed> $afterPayload
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function volatileChanges(array $beforePayload, array $afterPayload): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
foreach ($this->normalizer->volatileRootFields() as $field) {
|
||||
$before = $beforePayload[$field] ?? null;
|
||||
$after = $afterPayload[$field] ?? null;
|
||||
|
||||
if ($before === $after) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changes[] = [
|
||||
'field' => $field,
|
||||
'classification' => 'ignored_volatile',
|
||||
'importance' => 'informational',
|
||||
];
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $before
|
||||
* @param array<string, mixed> $after
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function diagnosticChanges(array $before, array $after): array
|
||||
{
|
||||
$changes = [];
|
||||
$manualReviewFields = $this->diagnosticFieldUnion($before, $after, 'manual_review_fields');
|
||||
|
||||
foreach ($this->diagnosticFieldUnion($before, $after, 'unsupported_fields') as $field) {
|
||||
if (in_array($field, $manualReviewFields, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changes[] = [
|
||||
'field' => $field,
|
||||
'classification' => 'unsupported_field',
|
||||
'importance' => 'informational',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->diagnosticFieldUnion($before, $after, 'redacted_fields') as $field) {
|
||||
$changes[] = [
|
||||
'field' => $field,
|
||||
'classification' => 'redacted',
|
||||
'importance' => 'informational',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($manualReviewFields as $field) {
|
||||
$changes[] = [
|
||||
'field' => $field,
|
||||
'classification' => 'manual_review_required',
|
||||
'importance' => 'manual_review_required',
|
||||
];
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $before
|
||||
* @param array<string, mixed> $after
|
||||
* @return list<string>
|
||||
*/
|
||||
private function diagnosticFieldUnion(array $before, array $after, string $key): array
|
||||
{
|
||||
$fields = [
|
||||
...$this->diagnosticFieldList($before, $key),
|
||||
...$this->diagnosticFieldList($after, $key),
|
||||
];
|
||||
$fields = array_values(array_unique($fields));
|
||||
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return list<string>
|
||||
*/
|
||||
private function diagnosticFieldList(array $payload, string $key): array
|
||||
{
|
||||
$fields = data_get($payload, 'diagnostics.'.$key, []);
|
||||
|
||||
if (! is_array($fields)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields),
|
||||
static fn (string $field): bool => $field !== '',
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
final class SecurityComplianceReadinessEvaluator
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|null $normalizedPayload
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed>|null $compareResult
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function evaluate(
|
||||
string $canonicalType,
|
||||
?array $normalizedPayload,
|
||||
array $context = [],
|
||||
?array $compareResult = null,
|
||||
): array {
|
||||
if ($normalizedPayload === null) {
|
||||
return $this->result(
|
||||
state: 'readiness_not_assessed',
|
||||
label: 'Not assessed',
|
||||
reason: 'No structured Security and Compliance evidence is available for operator review.',
|
||||
blockers: ['not_assessed'],
|
||||
);
|
||||
}
|
||||
|
||||
if (($normalizedPayload['supported'] ?? false) !== true) {
|
||||
return $this->result(
|
||||
state: 'readiness_blocked_unsupported',
|
||||
label: 'Blocked',
|
||||
reason: 'This Security and Compliance resource type is not supported by this readiness pack.',
|
||||
blockers: ['unsupported_type'],
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->evidenceBlocked($context)) {
|
||||
return $this->result(
|
||||
state: $this->permissionBlocked($context) ? 'readiness_blocked_permission' : 'readiness_blocked_evidence',
|
||||
label: 'Blocked',
|
||||
reason: $this->permissionBlocked($context)
|
||||
? 'Provider permission or source availability blocks reliable operator review.'
|
||||
: 'Content-backed captured evidence is required before readiness can be assessed.',
|
||||
blockers: [$this->permissionBlocked($context) ? 'permission_blocked' : 'evidence_blocked'],
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->identityBlocked($context)) {
|
||||
return $this->result(
|
||||
state: 'readiness_blocked_identity',
|
||||
label: 'Blocked',
|
||||
reason: 'Identity is unsafe for Security and Compliance readiness assessment.',
|
||||
blockers: ['identity_blocked'],
|
||||
);
|
||||
}
|
||||
|
||||
$unsupportedFields = $this->actionableUnsupportedFields($normalizedPayload);
|
||||
$manualReviewFields = $this->diagnosticFields($normalizedPayload, 'manual_review_fields');
|
||||
|
||||
if ($manualReviewFields !== []) {
|
||||
return $this->result(
|
||||
state: 'readiness_requires_manual_review',
|
||||
label: 'Manual review required',
|
||||
reason: 'Unsupported Security and Compliance fields require internal operator review.',
|
||||
manualReviewRequired: true,
|
||||
blockers: array_values(array_unique([
|
||||
...array_map(static fn (string $field): string => 'manual_review:'.$field, $manualReviewFields),
|
||||
])),
|
||||
);
|
||||
}
|
||||
|
||||
if ($unsupportedFields !== []) {
|
||||
return $this->result(
|
||||
state: 'readiness_blocked_unsupported',
|
||||
label: 'Blocked',
|
||||
reason: 'Unsupported fields require review before this evidence can be trusted.',
|
||||
blockers: array_values(array_unique([
|
||||
...array_map(static fn (string $field): string => 'unsupported:'.$field, $unsupportedFields),
|
||||
])),
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->requiresManualReview($compareResult)) {
|
||||
return $this->result(
|
||||
state: 'readiness_requires_manual_review',
|
||||
label: 'Manual review required',
|
||||
reason: 'Critical Security and Compliance changes require internal operator review.',
|
||||
manualReviewRequired: true,
|
||||
blockers: ['critical_material_change'],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->result(
|
||||
state: 'readiness_ready_for_operator_review',
|
||||
label: 'Ready for operator review',
|
||||
reason: 'Structured evidence is available for internal operator review.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function result(
|
||||
string $state,
|
||||
string $label,
|
||||
string $reason,
|
||||
bool $manualReviewRequired = false,
|
||||
array $blockers = [],
|
||||
): array {
|
||||
return [
|
||||
'state' => $state,
|
||||
'label' => $label,
|
||||
'reason' => $reason,
|
||||
'manual_review_required' => $manualReviewRequired,
|
||||
'blockers' => array_values($blockers),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function evidenceBlocked(array $context): bool
|
||||
{
|
||||
return $this->permissionBlocked($context)
|
||||
|| ($context['evidence_state'] ?? null) !== 'content_backed'
|
||||
|| ($context['capture_outcome'] ?? null) !== 'captured'
|
||||
|| ! in_array($context['coverage_level'] ?? null, ['renderable', 'comparable'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function permissionBlocked(array $context): bool
|
||||
{
|
||||
return (bool) ($context['permission_blocked'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function identityBlocked(array $context): bool
|
||||
{
|
||||
return in_array($context['identity_state'] ?? null, [
|
||||
'identity_conflict',
|
||||
'missing_external_id',
|
||||
'unsupported_identity',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $compareResult
|
||||
*/
|
||||
private function requiresManualReview(?array $compareResult): bool
|
||||
{
|
||||
if ($compareResult === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($compareResult['classification'] ?? null) === 'manual_review_required') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$changes = $compareResult['changes'] ?? [];
|
||||
|
||||
if (! is_array($changes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($changes as $change) {
|
||||
if (! is_array($change)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($change['classification'] ?? null) === 'manual_review_required'
|
||||
|| ($change['importance'] ?? null) === 'critical'
|
||||
|| ($change['importance'] ?? null) === 'manual_review_required'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return list<string>
|
||||
*/
|
||||
private function diagnosticFields(array $payload, string $key): array
|
||||
{
|
||||
$fields = data_get($payload, 'diagnostics.'.$key, []);
|
||||
|
||||
if (! is_array($fields)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields),
|
||||
static fn (string $field): bool => $field !== '',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return list<string>
|
||||
*/
|
||||
private function actionableUnsupportedFields(array $payload): array
|
||||
{
|
||||
$unsupportedFields = $this->diagnosticFields($payload, 'unsupported_fields');
|
||||
$redactedFields = $this->diagnosticFields($payload, 'redacted_fields');
|
||||
|
||||
return array_values(array_filter(
|
||||
$unsupportedFields,
|
||||
static fn (string $field): bool => ! in_array($field, $redactedFields, true),
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
final class SecurityComplianceRenderableSummaryBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SecurityComplianceComparablePayloadNormalizer $normalizer,
|
||||
private readonly SecurityComplianceReadinessEvaluator $readinessEvaluator,
|
||||
) {}
|
||||
|
||||
public function supports(string $canonicalType): bool
|
||||
{
|
||||
return $this->normalizer->supports($canonicalType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function canBuild(string $canonicalType, array $payload): bool
|
||||
{
|
||||
if (! $this->supports($canonicalType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizer->normalize($canonicalType, $payload);
|
||||
|
||||
return ($normalized['supported'] ?? false) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function build(string $canonicalType, array $payload, array $context = []): ?array
|
||||
{
|
||||
if (! $this->supports($canonicalType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizer->normalize($canonicalType, $payload);
|
||||
|
||||
if (($normalized['supported'] ?? false) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$readiness = $this->readinessEvaluator->evaluate($canonicalType, $normalized, [
|
||||
'evidence_state' => $this->stringContext($context, 'evidence_state') ?? 'content_backed',
|
||||
'coverage_level' => $this->stringContext($context, 'coverage_level') ?? 'renderable',
|
||||
'identity_state' => $this->stringContext($context, 'identity_state') ?? 'stable',
|
||||
'capture_outcome' => $this->stringContext($context, 'capture_outcome') ?? 'captured',
|
||||
], $this->arrayContext($context, 'compare_result'));
|
||||
|
||||
return match ($canonicalType) {
|
||||
'retentionCompliancePolicy' => $this->retentionPolicySummary($normalized, $context, $readiness),
|
||||
'labelPolicy' => $this->labelPolicySummary($normalized, $context, $readiness),
|
||||
'dlpCompliancePolicy' => $this->dlpPolicySummary($normalized, $context, $readiness),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed>|null $compareResult
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function withReadiness(
|
||||
string $canonicalType,
|
||||
array $summary,
|
||||
array $payload,
|
||||
array $context,
|
||||
?array $compareResult,
|
||||
): array {
|
||||
if (! $this->supports($canonicalType)) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizer->normalize($canonicalType, $payload);
|
||||
$readiness = $this->readinessEvaluator->evaluate($canonicalType, $normalized, [
|
||||
'evidence_state' => $this->stringContext($context, 'evidence_state'),
|
||||
'coverage_level' => $this->stringContext($context, 'coverage_level'),
|
||||
'identity_state' => $this->stringContext($context, 'identity_state'),
|
||||
'capture_outcome' => $this->stringContext($context, 'capture_outcome'),
|
||||
], $compareResult);
|
||||
|
||||
$summaryFields = array_values(array_filter(
|
||||
$summary['summary_fields'] ?? [],
|
||||
static fn (mixed $field): bool => is_array($field) && ($field['label'] ?? null) !== 'Review readiness',
|
||||
));
|
||||
|
||||
$summary['readiness'] = $readiness;
|
||||
$summary['summary_fields'] = [
|
||||
['label' => 'Review readiness', 'value' => $readiness['label']],
|
||||
...$summaryFields,
|
||||
];
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $normalized
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $readiness
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function retentionPolicySummary(array $normalized, array $context, array $readiness): array
|
||||
{
|
||||
$duration = trim(implode(' ', array_filter([
|
||||
$normalized['retention']['duration'] ?? null,
|
||||
$normalized['retention']['duration_unit'] ?? null,
|
||||
], static fn (mixed $value): bool => filled($value))));
|
||||
|
||||
return $this->baseSummary('Retention compliance policy', $normalized, $context, $readiness, [
|
||||
['label' => 'Display name', 'value' => $normalized['display_name'] ?? 'Unnamed retention policy'],
|
||||
['label' => 'Enabled/state', 'value' => $normalized['enabled_state'] ?? null],
|
||||
['label' => 'Retention period', 'value' => $duration !== '' ? $duration : null],
|
||||
['label' => 'Disposition action', 'value' => $normalized['retention']['disposition_action'] ?? null],
|
||||
['label' => 'Included locations', 'value' => $this->listSummary(data_get($normalized, 'scope.included_locations', []))],
|
||||
['label' => 'Excluded locations', 'value' => $this->listSummary(data_get($normalized, 'scope.excluded_locations', []))],
|
||||
], state: $normalized['enabled_state'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $normalized
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $readiness
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function labelPolicySummary(array $normalized, array $context, array $readiness): array
|
||||
{
|
||||
return $this->baseSummary('Label policy', $normalized, $context, $readiness, [
|
||||
['label' => 'Display name', 'value' => $normalized['display_name'] ?? 'Unnamed label policy'],
|
||||
['label' => 'Published labels', 'value' => $this->listSummary(data_get($normalized, 'labeling.published_labels', []))],
|
||||
['label' => 'Default label', 'value' => data_get($normalized, 'labeling.default_label')],
|
||||
['label' => 'Mandatory labeling', 'value' => data_get($normalized, 'labeling.mandatory')],
|
||||
['label' => 'Included groups', 'value' => $this->listSummary(data_get($normalized, 'scope.included_groups', []))],
|
||||
], state: $normalized['state'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $normalized
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $readiness
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function dlpPolicySummary(array $normalized, array $context, array $readiness): array
|
||||
{
|
||||
return $this->baseSummary('DLP compliance policy', $normalized, $context, $readiness, [
|
||||
['label' => 'Display name', 'value' => $normalized['display_name'] ?? 'Unnamed DLP policy'],
|
||||
['label' => 'Mode / enforcement', 'value' => $normalized['mode'] ?? null],
|
||||
['label' => 'State', 'value' => $normalized['state'] ?? null],
|
||||
['label' => 'Locations', 'value' => $this->listSummary(data_get($normalized, 'scope.locations', []))],
|
||||
['label' => 'Actions', 'value' => $this->listSummary($normalized['actions'] ?? [])],
|
||||
['label' => 'Rules', 'value' => $this->ruleSummary($normalized['rules'] ?? [])],
|
||||
], state: $normalized['mode'] ?? $normalized['state'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $normalized
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $readiness
|
||||
* @param list<array{label: string, value: mixed}> $fields
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function baseSummary(
|
||||
string $resourceType,
|
||||
array $normalized,
|
||||
array $context,
|
||||
array $readiness,
|
||||
array $fields,
|
||||
?string $state = null,
|
||||
): array {
|
||||
return [
|
||||
'resource_type' => $resourceType,
|
||||
'display_name' => $normalized['display_name'] ?? 'Unnamed '.$resourceType,
|
||||
'state' => $state,
|
||||
'readiness' => $readiness,
|
||||
'summary_fields' => array_values(array_filter(
|
||||
array_map(fn (array $field): array => [
|
||||
'label' => $field['label'],
|
||||
'value' => $this->summaryValue($field['value']),
|
||||
], [
|
||||
['label' => 'Review readiness', 'value' => $readiness['label'] ?? null],
|
||||
...$fields,
|
||||
]),
|
||||
static fn (array $field): bool => filled($field['value'] ?? null),
|
||||
)),
|
||||
'targets' => [],
|
||||
'conditions' => [],
|
||||
'claim_state' => $this->stringContext($context, 'claim_state'),
|
||||
'identity_state' => $this->stringContext($context, 'identity_state'),
|
||||
'last_captured' => $this->stringContext($context, 'last_captured'),
|
||||
'unsupported_fields' => data_get($normalized, 'diagnostics.unsupported_fields', []),
|
||||
'redacted_fields' => data_get($normalized, 'diagnostics.redacted_fields', []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $values
|
||||
*/
|
||||
private function listSummary(array $values): ?string
|
||||
{
|
||||
$values = array_values(array_filter(
|
||||
array_map(static fn (mixed $value): string => is_scalar($value) ? trim((string) $value) : '', $values),
|
||||
static fn (string $value): bool => $value !== '',
|
||||
));
|
||||
|
||||
return $values === [] ? null : implode(', ', $values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rules
|
||||
*/
|
||||
private function ruleSummary(array $rules): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if (! is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $this->summaryValue($rule['name'] ?? null);
|
||||
$actions = $this->listSummary(is_array($rule['actions'] ?? null) ? $rule['actions'] : []);
|
||||
$parts[] = trim(implode(': ', array_filter([$name, $actions], static fn (?string $value): bool => filled($value))));
|
||||
}
|
||||
|
||||
$parts = array_values(array_filter($parts));
|
||||
|
||||
return $parts === [] ? null : implode('; ', $parts);
|
||||
}
|
||||
|
||||
private function summaryValue(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '' || $value === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'yes' : 'no';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function stringContext(array $context, string $key): ?string
|
||||
{
|
||||
$value = $context[$key] ?? null;
|
||||
|
||||
if ($value instanceof \BackedEnum) {
|
||||
return (string) $value->value;
|
||||
}
|
||||
|
||||
if (! is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function arrayContext(array $context, string $key): ?array
|
||||
{
|
||||
$value = $context[$key] ?? null;
|
||||
|
||||
return is_array($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,363 @@
|
||||
<?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('Spec423 smokes the Coverage v2 inspect surface for Security and Compliance comparable renderable evidence', function (): void {
|
||||
[$user, $environment] = spec423CoverageV2BrowserFixture();
|
||||
spec423AuthenticateCoverageV2Browser($this, $user, $environment);
|
||||
|
||||
$page = visit(CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin'))
|
||||
->resize(768, 1100)
|
||||
->waitForText('Coverage v2 Readiness')
|
||||
->waitForText('Spec423 Browser DLP Policy')
|
||||
->assertSee('Resource type registry')
|
||||
->assertSee('Resource instances')
|
||||
->assertSee('DLP compliance policy')
|
||||
->assertSee('Coverage level')
|
||||
->assertSee('Renderable')
|
||||
->assertSee('Internal only')
|
||||
->assertDontSee('dlpCompliancePolicy:provider_external_id:spec423-browser')
|
||||
->assertDontSee('Security and Compliance covered')
|
||||
->assertDontSee('Purview coverage')
|
||||
->assertDontSee('certified')
|
||||
->assertDontSee('restore-ready')
|
||||
->assertDontSee('customer-ready')
|
||||
->assertDontSee('legal-ready')
|
||||
->assertDontSee('spec423-browser-raw-secret')
|
||||
->assertDontSee('spec423-browser-client-secret')
|
||||
->assertDontSee('spec423-browser-dlp-incident-content')
|
||||
->assertScript('typeof window.Livewire !== "undefined"', true)
|
||||
->assertScript('(() => document.querySelectorAll("table tbody tr").length > 0)()', true)
|
||||
->assertScript(<<<'JS'
|
||||
(() => {
|
||||
const row = Array.from(document.querySelectorAll('table tbody tr'))
|
||||
.find((candidate) => candidate.textContent.includes('Spec423 Browser DLP Policy'));
|
||||
const resourceTypeCellText = row?.querySelectorAll('td')?.[1]?.innerText ?? '';
|
||||
|
||||
return resourceTypeCellText.includes('DLP compliance policy')
|
||||
&& ! resourceTypeCellText.includes('dlpCompliancePolicy');
|
||||
})()
|
||||
JS, 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('Spec423 Browser DLP Policy'));
|
||||
const inspect = Array.from(row?.querySelectorAll('button, a') ?? [])
|
||||
.find((element) => element.textContent.includes('Spec423 Browser DLP Policy'));
|
||||
|
||||
inspect?.click();
|
||||
})()
|
||||
JS);
|
||||
|
||||
$page
|
||||
->waitForText('Coverage: Renderable')
|
||||
->assertSee('DLP compliance policy')
|
||||
->assertSee('Display name')
|
||||
->assertSee('Spec423 Browser DLP Policy')
|
||||
->assertSee('Review readiness')
|
||||
->assertSee('Manual review required')
|
||||
->assertSee('Mode / enforcement')
|
||||
->assertSee('Enforce')
|
||||
->assertSee('Rules')
|
||||
->assertSee('BlockAccess')
|
||||
->assertSee('Compare summary')
|
||||
->assertSee('Previous comparable evidence')
|
||||
->assertSee('Mode')
|
||||
->assertSee('Redacted fields')
|
||||
->assertSee('Rules.0.DlpIncidentContent')
|
||||
->assertSee('Evidence: Content backed')
|
||||
->assertSee('Identity: Stable')
|
||||
->assertSee('Claim: Internal only')
|
||||
->assertSee('View technical details')
|
||||
->assertDontSee('Source: TCM')
|
||||
->assertDontSee('Evidence hash')
|
||||
->assertDontSee('Source contract')
|
||||
->assertDontSee('Source schema hash')
|
||||
->assertDontSee('Operation #')
|
||||
->assertDontSee('dlpCompliancePolicy:provider_external_id:spec423-browser')
|
||||
->assertScript('(() => document.querySelector("details")?.open === false)()', true)
|
||||
->assertScript(<<<'JS'
|
||||
(() => {
|
||||
const technicalDetails = document.querySelector('details')?.textContent ?? '';
|
||||
|
||||
return technicalDetails.includes('Source: TCM')
|
||||
&& technicalDetails.includes('Evidence hash')
|
||||
&& technicalDetails.includes('Source contract')
|
||||
&& technicalDetails.includes('Source schema hash')
|
||||
&& technicalDetails.includes('Operation #');
|
||||
})()
|
||||
JS, true)
|
||||
->assertDontSee('Security and Compliance covered')
|
||||
->assertDontSee('Purview coverage')
|
||||
->assertDontSee('certified')
|
||||
->assertDontSee('restore-ready')
|
||||
->assertDontSee('customer-ready')
|
||||
->assertDontSee('legal-ready')
|
||||
->assertDontSee('spec423-browser-raw-secret')
|
||||
->assertDontSee('spec423-browser-client-secret')
|
||||
->assertDontSee('spec423-browser-dlp-incident-content')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: ManagedEnvironment}
|
||||
*/
|
||||
function spec423CoverageV2BrowserFixture(): array
|
||||
{
|
||||
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||
|
||||
$environment = ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Spec423 Browser Environment',
|
||||
'external_id' => 'spec423-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' => 'Spec423 Browser Microsoft provider',
|
||||
]);
|
||||
|
||||
TenantConfigurationSupportedScope::factory()->create([
|
||||
'scope_key' => 'spec423_browser_internal_security_compliance_scope',
|
||||
'display_name' => 'Spec423 Browser internal Security Compliance scope',
|
||||
'minimum_coverage_level' => CoverageLevel::ContentBacked->value,
|
||||
'included_resource_types' => ['dlpCompliancePolicy'],
|
||||
'allow_graph_fallback' => false,
|
||||
'allow_beta' => false,
|
||||
'customer_claims_allowed' => false,
|
||||
]);
|
||||
|
||||
spec423BrowserEvidenceResource(
|
||||
environment: $environment,
|
||||
user: $user,
|
||||
connection: $connection,
|
||||
canonicalType: 'dlpCompliancePolicy',
|
||||
displayName: 'Spec423 Browser DLP Policy',
|
||||
previousPayload: [
|
||||
'DisplayName' => 'Spec423 Browser DLP Policy',
|
||||
'Mode' => 'Audit',
|
||||
'Locations' => ['Exchange'],
|
||||
'Rules' => [['Name' => 'Rule', 'Actions' => ['NotifyUser']]],
|
||||
],
|
||||
latestPayload: [
|
||||
'DisplayName' => 'Spec423 Browser DLP Policy',
|
||||
'Mode' => 'Enforce',
|
||||
'Locations' => ['Exchange'],
|
||||
'clientSecret' => 'spec423-browser-client-secret',
|
||||
'Rules' => [[
|
||||
'Name' => 'Rule',
|
||||
'Actions' => ['BlockAccess'],
|
||||
'DlpIncidentContent' => 'spec423-browser-dlp-incident-content',
|
||||
]],
|
||||
],
|
||||
);
|
||||
|
||||
return [$user, $environment->refresh()];
|
||||
}
|
||||
|
||||
function spec423BrowserEvidenceResource(
|
||||
ManagedEnvironment $environment,
|
||||
User $user,
|
||||
ProviderConnection $connection,
|
||||
string $canonicalType,
|
||||
string $displayName,
|
||||
array $previousPayload,
|
||||
array $latestPayload,
|
||||
): TenantConfigurationResource {
|
||||
$resourceType = TenantConfigurationResourceType::query()
|
||||
->where('canonical_type', $canonicalType)
|
||||
->where('source_class', SourceClass::Tcm->value)
|
||||
->firstOrFail();
|
||||
$previousRun = spec423BrowserRun($environment, $user, $connection, $canonicalType, minutesAgo: 5);
|
||||
$run = spec423BrowserRun($environment, $user, $connection, $canonicalType);
|
||||
$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' => $canonicalType,
|
||||
'canonical_resource_key' => $canonicalType.':provider_external_id:spec423-browser',
|
||||
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
|
||||
'source_resource_id' => 'spec423-browser',
|
||||
'source_display_name' => $displayName,
|
||||
'source_class' => SourceClass::Tcm->value,
|
||||
'source_metadata' => [
|
||||
'source_contract_key' => 'spec423.synthetic.'.$canonicalType,
|
||||
'source_endpoint' => '/spec423/synthetic/'.$canonicalType,
|
||||
'source_version' => 'v1.0',
|
||||
'registry_source_class' => SourceClass::Tcm->value,
|
||||
'registry_support_state' => 'out_of_scope',
|
||||
],
|
||||
'identity_strategy' => 'provider.external.id.v1',
|
||||
'source_identity' => [
|
||||
'primary_field' => 'id',
|
||||
'primary_value' => 'spec423-browser',
|
||||
],
|
||||
'identity_diagnostics' => [
|
||||
'reason_code' => 'provider_external_id',
|
||||
],
|
||||
'identity_evaluated_at' => now(),
|
||||
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
||||
'latest_identity_state' => IdentityState::Stable->value,
|
||||
'latest_claim_state' => ClaimState::InternalOnly->value,
|
||||
'latest_captured_at' => now(),
|
||||
]);
|
||||
|
||||
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' => 'spec423.synthetic.'.$canonicalType,
|
||||
'source_endpoint' => '/spec423/synthetic/'.$canonicalType,
|
||||
'source_version' => 'v1.0',
|
||||
'source_schema_hash' => 'spec423-browser-previous-schema-hash',
|
||||
'source_metadata' => [
|
||||
'registry_source_class' => SourceClass::Tcm->value,
|
||||
'registry_support_state' => 'out_of_scope',
|
||||
],
|
||||
'raw_payload' => ['id' => 'spec423-browser'],
|
||||
'normalized_payload' => $previousPayload,
|
||||
'payload_hash' => str_repeat('a', 64),
|
||||
'permission_context' => ['scopes_granted' => []],
|
||||
'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' => 'spec423.synthetic.'.$canonicalType,
|
||||
'source_endpoint' => '/spec423/synthetic/'.$canonicalType,
|
||||
'source_version' => 'v1.0',
|
||||
'source_schema_hash' => 'spec423-browser-schema-hash',
|
||||
'source_metadata' => [
|
||||
'registry_source_class' => SourceClass::Tcm->value,
|
||||
'registry_support_state' => 'out_of_scope',
|
||||
],
|
||||
'raw_payload' => ['id' => 'spec423-browser', 'secret' => 'spec423-browser-raw-secret'],
|
||||
'normalized_payload' => $latestPayload,
|
||||
'payload_hash' => str_repeat('b', 64),
|
||||
'permission_context' => ['scopes_granted' => []],
|
||||
'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' => (string) $evidence->payload_hash,
|
||||
])->save();
|
||||
|
||||
return $resource->refresh();
|
||||
}
|
||||
|
||||
function spec423BrowserRun(
|
||||
ManagedEnvironment $environment,
|
||||
User $user,
|
||||
ProviderConnection $connection,
|
||||
string $canonicalType,
|
||||
int $minutesAgo = 0,
|
||||
): OperationRun {
|
||||
$timestamp = $minutesAgo > 0 ? now()->subMinutes($minutesAgo) : now();
|
||||
|
||||
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' => [$canonicalType],
|
||||
'outcomes' => [
|
||||
['canonical_type' => $canonicalType, 'outcome' => CaptureOutcome::Captured->value],
|
||||
],
|
||||
'target_scope' => [
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
],
|
||||
'started_at' => $timestamp,
|
||||
'completed_at' => $timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
function spec423AuthenticateCoverageV2Browser(
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessDecision;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
it('Spec423 keeps Coverage v2 readiness access deny-as-not-found for non-members', function (): void {
|
||||
[$owner, $environment] = createUserWithTenant(role: 'owner');
|
||||
$outsider = User::factory()->create();
|
||||
|
||||
$this->actingAs($outsider)
|
||||
->get(CoverageV2Readiness::getUrl(tenant: $environment))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('Spec423 keeps Coverage v2 readiness access deny-as-not-found for wrong managed environment scope', function (): void {
|
||||
[$owner, $environment] = createUserWithTenant(role: 'owner');
|
||||
$otherEnvironment = ManagedEnvironment::factory()->create(['workspace_id' => (int) $environment->workspace_id]);
|
||||
$outsider = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'user_id' => (int) $outsider->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
DB::table('managed_environment_memberships')->insert([
|
||||
'id' => (string) Str::uuid(),
|
||||
'managed_environment_id' => (int) $otherEnvironment->getKey(),
|
||||
'user_id' => (int) $outsider->getKey(),
|
||||
'role' => 'owner',
|
||||
'source' => 'manual',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($outsider);
|
||||
$environment->makeCurrent();
|
||||
Filament::setTenant($environment, true);
|
||||
|
||||
$this->get(CoverageV2Readiness::getUrl(tenant: $environment))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('Spec423 keeps Coverage v2 readiness access forbidden for in-scope members missing capability', function (): void {
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$environment->makeCurrent();
|
||||
Filament::setTenant($environment, true);
|
||||
|
||||
app()->instance(ManagedEnvironmentAccessScopeResolver::class, new class
|
||||
{
|
||||
public function decision(User $user, ManagedEnvironment $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);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,454 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\TenantConfigurationResource;
|
||||
use App\Models\TenantConfigurationResourceEvidence;
|
||||
use App\Models\TenantConfigurationResourceType;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\TenantConfiguration\CoverageEvidenceWriter;
|
||||
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
|
||||
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
|
||||
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;
|
||||
|
||||
it('Spec423 exposes typed Security and Compliance summaries with readiness and compare details without provider calls', function (string $canonicalType, array $previousPayload, array $latestPayload, string $resourceType, string $expectedText, string $expectedChange): void {
|
||||
[$user, $environment, $resource] = spec423FeatureEvidencePair($canonicalType, $previousPayload, $latestPayload);
|
||||
app()->instance(GraphClientInterface::class, spec423FailingGraphClient());
|
||||
|
||||
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $environment, $user);
|
||||
$summary = $details['typed_render_summary'] ?? null;
|
||||
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($summary)->toBeArray()
|
||||
->and($summary['resource_type'])->toBe($resourceType)
|
||||
->and($encoded)->toContain($expectedText)
|
||||
->and($encoded)->toContain('readiness_requires_manual_review')
|
||||
->and($encoded)->not->toContain('raw_payload')
|
||||
->and($encoded)->not->toContain('source_endpoint')
|
||||
->and($encoded)->not->toContain('spec423-feature-secret')
|
||||
->and($encoded)->not->toContain('spec423-feature-content')
|
||||
->and($summary['compare_summary']['status'])->toBe('Manual review required')
|
||||
->and($summary['compare_summary']['changed'])->toBeTrue()
|
||||
->and(collect($summary['compare_summary']['changes'])->pluck('label'))->toContain($expectedChange);
|
||||
})->with([
|
||||
'retention policy' => [
|
||||
'retentionCompliancePolicy',
|
||||
[
|
||||
'DisplayName' => 'Spec423 Feature Retention Policy',
|
||||
'RetentionDuration' => 5,
|
||||
'RetentionDurationUnit' => 'Years',
|
||||
'DispositionAction' => 'Keep',
|
||||
'IncludedLocations' => ['Exchange'],
|
||||
],
|
||||
[
|
||||
'DisplayName' => 'Spec423 Feature Retention Policy',
|
||||
'RetentionDuration' => 7,
|
||||
'RetentionDurationUnit' => 'Years',
|
||||
'DispositionAction' => 'Delete',
|
||||
'IncludedLocations' => ['Exchange'],
|
||||
'clientSecret' => 'spec423-feature-secret',
|
||||
],
|
||||
'Retention compliance policy',
|
||||
'7 Years',
|
||||
'Retention Duration',
|
||||
],
|
||||
'label policy' => [
|
||||
'labelPolicy',
|
||||
[
|
||||
'DisplayName' => 'Spec423 Feature Label Policy',
|
||||
'PublishedLabels' => [['displayName' => 'General']],
|
||||
'Mandatory' => false,
|
||||
],
|
||||
[
|
||||
'DisplayName' => 'Spec423 Feature Label Policy',
|
||||
'PublishedLabels' => [['displayName' => 'Highly Confidential']],
|
||||
'Mandatory' => true,
|
||||
],
|
||||
'Label policy',
|
||||
'Highly Confidential',
|
||||
'Labeling Published Labels',
|
||||
],
|
||||
'dlp policy' => [
|
||||
'dlpCompliancePolicy',
|
||||
[
|
||||
'DisplayName' => 'Spec423 Feature DLP Policy',
|
||||
'Mode' => 'Audit',
|
||||
'Locations' => ['Exchange'],
|
||||
'Rules' => [['Name' => 'Rule', 'Actions' => ['NotifyUser']]],
|
||||
],
|
||||
[
|
||||
'DisplayName' => 'Spec423 Feature DLP Policy',
|
||||
'Mode' => 'Enforce',
|
||||
'Locations' => ['Exchange'],
|
||||
'Rules' => [['Name' => 'Rule', 'Actions' => ['BlockAccess'], 'DlpIncidentContent' => 'spec423-feature-content']],
|
||||
],
|
||||
'DLP compliance policy',
|
||||
'Enforce',
|
||||
'Mode',
|
||||
],
|
||||
]);
|
||||
|
||||
it('Spec423 does not render typed summaries for non-renderable latest evidence', function (): void {
|
||||
[$user, $environment, $resource, $latestEvidence] = spec423FeatureEvidencePair(
|
||||
'retentionCompliancePolicy',
|
||||
['DisplayName' => 'Spec423 Non Renderable Retention', 'RetentionDuration' => 5],
|
||||
['DisplayName' => 'Spec423 Non Renderable Retention', 'RetentionDuration' => 7],
|
||||
);
|
||||
$latestEvidence->forceFill(['coverage_level' => CoverageLevel::ContentBacked->value])->save();
|
||||
$resource->unsetRelation('latestEvidence');
|
||||
|
||||
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource->fresh(), $environment, $user);
|
||||
|
||||
expect($details['typed_render_summary'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
it('Spec423 requires latest evidence to belong to the same provider connection as the resource', function (): void {
|
||||
[$user, $environment, $resource, $latestEvidence] = spec423FeatureEvidencePair(
|
||||
'labelPolicy',
|
||||
['DisplayName' => 'Spec423 Provider Labels', 'Mandatory' => false],
|
||||
['DisplayName' => 'Spec423 Provider Labels', 'Mandatory' => true],
|
||||
);
|
||||
$foreignConnection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
]);
|
||||
$latestEvidence->forceFill(['provider_connection_id' => (int) $foreignConnection->getKey()])->save();
|
||||
$resource->unsetRelation('latestEvidence');
|
||||
|
||||
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource->fresh(), $environment, $user);
|
||||
|
||||
expect($details['typed_render_summary'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
it('Spec423 promotes mandatory Security and Compliance content-backed evidence rows to renderable coverage', function (string $canonicalType, array $payload): 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(),
|
||||
]);
|
||||
$resourceType = spec423FeatureResourceType($canonicalType);
|
||||
$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' => $canonicalType,
|
||||
'canonical_resource_key' => $canonicalType.':provider_external_id:spec423-promotion',
|
||||
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
|
||||
'source_resource_id' => 'spec423-promotion',
|
||||
'source_display_name' => 'Spec423 promotion '.$canonicalType,
|
||||
'source_class' => SourceClass::Tcm->value,
|
||||
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
||||
'latest_identity_state' => IdentityState::Stable->value,
|
||||
'latest_claim_state' => ClaimState::InternalOnly->value,
|
||||
]);
|
||||
$run = OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
||||
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
$evidence = app(CoverageEvidenceWriter::class)->append(
|
||||
resource: $resource,
|
||||
resourceType: $resourceType,
|
||||
providerConnection: $connection,
|
||||
operationRun: $run,
|
||||
decision: new CoverageSourceContractDecision(
|
||||
canonicalType: $canonicalType,
|
||||
outcome: CaptureOutcome::Captured,
|
||||
contractKey: 'spec423.synthetic.'.$canonicalType,
|
||||
sourceEndpoint: '/spec423/synthetic/'.$canonicalType,
|
||||
),
|
||||
rawPayload: $payload,
|
||||
normalizedPayload: $payload,
|
||||
payloadHash: hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)),
|
||||
);
|
||||
|
||||
expect($evidence)->toBeInstanceOf(TenantConfigurationResourceEvidence::class)
|
||||
->and($evidence->coverage_level)->toBe(CoverageLevel::Renderable)
|
||||
->and($resource->fresh()->latest_evidence_state)->toBe(EvidenceState::ContentBacked);
|
||||
})->with([
|
||||
'retentionCompliancePolicy' => ['retentionCompliancePolicy', ['DisplayName' => 'Retention', 'RetentionDuration' => 7, 'DispositionAction' => 'Delete', 'clientSecret' => 'spec423-promotion-secret']],
|
||||
'labelPolicy' => ['labelPolicy', ['DisplayName' => 'Labels', 'PublishedLabels' => [['displayName' => 'Highly Confidential']]]],
|
||||
'dlpCompliancePolicy' => ['dlpCompliancePolicy', ['DisplayName' => 'DLP', 'Mode' => 'Enforce', 'Rules' => [['Name' => 'Rule', 'Actions' => ['BlockAccess']]]]],
|
||||
]);
|
||||
|
||||
it('Spec423 keeps unknown nested material fields renderable with manual-review readiness', 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(),
|
||||
]);
|
||||
$canonicalType = 'dlpCompliancePolicy';
|
||||
$resourceType = spec423FeatureResourceType($canonicalType);
|
||||
$payload = [
|
||||
'DisplayName' => 'Spec423 Nested DLP',
|
||||
'Mode' => 'Enforce',
|
||||
'Rules' => [[
|
||||
'Name' => 'Nested condition rule',
|
||||
'Actions' => ['BlockAccess'],
|
||||
'Conditions' => ['SensitiveInfoTypes' => ['Spec423 Credit Card Detector']],
|
||||
]],
|
||||
];
|
||||
$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' => $canonicalType,
|
||||
'canonical_resource_key' => $canonicalType.':provider_external_id:spec423-nested',
|
||||
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
|
||||
'source_resource_id' => 'spec423-nested',
|
||||
'source_display_name' => 'Spec423 Nested DLP',
|
||||
'source_class' => SourceClass::Tcm->value,
|
||||
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
||||
'latest_identity_state' => IdentityState::Stable->value,
|
||||
'latest_claim_state' => ClaimState::InternalOnly->value,
|
||||
]);
|
||||
$run = OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
||||
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
$evidence = app(CoverageEvidenceWriter::class)->append(
|
||||
resource: $resource,
|
||||
resourceType: $resourceType,
|
||||
providerConnection: $connection,
|
||||
operationRun: $run,
|
||||
decision: new CoverageSourceContractDecision(
|
||||
canonicalType: $canonicalType,
|
||||
outcome: CaptureOutcome::Captured,
|
||||
contractKey: 'spec423.synthetic.'.$canonicalType,
|
||||
sourceEndpoint: '/spec423/synthetic/'.$canonicalType,
|
||||
),
|
||||
rawPayload: $payload,
|
||||
normalizedPayload: $payload,
|
||||
payloadHash: hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)),
|
||||
);
|
||||
|
||||
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource->refresh(), $environment, $user);
|
||||
$summary = $details['typed_render_summary'] ?? null;
|
||||
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($evidence->coverage_level)->toBe(CoverageLevel::Renderable)
|
||||
->and($summary)->toBeArray()
|
||||
->and(data_get($summary, 'readiness.state'))->toBe('readiness_requires_manual_review')
|
||||
->and(data_get($summary, 'readiness.label'))->toBe('Manual review required')
|
||||
->and($summary['unsupported_fields'])->toContain('Rules.0.Conditions')
|
||||
->and($encoded)->not->toContain('Spec423 Credit Card Detector');
|
||||
});
|
||||
|
||||
it('Spec423 leaves optional Security and Compliance types unpromoted without bounded evidence', function (): void {
|
||||
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||
|
||||
$resourceTypes = TenantConfigurationResourceType::query()
|
||||
->whereIn('canonical_type', [
|
||||
'autoSensitivityLabelPolicy',
|
||||
'protectionAlert',
|
||||
'complianceTag',
|
||||
])
|
||||
->get()
|
||||
->keyBy('canonical_type');
|
||||
|
||||
expect($resourceTypes)->toHaveCount(3);
|
||||
|
||||
foreach ($resourceTypes as $resourceType) {
|
||||
expect($resourceType->default_coverage_level)->toBe(CoverageLevel::Detected)
|
||||
->and($resourceType->default_evidence_state)->toBe(EvidenceState::NotCaptured)
|
||||
->and($resourceType->allows_certified_claims)->toBeFalse()
|
||||
->and($resourceType->restore_tier->value)->toBe('not_restorable');
|
||||
}
|
||||
});
|
||||
|
||||
it('Spec423 keeps Security and Compliance support separate from restore, legal, certification, customer output, and tenant ownership', function (): void {
|
||||
$paths = [
|
||||
'apps/platform/app/Services/TenantConfiguration/SecurityComplianceComparablePayloadNormalizer.php',
|
||||
'apps/platform/app/Services/TenantConfiguration/SecurityComplianceCoverageComparator.php',
|
||||
'apps/platform/app/Services/TenantConfiguration/SecurityComplianceRenderableSummaryBuilder.php',
|
||||
'apps/platform/app/Services/TenantConfiguration/SecurityComplianceReadinessEvaluator.php',
|
||||
'apps/platform/app/Services/TenantConfiguration/ClaimGuard.php',
|
||||
];
|
||||
$content = collect($paths)
|
||||
->map(fn (string $path): string => file_exists(repo_path($path)) ? (file_get_contents(repo_path($path)) ?: '') : '')
|
||||
->implode("\n");
|
||||
|
||||
expect($content)
|
||||
->not->toContain('GraphClientInterface')
|
||||
->not->toContain('Http::')
|
||||
->not->toContain('tenant_id')
|
||||
->not->toContain('restore-ready')
|
||||
->not->toContain('certification-ready')
|
||||
->not->toContain('legal-ready')
|
||||
->not->toContain('customer-ready')
|
||||
->not->toContain('ReviewPack')
|
||||
->not->toContain('namespace App\\Services\\TenantConfiguration\\SecurityCompliance');
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: mixed, 1: mixed, 2: TenantConfigurationResource, 3: TenantConfigurationResourceEvidence}
|
||||
*/
|
||||
function spec423FeatureEvidencePair(string $canonicalType, array $previousPayload, array $latestPayload): array
|
||||
{
|
||||
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(),
|
||||
]);
|
||||
$resourceType = spec423FeatureResourceType($canonicalType);
|
||||
$displayName = (string) ($latestPayload['DisplayName'] ?? $latestPayload['displayName'] ?? 'Spec423 Feature Resource');
|
||||
$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' => $canonicalType,
|
||||
'canonical_resource_key' => $canonicalType.':provider_external_id:spec423-feature',
|
||||
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
|
||||
'source_resource_id' => 'spec423-feature',
|
||||
'source_display_name' => $displayName,
|
||||
'source_class' => SourceClass::Tcm->value,
|
||||
'source_metadata' => [
|
||||
'source_contract_key' => 'spec423.synthetic.'.$canonicalType,
|
||||
'source_endpoint' => '/spec423/synthetic/'.$canonicalType,
|
||||
'source_version' => 'v1.0',
|
||||
'registry_source_class' => SourceClass::Tcm->value,
|
||||
'registry_support_state' => 'out_of_scope',
|
||||
],
|
||||
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
||||
'latest_identity_state' => IdentityState::Stable->value,
|
||||
'latest_claim_state' => ClaimState::InternalOnly->value,
|
||||
'latest_captured_at' => now(),
|
||||
]);
|
||||
$previousRun = spec423FeatureRun($user, $environment, $connection, $canonicalType, minutesAgo: 5);
|
||||
$latestRun = spec423FeatureRun($user, $environment, $connection, $canonicalType);
|
||||
|
||||
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' => 'spec423.synthetic.'.$canonicalType,
|
||||
'source_endpoint' => '/spec423/synthetic/'.$canonicalType,
|
||||
'source_version' => 'v1.0',
|
||||
'raw_payload' => ['id' => 'spec423-feature'],
|
||||
'normalized_payload' => $previousPayload,
|
||||
'payload_hash' => hash('sha256', json_encode($previousPayload, JSON_THROW_ON_ERROR)),
|
||||
'evidence_state' => EvidenceState::ContentBacked->value,
|
||||
'coverage_level' => CoverageLevel::Comparable->value,
|
||||
'capture_outcome' => CaptureOutcome::Captured->value,
|
||||
'captured_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$latestEvidence = 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) $latestRun->getKey(),
|
||||
'source_contract_key' => 'spec423.synthetic.'.$canonicalType,
|
||||
'source_endpoint' => '/spec423/synthetic/'.$canonicalType,
|
||||
'source_version' => 'v1.0',
|
||||
'raw_payload' => ['id' => 'spec423-feature', 'secret' => 'spec423-feature-secret'],
|
||||
'normalized_payload' => $latestPayload,
|
||||
'payload_hash' => hash('sha256', json_encode($latestPayload, JSON_THROW_ON_ERROR)),
|
||||
'evidence_state' => EvidenceState::ContentBacked->value,
|
||||
'coverage_level' => CoverageLevel::Renderable->value,
|
||||
'capture_outcome' => CaptureOutcome::Captured->value,
|
||||
'captured_at' => now(),
|
||||
]);
|
||||
|
||||
$resource->forceFill([
|
||||
'latest_evidence_id' => (int) $latestEvidence->getKey(),
|
||||
'latest_payload_hash' => (string) $latestEvidence->payload_hash,
|
||||
])->save();
|
||||
|
||||
return [$user, $environment, $resource->refresh(), $latestEvidence];
|
||||
}
|
||||
|
||||
function spec423FeatureResourceType(string $canonicalType): TenantConfigurationResourceType
|
||||
{
|
||||
return TenantConfigurationResourceType::query()
|
||||
->where('canonical_type', $canonicalType)
|
||||
->where('source_class', SourceClass::Tcm->value)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
function spec423FeatureRun($user, $environment, ProviderConnection $connection, string $canonicalType, int $minutesAgo = 0): OperationRun
|
||||
{
|
||||
$timestamp = $minutesAgo > 0 ? now()->subMinutes($minutesAgo) : now();
|
||||
|
||||
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
||||
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'target_scope' => [
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
'resource_types' => [$canonicalType],
|
||||
],
|
||||
'started_at' => $timestamp,
|
||||
'completed_at' => $timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
function spec423FailingGraphClient(): GraphClientInterface
|
||||
{
|
||||
return new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\TenantConfiguration\ClaimGuard;
|
||||
use App\Support\TenantConfiguration\ClaimState;
|
||||
|
||||
it('Spec423 allows only scoped internal Security and Compliance comparable/renderable/readiness wording', function (string $claim): void {
|
||||
expect(app(ClaimGuard::class)->evaluateStatement($claim, internalOperatorOnly: true))
|
||||
->toBe(ClaimState::InternalOnly);
|
||||
})->with([
|
||||
'Selected Security and Compliance resources are comparable for internal operator review',
|
||||
'Selected Security and Compliance resources are renderable for internal review',
|
||||
'Selected Security and Compliance resources are ready for operator review',
|
||||
'Selected retention compliance policies are comparable for internal operator review',
|
||||
'Selected DLP compliance policies are renderable for internal operator review',
|
||||
]);
|
||||
|
||||
it('Spec423 blocks unsafe Security, Compliance, Purview, legal, restore, customer, and broad claims', function (string $claim): void {
|
||||
expect(app(ClaimGuard::class)->evaluateStatement($claim, internalOperatorOnly: true))
|
||||
->toBe(ClaimState::ClaimBlocked);
|
||||
})->with([
|
||||
'Security and Compliance resources are comparable for internal operator review',
|
||||
'Security and Compliance resources are renderable for internal review',
|
||||
'Selected Security and Compliance resources are comparable',
|
||||
'Security and Compliance coverage',
|
||||
'Security and Compliance supported',
|
||||
'Purview coverage',
|
||||
'Full Purview coverage',
|
||||
'Complete compliance coverage',
|
||||
'Full coverage',
|
||||
'Complete coverage',
|
||||
'All coverage',
|
||||
'All Security and Compliance resources are supported',
|
||||
'100 percent Security and Compliance coverage',
|
||||
'Security and Compliance certified coverage',
|
||||
'Retention is restore-ready',
|
||||
'DLP policies are apply-ready',
|
||||
'Security and Compliance legal attestation',
|
||||
'Regulatory compliance verified',
|
||||
'Security and Compliance customer-ready evidence',
|
||||
'Security and Compliance Review Pack proof',
|
||||
'M365 compliance coverage',
|
||||
]);
|
||||
@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\TenantConfiguration\SecurityComplianceComparablePayloadNormalizer;
|
||||
|
||||
it('Spec423 normalizes retention compliance policies deterministically', function (): void {
|
||||
$normalized = app(SecurityComplianceComparablePayloadNormalizer::class)->normalize('retentionCompliancePolicy', [
|
||||
'Identity' => 'retention-policy-423',
|
||||
'DisplayName' => 'Spec423 Retention Policy',
|
||||
'Enabled' => true,
|
||||
'RetentionDuration' => 7,
|
||||
'RetentionDurationUnit' => 'Years',
|
||||
'DispositionAction' => 'Delete',
|
||||
'IncludedLocations' => ['Exchange', 'SharePoint'],
|
||||
'ExcludedLocations' => ['Teams'],
|
||||
'@odata.etag' => 'retention-etag-423',
|
||||
]);
|
||||
|
||||
expect($normalized['canonical_type'])->toBe('retentionCompliancePolicy')
|
||||
->and($normalized['supported'])->toBeTrue()
|
||||
->and($normalized['display_name'])->toBe('Spec423 Retention Policy')
|
||||
->and($normalized['enabled_state'])->toBe('enabled')
|
||||
->and($normalized['retention']['duration'])->toBe('7')
|
||||
->and($normalized['retention']['duration_unit'])->toBe('Years')
|
||||
->and($normalized['retention']['disposition_action'])->toBe('Delete')
|
||||
->and($normalized['scope']['included_locations'])->toBe(['Exchange', 'SharePoint'])
|
||||
->and($normalized['scope']['excluded_locations'])->toBe(['Teams'])
|
||||
->and($normalized['diagnostics']['volatile_fields'])->toContain('@odata.etag');
|
||||
});
|
||||
|
||||
it('Spec423 normalizes label policies without raw label identifiers as the summary truth', function (): void {
|
||||
$normalized = app(SecurityComplianceComparablePayloadNormalizer::class)->normalize('labelPolicy', [
|
||||
'DisplayName' => 'Spec423 Label Policy',
|
||||
'PublishedLabels' => [
|
||||
['id' => 'secret-label-guid-2', 'displayName' => 'Highly Confidential'],
|
||||
['id' => 'secret-label-guid-1', 'name' => 'General'],
|
||||
],
|
||||
'DefaultLabel' => ['id' => 'default-label-guid', 'displayName' => 'General'],
|
||||
'Mandatory' => true,
|
||||
'IncludedGroups' => ['Finance', 'Legal'],
|
||||
]);
|
||||
|
||||
$encoded = json_encode($normalized, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($normalized['display_name'])->toBe('Spec423 Label Policy')
|
||||
->and($normalized['labeling']['published_labels'])->toBe(['General', 'Highly Confidential'])
|
||||
->and($normalized['labeling']['default_label'])->toBe('General')
|
||||
->and($normalized['labeling']['mandatory'])->toBe('yes')
|
||||
->and($normalized['scope']['included_groups'])->toBe(['Finance', 'Legal'])
|
||||
->and($encoded)->not->toContain('secret-label-guid')
|
||||
->and($encoded)->not->toContain('default-label-guid');
|
||||
});
|
||||
|
||||
it('Spec423 normalizes DLP compliance policies and redacts content-bearing rule details', function (): void {
|
||||
$normalized = app(SecurityComplianceComparablePayloadNormalizer::class)->normalize('dlpCompliancePolicy', [
|
||||
'DisplayName' => 'Spec423 DLP Policy',
|
||||
'Mode' => 'Enforce',
|
||||
'Locations' => ['Exchange', 'Teams'],
|
||||
'Rules' => [
|
||||
[
|
||||
'Name' => 'Credit card detector',
|
||||
'State' => 'Enabled',
|
||||
'Actions' => ['BlockAccess', 'NotifyUser'],
|
||||
'ContentContainsSensitiveInformation' => 'spec423-credit-card-content',
|
||||
],
|
||||
],
|
||||
]);
|
||||
$encoded = json_encode($normalized, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($normalized['display_name'])->toBe('Spec423 DLP Policy')
|
||||
->and($normalized['mode'])->toBe('Enforce')
|
||||
->and($normalized['scope']['locations'])->toBe(['Exchange', 'Teams'])
|
||||
->and($normalized['rules'][0]['name'])->toBe('Credit card detector')
|
||||
->and($normalized['rules'][0]['actions'])->toBe(['BlockAccess', 'NotifyUser'])
|
||||
->and($normalized['diagnostics']['redacted_fields'])->toContain('Rules.0.ContentContainsSensitiveInformation')
|
||||
->and($encoded)->not->toContain('spec423-credit-card-content');
|
||||
});
|
||||
|
||||
it('Spec423 records nested unsupported material DLP fields as manual-review diagnostics', function (): void {
|
||||
$normalized = app(SecurityComplianceComparablePayloadNormalizer::class)->normalize('dlpCompliancePolicy', [
|
||||
'DisplayName' => 'Spec423 Nested DLP Policy',
|
||||
'Mode' => 'Enforce',
|
||||
'Rules' => [
|
||||
[
|
||||
'Name' => 'Nested condition rule',
|
||||
'Actions' => ['BlockAccess'],
|
||||
'Conditions' => [
|
||||
'SensitiveInfoTypes' => ['Spec423 Credit Card Detector'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$encoded = json_encode($normalized, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($normalized['diagnostics']['unsupported_fields'])->toContain('Rules.0.Conditions')
|
||||
->and($normalized['diagnostics']['manual_review_fields'])->toContain('Rules.0.Conditions')
|
||||
->and($encoded)->not->toContain('Spec423 Credit Card Detector');
|
||||
});
|
||||
|
||||
it('Spec423 accepts bounded DLP rule action object fields while flagging unknown action payloads', function (): void {
|
||||
$normalized = app(SecurityComplianceComparablePayloadNormalizer::class)->normalize('dlpCompliancePolicy', [
|
||||
'DisplayName' => 'Spec423 Action DLP Policy',
|
||||
'Mode' => 'Enforce',
|
||||
'Rules' => [
|
||||
[
|
||||
'Name' => 'Action object rule',
|
||||
'Actions' => [
|
||||
[
|
||||
'Type' => 'BlockAccess',
|
||||
'DisplayName' => 'Block access',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'Name' => 'Unknown action payload rule',
|
||||
'Actions' => [
|
||||
[
|
||||
'Type' => 'NotifyUser',
|
||||
'CustomActionPayload' => ['Detector' => 'Spec423 DLP detector payload'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$encoded = json_encode($normalized, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($normalized['rules'][0]['actions'])->toBe(['Block access'])
|
||||
->and($normalized['diagnostics']['unsupported_fields'])->not->toContain('Rules.0.Actions.0.Type')
|
||||
->and($normalized['diagnostics']['manual_review_fields'])->not->toContain('Rules.0.Actions.0.Type')
|
||||
->and($normalized['diagnostics']['unsupported_fields'])->toContain('Rules.1.Actions.0.CustomActionPayload')
|
||||
->and($normalized['diagnostics']['manual_review_fields'])->toContain('Rules.1.Actions.0.CustomActionPayload')
|
||||
->and($encoded)->not->toContain('Spec423 DLP detector payload');
|
||||
});
|
||||
|
||||
it('Spec423 records volatile, redacted, unsupported, and manual-review diagnostics without leaking values', function (): void {
|
||||
$normalized = app(SecurityComplianceComparablePayloadNormalizer::class)->normalize('retentionCompliancePolicy', [
|
||||
'DisplayName' => 'Spec423 Diagnostic Retention',
|
||||
'RetentionDuration' => 5,
|
||||
'modifiedDateTime' => '2026-06-30T01:00:00Z',
|
||||
'clientSecret' => 'spec423-client-secret',
|
||||
'LegalHoldOverride' => ['caseContent' => 'spec423-legal-case-content'],
|
||||
]);
|
||||
$encoded = json_encode($normalized, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($normalized['diagnostics']['volatile_fields'])->toContain('modifiedDateTime')
|
||||
->and($normalized['diagnostics']['redacted_fields'])->toContain('clientSecret', 'LegalHoldOverride.caseContent')
|
||||
->and($normalized['diagnostics']['unsupported_fields'])->toContain('clientSecret', 'LegalHoldOverride')
|
||||
->and($normalized['diagnostics']['manual_review_fields'])->toContain('LegalHoldOverride')
|
||||
->and($encoded)->not->toContain('spec423-client-secret')
|
||||
->and($encoded)->not->toContain('spec423-legal-case-content');
|
||||
});
|
||||
|
||||
it('Spec423 keeps unsupported Security and Compliance types unpromoted', function (): void {
|
||||
$normalized = app(SecurityComplianceComparablePayloadNormalizer::class)->normalize('protectionAlert', [
|
||||
'DisplayName' => 'Spec423 Alert',
|
||||
]);
|
||||
|
||||
expect($normalized['supported'])->toBeFalse()
|
||||
->and($normalized['canonical_type'])->toBe('protectionAlert');
|
||||
});
|
||||
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\TenantConfiguration\SecurityComplianceCoverageComparator;
|
||||
|
||||
it('Spec423 emits bounded compare labels including diagnostics', function (): void {
|
||||
$comparator = app(SecurityComplianceCoverageComparator::class);
|
||||
|
||||
$changed = $comparator->compare('retentionCompliancePolicy', [
|
||||
'DisplayName' => 'Retention',
|
||||
'RetentionDuration' => 7,
|
||||
'modifiedDateTime' => '2026-06-30T01:00:00Z',
|
||||
], [
|
||||
'DisplayName' => 'Retention',
|
||||
'RetentionDuration' => 10,
|
||||
'DispositionAction' => 'Delete',
|
||||
'modifiedDateTime' => '2026-06-30T02:00:00Z',
|
||||
'clientSecret' => 'spec423-secret',
|
||||
'LegalHoldOverride' => true,
|
||||
]);
|
||||
$removed = $comparator->compare('labelPolicy', [
|
||||
'DisplayName' => 'Labels',
|
||||
'PublishedLabels' => [['displayName' => 'General']],
|
||||
], [
|
||||
'DisplayName' => 'Labels',
|
||||
'PublishedLabels' => [],
|
||||
]);
|
||||
$unsupported = $comparator->compare('protectionAlert', ['DisplayName' => 'Alert'], ['DisplayName' => 'Alert']);
|
||||
|
||||
$labels = collect($changed['changes'])
|
||||
->merge($removed['changes'])
|
||||
->merge($unsupported['changes'])
|
||||
->pluck('classification')
|
||||
->all();
|
||||
|
||||
expect($changed['changed'])->toBeTrue()
|
||||
->and($changed['classification'])->toBe('manual_review_required')
|
||||
->and($labels)->toContain('changed')
|
||||
->and($labels)->toContain('added')
|
||||
->and($labels)->toContain('removed')
|
||||
->and($labels)->toContain('ignored_volatile')
|
||||
->and($labels)->toContain('redacted')
|
||||
->and($labels)->toContain('unsupported_field')
|
||||
->and($labels)->toContain('manual_review_required')
|
||||
->and(json_encode($changed, JSON_THROW_ON_ERROR))->not->toContain('spec423-secret');
|
||||
});
|
||||
|
||||
it('Spec423 marks retention, DLP, and label material fields with non-informational importance', function (string $canonicalType, array $before, array $after, string $field, string $importance): void {
|
||||
$result = app(SecurityComplianceCoverageComparator::class)->compare($canonicalType, $before, $after);
|
||||
$change = collect($result['changes'])->firstWhere('field', $field);
|
||||
|
||||
expect($result['changed'])->toBeTrue()
|
||||
->and($change)->not->toBeNull()
|
||||
->and($change['classification'])->toBe('changed')
|
||||
->and($change['importance'])->toBe($importance);
|
||||
})->with([
|
||||
'retention duration' => [
|
||||
'retentionCompliancePolicy',
|
||||
['DisplayName' => 'Retention', 'RetentionDuration' => 5],
|
||||
['DisplayName' => 'Retention', 'RetentionDuration' => 7],
|
||||
'retention.duration',
|
||||
'critical',
|
||||
],
|
||||
'retention disposition' => [
|
||||
'retentionCompliancePolicy',
|
||||
['DisplayName' => 'Retention', 'DispositionAction' => 'Keep'],
|
||||
['DisplayName' => 'Retention', 'DispositionAction' => 'Delete'],
|
||||
'retention.disposition_action',
|
||||
'critical',
|
||||
],
|
||||
'dlp mode' => [
|
||||
'dlpCompliancePolicy',
|
||||
['DisplayName' => 'DLP', 'Mode' => 'Audit'],
|
||||
['DisplayName' => 'DLP', 'Mode' => 'Enforce'],
|
||||
'mode',
|
||||
'critical',
|
||||
],
|
||||
'dlp action' => [
|
||||
'dlpCompliancePolicy',
|
||||
['DisplayName' => 'DLP', 'Rules' => [['Name' => 'Rule', 'Actions' => ['NotifyUser']]]],
|
||||
['DisplayName' => 'DLP', 'Rules' => [['Name' => 'Rule', 'Actions' => ['BlockAccess']]]],
|
||||
'rules',
|
||||
'critical',
|
||||
],
|
||||
'label mandatory behavior' => [
|
||||
'labelPolicy',
|
||||
['DisplayName' => 'Labels', 'Mandatory' => false],
|
||||
['DisplayName' => 'Labels', 'Mandatory' => true],
|
||||
'labeling.mandatory',
|
||||
'important',
|
||||
],
|
||||
'published labels' => [
|
||||
'labelPolicy',
|
||||
['DisplayName' => 'Labels', 'PublishedLabels' => [['displayName' => 'General']]],
|
||||
['DisplayName' => 'Labels', 'PublishedLabels' => [['displayName' => 'Highly Confidential']]],
|
||||
'labeling.published_labels',
|
||||
'important',
|
||||
],
|
||||
]);
|
||||
|
||||
it('Spec423 treats volatile-only differences as unchanged', function (): void {
|
||||
$result = app(SecurityComplianceCoverageComparator::class)->compare('labelPolicy', [
|
||||
'DisplayName' => 'Labels',
|
||||
'PublishedLabels' => [['displayName' => 'General']],
|
||||
'modifiedDateTime' => '2026-06-30T01:00:00Z',
|
||||
], [
|
||||
'DisplayName' => 'Labels',
|
||||
'PublishedLabels' => [['displayName' => 'General']],
|
||||
'modifiedDateTime' => '2026-06-30T02:00:00Z',
|
||||
]);
|
||||
|
||||
expect($result['changed'])->toBeFalse()
|
||||
->and($result['classification'])->toBe('unchanged')
|
||||
->and(collect($result['changes'])->pluck('classification'))->toContain('ignored_volatile');
|
||||
});
|
||||
|
||||
it('Spec423 requires manual review for nested unknown material fields without leaking values', function (): void {
|
||||
$result = app(SecurityComplianceCoverageComparator::class)->compare('dlpCompliancePolicy', [
|
||||
'DisplayName' => 'DLP',
|
||||
'Mode' => 'Enforce',
|
||||
'Rules' => [['Name' => 'Rule', 'Actions' => ['NotifyUser']]],
|
||||
], [
|
||||
'DisplayName' => 'DLP',
|
||||
'Mode' => 'Enforce',
|
||||
'Rules' => [[
|
||||
'Name' => 'Rule',
|
||||
'Actions' => ['NotifyUser'],
|
||||
'Conditions' => ['SensitiveInfoTypes' => ['Spec423 Credit Card Detector']],
|
||||
]],
|
||||
]);
|
||||
$change = collect($result['changes'])->firstWhere('field', 'Rules.0.Conditions');
|
||||
|
||||
expect($result['classification'])->toBe('manual_review_required')
|
||||
->and($result['changed'])->toBeTrue()
|
||||
->and($change)->not->toBeNull()
|
||||
->and($change['classification'])->toBe('manual_review_required')
|
||||
->and($change['importance'])->toBe('manual_review_required')
|
||||
->and(json_encode($result, JSON_THROW_ON_ERROR))->not->toContain('Spec423 Credit Card Detector');
|
||||
});
|
||||
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\TenantConfiguration\SecurityComplianceComparablePayloadNormalizer;
|
||||
use App\Services\TenantConfiguration\SecurityComplianceCoverageComparator;
|
||||
use App\Services\TenantConfiguration\SecurityComplianceReadinessEvaluator;
|
||||
|
||||
it('Spec423 derives bounded readiness states', function (array $context, ?array $normalized, ?array $compareResult, string $expectedState): void {
|
||||
$result = app(SecurityComplianceReadinessEvaluator::class)->evaluate(
|
||||
'retentionCompliancePolicy',
|
||||
$normalized,
|
||||
$context,
|
||||
$compareResult,
|
||||
);
|
||||
|
||||
expect($result['state'])->toBe($expectedState)
|
||||
->and(json_encode($result, JSON_THROW_ON_ERROR))->not->toContain('restore')
|
||||
->not->toContain('certification')
|
||||
->not->toContain('legal-ready')
|
||||
->not->toContain('customer-ready');
|
||||
})->with([
|
||||
'not assessed' => [
|
||||
['evidence_state' => 'not_captured', 'identity_state' => 'stable', 'capture_outcome' => null],
|
||||
null,
|
||||
null,
|
||||
'readiness_not_assessed',
|
||||
],
|
||||
'ready for operator review' => [
|
||||
['evidence_state' => 'content_backed', 'coverage_level' => 'renderable', 'identity_state' => 'stable', 'capture_outcome' => 'captured'],
|
||||
['supported' => true, 'diagnostics' => ['unsupported_fields' => [], 'manual_review_fields' => []]],
|
||||
['changed' => false, 'changes' => []],
|
||||
'readiness_ready_for_operator_review',
|
||||
],
|
||||
'requires manual review' => [
|
||||
['evidence_state' => 'content_backed', 'coverage_level' => 'renderable', 'identity_state' => 'stable', 'capture_outcome' => 'captured'],
|
||||
['supported' => true, 'diagnostics' => ['unsupported_fields' => [], 'manual_review_fields' => []]],
|
||||
['changed' => true, 'changes' => [['field' => 'retention.duration', 'classification' => 'changed', 'importance' => 'critical']]],
|
||||
'readiness_requires_manual_review',
|
||||
],
|
||||
'blocked identity' => [
|
||||
['evidence_state' => 'content_backed', 'coverage_level' => 'renderable', 'identity_state' => 'identity_conflict', 'capture_outcome' => 'captured'],
|
||||
['supported' => true, 'diagnostics' => ['unsupported_fields' => [], 'manual_review_fields' => []]],
|
||||
['changed' => false, 'changes' => []],
|
||||
'readiness_blocked_identity',
|
||||
],
|
||||
'blocked evidence' => [
|
||||
['evidence_state' => 'permission_blocked', 'coverage_level' => 'detected', 'identity_state' => 'stable', 'capture_outcome' => 'blocked_permission'],
|
||||
['supported' => true, 'diagnostics' => ['unsupported_fields' => [], 'manual_review_fields' => []]],
|
||||
['changed' => false, 'changes' => []],
|
||||
'readiness_blocked_evidence',
|
||||
],
|
||||
'blocked permission' => [
|
||||
['evidence_state' => 'content_backed', 'coverage_level' => 'renderable', 'identity_state' => 'stable', 'capture_outcome' => 'captured', 'permission_blocked' => true],
|
||||
['supported' => true, 'diagnostics' => ['unsupported_fields' => [], 'manual_review_fields' => []]],
|
||||
['changed' => false, 'changes' => []],
|
||||
'readiness_blocked_permission',
|
||||
],
|
||||
'manual review for unknown material field' => [
|
||||
['evidence_state' => 'content_backed', 'coverage_level' => 'renderable', 'identity_state' => 'stable', 'capture_outcome' => 'captured'],
|
||||
['supported' => true, 'diagnostics' => ['unsupported_fields' => ['UnknownMaterialField'], 'manual_review_fields' => ['UnknownMaterialField']]],
|
||||
['changed' => false, 'changes' => []],
|
||||
'readiness_requires_manual_review',
|
||||
],
|
||||
]);
|
||||
|
||||
it('Spec423 readiness flags high-risk material changes for manual review', function (): void {
|
||||
$payload = ['DisplayName' => 'Retention', 'RetentionDuration' => 7];
|
||||
$normalized = app(SecurityComplianceComparablePayloadNormalizer::class)->normalize('retentionCompliancePolicy', $payload);
|
||||
$compare = app(SecurityComplianceCoverageComparator::class)->compare('retentionCompliancePolicy', $payload, [
|
||||
'DisplayName' => 'Retention',
|
||||
'RetentionDuration' => 10,
|
||||
]);
|
||||
|
||||
$result = app(SecurityComplianceReadinessEvaluator::class)->evaluate('retentionCompliancePolicy', $normalized, [
|
||||
'evidence_state' => 'content_backed',
|
||||
'coverage_level' => 'renderable',
|
||||
'identity_state' => 'stable',
|
||||
'capture_outcome' => 'captured',
|
||||
], $compare);
|
||||
|
||||
expect($result['state'])->toBe('readiness_requires_manual_review')
|
||||
->and($result['manual_review_required'])->toBeTrue()
|
||||
->and($result['blockers'])->toContain('critical_material_change');
|
||||
});
|
||||
|
||||
it('Spec423 readiness flags nested unknown material fields for manual review', function (): void {
|
||||
$payload = [
|
||||
'DisplayName' => 'DLP',
|
||||
'Mode' => 'Enforce',
|
||||
'Rules' => [[
|
||||
'Name' => 'Rule',
|
||||
'Actions' => ['NotifyUser'],
|
||||
'Conditions' => ['SensitiveInfoTypes' => ['Spec423 Credit Card Detector']],
|
||||
]],
|
||||
];
|
||||
$normalized = app(SecurityComplianceComparablePayloadNormalizer::class)->normalize('dlpCompliancePolicy', $payload);
|
||||
|
||||
$result = app(SecurityComplianceReadinessEvaluator::class)->evaluate('dlpCompliancePolicy', $normalized, [
|
||||
'evidence_state' => 'content_backed',
|
||||
'coverage_level' => 'renderable',
|
||||
'identity_state' => 'stable',
|
||||
'capture_outcome' => 'captured',
|
||||
]);
|
||||
|
||||
expect($result['state'])->toBe('readiness_requires_manual_review')
|
||||
->and($result['label'])->toBe('Manual review required')
|
||||
->and($result['manual_review_required'])->toBeTrue()
|
||||
->and($result['blockers'])->toContain('manual_review:Rules.0.Conditions')
|
||||
->and(json_encode($result, JSON_THROW_ON_ERROR))->not->toContain('Spec423 Credit Card Detector');
|
||||
});
|
||||
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\TenantConfiguration\SecurityComplianceRenderableSummaryBuilder;
|
||||
|
||||
it('Spec423 renders operator-safe Security and Compliance summaries', function (string $canonicalType, array $payload, string $resourceType, string $expectedText): void {
|
||||
$summary = app(SecurityComplianceRenderableSummaryBuilder::class)->build($canonicalType, $payload, [
|
||||
'claim_state' => 'internal_only',
|
||||
'identity_state' => 'stable',
|
||||
'last_captured' => 'Jun 30, 2026 07:30 AM',
|
||||
]);
|
||||
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($summary)->toBeArray()
|
||||
->and($summary['resource_type'])->toBe($resourceType)
|
||||
->and($encoded)->toContain($expectedText)
|
||||
->and($encoded)->toContain('internal_only')
|
||||
->and($encoded)->toContain('Ready for operator review')
|
||||
->and($encoded)->not->toContain('raw_payload')
|
||||
->and($encoded)->not->toContain('source_endpoint');
|
||||
})->with([
|
||||
'retention policy' => [
|
||||
'retentionCompliancePolicy',
|
||||
[
|
||||
'DisplayName' => 'Spec423 Retention',
|
||||
'RetentionDuration' => 7,
|
||||
'RetentionDurationUnit' => 'Years',
|
||||
'DispositionAction' => 'Delete',
|
||||
'IncludedLocations' => ['Exchange'],
|
||||
],
|
||||
'Retention compliance policy',
|
||||
'7 Years',
|
||||
],
|
||||
'label policy' => [
|
||||
'labelPolicy',
|
||||
[
|
||||
'DisplayName' => 'Spec423 Labels',
|
||||
'PublishedLabels' => [['displayName' => 'Highly Confidential']],
|
||||
'Mandatory' => true,
|
||||
],
|
||||
'Label policy',
|
||||
'Highly Confidential',
|
||||
],
|
||||
'dlp policy' => [
|
||||
'dlpCompliancePolicy',
|
||||
[
|
||||
'DisplayName' => 'Spec423 DLP',
|
||||
'Mode' => 'Enforce',
|
||||
'Locations' => ['Exchange'],
|
||||
'Rules' => [['Name' => 'Rule', 'Actions' => ['BlockAccess']]],
|
||||
],
|
||||
'DLP compliance policy',
|
||||
'BlockAccess',
|
||||
],
|
||||
]);
|
||||
|
||||
it('Spec423 summaries hide raw JSON, provider responses, secrets, fingerprints, and content payloads', function (): void {
|
||||
$summary = app(SecurityComplianceRenderableSummaryBuilder::class)->build('dlpCompliancePolicy', [
|
||||
'DisplayName' => 'Spec423 DLP',
|
||||
'Mode' => 'Enforce',
|
||||
'providerResponse' => ['body' => 'spec423-provider-response'],
|
||||
'fingerprint' => 'spec423-fingerprint',
|
||||
'clientSecret' => 'spec423-render-secret',
|
||||
'Rules' => [
|
||||
[
|
||||
'Name' => 'Rule',
|
||||
'Actions' => ['BlockAccess'],
|
||||
'DlpIncidentContent' => 'spec423-dlp-incident-content',
|
||||
'MailContent' => 'spec423-mail-content',
|
||||
'FileContent' => 'spec423-file-content',
|
||||
],
|
||||
],
|
||||
]);
|
||||
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($summary['redacted_fields'])->toContain(
|
||||
'providerResponse',
|
||||
'fingerprint',
|
||||
'clientSecret',
|
||||
'Rules.0.DlpIncidentContent',
|
||||
'Rules.0.MailContent',
|
||||
'Rules.0.FileContent',
|
||||
)
|
||||
->and($encoded)->not->toContain('spec423-provider-response')
|
||||
->and($encoded)->not->toContain('spec423-fingerprint')
|
||||
->and($encoded)->not->toContain('spec423-render-secret')
|
||||
->and($encoded)->not->toContain('spec423-dlp-incident-content')
|
||||
->and($encoded)->not->toContain('spec423-mail-content')
|
||||
->and($encoded)->not->toContain('spec423-file-content');
|
||||
});
|
||||
@ -0,0 +1,68 @@
|
||||
# Requirements Checklist: Spec 423 - Security and Compliance Readiness Pack
|
||||
|
||||
**Purpose**: Validate that the Spec 423 artifacts are ready for implementation without widening scope beyond bounded Coverage v2 compare/render/readiness support.
|
||||
**Created**: 2026-06-30
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Scope and Candidate Fit
|
||||
|
||||
- [x] CHK001 The selected candidate is explicitly Spec 423 - Security and Compliance Readiness Pack.
|
||||
- [x] CHK002 The spec explains why the active auto-prep queue being empty does not block this user-promoted candidate.
|
||||
- [x] CHK003 The spec states the smallest viable slice as DB-only compare/render/readiness over existing content-backed Coverage v2 evidence.
|
||||
- [x] CHK004 The mandatory first resource types are limited to `retentionCompliancePolicy`, `labelPolicy`, and `dlpCompliancePolicy`.
|
||||
- [x] CHK005 Optional resource types `autoSensitivityLabelPolicy`, `protectionAlert`, and `complianceTag` are evidence-gated and test-gated.
|
||||
- [x] CHK006 Explicit non-goals exclude restore/apply, certification, legal/regulatory attestation, customer reports, Review Pack output, new capture/source contracts, new routes/navigation/dashboards, new tables, live provider calls, and a Security/Purview mini-platform.
|
||||
|
||||
## Existing Evidence and Ownership
|
||||
|
||||
- [x] CHK007 The spec and plan identify existing Coverage v2 registry/read-model truth as the implementation base.
|
||||
- [x] CHK008 The implementation tasks require an evidence-promotion matrix for all six candidate resource types before runtime work.
|
||||
- [x] CHK009 Ownership is workspace/environment/provider-connection scoped and does not introduce `tenant_id`.
|
||||
- [x] CHK010 Related completed Specs 414, 415, and 417-422 are treated as read-only context.
|
||||
|
||||
## Compare, Render, and Readiness Semantics
|
||||
|
||||
- [x] CHK011 Compare labels are bounded to `added`, `removed`, `changed`, `unchanged`, `ignored_volatile`, `redacted`, `unsupported_field`, and `manual_review_required`.
|
||||
- [x] CHK012 Importance labels are derived and bounded to `critical`, `important`, `informational`, and `manual_review_required`.
|
||||
- [x] CHK013 Readiness labels are derived, non-persisted, and bounded to the seven states listed in the spec.
|
||||
- [x] CHK014 Readiness wording cannot imply restore readiness, certification readiness, legal readiness, customer readiness, or Microsoft tenant mutation readiness.
|
||||
- [x] CHK015 Unsupported or high-risk fields require redaction, unsupported-field handling, or manual-review handling rather than raw default output.
|
||||
|
||||
## Safety, Claims, and Redaction
|
||||
|
||||
- [x] CHK016 Claim Guard allows only scoped internal/operator comparable/renderable/readiness claims for selected Security and Compliance evidence.
|
||||
- [x] CHK017 Claim Guard blocks restore-ready, apply-ready, certified, legal/regulatory, customer-facing, Review Pack, broad Security and Compliance, broad Purview, and 100 percent coverage claims.
|
||||
- [x] CHK018 Default-visible summaries hide raw JSON, provider responses, secrets, fingerprints, incident/content payloads, and internal debug fields.
|
||||
- [x] CHK019 Render/compare/readiness paths are DB-only and cannot call Graph, TCM, HTTP, live providers, or Microsoft documentation.
|
||||
- [x] CHK020 Selected resource types remain non-restorable and no destructive/high-impact action becomes reachable.
|
||||
|
||||
## Product Surface Contract
|
||||
|
||||
- [x] CHK021 The UI Surface Impact section identifies only existing internal/operator Coverage v2 status/evidence/review presentation changes.
|
||||
- [x] CHK022 The Product Surface plan classifies the page archetype as Technical Annex / read-only evidence inspection.
|
||||
- [x] CHK023 No new route, navigation entry, modal, wizard, table, dashboard, panel provider, customer surface, or action is planned.
|
||||
- [x] CHK024 Browser proof is required if rendered output changes; otherwise the implementation report must record exact `N/A - no rendered UI surface changed`.
|
||||
- [x] CHK025 Human Product Sanity must verify that an internal operator can decide manual-review need without raw payloads or overclaim.
|
||||
- [x] CHK026 Product Surface exceptions are `none` unless implementation amends the spec/plan before runtime UI work.
|
||||
|
||||
## Filament, Livewire, and Deployment
|
||||
|
||||
- [x] CHK027 Livewire v4 and Filament v5 posture is explicit.
|
||||
- [x] CHK028 Panel provider registration location is `apps/platform/bootstrap/providers.php`, with no panel change planned.
|
||||
- [x] CHK029 Global search posture is no resource/global search change.
|
||||
- [x] CHK030 Asset strategy is no new assets unless later amended.
|
||||
- [x] CHK031 Deployment impact is expected to be no migrations, env vars, queues, scheduler, storage, or assets.
|
||||
|
||||
## Testing and Review Readiness
|
||||
|
||||
- [x] CHK032 Tasks include tests for mandatory type normalization, compare labels, render summaries, readiness states, redaction, Claim Guard, RBAC, no remote calls, and no overclaim.
|
||||
- [x] CHK033 Tasks include optional type defer/promotion rules with evidence and test gates.
|
||||
- [x] CHK034 Tasks include implementation-report close-out fields for promoted/deferred type matrix, Product Surface proof, Filament/Livewire posture, deployment impact, no `tenant_id`, no completed-spec rewrites, no remote calls, and no mini-platform.
|
||||
- [x] CHK035 Stop conditions require spec/plan amendment before widening scope.
|
||||
- [x] CHK036 Preparation analysis finds no unresolved placeholders, contradiction between spec/plan/tasks, or missing hard-gate artifact.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- [x] CHK037 Ready for implementation without scope change.
|
||||
- [ ] CHK038 Ready only after checklist items are corrected.
|
||||
- [ ] CHK039 Blocked pending user/product/legal/security decision.
|
||||
@ -0,0 +1,127 @@
|
||||
# Implementation Report: Spec 423 - Security and Compliance Readiness Pack
|
||||
|
||||
## Preflight
|
||||
|
||||
- **Active spec**: `specs/423-security-compliance-readiness-pack/`
|
||||
- **Implementation start**: 2026-06-30 07:07:58 CEST
|
||||
- **Branch**: `423-security-compliance-readiness-pack`
|
||||
- **HEAD**: `13d363c8 feat: complete spec 422 exchange teams comparable renderable pack (#489)`
|
||||
- **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/evidence-anchor-contract`, `.agent/repo-contracts/product-surface-gate`, `.agent/workflows/filament-livewire-v5-change-loop`, `.agent/repo-contracts/provider-freshness-semantics`, `.agent/temporary-migrations/tcm-cutover-guard`.
|
||||
- **Hard-gate stop conditions checked**: no unrelated dirty files; no completed-spec rewrite; no new capture/source contract; no live Graph/TCM/provider/HTTP/docs call; no restore/apply/certification/legal/customer output; no new route/navigation/action/table/dashboard; no OperationRun lifecycle change; no `tenant_id` ownership path; no raw payload/default customer proof; no Security/Purview mini-platform.
|
||||
|
||||
## Completed-Spec Guardrail
|
||||
|
||||
Specs 414, 415, and 417 through 422 were used as read-only dependency context. No files under their spec directories were edited.
|
||||
|
||||
## Security and Compliance Registry Rows
|
||||
|
||||
| Canonical type | Source aliases | Restore tier | Risk posture | Repo source truth |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `retentionCompliancePolicy` | `retentionPolicy` | `not_restorable` | high | Registry-only Security and Compliance representative row from Spec 419; live capture remains deferred. |
|
||||
| `labelPolicy` | `sensitivityLabelPolicy` | `not_restorable` | high | Registry-only Security and Compliance representative row from Spec 419; live capture remains deferred. |
|
||||
| `dlpCompliancePolicy` | `dataLossPreventionPolicy` | `not_restorable` | high | Registry-only row; Spec 420 proves capture blocks as `capture_blocked_missing_contract`. |
|
||||
| `autoSensitivityLabelPolicy` | `autoLabelingPolicy` | `not_restorable` | high | Registry-only row; no existing bounded content-backed support evidence found in repo. |
|
||||
| `protectionAlert` | `alertPolicy` | `not_restorable` | high | Registry-only row; no existing bounded content-backed support evidence found in repo. |
|
||||
| `complianceTag` | `retentionLabel` | `not_restorable` | high | Registry-only row; no existing bounded content-backed support evidence found in repo. |
|
||||
|
||||
## Evidence Promotion Matrix
|
||||
|
||||
| Canonical type | Decision | Reason |
|
||||
| --- | --- | --- |
|
||||
| `retentionCompliancePolicy` | `promote` | Mandatory type; implement typed support for existing or synthetic content-backed Coverage v2 evidence only. No live capture/source contract added. |
|
||||
| `labelPolicy` | `promote` | Mandatory type; implement typed support for existing or synthetic content-backed Coverage v2 evidence only. No live capture/source contract added. |
|
||||
| `dlpCompliancePolicy` | `promote` | Mandatory type; implement typed support for existing or synthetic content-backed Coverage v2 evidence only. Spec 420 missing-contract capture blocker remains unchanged. |
|
||||
| `autoSensitivityLabelPolicy` | `defer_missing_evidence` | Optional type lacks existing bounded content-backed evidence and focused tests in current repo truth. |
|
||||
| `protectionAlert` | `defer_missing_evidence` | Optional type lacks existing bounded content-backed evidence and would risk sensitive incident-content exposure without a separate proof slice. |
|
||||
| `complianceTag` | `defer_missing_evidence` | Optional type lacks existing bounded content-backed evidence and focused tests in current repo truth. |
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
- Added bounded Security/Compliance helpers under `apps/platform/app/Services/TenantConfiguration/`:
|
||||
- `SecurityComplianceComparablePayloadNormalizer.php`
|
||||
- `SecurityComplianceCoverageComparator.php`
|
||||
- `SecurityComplianceRenderableSummaryBuilder.php`
|
||||
- `SecurityComplianceReadinessEvaluator.php`
|
||||
- Wired selected Security/Compliance summary and comparator dispatch into `CoverageV2ReadinessReadModel.php` using the existing Entra and Exchange/Teams pattern.
|
||||
- Wired `CoverageEvidenceWriter.php` so only selected mandatory Security/Compliance content-backed payloads with renderable summaries promote to `renderable`.
|
||||
- Hardened `ClaimGuard.php` so only selected scoped internal/operator comparable/renderable/ready-for-operator-review language is allowed; broad Security/Compliance, Purview, certification, restore/apply, legal/regulatory, customer, Review Pack, and 100 percent claims are blocked.
|
||||
- Reused `CoveragePayloadRedactor.php`; no extension was required. Additional helper-local content redaction covers provider responses, fingerprints, DLP incident/mail/file/case/security incident content, and similar content-bearing keys before summaries/compare output.
|
||||
- Optional `autoSensitivityLabelPolicy`, `protectionAlert`, and `complianceTag` remain deferred because the current repo has no bounded evidence/test proof sufficient for default-visible operator summaries.
|
||||
|
||||
## Implemented Type Matrix
|
||||
|
||||
| Canonical type | Normalizer | Compare | Render summary | Readiness | Claim Guard | Promotion |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `retentionCompliancePolicy` | implemented | implemented | implemented | implemented | scoped internal only | `renderable` for supported content-backed evidence |
|
||||
| `labelPolicy` | implemented | implemented | implemented | implemented | scoped internal only | `renderable` for supported content-backed evidence |
|
||||
| `dlpCompliancePolicy` | implemented | implemented | implemented | implemented | scoped internal only | `renderable` for supported content-backed evidence |
|
||||
| `autoSensitivityLabelPolicy` | deferred | deferred | deferred | deferred | unsafe broad claims blocked | remains registry/detected only |
|
||||
| `protectionAlert` | deferred | deferred | deferred | deferred | unsafe broad claims blocked | remains registry/detected only |
|
||||
| `complianceTag` | deferred | deferred | deferred | deferred | unsafe broad claims blocked | remains registry/detected only |
|
||||
|
||||
## Product Surface Close-Out
|
||||
|
||||
- **No-legacy posture**: no legacy UI path, no completed-spec rewrite, no alternate ownership key, no `tenant_id`.
|
||||
- **Product Surface Impact**: existing Coverage v2 readiness/inspect surface gains selected Security/Compliance typed summaries, compare details, readiness labels, and redaction diagnostics for existing content-backed evidence.
|
||||
- **UI Surface Impact**: no new route, navigation item, dashboard, action, table, panel, provider, asset, or page class. Existing inspector content changes only when selected Security/Compliance renderable evidence is present.
|
||||
- **Page archetype**: Technical Annex Page / read-only evidence inspection.
|
||||
- **Surface budgets**: decision first (`Review readiness`, `Manual review required` / `Ready for operator review`), diagnostics second (`Compare summary`, redacted/unsupported fields), support/raw context third and collapsed under existing technical details.
|
||||
- **Technical Annex / deep-link demotion**: existing technical details remain collapsed by default; evidence hash, source contract/schema, source class, and operation link are not default-visible.
|
||||
- **Canonical status vocabulary**: bounded readiness states only: `readiness_not_assessed`, `readiness_ready_for_operator_review`, `readiness_requires_manual_review`, `readiness_blocked_identity`, `readiness_blocked_evidence`, `readiness_blocked_permission`, `readiness_blocked_unsupported`.
|
||||
- **Product Surface exceptions**: none.
|
||||
- **Focused browser proof**: `cd apps/platform && ./vendor/bin/sail artisan test tests/Browser/Spec423SecurityComplianceComparableRenderableOperatorSurfaceSmokeTest.php` passed, proving rendered DLP inspector output, collapsed technical details, no raw/secrets/content values, no customer/legal/certification/restore wording, no remote Graph/TCM/provider resource calls, and no JavaScript/console errors.
|
||||
- **Human Product Sanity result**: pass. An internal operator can see that a DLP policy requires manual review because mode/rule behavior changed, without seeing raw payloads, secrets, DLP incident content, or overclaim wording.
|
||||
- **Visible complexity outcome**: unchanged page structure; added summary fields reuse the existing inspector hierarchy and do not introduce nested cards, new actions, or new navigation.
|
||||
- **Livewire v4**: unchanged; platform uses Livewire v4 and tests mount/render the existing Filament surface.
|
||||
- **Filament provider registration**: unchanged; Laravel 12 provider registration remains in `apps/platform/bootstrap/providers.php`.
|
||||
- **Global search**: unchanged; no resources/pages were added or made globally searchable.
|
||||
- **Destructive/high-impact actions**: none added.
|
||||
- **Asset strategy**: no new assets and no new `filament:assets` deployment requirement.
|
||||
|
||||
## Validation
|
||||
|
||||
- Initial pre-review validation:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec423` -> passed, 62 tests / 297 assertions, including browser smoke.
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Browser/Spec423SecurityComplianceComparableRenderableOperatorSurfaceSmokeTest.php` -> passed, 1 test / 63 assertions.
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=ClaimGuard` -> passed, 107 tests / 120 assertions.
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec421|Spec422'` -> passed, 96 tests / 500 assertions, including existing Spec 421 and Spec 422 browser smokes.
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` -> passed.
|
||||
- Post-review hardening validation:
|
||||
- `cd apps/platform && php artisan test --compact tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceComparablePayloadNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceCoverageComparatorTest.php tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceReadinessEvaluatorTest.php tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceRenderableSummaryBuilderTest.php tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceClaimGuardTest.php` -> passed, 55 tests / 188 assertions.
|
||||
- `cd apps/platform && php artisan test --compact tests/Feature/TenantConfiguration/Spec423SecurityComplianceCoverageReadinessTest.php tests/Feature/TenantConfiguration/Spec423SecurityComplianceCoverageAuthorizationTest.php` -> passed, 14 tests / 75 assertions.
|
||||
- `cd apps/platform && ./vendor/bin/pint app/Services/TenantConfiguration/SecurityComplianceComparablePayloadNormalizer.php app/Services/TenantConfiguration/ClaimGuard.php app/Services/TenantConfiguration/SecurityComplianceCoverageComparator.php app/Services/TenantConfiguration/SecurityComplianceReadinessEvaluator.php app/Services/TenantConfiguration/SecurityComplianceRenderableSummaryBuilder.php tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceComparablePayloadNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceCoverageComparatorTest.php tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceReadinessEvaluatorTest.php tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceClaimGuardTest.php tests/Feature/TenantConfiguration/Spec423SecurityComplianceCoverageReadinessTest.php tests/Browser/Spec423SecurityComplianceComparableRenderableOperatorSurfaceSmokeTest.php --format agent` -> passed.
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec423 --compact` -> attempted after post-review hardening; aborted after 60 seconds with no output because Sail/Docker exec was not progressing in this session.
|
||||
- `cd apps/platform && php artisan test --compact tests/Browser/Spec423SecurityComplianceComparableRenderableOperatorSurfaceSmokeTest.php` -> local Browser lane timed out before assertions at `visit(...)->resize(...)`; latest successful Browser proof remains the initial Sail Browser run above.
|
||||
- `git diff --check` -> passed.
|
||||
- Static service guard: fixed-string scan over touched Security/Compliance helpers and `ClaimGuard.php` found no `GraphClientInterface`, `Http::`, `tenant_id`, `restore-ready`, `certification-ready`, `legal-ready`, `customer-ready`, `ReviewPack`, or nested SecurityCompliance namespace.
|
||||
|
||||
## Post-Implementation Analysis
|
||||
|
||||
- **Loop count**: 1 completed implementation loop after initial red/green test cycle.
|
||||
- **Confirmed in-scope findings fixed during loop**:
|
||||
- Display-name fields were too strict in the retention allowlist and blocked renderable promotion.
|
||||
- Volatile and redacted roots needed diagnostic treatment without becoming material compare changes.
|
||||
- Manual-review compare status needed to surface as `Manual review required`.
|
||||
- Broad "complete compliance coverage" wording needed Claim Guard blocking.
|
||||
- Root redaction and nested content redaction needed different manual-review behavior.
|
||||
- Nested unsupported Security/Compliance material fields needed manual-review diagnostics without leaking nested detector/content values.
|
||||
- Generic "full coverage", "complete coverage", and "all coverage" wording needed Claim Guard blocking.
|
||||
- **Remaining in-scope findings**: none found in service, unit, feature, formatting, diff, and static-guard validation. Post-review Sail/Browser re-run remains an environment validation gap, not a confirmed code finding.
|
||||
|
||||
## Deployment Impact
|
||||
|
||||
- **Migrations**: none.
|
||||
- **Environment variables**: none.
|
||||
- **Queues / scheduler / workers**: none.
|
||||
- **Storage / volumes**: none.
|
||||
- **Runtime assets**: none.
|
||||
- **Provider registration**: none.
|
||||
- **External services**: no new live Graph, TCM, provider, HTTP, Microsoft docs, or remote network calls.
|
||||
- **Staging / production**: deploy as normal code-only change; no database, config, queue, cron, storage, reverse-proxy, or asset-publish step introduced.
|
||||
|
||||
## Residual Risks / Follow-Up Candidates
|
||||
|
||||
- Optional Security/Compliance types remain intentionally deferred until a later spec proves content-backed evidence, redaction, render, compare, readiness, RBAC, browser, and no-remote behavior.
|
||||
- This pack does not add live capture/source contracts; it only renders and compares existing or synthetic content-backed Coverage v2 evidence.
|
||||
- Readiness is internal operator readiness only. It is not restore readiness, certification, legal/regulatory attestation, customer proof, or a Security/Purview platform.
|
||||
240
specs/423-security-compliance-readiness-pack/plan.md
Normal file
240
specs/423-security-compliance-readiness-pack/plan.md
Normal file
@ -0,0 +1,240 @@
|
||||
# Implementation Plan: Spec 423 - Security and Compliance Readiness Pack
|
||||
|
||||
**Branch**: `423-security-compliance-readiness-pack` | **Date**: 2026-06-30 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `specs/423-security-compliance-readiness-pack/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Promote selected Security and Compliance Coverage v2 evidence into typed, deterministic compare/render/readiness support for internal operators. The implementation is bounded to existing content-backed evidence and the existing Coverage v2 read/inspect surface: no restore, no apply, no certification, no legal attestation, no customer-facing output, no new source contracts, no new routes/navigation/dashboards, no migrations, no remote calls, and no Purview or Security mini-platform.
|
||||
|
||||
Mandatory first support is evidence-gated for `retentionCompliancePolicy`, `labelPolicy`, and `dlpCompliancePolicy`. `autoSensitivityLabelPolicy`, `protectionAlert`, and `complianceTag` may be promoted only when implementation preflight proves content-backed evidence exists and focused tests remain bounded.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15; Laravel 12; Laravel Sail-first local workflow.
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing TenantConfiguration Coverage v2 services (`ResourceTypeRegistry`, `SupportedScopeResolver`, `ClaimGuard`, `CoveragePayloadRedactor`, `CoverageV2ReadinessReadModel`, `GenericPayloadNormalizer`, `CanonicalIdentityResolver`, Entra and Exchange/Teams comparable/renderable support).
|
||||
**Storage**: PostgreSQL via existing Coverage v2 tables/models only. No schema change is planned.
|
||||
**Testing**: Pest 4 unit and feature tests, plus focused browser proof only if rendered UI output changes.
|
||||
**Validation Lanes**: fast-feedback and confidence for normalizers/comparators/readiness/claims/RBAC/redaction/no-remote; browser-if-rendered for existing Coverage v2 inspect/readiness output; pgsql lane only if implementation unexpectedly requires persistence changes, which should trigger a stop-and-amend.
|
||||
**Target Platform**: `apps/platform` Laravel monolith on Sail locally; Dokploy container deployment for staging/production.
|
||||
**Project Type**: Web application / Laravel monolith.
|
||||
**Performance Goals**: DB-only compare/render/readiness; no render-time provider/API/HTTP/documentation calls; no N+1 remote work.
|
||||
**Constraints**: No restore/apply/certification/legal/customer claims; no new routes/navigation/capture/source contracts/tables; no `tenant_id`; no completed-spec rewrites; no mini-platform or global taxonomy; no raw payload exposure by default.
|
||||
**Scale/Scope**: Focused resource-type family support for selected Security and Compliance Coverage v2 evidence rows in one existing internal/operator surface.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: Changed data-driven status/evidence/review presentation on the existing internal/operator Coverage v2 readiness/inspect surface only.
|
||||
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: Existing Coverage v2 readiness/resource/evidence inspect surface if rendered data changes. No new routes, navigation entries, actions, modals, dashboards, panels, or provider registrations.
|
||||
- **No-impact class, if applicable**: N/A because status/evidence/review presentation changes are expected when evidence is rendered.
|
||||
- **Native vs custom classification summary**: Existing shared/native Coverage v2 surface; no new custom UI system.
|
||||
- **Shared-family relevance**: Coverage v2 evidence/readiness/inspect family, status badge vocabulary, Claim Guard wording family.
|
||||
- **State layers in scope**: Page/detail evidence state only; no shell or URL-query changes.
|
||||
- **Audience modes in scope**: Operator-MSP/internal operator only.
|
||||
- **Decision/diagnostic/raw hierarchy plan**: Decision first (`manual_review_required`, readiness state, material change summary), diagnostics second (field-level diff labels and reasons), raw/support evidence third and gated/collapsed.
|
||||
- **Raw/support gating plan**: Raw JSON, provider responses, sensitive values, incident/content details, fingerprints, and internal debug fields stay hidden from default-visible summaries and are exposed only through existing gated support/inspect mechanics if already available.
|
||||
- **One-primary-action / duplicate-truth control**: No new action; the existing inspect/read flow remains the primary path.
|
||||
- **Handling modes by drift class or surface**: Review-mandatory for high-risk retention/DLP/labeling changes; blocked for missing identity/evidence/permission/unsupported fields.
|
||||
- **Repository-signal treatment**: Report-only in this prep package; implementation must use current repo tests and services as truth.
|
||||
- **Special surface test profiles**: Existing Coverage v2 technical-annex/evidence-inspection surface; focused browser smoke if rendered output changes.
|
||||
- **Required tests or manual smoke**: Functional-core, redaction/claim/state-contract tests, and browser-if-rendered proof.
|
||||
- **Exception path and spread control**: none.
|
||||
- **Active feature PR close-out entry**: Smoke Coverage if rendered output changes; otherwise exact `N/A - no rendered UI surface changed`.
|
||||
- **UI/Productization coverage decision**: Existing internal surface only; update audit coverage artifacts only if runtime UI files/routes/navigation change.
|
||||
- **Coverage artifacts to update**: none expected.
|
||||
- **No-impact rationale**: N/A.
|
||||
- **Navigation / Filament provider-panel handling**: Checked no-impact; no panel/provider/navigation changes planned.
|
||||
- **Screenshot or page-report need**: Focused screenshot/browser proof only if implementation changes rendered output; no standalone page report unless runtime UI structure changes.
|
||||
|
||||
## Product Surface Contract Plan
|
||||
|
||||
- **Product Surface Contract reference**: `docs/product/standards/product-surface-contract.md`.
|
||||
- **No-legacy posture**: Canonical Coverage v2 extension; no compatibility aliases, old labels, duplicate surfaces, fallback readers, or historical fixtures.
|
||||
- **Page archetype and surface budget plan**: Technical Annex / read-only evidence inspection; default-visible information limited to operator decision needs.
|
||||
- **Technical Annex and deep-link demotion plan**: Evidence IDs, raw source keys, raw JSON, provider payloads, fingerprints, incident/content details, and internal reason ownership remain diagnostics/support-only.
|
||||
- **Canonical status vocabulary plan**: Reuse existing badge/status rendering. Derived compare labels are limited to `added`, `removed`, `changed`, `unchanged`, `ignored_volatile`, `redacted`, `unsupported_field`, and `manual_review_required`. Derived readiness labels are limited to `readiness_not_assessed`, `readiness_ready_for_operator_review`, `readiness_requires_manual_review`, `readiness_blocked_identity`, `readiness_blocked_evidence`, `readiness_blocked_permission`, and `readiness_blocked_unsupported`.
|
||||
- **Product Surface exceptions**: none.
|
||||
- **Browser verification plan**: Focused existing Coverage v2 readiness/inspect route smoke when rendered output changes; otherwise record `N/A - no rendered UI surface changed`.
|
||||
- **Human Product Sanity plan**: Verify an internal operator can decide whether Security/Compliance evidence needs manual review without seeing raw payloads and without any customer/certification/legal/restore claim.
|
||||
- **Visible complexity outcome target**: Neutral to decreased; raw payload dependence decreases while no new page/action/navigation complexity is added.
|
||||
- **Implementation report target**: `specs/423-security-compliance-readiness-pack/implementation-report.md`.
|
||||
|
||||
## Filament / Livewire / Deployment Posture
|
||||
|
||||
- **Livewire v4 compliance**: Livewire v4.x is the repo baseline for Filament v5 and remains required.
|
||||
- **Panel provider registration location**: `apps/platform/bootstrap/providers.php`; no panel provider change is planned.
|
||||
- **Global search posture**: No Filament Resource/global search behavior changes.
|
||||
- **Destructive/high-impact action posture**: none; this feature is read-only and must not add restore/apply/mutate actions.
|
||||
- **Asset strategy**: No new assets expected; no `filament:assets` deployment requirement unless future implementation amends the plan to register assets.
|
||||
- **Testing plan**: Pest unit tests for normalization/compare/render/readiness; feature tests for Claim Guard, RBAC/404/403, redaction, no raw payload, no remote calls, no restore/certification/customer/legal claims; browser smoke if rendered output changes.
|
||||
- **Deployment impact**: No env vars, migrations, queues, scheduler changes, storage changes, or asset changes expected. Staging validation still required before production because Security and Compliance evidence is high risk.
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes.
|
||||
- **Systems touched**: TenantConfiguration Coverage v2 registry/read model, typed payload normalization, compare/render summary generation, readiness/manual-review derivation, redaction, identity resolution, Claim Guard.
|
||||
- **Shared abstractions reused**: `ResourceTypeRegistry`, `SupportedScopeResolver`, `GenericPayloadNormalizer`, `CoveragePayloadRedactor`, `CanonicalIdentityResolver`, `CoverageV2ReadinessReadModel`, existing Entra and Exchange/Teams comparator/builder patterns, existing badge/status rendering.
|
||||
- **New abstraction introduced? why?**: Focused Security/Compliance typed helpers may be introduced only when the existing Entra and Exchange/Teams helpers cannot express resource-specific retention/label/DLP semantics without unsafe branching.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: Existing generic support can store and render raw evidence but cannot safely classify Security/Compliance materiality, manual-review blockers, unsupported fields, or overclaim boundaries.
|
||||
- **Bounded deviation / spread control**: New helpers are scoped to the selected Security/Compliance type list and must not become a generic Purview/Security platform, framework, or persistence layer.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: no.
|
||||
- **Central contract reused**: N/A.
|
||||
- **Delegated UX behaviors**: N/A.
|
||||
- **Surface-owned behavior kept local**: none.
|
||||
- **Queued DB-notification policy**: N/A.
|
||||
- **Terminal notification path**: N/A.
|
||||
- **Exception path**: none.
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes.
|
||||
- **Provider-owned seams**: Microsoft Security and Compliance/Purview resource type semantics, provider payload keys, source aliases, Graph/TCM evidence provenance.
|
||||
- **Platform-core seams**: Coverage v2 evidence ownership, read authorization, redaction, derived readiness state, operator-safe compare/render summaries, Claim Guard wording.
|
||||
- **Neutral platform terms / contracts preserved**: `workspace_id`, `managed_environment_id`, `provider_connection_id`, canonical resource type keys, coverage/readiness states, manual review, redaction, unsupported-field handling.
|
||||
- **Retained provider-specific semantics and why**: Resource type keys and selected field names remain provider-specific because they are needed to produce accurate retention/label/DLP compare semantics.
|
||||
- **Bounded extraction or follow-up path**: No speculative multi-provider abstraction. If a second provider or customer-facing compliance output appears, create a separate spec.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation and be re-checked after design drift.*
|
||||
|
||||
- **Inventory-first**: PASS. This pack reads existing Coverage v2 evidence; it does not create backup/restore truth.
|
||||
- **Read/write separation**: PASS. No Microsoft tenant writes, no restore, no apply, no destructive action.
|
||||
- **Graph/provider contract path**: PASS. Render/compare/readiness is DB-only; any need for new Graph/TCM/source contracts is a stop condition requiring spec amendment.
|
||||
- **Deterministic capabilities**: PASS with planned tests for fixed payloads, compare labels, readiness states, redaction, and no remote calls.
|
||||
- **RBAC/workspace isolation**: PASS with planned feature tests for existing workspace/environment/provider-connection scope, non-member 404, missing-capability 403.
|
||||
- **Data minimization**: PASS with planned redaction and no-default-raw-payload tests.
|
||||
- **Test governance (TEST-GOV-001)**: PASS with unit/feature/browser-if-rendered lane split and no broad seed/helper growth.
|
||||
- **Proportionality (PROP-001)**: PASS. New derived states/importance labels are feature-local and non-persisted; no tables/enums/global taxonomy.
|
||||
- **No premature abstraction (ABSTR-001)**: PASS if helpers stay local to selected concrete type families and reuse existing patterns.
|
||||
- **Persisted truth (PERSIST-001)**: PASS. No new persisted entity/table/artifact.
|
||||
- **Behavioral state (STATE-001)**: PASS because derived readiness labels change operator review behavior but remain non-persisted and bounded.
|
||||
- **UI semantics (UI-SEM-001)**: PASS if labels remain direct domain-to-UI summaries and do not become a mandatory broad framework.
|
||||
- **Shared pattern first (XCUT-001)**: PASS via Coverage v2, redaction, identity, Claim Guard, and existing compare/render families.
|
||||
- **Provider boundary (PROV-001)**: PASS with explicit provider-owned/platform-core split.
|
||||
- **V1 explicitness / few layers**: PASS if implementation avoids registries/factories/orchestrators beyond focused helpers.
|
||||
- **Spec discipline / bloat check**: PASS with bloat risk documented and constrained.
|
||||
- **Badge semantics / Filament-native UI**: PASS if implementation reuses existing badge/status primitives and avoids ad-hoc UI.
|
||||
- **Product Surface Contract**: PASS with Technical Annex, no customer output, no new surface, browser-if-rendered proof, and Human Product Sanity requirements.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for normalizer/comparator/render/readiness helpers; Feature for Claim Guard, RBAC, redaction, no-remote, no-restore/no-certification/no-customer/legal claim posture; Browser only if existing rendered output changes.
|
||||
- **Affected validation lanes**: fast-feedback, confidence, browser-if-rendered. No heavy-governance default lane unless implementation adds broad fixtures or persistence, which is a stop condition.
|
||||
- **Why this lane mix is the narrowest sufficient proof**: Core risk is deterministic classification and wording over DB evidence; unit/feature tests prove behavior without live providers, and browser proof is needed only for rendered surface changes.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec423`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=ClaimGuard`
|
||||
- focused browser smoke for Coverage v2 inspect/readiness only if rendered output changes.
|
||||
- **Fixture / helper / factory / seed / context cost risks**: Keep fixtures fake, minimal, and local to Spec 423 tests. Do not add global seed defaults.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; any helper must be explicit and local.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: Security/Compliance evidence classification is high-risk but not a heavy suite by default; escalate if optional types require broad fixtures.
|
||||
- **Surface-class relief / special coverage rule**: Technical Annex focused browser proof is enough unless runtime UI files/routes/navigation change.
|
||||
- **Closing validation and reviewer handoff**: Reviewer should verify promoted/deferred type matrix, claim restrictions, redaction proof, RBAC proof, no-remote proof, Product Surface proof/N/A, and no completed-spec rewrites.
|
||||
- **Budget / baseline / trend follow-up**: none.
|
||||
- **Review-stop questions**: Did implementation add persistence, live provider calls, routes/navigation, customer output, restore/certification/legal claims, raw payload default visibility, or broad optional type coverage without evidence?
|
||||
- **Escalation path**: reject-or-split and amend spec/plan before implementation proceeds.
|
||||
- **Active feature PR close-out entry**: Smoke Coverage if rendered output changes; otherwise explicit N/A.
|
||||
- **Why no dedicated follow-up spec is needed**: This is a bounded readiness/compare/render pack. Customer reports, certification, restore/apply, and broader Security/Purview productization are already follow-up candidates.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/423-security-compliance-readiness-pack/
|
||||
+-- spec.md
|
||||
+-- plan.md
|
||||
+-- tasks.md
|
||||
+-- checklists/
|
||||
+-- requirements.md
|
||||
```
|
||||
|
||||
### Source Code (expected implementation touch points)
|
||||
|
||||
```text
|
||||
apps/platform/app/Services/TenantConfiguration/
|
||||
+-- ClaimGuard.php
|
||||
+-- CoverageV2ReadinessReadModel.php
|
||||
+-- CoveragePayloadRedactor.php
|
||||
+-- SecurityComplianceComparablePayloadNormalizer.php
|
||||
+-- SecurityComplianceCoverageComparator.php
|
||||
+-- SecurityComplianceRenderableSummaryBuilder.php
|
||||
+-- SecurityComplianceReadinessEvaluator.php
|
||||
|
||||
apps/platform/tests/Unit/Support/TenantConfiguration/
|
||||
+-- Spec423SecurityComplianceComparablePayloadNormalizerTest.php
|
||||
+-- Spec423SecurityComplianceCoverageComparatorTest.php
|
||||
+-- Spec423SecurityComplianceRenderableSummaryBuilderTest.php
|
||||
+-- Spec423SecurityComplianceReadinessEvaluatorTest.php
|
||||
+-- Spec423SecurityComplianceClaimGuardTest.php
|
||||
|
||||
apps/platform/tests/Feature/TenantConfiguration/
|
||||
+-- Spec423SecurityComplianceCoverageReadinessTest.php
|
||||
+-- Spec423SecurityComplianceCoverageRedactionTest.php
|
||||
+-- Spec423SecurityComplianceCoverageAuthorizationTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Reuse the existing TenantConfiguration service/test layout. Names may be adjusted to match sibling conventions, but implementation must not introduce new base folders, new modules, or a separate Security/Purview subsystem.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation/Risk | Why Needed | Simpler Alternative Rejected Because |
|
||||
|---|---|---|
|
||||
| Derived readiness/status family for high-risk Security and Compliance evidence | Operators need to distinguish operator-review-ready, manual-review-required, and blocked evidence without legal/certification overclaim | Generic raw evidence rendering would either expose payloads or hide material compliance risk |
|
||||
| Focused typed helper family | Retention, label, and DLP fields have resource-specific materiality and redaction rules | A large generic strategy registry would be premature; embedding all logic in the read model would make overclaim/redaction behavior harder to test |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Internal operators need to review selected Security and Compliance evidence without inspecting raw payloads and without mistaking evidence readiness for legal, restore, certification, or customer readiness.
|
||||
- **Existing structure is insufficient because**: Generic Coverage v2 evidence can store and expose content-backed data but cannot safely explain retention/label/DLP materiality, manual-review blockers, unsupported fields, or Security/Compliance overclaim boundaries.
|
||||
- **Narrowest correct implementation**: Focused normalizer/comparator/render/readiness helpers for the selected concrete types, wired into the existing Coverage v2 read model and Claim Guard.
|
||||
- **Ownership cost created**: Additional helper tests, fixture payloads, claim phrases, and a type-promotion matrix in the implementation report.
|
||||
- **Alternative intentionally rejected**: Raw JSON/operator-only display, because it leaks sensitive payload shape and forces manual interpretation; broad Purview/Security platform, because it imports routes, persistence, capture, and legal/product claims outside the current need.
|
||||
- **Release truth**: Current-release truth for internal operator readiness over existing Coverage v2 evidence; not future customer/certification/legal readiness.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0 - Preflight
|
||||
|
||||
Confirm branch, HEAD, dirty state, hard-gate skills, existing Security/Compliance registry rows, evidence availability for all six candidate types, related completed spec read-only posture, and no duplicate 423 branch/spec directory. Stop if implementation requires new capture contracts, new persistence, live provider calls, customer output, or completed-spec rewrites.
|
||||
|
||||
### Phase 1 - Tests First
|
||||
|
||||
Add focused failing tests for mandatory type normalization, compare labels, render summaries, readiness states, redaction, Claim Guard allowed/blocked phrases, RBAC scope, and no remote calls. Optional type tests must be added only after evidence is confirmed.
|
||||
|
||||
### Phase 2 - Typed Normalization
|
||||
|
||||
Implement the smallest Security/Compliance normalizer needed for selected evidence fields. Drop volatile fields, redact sensitive values, preserve stable identifiers only when operator-safe, and mark unsupported/high-risk fields for manual review.
|
||||
|
||||
### Phase 3 - Compare and Render
|
||||
|
||||
Implement deterministic compare labels and render summaries with domain-safe materiality. Summaries must show operator decisions first and must not surface raw provider payloads by default.
|
||||
|
||||
### Phase 4 - Readiness and Claim Guard
|
||||
|
||||
Derive bounded readiness states and importance labels. Harden Claim Guard so scoped internal/operator comparable/renderable claims are allowed while restore-ready, certified, customer-ready, legal/regulatory, broad Purview/Security, and 100 percent coverage claims are blocked.
|
||||
|
||||
### Phase 5 - Integration
|
||||
|
||||
Wire selected helpers into `CoverageV2ReadinessReadModel` or the repo-equivalent dispatch path without new registries or frameworks unless implementation evidence proves the existing dispatch pattern requires it.
|
||||
|
||||
### Phase 6 - Product Surface Proof
|
||||
|
||||
If rendered output changes, run a focused browser smoke on the existing Coverage v2 readiness/inspect route and record Human Product Sanity. If no rendered output changes, record exact N/A proof in the implementation report.
|
||||
|
||||
### Phase 7 - Validation and Report
|
||||
|
||||
Run the narrowest sufficient Sail/Pest validation, record commands/results, promoted/deferred type matrix, Product Surface Contract fields, Filament/Livewire posture, deployment impact, no-tenant-id proof, no-remote proof, and no completed-spec rewrite assertion.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
- A source contract, capture contract, live Graph/TCM/docs call, or Microsoft tenant write is needed.
|
||||
- A migration, new table, new persisted enum/status taxonomy, new route/navigation/dashboard, or mini-platform appears necessary.
|
||||
- A customer-facing report/output, certification, legal/regulatory attestation, restore/apply, or Review Pack claim is needed.
|
||||
- Optional type support requires broad fixtures or evidence that is not already content-backed and bounded.
|
||||
- Raw payloads, incident/content details, secrets, fingerprints, or internal debug fields must be default-visible to make the feature useful.
|
||||
- Completed specs 414, 415, or 417-422 would need to be rewritten.
|
||||
395
specs/423-security-compliance-readiness-pack/spec.md
Normal file
395
specs/423-security-compliance-readiness-pack/spec.md
Normal file
@ -0,0 +1,395 @@
|
||||
# Feature Specification: Spec 423 - Security and Compliance Readiness Pack
|
||||
|
||||
**Feature Branch**: `423-security-compliance-readiness-pack`
|
||||
**Created**: 2026-06-30
|
||||
**Status**: Draft
|
||||
**Input**: User-provided "Spec 423 - Security and Compliance Readiness Pack" draft plus repo checks against Specs 414-422, roadmap/candidate queue, constitution, Product Surface Contract, TenantPilot agent skill gates, and current TenantConfiguration Coverage v2 runtime.
|
||||
|
||||
## Preparation Metadata
|
||||
|
||||
- **Selected candidate**: Spec 423 - Security and Compliance Readiness Pack.
|
||||
- **Source location**: User attachment `/Users/ahmeddarrazi/.codex/attachments/ac35bc16-b85d-4b3e-8202-efb18a8c733b/pasted-text.txt`; related deferral anchors in `specs/421-entra-core-comparable-renderable-pack/spec.md` and `specs/422-exchange-teams-comparable-renderable-pack/spec.md`.
|
||||
- **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. Specs 419-422 establish M365 registry, generic evidence, Entra comparable/renderable support, and Exchange/Teams comparable/renderable support. The next logical product-safety slice is a stricter Security and Compliance pack that supports compare/render/readiness without restore, certification, legal attestation, or customer claims.
|
||||
- **Roadmap relationship**: Aligns with the roadmap themes for security posture, compliance evidence, evidence/coverage hardening, provider-boundary discipline, and safe M365 governance expansion. This package is not auto-selected from the 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. Security and Compliance restore/apply, certification, customer reports, legal/regulatory attestation, Review Pack output, and broad M365 claims are deferred to later explicit specs.
|
||||
- **Related completed-spec guardrail**: `specs/414-tcm-first-coverage-core-cutover/`, `specs/415-generic-content-backed-capture/`, and `specs/417-canonical-identity-engine/` through `specs/422-exchange-teams-comparable-renderable-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.
|
||||
- **Prerequisite gate result**: PASS for preparation. Repo truth includes `TenantConfigurationResourceType`, `TenantConfigurationResource`, `TenantConfigurationResourceEvidence`, `TenantConfigurationSupportedScope`, `ResourceTypeRegistry`, `SupportedScopeResolver`, `ClaimGuard`, `GenericPayloadNormalizer`, `CoveragePayloadRedactor`, `CanonicalIdentityResolver`, `CoverageV2ReadinessReadModel`, Entra and Exchange/Teams comparable/renderable patterns, and Security and Compliance registry rows.
|
||||
- **Smallest viable implementation slice**: Add bounded Security and Compliance typed normalizers, comparators, render summaries, readiness/manual-review derivation, Claim Guard hardening, redaction, and tests for selected existing content-backed evidence rows. Mandatory first support is evidence-gated for `retentionCompliancePolicy`, `labelPolicy`, and `dlpCompliancePolicy`; `autoSensitivityLabelPolicy`, `protectionAlert`, and `complianceTag` may be promoted only when evidence exists and tests stay bounded.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Security and Compliance Coverage v2 registry/evidence can exist without operator-safe interpretation. Operators would otherwise need raw payloads to decide whether retention, labeling, or DLP changes require review.
|
||||
- **Today's failure**: TenantPilot can show registry/evidence truth for selected Security and Compliance resource types, but cannot safely answer what changed, whether the change is compliance-impacting, or whether manual review is required without risking restore-ready, certified, legal, or customer-ready overclaim.
|
||||
- **User-visible improvement**: Authorized internal operators can inspect selected Security and Compliance evidence as structured compare/render/readiness summaries, with raw payloads hidden and high-risk changes clearly marked for manual review.
|
||||
- **Smallest enterprise-capable version**: DB-only typed compare/render/readiness over existing content-backed evidence for `retentionCompliancePolicy`, `labelPolicy`, and `dlpCompliancePolicy`, with optional promotion of `autoSensitivityLabelPolicy`, `protectionAlert`, and `complianceTag` only when evidence and tests exist.
|
||||
- **Explicit non-goals**: No restore/apply, no certification, no legal/regulatory attestation, no customer-facing reports, no Review Pack output, no new capture contracts, no new route/navigation/dashboard, no Purview or Security mini-platform, no new tables, no live Graph/TCM/docs calls.
|
||||
- **Permanent complexity imported**: Focused typed normalizer/comparator/render-summary/readiness helpers, bounded derived readiness labels, Claim Guard phrase coverage, and focused unit/feature/browser-if-rendered tests. No new persistence, global taxonomy table, or cross-domain UI framework is planned.
|
||||
- **Why now**: Specs 421 and 422 proved the comparable/renderable pattern for Entra, Exchange, and Teams and explicitly deferred Security and Compliance. The domain is high blast radius, so it needs a safety-first pack before any future customer or certification claims.
|
||||
- **Why not local**: A local Purview/Security helper would bypass the existing Coverage v2 evidence, identity, redaction, read-model, and Claim Guard paths. The shared Coverage v2 path already exists and is the narrowest correct implementation.
|
||||
- **Approval class**: Core Enterprise.
|
||||
- **Red flags triggered**: New derived readiness labels for a high-risk domain. Defense: labels are derived, feature-local, non-persisted, and change operator manual-review behavior; they do not create a platform-wide taxonomy.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace.
|
||||
- **Primary Routes**: Existing internal/operator Coverage v2 readiness/inspect surface only if rendered data changes. No new route, navigation entry, customer route, report, download, or dashboard 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`. Provider-native 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. Any inspect/read flow must validate provider connection scope.
|
||||
|
||||
## 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 typed support pack over Coverage v2. No customer contract requires old labels, Coverage v1 adapters, dual writes, fallback readers, or compatibility fixtures.
|
||||
|
||||
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||
|
||||
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [ ] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [ ] Navigation changed
|
||||
- [ ] Filament panel/provider surface changed
|
||||
- [ ] New modal/drawer/wizard/action added
|
||||
- [ ] New table/form/state added
|
||||
- [ ] Customer-facing surface changed
|
||||
- [ ] Dangerous action changed
|
||||
- [x] Status/evidence/review presentation changed
|
||||
- [ ] Workspace/environment context presentation changed
|
||||
|
||||
The expected impact is data-driven presentation on the existing Coverage v2 internal/operator readiness and inspect surface when comparable/renderable/readiness summaries become visible. No new UI files are required by this spec; if implementation needs runtime UI edits, the plan/tasks must be amended before editing runtime UI.
|
||||
|
||||
## 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, existing internal/operator inspect flow, existing badge/status patterns.
|
||||
- **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.
|
||||
- **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**: Which selected Security and Compliance evidence can be compared, rendered, and safely reviewed by an internal operator?
|
||||
- **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, source IDs, provider IDs, evidence hashes, OperationRun links, unsupported fields, source contract keys, and diagnostics remain hidden/demoted to internal inspect/diagnostics and must not become default-visible product proof.
|
||||
- **Canonical status vocabulary**: Product-facing labels must use existing canonical states such as `Ready`, `Needs attention`, `Blocked`, `Unknown`, and scoped internal labels. Do not expose `certified`, `restore-ready`, `customer-ready`, `legal compliance verified`, or broad "Security and Compliance covered" language.
|
||||
- **Visible complexity impact**: neutral if the existing inspect surface gains concise summaries and manual-review blockers; increased complexity requires a documented exception.
|
||||
- **Product Surface exceptions**: none planned.
|
||||
|
||||
## Browser Verification Plan *(mandatory)*
|
||||
|
||||
- **Browser proof required?**: yes if implementation-created summaries/readiness states 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.
|
||||
- **Primary interaction to execute**: Load the existing route as an authorized operator, inspect a Security and Compliance resource row, and verify typed summary/readiness/manual-review presentation.
|
||||
- **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**: purpose clear, one dominant inspect action, technical details demoted, manual-review warnings clear, no restore/certified/customer/legal claims, complexity not worse, trust acceptable.
|
||||
- **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)**: status messaging, evidence inspection, typed summaries, claim safety, readiness/manual-review presentation.
|
||||
- **Systems touched**: `CoveragePayloadRedactor`, `ClaimGuard`, `CoverageV2ReadinessReadModel`, existing Coverage v2 read authorization, existing comparable/renderable service patterns, existing badge/readiness display if rendered.
|
||||
- **Existing pattern(s) to extend**: Entra and Exchange/Teams comparable/renderable patterns from Specs 421/422.
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: Coverage v2 resource/evidence/read-model path, redactor, Claim Guard, existing render-summary pattern, existing comparator pattern.
|
||||
- **Why the existing shared path is sufficient or insufficient**: Sufficient for DB-only typed interpretation over existing evidence. No separate Security/Purview engine is justified.
|
||||
- **Allowed deviation and why**: none planned.
|
||||
- **Consistency impact**: Readiness, compare, render, redaction, and claim wording must align with existing Coverage v2 semantics while adding stricter manual-review behavior for high-risk Security and Compliance changes.
|
||||
- **Review focus**: Verify no parallel Security/Purview mini-platform, no raw payload/default proof, no customer/legal/certification claim, and no historical-spec rewrite.
|
||||
|
||||
## OperationRun UX Impact *(mandatory when touched)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: no.
|
||||
- **Shared OperationRun UX contract/layer reused**: N/A.
|
||||
- **Delegated start/completion UX behaviors**: N/A.
|
||||
- **Local surface-owned behavior that remains**: Existing diagnostic OperationRun references, if present, remain secondary/internal and authorized.
|
||||
- **Queued DB-notification policy**: N/A.
|
||||
- **Terminal notification path**: N/A.
|
||||
- **Exception required?**: none.
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes.
|
||||
- **Boundary classification**: mixed. Coverage v2 evidence, identity, claim, readiness, redaction, and read-model semantics are platform-core; Microsoft TCM/Security/Compliance/Purview names and source aliases are provider-owned metadata.
|
||||
- **Seams affected**: resource type canonical names, source aliases, typed payload field mapping, compare/render/readiness interpretation, claim wording.
|
||||
- **Neutral platform terms preserved or introduced**: provider connection, managed environment, resource, evidence, coverage level, readiness, manual review, claim state.
|
||||
- **Provider-specific semantics retained and why**: Microsoft Security and Compliance resource names remain because this spec is explicitly for M365 Coverage v2 representative resource types. They must stay source metadata and typed support keys, not ownership truth.
|
||||
- **Why this does not deepen provider coupling accidentally**: No new provider framework, provider-owned table, provider ownership key, customer claim, restore path, or live Microsoft call is introduced.
|
||||
- **Follow-up path**: document-in-feature for bounded provider-specific hotspots; follow-up-spec for restore/certification/customer output if ever promoted.
|
||||
|
||||
## 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 selected Security and Compliance evidence | Resource name, coverage/evidence/identity/claim state, readiness/manual-review state, typed summary if rendered | 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 selected Security and Compliance evidence |
|
||||
|
||||
## 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 | selected resource summary, manual-review state, material changes, blockers, claim state | unsupported fields, source metadata, identity diagnostics | raw payload stays hidden or secondary/internal; secrets and content never shown | Inspect | raw payload, provider IDs, OperationRun details, source keys | Coverage/readiness appears once; do not duplicate as broad Security and Compliance readiness |
|
||||
|
||||
## 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 selected evidence | Existing inspect affordance | existing behavior | not required | none | existing route | inspect disclosure | workspace + managed environment | Coverage v2 resources | coverage level, evidence state, identity state, claim state, readiness/manual-review state | none |
|
||||
|
||||
## UI Action Matrix *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Header Actions | Row Actions | Bulk Actions | Empty-State CTA(s) | Primary Inspect / Open Affordance | Destructive / High-Impact Actions | Authorization / Visibility | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Coverage v2 readiness / inspect | no new header action | existing Inspect only; no new row action | none | no new empty-state CTA | existing inspect affordance / disclosure | none | existing Coverage v2 read authorization; non-member/wrong scope 404, member missing view capability 403 | Data-driven summary/readiness presentation only; no route, navigation, action, restore, certify, export, or customer output added |
|
||||
|
||||
## 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 | Tenant operator / release reviewer | Verify selected Security and Compliance evidence interpretation without unsafe claims | Technical Annex / read-only evidence inspection | What selected Security and Compliance evidence is comparable/renderable/readiness-assessed, and what requires manual review? | typed summary, coverage/evidence/identity/claim/readiness state, last captured | raw payload, unsupported fields, source metadata, evidence hash, OperationRun | coverage level, evidence state, identity state, claim state, readiness/manual-review, compare importance | read-only | Inspect | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no. Existing Coverage v2 resource/evidence rows remain truth.
|
||||
- **New persisted entity/table/artifact?**: no.
|
||||
- **New abstraction?**: yes, bounded typed helpers for Security and Compliance normalization/compare/render/readiness may be added.
|
||||
- **New enum/state/reason family?**: no persisted enum/state family. Derived readiness/manual-review labels may be local result values only.
|
||||
- **New cross-domain UI framework/taxonomy?**: no.
|
||||
- **Current operator problem**: Operators cannot safely understand selected Security and Compliance evidence without raw payloads or overclaiming compliance readiness.
|
||||
- **Existing structure is insufficient because**: Generic payload summaries and existing Entra/Exchange/Teams helpers do not know retention, label, DLP, auto-label, alert, or compliance tag material fields and manual-review requirements.
|
||||
- **Narrowest correct implementation**: Add Security and Compliance typed helpers that plug into existing Coverage v2 read-model/redaction/Claim Guard paths and only promote types with content-backed evidence and tests.
|
||||
- **Ownership cost**: Maintain typed mappings, compare importance rules, readiness/manual-review rules, redaction tests, Claim Guard phrase tests, and focused browser-if-rendered proof.
|
||||
- **Alternative intentionally rejected**: A Purview/Security dashboard, restore engine, certification pack, persisted readiness table, or workload-specific service platform.
|
||||
- **Release truth**: Current-release truth over existing Coverage v2 evidence, not future capture, restore, certification, or customer reporting preparation.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit for typed normalization/compare/render/redaction/readiness/Claim Guard; Feature for evidence-gated promotion, RBAC/scope, no overclaim/no restore/no certification/no legal attestation/no `tenant_id`; Browser if rendered Coverage v2 output changes.
|
||||
- **Validation lane(s)**: fast-feedback, confidence, browser-if-rendered. PostgreSQL lane only if implementation unexpectedly changes migrations/indexes/constraints.
|
||||
- **Why this classification and these lanes are sufficient**: The core behavior is deterministic service/read-model interpretation over existing DB evidence. Feature tests prove authorization/scope and no unsafe claims. Browser proof is only needed when the existing rendered surface changes.
|
||||
- **New or expanded test families**: focused `Spec423*` unit/feature tests, and a focused existing-surface browser smoke only if rendered output changes.
|
||||
- **Fixture / helper cost impact**: Use minimal workspace/managed-environment/provider/evidence factories and fake content-backed evidence payloads. No live Graph/TCM/Microsoft documentation calls.
|
||||
- **Heavy-family visibility / justification**: none unless focused browser proof is required for rendered output.
|
||||
- **Special surface test profile**: existing technical/evidence surface profile if browser proof is needed.
|
||||
- **Standard-native relief or required special coverage**: standard-native-filament relief for layout; focused browser proof if rendered output changes.
|
||||
- **Reviewer handoff**: Verify no hidden browser/heavy cost, no broad fixture defaults, no raw payload/default proof, no legal/certified/restore/customer claims, no mini-platform, and exact commands in the implementation report.
|
||||
- **Budget / baseline / trend impact**: none expected; document if browser or feature fixture cost materially expands.
|
||||
- **Escalation needed**: document-in-feature if contained; follow-up-spec for certification, restore, customer output, or broader Security/Purview coverage.
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage.
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec423`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=ClaimGuard`
|
||||
- focused browser smoke for the existing Coverage v2 readiness/inspect route if rendered output changes.
|
||||
- `git diff --check`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Interpret Mandatory Security and Compliance Evidence (Priority: P1)
|
||||
|
||||
As an internal operator, I need selected content-backed Security and Compliance evidence to be normalized, compared, and rendered safely so I can understand retention, label, and DLP configuration changes without raw payloads.
|
||||
|
||||
**Why this priority**: Retention, labeling, and DLP are high-blast-radius controls and the mandatory minimum value of the pack.
|
||||
|
||||
**Independent Test**: Given content-backed evidence payloads for `retentionCompliancePolicy`, `labelPolicy`, and `dlpCompliancePolicy`, compare output identifies material changes, render output shows operator-readable summaries, and raw payload/secrets/content remain hidden.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** content-backed `retentionCompliancePolicy` evidence, **When** retention duration, disposition action, enabled state, or scope changes, **Then** compare output marks the change critical and render output shows a concise retention summary with manual-review warning.
|
||||
2. **Given** content-backed `labelPolicy` evidence, **When** published labels, default/mandatory behavior, or scope changes, **Then** compare output marks the material change and render output summarizes label publication and labeling behavior without raw payload.
|
||||
3. **Given** content-backed `dlpCompliancePolicy` evidence, **When** mode, rules/actions, or workload/location scope changes, **Then** compare output marks the change critical and render output summarizes policy mode/scope/action behavior safely.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Require Manual Review and Block Unsafe Readiness (Priority: P1)
|
||||
|
||||
As a release reviewer, I need Security and Compliance readiness to mean "ready for operator review" only, with manual-review and blocked states when evidence, identity, permissions, or high-risk changes make automated confidence unsafe.
|
||||
|
||||
**Why this priority**: The term readiness is dangerous in this domain unless it explicitly avoids legal, restore, certification, and customer-ready meanings.
|
||||
|
||||
**Independent Test**: Readiness derivation produces manual-review for high-risk material changes, blocks missing/identity/permission cases, and never maps to restore/certification/legal/customer-ready claims.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a high-risk material retention or DLP change, **When** readiness is derived, **Then** the result requires manual review and does not say ready for customer proof.
|
||||
2. **Given** evidence is missing or not content-backed, **When** readiness is derived, **Then** the result remains blocked/unassessed and the type is not promoted to readiness-assessed.
|
||||
3. **Given** identity conflict, missing external identity, unsupported identity, or provider-scope mismatch exists, **When** readiness is derived, **Then** readiness is blocked and no claim is allowed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Prevent Overclaim and Sensitive Content Leakage (Priority: P1)
|
||||
|
||||
As a platform reviewer, I need Claim Guard and redaction proof so TenantPilot cannot claim Security/Compliance certification, restore readiness, legal approval, customer-ready proof, or broad Purview coverage from this internal operator pack.
|
||||
|
||||
**Why this priority**: Incorrect claims or content leaks would create product, legal, and customer trust risk.
|
||||
|
||||
**Independent Test**: Claim Guard allows only scoped internal compare/render/readiness-for-operator-review language and blocks certified, restore-ready, customer-ready, legal/regulatory, 100 percent, full Purview, and broad Security and Compliance claims.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an internal statement "Selected Security and Compliance resources are ready for operator review", **When** Claim Guard evaluates it, **Then** it remains internal-only and scoped.
|
||||
2. **Given** a statement "Security and Compliance is certified" or "Retention is restore-ready", **When** Claim Guard evaluates it, **Then** it is blocked.
|
||||
3. **Given** render summaries are generated, **When** they are inspected, **Then** raw payloads, provider responses, tokens, secrets, case content, mail/message/file content, DLP incident content, security incident content, and unredacted PII are absent from default-visible output.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Promote Only Evidence-Backed Resource Types (Priority: P2)
|
||||
|
||||
As a release reviewer, I need optional Security and Compliance resource types to remain unpromoted unless content-backed evidence and focused tests exist so TenantPilot never fakes typed support.
|
||||
|
||||
**Why this priority**: The draft lists six resource types, but the repo must promote only what evidence and tests can prove.
|
||||
|
||||
**Independent Test**: Mandatory and optional types are promoted only when content-backed evidence exists; missing-evidence types remain detected/content-backed-only with explicit blocker/deferred reasons.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a selected resource type has no content-backed evidence, **When** promotion is evaluated, **Then** it remains unpromoted and the implementation report records the blocker.
|
||||
2. **Given** `autoSensitivityLabelPolicy`, `protectionAlert`, or `complianceTag` has content-backed evidence and tests, **When** implementation promotes it, **Then** it receives comparable/renderable/readiness-assessed support without restore/certification/customer claims.
|
||||
3. **Given** implementation pressure to include all Security and Compliance resource types, **When** evidence or tests are missing, **Then** the type is deferred rather than simulated.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Missing source contracts or absent content-backed evidence must not be papered over by registry-only promotion.
|
||||
- Unknown fields in material retention, labeling, DLP, auto-label, alert, or compliance tag sections must become unsupported-field diagnostics or manual-review triggers, not false calm.
|
||||
- Redacted values must not produce false material changes.
|
||||
- Array ordering must be stable when order is not semantically meaningful; priority/order fields remain order-sensitive where business behavior depends on order.
|
||||
- Render/compare/readiness must not perform Graph, TCM, provider, HTTP, or Microsoft documentation calls.
|
||||
- Customer-facing paths must not show Security and Compliance readiness as customer proof.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-423-001**: The implementation MUST use existing Coverage v2 resource type, resource, evidence, identity, redaction, read-model, and Claim Guard boundaries.
|
||||
- **FR-423-002**: A Security and Compliance resource type MUST be promoted to comparable only when content-backed evidence exists, typed normalization exists, deterministic compare rules exist, volatile fields are excluded or labeled, redaction rules exist, and focused tests cover material changes.
|
||||
- **FR-423-003**: A Security and Compliance resource type MUST be promoted to renderable only when comparable support exists, an operator-safe summary exists, raw payload is unnecessary for understanding, sensitive fields are redacted, and focused tests cover default-visible output.
|
||||
- **FR-423-004**: A Security and Compliance resource type MUST be promoted to readiness-assessed only when renderable support exists, readiness/manual-review rules are derived from existing Coverage v2 states and resource-specific safety tiers, and focused tests prove no restore/certification/legal/customer-ready meaning.
|
||||
- **FR-423-005**: `retentionCompliancePolicy`, `labelPolicy`, and `dlpCompliancePolicy` MUST have typed compare/render/readiness support when content-backed evidence exists.
|
||||
- **FR-423-006**: `autoSensitivityLabelPolicy`, `protectionAlert`, and `complianceTag` MAY be promoted only when content-backed evidence exists and scope remains bounded with focused tests.
|
||||
- **FR-423-007**: Missing source contracts, missing evidence, blocked capture outcomes, identity conflicts, provider-scope mismatches, permission blockers, or unsupported resource types MUST remain explicit blockers/deferred reasons.
|
||||
- **FR-423-008**: Compare output MUST classify changes using bounded result labels: `added`, `removed`, `changed`, `unchanged`, `ignored_volatile`, `redacted`, `unsupported_field`, and `manual_review_required`.
|
||||
- **FR-423-009**: Compare output MAY use derived importance labels `critical`, `important`, `informational`, and `manual_review_required`; these labels MUST NOT become a persisted global risk taxonomy.
|
||||
- **FR-423-010**: Retention duration, disposition/action, included/excluded scope, enabled/state, DLP mode/actions/rules/scope, label publication/default/mandatory behavior, auto-label enforcement, alert threshold/recipient changes, and compliance tag retention/disposition changes MUST NOT be downgraded to informational.
|
||||
- **FR-423-011**: Render output MUST answer what the resource is, whether it is enabled/active, what scope/workload/location it affects, what enforcement/retention/label/DLP behavior applies, what changed, what requires manual review, and what is unsupported or redacted.
|
||||
- **FR-423-012**: Render output MUST hide raw payloads, raw provider responses, tokens, authorization headers, cookies, client secrets, passwords, private keys, certificate material, mail/message/file content, DLP incident content, security incident content, eDiscovery case content, audit search content, and unredacted PII beyond necessary admin-visible identifiers.
|
||||
- **FR-423-013**: Readiness MUST mean "enough structured evidence for internal operator review" only.
|
||||
- **FR-423-014**: Readiness MUST require manual review for high-risk compliance/legal-impacting resources, critical material changes, unknown fields in material sections, and retention/disposition/legal-impacting changes.
|
||||
- **FR-423-015**: Readiness MUST block or remain unassessed when evidence is missing, identity is unsafe, permissions/source availability are blocked, the type is unsupported, or redaction fails.
|
||||
- **FR-423-016**: Claim Guard MUST allow only scoped internal/operator statements for selected comparable/renderable/readiness-for-operator-review support.
|
||||
- **FR-423-017**: Claim Guard MUST block certification, restore-ready, customer-ready proof, legal/regulatory attestation, 100 percent Security and Compliance/Purview coverage, full compliance coverage, and broad M365 claims.
|
||||
- **FR-423-018**: Render/compare/readiness MUST be DB-only from existing evidence at render time and MUST NOT call Graph, TCM, provider clients, HTTP, remote APIs, or Microsoft documentation.
|
||||
- **FR-423-019**: Existing Coverage v2 read authorization MUST apply: non-member/wrong workspace/environment is 404, member missing required view capability is 403, and provider connection scope must match workspace and managed environment.
|
||||
- **FR-423-020**: The implementation MUST NOT add restore/apply/certify/legal-attestation/customer-output actions, new routes, new navigation, new dashboard, new capture action, new source contracts, new workload-specific table family, new Security/Purview engine, or new customer-facing surface.
|
||||
- **FR-423-021**: No `tenant_id` may be introduced as Coverage v2 ownership truth, compatibility alias, dual-write target, fallback reader, or parallel scope key.
|
||||
- **FR-423-022**: Existing Coverage v2 operator surface MAY show comparable/renderable/readiness Security and Compliance summaries; any rendered change requires focused browser proof and Human Product Sanity.
|
||||
- **FR-423-023**: Implementation close-out MUST record evidence availability, promoted/deferred resource types, normalizer/compare/render/readiness matrices, Claim Guard proof, redaction proof, no-restore/no-certification/no-legal-attestation proof, no `tenant_id`, no mini-platform, Product Surface result, tests/browser/no-browser, deployment impact, and deferred work.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-423-001**: The pack MUST be deterministic: identical input payloads produce identical normalized summaries, compare results, readiness results, and redaction outcomes.
|
||||
- **NFR-423-002**: Existing Coverage v2 surface performance MUST remain DB-only with no render-time provider/API calls and no new per-row remote work.
|
||||
- **NFR-423-003**: Test fixtures MUST use fake content-backed payloads and must not require live Graph, TCM, Purview, Security and Compliance, or Microsoft documentation access.
|
||||
- **NFR-423-004**: Output language MUST remain scoped and safe for internal operators; customer-readable claims are out of scope.
|
||||
- **NFR-423-005**: The implementation MUST stay within existing Laravel 12, Filament v5, Livewire v4, Pest v4, PostgreSQL, and Sail conventions.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **TenantConfigurationResourceType**: Existing Coverage v2 registry definition for selected Security and Compliance canonical resource types.
|
||||
- **TenantConfigurationResource**: Existing environment-owned concrete resource observed in one workspace, managed environment, provider connection, and resource type.
|
||||
- **TenantConfigurationResourceEvidence**: Existing append-only evidence row with content-backed payload metadata, normalized payload, evidence state, coverage level, capture outcome, and capture time.
|
||||
- **Typed Security and Compliance summary**: Derived, non-persisted render/compare/readiness result generated from existing evidence.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-423-001**: For each mandatory type with content-backed evidence, focused tests prove deterministic normalization, material compare detection, operator-safe rendering, and readiness/manual-review behavior.
|
||||
- **SC-423-002**: Focused tests prove no selected Security and Compliance type can be promoted to comparable/renderable/readiness-assessed without content-backed evidence and corresponding tests.
|
||||
- **SC-423-003**: Focused tests prove Claim Guard blocks restore-ready, certified, customer-ready, legal/regulatory, broad Security and Compliance, broad Purview, and 100 percent coverage claims.
|
||||
- **SC-423-004**: Focused tests prove raw payloads, secrets, provider responses, case/mail/message/file/DLP incident/security incident content, and unredacted sensitive values are absent from default-visible summaries.
|
||||
- **SC-423-005**: Focused tests prove render/compare/readiness performs no Graph, TCM, provider, HTTP, or remote documentation calls.
|
||||
- **SC-423-006**: Product Surface proof records focused browser/Human Product Sanity results when rendered output changes, or exact `N/A - no rendered UI surface changed` proof.
|
||||
- **SC-423-007**: Implementation report records promoted and deferred types with reasons, no `tenant_id`, no mini-platform, no restore/apply/certification/legal/customer output, and validation commands/results.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Specs 414, 415, and 417-422 remain completed dependency context and will not be modified.
|
||||
- Existing Coverage v2 models and coverage-level enum can represent comparable/renderable/readiness-assessed behavior without schema changes.
|
||||
- Security and Compliance registry rows from Spec 419 remain active but conservative by default.
|
||||
- Spec 420 proved missing-contract behavior for `dlpCompliancePolicy`; this spec does not add capture contracts and promotes only evidence-backed rows already present or test-seeded.
|
||||
- Existing Coverage v2 surface is internal/operator only and read-only.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|---|---:|---|
|
||||
| Readiness is mistaken for legal approval | High | Claim Guard, copy restrictions, manual-review labels, tests |
|
||||
| Readiness is mistaken for restore/certification | High | no restore/certification requirements and tests |
|
||||
| Retention/DLP changes are underexplained | High | critical/manual-review compare importance rules |
|
||||
| Sensitive compliance content leaks | High | redaction tests and browser-if-rendered proof |
|
||||
| Purview/Security mini-platform appears | High | reuse Coverage v2 shared services; no tables/dashboard |
|
||||
| Raw payload becomes default UI proof | High | Product Surface proof and redaction/feature tests |
|
||||
| Too many resource types included | Medium | mandatory first pack plus evidence-gated optional types |
|
||||
| `tenant_id` returns | High | no-tenant-id static/feature tests |
|
||||
| Remote calls during render | High | no-remote-render tests |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None blocking preparation. Implementation preflight must record which of the six listed resource types have content-backed evidence and defer any missing-evidence type.
|
||||
|
||||
## Follow-up Spec Candidates
|
||||
|
||||
- Security and Compliance certified compare pack, if customer proof is ever explicitly approved.
|
||||
- Security and Compliance restore/apply evaluation, only after separate safety and legal/product review.
|
||||
- Customer-facing M365 compliance report or Review Pack output, only through a separate customer-output spec.
|
||||
- M365 customer reporting Claim Guard pack.
|
||||
- M365 pilot readiness gate.
|
||||
115
specs/423-security-compliance-readiness-pack/tasks.md
Normal file
115
specs/423-security-compliance-readiness-pack/tasks.md
Normal file
@ -0,0 +1,115 @@
|
||||
# Tasks: Spec 423 - Security and Compliance Readiness Pack
|
||||
|
||||
**Input**: [spec.md](./spec.md), [plan.md](./plan.md), user-provided Spec 423 draft
|
||||
**Prerequisites**: Completed read-only Specs 414, 415, 417, 418, 419, 420, 421, and 422; existing Coverage v2 registry/read model; existing Security and Compliance registry rows; existing Sail/Pest platform test workflow.
|
||||
|
||||
**Scope Reminder**: Implement compare/render/readiness over existing content-backed Coverage v2 evidence only. Do not add restore/apply, certification, legal attestation, customer-facing output, Review Pack output, new capture/source contracts, routes, navigation, dashboards, migrations, tables, live provider calls, or a Security/Purview mini-platform.
|
||||
|
||||
## Phase 1: Preflight and Evidence Gate
|
||||
|
||||
- [x] T001 Record branch, HEAD, dirty state, activated skills, hard-gate status, and implementation start timestamp in `specs/423-security-compliance-readiness-pack/implementation-report.md`.
|
||||
- [x] T002 Verify `specs/414-tcm-first-coverage-core-cutover/`, `specs/415-generic-content-backed-capture/`, and `specs/417-canonical-identity-engine/` through `specs/422-exchange-teams-comparable-renderable-pack/` are treated as read-only dependency context; record no completed-spec rewrites in `implementation-report.md`.
|
||||
- [x] T003 Inspect existing Security and Compliance registry rows in `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php` and `apps/platform/app/Services/TenantConfiguration/SupportedScopeResolver.php`; record canonical keys, aliases, restore tier, and risk posture in `implementation-report.md`.
|
||||
- [x] T004 Build the evidence-promotion matrix for `retentionCompliancePolicy`, `labelPolicy`, `dlpCompliancePolicy`, `autoSensitivityLabelPolicy`, `protectionAlert`, and `complianceTag` from existing Coverage v2 evidence/test fixtures; mark each type `promote`, `defer_missing_evidence`, `defer_missing_tests`, or `defer_out_of_scope`.
|
||||
- [x] T005 Stop and amend `spec.md`/`plan.md` before runtime implementation if any promoted type needs a new source contract, capture contract, migration, live provider call, route/navigation, customer output, restore/apply behavior, or completed-spec rewrite.
|
||||
|
||||
## Phase 2: Tests First - Mandatory Type Normalization
|
||||
|
||||
- [x] T006 Add failing unit coverage for deterministic `retentionCompliancePolicy` normalization in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceComparablePayloadNormalizerTest.php`.
|
||||
- [x] T007 Add failing unit coverage for deterministic `labelPolicy` normalization in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceComparablePayloadNormalizerTest.php`.
|
||||
- [x] T008 Add failing unit coverage for deterministic `dlpCompliancePolicy` normalization in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceComparablePayloadNormalizerTest.php`.
|
||||
- [x] T009 Add failing unit coverage proving volatile fields are ignored and sensitive fields are redacted for mandatory types in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceComparablePayloadNormalizerTest.php`.
|
||||
- [x] T010 Add failing unit coverage proving unsupported or high-risk fields produce `unsupported_field` or `manual_review_required` instead of raw output in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceComparablePayloadNormalizerTest.php`.
|
||||
|
||||
## Phase 3: Tests First - Compare, Render, and Readiness
|
||||
|
||||
- [x] T011 Add failing unit coverage for compare labels `added`, `removed`, `changed`, `unchanged`, `ignored_volatile`, `redacted`, `unsupported_field`, and `manual_review_required` in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceCoverageComparatorTest.php`.
|
||||
- [x] T012 Add failing unit coverage for derived importance labels `critical`, `important`, `informational`, and `manual_review_required` in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceCoverageComparatorTest.php`.
|
||||
- [x] T013 Add failing field-level materiality coverage for FR-423-010 in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceCoverageComparatorTest.php`, proving retention duration/disposition/scope/state, DLP mode/actions/rules/scope, label publication/default/mandatory behavior, and evidence-backed optional auto-label/alert/compliance-tag material fields are never downgraded to informational.
|
||||
- [x] T014 Add failing unit coverage for operator-safe render summaries in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceRenderableSummaryBuilderTest.php`.
|
||||
- [x] T015 Add failing unit coverage proving render summaries hide raw JSON, provider responses, secrets, fingerprints, mail/chat/file/case content, DLP incident content, and security incident content in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceRenderableSummaryBuilderTest.php`.
|
||||
- [x] T016 Add failing unit coverage for readiness states `readiness_not_assessed`, `readiness_ready_for_operator_review`, `readiness_requires_manual_review`, `readiness_blocked_identity`, `readiness_blocked_evidence`, `readiness_blocked_permission`, and `readiness_blocked_unsupported` in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceReadinessEvaluatorTest.php`.
|
||||
- [x] T017 Add failing unit coverage proving readiness never implies restore-ready, certification-ready, legal-ready, customer-ready, or support for Microsoft tenant writes in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceReadinessEvaluatorTest.php`.
|
||||
|
||||
## Phase 4: Tests First - Claim Guard, Authorization, and No Remote Work
|
||||
|
||||
- [x] T018 Add failing Claim Guard tests allowing only scoped internal/operator comparable/renderable/readiness wording for selected Security and Compliance evidence in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceClaimGuardTest.php`.
|
||||
- [x] T019 Add failing Claim Guard tests blocking restore-ready, apply-ready, certified, legal/regulatory, customer-facing, Review Pack, broad Security and Compliance, broad Purview, and 100 percent coverage claims in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec423SecurityComplianceClaimGuardTest.php`.
|
||||
- [x] T020 Add failing feature tests proving wrong-workspace/non-member access is deny-as-not-found and missing read capability is 403 in `apps/platform/tests/Feature/TenantConfiguration/Spec423SecurityComplianceCoverageAuthorizationTest.php`.
|
||||
- [x] T021 Add failing feature tests proving provider connection, managed environment, and workspace scope are enforced without `tenant_id` ownership in `apps/platform/tests/Feature/TenantConfiguration/Spec423SecurityComplianceCoverageAuthorizationTest.php`.
|
||||
- [x] T022 Add failing feature/unit tests proving compare/render/readiness performs no Graph, TCM, HTTP, provider, Microsoft docs, or remote network calls in `apps/platform/tests/Feature/TenantConfiguration/Spec423SecurityComplianceCoverageReadinessTest.php`.
|
||||
|
||||
## Phase 5: Implement Typed Security and Compliance Helpers
|
||||
|
||||
- [x] T023 Create the smallest repo-conventional typed normalizer for selected Security and Compliance payloads in `apps/platform/app/Services/TenantConfiguration/SecurityComplianceComparablePayloadNormalizer.php`.
|
||||
- [x] T024 Implement mandatory type field allowlists, volatile-field dropping, stable value shaping, and redaction handoff in `apps/platform/app/Services/TenantConfiguration/SecurityComplianceComparablePayloadNormalizer.php`.
|
||||
- [x] T025 Implement deterministic compare behavior in `apps/platform/app/Services/TenantConfiguration/SecurityComplianceCoverageComparator.php`.
|
||||
- [x] T026 Implement operator-safe render summaries in `apps/platform/app/Services/TenantConfiguration/SecurityComplianceRenderableSummaryBuilder.php`.
|
||||
- [x] T027 Implement bounded readiness/manual-review derivation in `apps/platform/app/Services/TenantConfiguration/SecurityComplianceReadinessEvaluator.php` or the repo-equivalent local helper if sibling naming dictates a different structure.
|
||||
- [x] T028 Reuse existing `CoveragePayloadRedactor.php` behavior; extend it only if focused tests prove Security/Compliance-sensitive values are not already covered.
|
||||
|
||||
## Phase 6: Integrate with Coverage v2 Read Model and Claims
|
||||
|
||||
- [x] T029 Wire selected Security/Compliance helper dispatch into `apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php` using the existing Entra/Exchange/Teams pattern and without a new generic registry/framework unless implementation evidence proves it is necessary.
|
||||
- [x] T030 Update `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php` so scoped internal/operator Security/Compliance comparable/renderable/readiness claims are allowed and prohibited claims are blocked.
|
||||
- [x] T031 Ensure selected type promotion respects the evidence-promotion matrix: unsupported optional types remain deferred and explain why in `implementation-report.md`.
|
||||
- [x] T032 Confirm existing registry and supported-scope metadata remain conservative: selected Security/Compliance types stay non-restorable and no restore/apply action becomes reachable.
|
||||
|
||||
## Phase 7: Optional Type Promotion Gate
|
||||
|
||||
- [ ] T033 Promote `autoSensitivityLabelPolicy` only if existing content-backed evidence and focused tests prove normalization, compare, render, readiness, redaction, Claim Guard, RBAC, and no-remote behavior.
|
||||
- [ ] T034 Promote `protectionAlert` only if existing content-backed evidence and focused tests prove default-visible summaries never expose security incident details or sensitive alert payloads.
|
||||
- [ ] T035 Promote `complianceTag` only if existing content-backed evidence and focused tests prove label/tag summaries remain operator-safe and non-certifying.
|
||||
- [x] T036 Defer any optional type that lacks evidence, test coverage, or bounded semantics; document the reason in `implementation-report.md` instead of widening scope.
|
||||
|
||||
## Phase 8: Product Surface and Browser Proof
|
||||
|
||||
- [x] T037 If rendered output changes, run a focused browser smoke against the existing Coverage v2 readiness/inspect surface and verify decision-first summary, diagnostics-second detail, raw/support gating, no customer/legal/certification/restore wording, and no overlapping/incoherent UI.
|
||||
- [x] T038 N/A - rendered Coverage v2 output changed, so focused browser proof was recorded under T037 instead of `N/A - no rendered UI surface changed`.
|
||||
- [x] T039 Record Human Product Sanity result in `implementation-report.md`: an internal operator can decide manual-review need without raw payloads and without overclaim.
|
||||
- [x] T040 Update `docs/ui-ux-enterprise-audit/` coverage artifacts only if implementation changes runtime UI files, routes, navigation, page structure, actions, or panel/provider surface.
|
||||
|
||||
## Phase 9: Validation and Close-Out
|
||||
|
||||
- [x] T041 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec423` and record the result in `implementation-report.md`.
|
||||
- [x] T042 Run focused Claim Guard validation, e.g. `cd apps/platform && ./vendor/bin/sail artisan test --filter=ClaimGuard`, and record the result in `implementation-report.md`.
|
||||
- [x] T043 Run the existing narrow Coverage v2 affected tests identified during implementation and record commands/results in `implementation-report.md`.
|
||||
- [x] T044 Run formatting/static validation used by the repo for touched PHP files and record commands/results in `implementation-report.md`.
|
||||
- [x] T045 Confirm no migration, env var, queue, scheduler, storage, or asset deployment step was introduced; if any was introduced, amend `plan.md` before close-out.
|
||||
- [x] T046 Confirm Livewire v4 compliance, panel provider registration location (`apps/platform/bootstrap/providers.php`), global search posture, destructive/high-impact action posture, asset strategy, deployment impact, and Product Surface Contract close-out fields in `implementation-report.md`.
|
||||
- [x] T047 Confirm no `tenant_id` ownership, no raw role-string checks, no completed-spec rewrites, no remote calls, no customer output, no certification/legal/restore/apply claims, and no Security/Purview mini-platform.
|
||||
|
||||
## Dependencies and Ordering
|
||||
|
||||
- T001-T005 must complete before runtime implementation.
|
||||
- T006-T022 should be written before implementation where practical; if repo helpers require small fixture discovery first, document the deviation in `implementation-report.md`.
|
||||
- T023-T028 depend on the relevant failing unit tests.
|
||||
- T029-T032 depend on core helper behavior.
|
||||
- T033-T036 are optional and may be skipped with documented defer reasons.
|
||||
- T037-T040 depend on whether rendered output changes.
|
||||
- T041-T047 are close-out tasks and must not be completed before implementation validation.
|
||||
|
||||
## Parallel Work Opportunities
|
||||
|
||||
- T006-T010 can be split by mandatory resource type.
|
||||
- T011-T017 can be split by compare/render/readiness helper.
|
||||
- T018-T019 can run in parallel with T020-T022.
|
||||
- T023-T028 can proceed in parallel after test contracts are clear, but one reviewer should keep Claim Guard wording aligned with readiness semantics.
|
||||
- T037-T040 can run after the read model wiring is stable.
|
||||
|
||||
## Implementation Guardrails
|
||||
|
||||
- Keep fake payload fixtures minimal and local to Spec 423 tests.
|
||||
- Use existing service/test naming conventions from sibling TenantConfiguration code.
|
||||
- Prefer direct concrete helpers over a new registry, factory, interface, or orchestration pipeline.
|
||||
- Do not introduce persisted states, enums, tables, migrations, routes, navigation entries, dashboards, actions, or assets without stopping to amend the spec/plan.
|
||||
- Do not rewrite completed specs to retrofit close-out wording.
|
||||
- Do not use live Microsoft Graph, TCM, Purview, Security and Compliance, Microsoft docs, or HTTP calls in tests or runtime render/compare/readiness paths.
|
||||
|
||||
## Completion Definition
|
||||
|
||||
- Spec, plan, tasks, checklist, implementation report, and implementation agree on promoted/deferred types.
|
||||
- Mandatory selected evidence types have deterministic normalization, compare, render, readiness, redaction, Claim Guard, RBAC, and no-remote proof.
|
||||
- Optional types are either fully proven or explicitly deferred.
|
||||
- Product Surface proof or exact N/A proof is recorded.
|
||||
- Deployment impact is assessed as none or amended before merge.
|
||||
Loading…
Reference in New Issue
Block a user