feat: complete spec 421 Entra comparable/renderable pack (#488)
Implements the bounded Spec 421 Entra comparable/renderable pack on the existing Coverage v2 operator surface. - Adds typed Conditional Access normalization, comparison, and render summaries - Keeps Security Defaults and other optional Entra types deferred until evidence-backed - Preserves the existing Coverage v2 surface with claim-guard and redaction hardening - Includes focused unit, feature, and browser coverage already recorded in the implementation report Validation is documented in `specs/421-entra-core-comparable-renderable-pack/implementation-report.md`. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #488
This commit is contained in:
parent
a73a8f5882
commit
69d4ecbbd2
@ -60,7 +60,6 @@ public function table(Table $table): Table
|
|||||||
->label('Resource type')
|
->label('Resource type')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable()
|
->sortable()
|
||||||
->description(fn (TenantConfigurationResource $record): string => (string) $record->canonical_type)
|
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('providerConnection.display_name')
|
TextColumn::make('providerConnection.display_name')
|
||||||
->label('Provider connection')
|
->label('Provider connection')
|
||||||
|
|||||||
@ -21,6 +21,10 @@ public function evaluateStatement(string $claim, bool $internalOperatorOnly = fa
|
|||||||
return ClaimState::ClaimBlocked;
|
return ClaimState::ClaimBlocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($internalOperatorOnly && $this->hasScopedInternalComparableRenderableClaim($tokens)) {
|
||||||
|
return ClaimState::InternalOnly;
|
||||||
|
}
|
||||||
|
|
||||||
if ($internalOperatorOnly && $registryScoped) {
|
if ($internalOperatorOnly && $registryScoped) {
|
||||||
return ClaimState::InternalOnly;
|
return ClaimState::InternalOnly;
|
||||||
}
|
}
|
||||||
@ -190,6 +194,10 @@ private function hasUnsafeBroadCoverageClaim(array $tokens, bool $registryScoped
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->hasCustomerReadyTerm($tokens) && ($hasWorkloadReference || $hasCoverageSurface || $registryScoped)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->hasAnyToken($tokens, ['full', 'complete', 'all'])
|
if ($this->hasAnyToken($tokens, ['full', 'complete', 'all'])
|
||||||
&& ($hasCoverageSurface || $this->hasToken($tokens, 'tenant'))
|
&& ($hasCoverageSurface || $this->hasToken($tokens, 'tenant'))
|
||||||
&& ($hasWorkloadReference || $this->hasToken($tokens, 'tenant'))) {
|
&& ($hasWorkloadReference || $this->hasToken($tokens, 'tenant'))) {
|
||||||
@ -199,6 +207,17 @@ private function hasUnsafeBroadCoverageClaim(array $tokens, bool $registryScoped
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $tokens
|
||||||
|
*/
|
||||||
|
private function hasScopedInternalComparableRenderableClaim(array $tokens): bool
|
||||||
|
{
|
||||||
|
return $this->hasToken($tokens, 'selected')
|
||||||
|
&& $this->hasToken($tokens, 'entra')
|
||||||
|
&& ($this->hasToken($tokens, 'comparable') || $this->hasToken($tokens, 'renderable'))
|
||||||
|
&& ($this->hasToken($tokens, 'internal') || $this->hasToken($tokens, 'operator'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $tokens
|
* @param list<string> $tokens
|
||||||
*/
|
*/
|
||||||
@ -240,6 +259,15 @@ private function hasRestoreReadyTerm(array $tokens): bool
|
|||||||
|| ($this->hasToken($tokens, 'restore') && $this->hasToken($tokens, 'coverage'));
|
|| ($this->hasToken($tokens, 'restore') && $this->hasToken($tokens, 'coverage'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $tokens
|
||||||
|
*/
|
||||||
|
private function hasCustomerReadyTerm(array $tokens): bool
|
||||||
|
{
|
||||||
|
return $this->hasToken($tokens, 'customer')
|
||||||
|
&& $this->hasAnyToken($tokens, ['ready', 'readiness', 'proof']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $tokens
|
* @param list<string> $tokens
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -18,6 +18,10 @@
|
|||||||
|
|
||||||
final class CoverageEvidenceWriter
|
final class CoverageEvidenceWriter
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntraRenderableSummaryBuilder $entraSummaryBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $rawPayload
|
* @param array<string, mixed> $rawPayload
|
||||||
* @param array<string, mixed> $normalizedPayload
|
* @param array<string, mixed> $normalizedPayload
|
||||||
@ -54,6 +58,8 @@ public function append(
|
|||||||
): TenantConfigurationResourceEvidence {
|
): TenantConfigurationResourceEvidence {
|
||||||
$capturedAt = now();
|
$capturedAt = now();
|
||||||
|
|
||||||
|
$coverageLevel = $this->coverageLevelFor($resourceType, $normalizedPayload);
|
||||||
|
|
||||||
$evidence = TenantConfigurationResourceEvidence::query()->create([
|
$evidence = TenantConfigurationResourceEvidence::query()->create([
|
||||||
'resource_id' => (int) $resource->getKey(),
|
'resource_id' => (int) $resource->getKey(),
|
||||||
'workspace_id' => (int) $resource->workspace_id,
|
'workspace_id' => (int) $resource->workspace_id,
|
||||||
@ -71,7 +77,7 @@ public function append(
|
|||||||
'payload_hash' => $payloadHash,
|
'payload_hash' => $payloadHash,
|
||||||
'permission_context' => $permissionContext === [] ? (object) [] : $permissionContext,
|
'permission_context' => $permissionContext === [] ? (object) [] : $permissionContext,
|
||||||
'evidence_state' => EvidenceState::ContentBacked->value,
|
'evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
'coverage_level' => CoverageLevel::ContentBacked->value,
|
'coverage_level' => $coverageLevel->value,
|
||||||
'capture_outcome' => CaptureOutcome::Captured->value,
|
'capture_outcome' => CaptureOutcome::Captured->value,
|
||||||
'captured_at' => $capturedAt,
|
'captured_at' => $capturedAt,
|
||||||
]);
|
]);
|
||||||
@ -91,6 +97,20 @@ public function append(
|
|||||||
return $evidence;
|
return $evidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $normalizedPayload
|
||||||
|
*/
|
||||||
|
private function coverageLevelFor(TenantConfigurationResourceType $resourceType, array $normalizedPayload): CoverageLevel
|
||||||
|
{
|
||||||
|
$canonicalType = (string) $resourceType->canonical_type;
|
||||||
|
|
||||||
|
if ($this->entraSummaryBuilder->canBuild($canonicalType, $normalizedPayload)) {
|
||||||
|
return CoverageLevel::Renderable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoverageLevel::ContentBacked;
|
||||||
|
}
|
||||||
|
|
||||||
private function assertScoped(
|
private function assertScoped(
|
||||||
TenantConfigurationResource $resource,
|
TenantConfigurationResource $resource,
|
||||||
TenantConfigurationResourceType $resourceType,
|
TenantConfigurationResourceType $resourceType,
|
||||||
|
|||||||
@ -50,9 +50,12 @@ public function redact(mixed $value): mixed
|
|||||||
private function isSensitiveKey(string $key): bool
|
private function isSensitiveKey(string $key): bool
|
||||||
{
|
{
|
||||||
$normalized = strtolower($key);
|
$normalized = strtolower($key);
|
||||||
|
$compact = str_replace(['_', '-', ' '], '', $normalized);
|
||||||
|
|
||||||
foreach (self::SENSITIVE_KEY_PARTS as $part) {
|
foreach (self::SENSITIVE_KEY_PARTS as $part) {
|
||||||
if (str_contains($normalized, $part)) {
|
$compactPart = str_replace(['_', '-', ' '], '', $part);
|
||||||
|
|
||||||
|
if (str_contains($normalized, $part) || str_contains($compact, $compactPart)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,12 @@
|
|||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\TenantConfigurationResource;
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
use App\Models\TenantConfigurationResourceType;
|
use App\Models\TenantConfigurationResourceType;
|
||||||
use App\Models\TenantConfigurationSupportedScope;
|
use App\Models\TenantConfigurationSupportedScope;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
use App\Support\TenantConfiguration\ClaimState;
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
use App\Support\TenantConfiguration\CoverageLevel;
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
use App\Support\TenantConfiguration\EvidenceState;
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
@ -25,6 +27,11 @@
|
|||||||
|
|
||||||
final class CoverageV2ReadinessReadModel
|
final class CoverageV2ReadinessReadModel
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntraRenderableSummaryBuilder $entraSummaryBuilder,
|
||||||
|
private readonly EntraCoverageComparator $entraCoverageComparator,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Builder<TenantConfigurationResourceType>
|
* @return Builder<TenantConfigurationResourceType>
|
||||||
*/
|
*/
|
||||||
@ -246,10 +253,10 @@ public function defaultScopeKey(): ?string
|
|||||||
*/
|
*/
|
||||||
public function inspectDetails(TenantConfigurationResource $resource, ManagedEnvironment $environment, ?User $user): array
|
public function inspectDetails(TenantConfigurationResource $resource, ManagedEnvironment $environment, ?User $user): array
|
||||||
{
|
{
|
||||||
$resource->loadMissing([
|
$resource->load([
|
||||||
'resourceType:id,canonical_type,display_name,source_class,workload,support_state,default_coverage_level',
|
'resourceType:id,canonical_type,display_name,source_class,workload,support_state,default_coverage_level',
|
||||||
'providerConnection:id,workspace_id,managed_environment_id,display_name,provider',
|
'providerConnection:id,workspace_id,managed_environment_id,display_name,provider',
|
||||||
'latestEvidence:id,resource_id,operation_run_id,payload_hash,evidence_state,coverage_level,capture_outcome,source_contract_key,source_endpoint,source_version,source_schema_hash,captured_at',
|
'latestEvidence:id,resource_id,workspace_id,managed_environment_id,provider_connection_id,resource_type_id,operation_run_id,payload_hash,evidence_state,coverage_level,capture_outcome,source_contract_key,source_endpoint,source_version,source_schema_hash,normalized_payload,captured_at',
|
||||||
'latestEvidence.operationRun:id,workspace_id,managed_environment_id,type,status,outcome,created_at,completed_at',
|
'latestEvidence.operationRun:id,workspace_id,managed_environment_id,type,status,outcome,created_at,completed_at',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -286,6 +293,7 @@ public function inspectDetails(TenantConfigurationResource $resource, ManagedEnv
|
|||||||
'identity_reason_code' => $this->safeIdentityReasonCode($resource),
|
'identity_reason_code' => $this->safeIdentityReasonCode($resource),
|
||||||
'operation_run_url' => $runUrl,
|
'operation_run_url' => $runUrl,
|
||||||
'operation_run_label' => $run !== null ? 'Operation #'.$run->getKey() : null,
|
'operation_run_label' => $run !== null ? 'Operation #'.$run->getKey() : null,
|
||||||
|
'typed_render_summary' => $this->typedRenderSummary($resource),
|
||||||
'blockers' => collect($this->blockersForResource($resource))
|
'blockers' => collect($this->blockersForResource($resource))
|
||||||
->map(fn (string $blocker): string => self::blockerLabel($blocker))
|
->map(fn (string $blocker): string => self::blockerLabel($blocker))
|
||||||
->values()
|
->values()
|
||||||
@ -293,6 +301,169 @@ public function inspectDetails(TenantConfigurationResource $resource, ManagedEnv
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function typedRenderSummary(TenantConfigurationResource $resource): ?array
|
||||||
|
{
|
||||||
|
$evidence = $resource->latestEvidence;
|
||||||
|
$canonicalType = (string) $resource->canonical_type;
|
||||||
|
|
||||||
|
if (
|
||||||
|
! $evidence instanceof TenantConfigurationResourceEvidence
|
||||||
|
|| ! $this->isRenderableEvidenceForResource($resource, $evidence)
|
||||||
|
|| ! is_array($evidence->normalized_payload)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = $this->entraSummaryBuilder->build($canonicalType, $evidence->normalized_payload, [
|
||||||
|
'claim_state' => $resource->latest_claim_state,
|
||||||
|
'identity_state' => $resource->latest_identity_state,
|
||||||
|
'last_captured' => $resource->latest_captured_at?->toDayDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($summary === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary['compare_summary'] = $this->compareSummary($resource, $evidence, $canonicalType);
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isRenderableEvidenceForResource(
|
||||||
|
TenantConfigurationResource $resource,
|
||||||
|
TenantConfigurationResourceEvidence $evidence,
|
||||||
|
): bool {
|
||||||
|
return (int) $evidence->resource_id === (int) $resource->getKey()
|
||||||
|
&& (int) $evidence->workspace_id === (int) $resource->workspace_id
|
||||||
|
&& (int) $evidence->managed_environment_id === (int) $resource->managed_environment_id
|
||||||
|
&& (int) $evidence->provider_connection_id === (int) $resource->provider_connection_id
|
||||||
|
&& (int) $evidence->resource_type_id === (int) $resource->resource_type_id
|
||||||
|
&& $evidence->evidence_state === EvidenceState::ContentBacked
|
||||||
|
&& $evidence->capture_outcome === CaptureOutcome::Captured
|
||||||
|
&& $evidence->coverage_level === CoverageLevel::Renderable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function compareSummary(
|
||||||
|
TenantConfigurationResource $resource,
|
||||||
|
TenantConfigurationResourceEvidence $latestEvidence,
|
||||||
|
string $canonicalType,
|
||||||
|
): array {
|
||||||
|
$previousEvidence = $this->previousComparableEvidence($resource, $latestEvidence);
|
||||||
|
|
||||||
|
if (
|
||||||
|
! $previousEvidence instanceof TenantConfigurationResourceEvidence
|
||||||
|
|| ! is_array($previousEvidence->normalized_payload)
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
'status' => 'No previous comparable evidence',
|
||||||
|
'classification' => 'baseline',
|
||||||
|
'changed' => false,
|
||||||
|
'previous_captured' => null,
|
||||||
|
'changes' => [],
|
||||||
|
'diagnostic_count' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->entraCoverageComparator->compare(
|
||||||
|
$canonicalType,
|
||||||
|
$previousEvidence->normalized_payload,
|
||||||
|
$latestEvidence->normalized_payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
$changes = collect(is_array($result['changes'] ?? null) ? $result['changes'] : []);
|
||||||
|
$materialChanges = $changes
|
||||||
|
->filter(fn (mixed $change): bool => is_array($change) && in_array($change['classification'] ?? null, [
|
||||||
|
'added',
|
||||||
|
'removed',
|
||||||
|
'changed',
|
||||||
|
], true));
|
||||||
|
|
||||||
|
$changed = (bool) ($result['changed'] ?? false);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $changed ? 'Material changes detected' : 'No material changes',
|
||||||
|
'classification' => is_scalar($result['classification'] ?? null)
|
||||||
|
? (string) $result['classification']
|
||||||
|
: 'unchanged',
|
||||||
|
'changed' => $changed,
|
||||||
|
'previous_captured' => $previousEvidence->captured_at?->toDayDateTimeString(),
|
||||||
|
'changes' => $materialChanges
|
||||||
|
->take(6)
|
||||||
|
->map(fn (array $change): array => [
|
||||||
|
'label' => self::compareFieldLabel($change['field'] ?? null),
|
||||||
|
'classification' => self::compareToken($change['classification'] ?? null, 'changed'),
|
||||||
|
'importance' => self::compareToken($change['importance'] ?? null, 'informational'),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
'diagnostic_count' => $changes->count() - $materialChanges->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousComparableEvidence(
|
||||||
|
TenantConfigurationResource $resource,
|
||||||
|
TenantConfigurationResourceEvidence $latestEvidence,
|
||||||
|
): ?TenantConfigurationResourceEvidence {
|
||||||
|
return TenantConfigurationResourceEvidence::query()
|
||||||
|
->where('resource_id', (int) $resource->getKey())
|
||||||
|
->where('workspace_id', (int) $resource->workspace_id)
|
||||||
|
->where('managed_environment_id', (int) $resource->managed_environment_id)
|
||||||
|
->where('provider_connection_id', (int) $resource->provider_connection_id)
|
||||||
|
->where('resource_type_id', (int) $resource->resource_type_id)
|
||||||
|
->where('id', '<>', (int) $latestEvidence->getKey())
|
||||||
|
->where('evidence_state', EvidenceState::ContentBacked->value)
|
||||||
|
->where('capture_outcome', CaptureOutcome::Captured->value)
|
||||||
|
->whereIn('coverage_level', [
|
||||||
|
CoverageLevel::Comparable->value,
|
||||||
|
CoverageLevel::Renderable->value,
|
||||||
|
])
|
||||||
|
->whereNotNull('captured_at')
|
||||||
|
->latest('captured_at')
|
||||||
|
->latest('id')
|
||||||
|
->first([
|
||||||
|
'id',
|
||||||
|
'resource_id',
|
||||||
|
'workspace_id',
|
||||||
|
'managed_environment_id',
|
||||||
|
'provider_connection_id',
|
||||||
|
'resource_type_id',
|
||||||
|
'normalized_payload',
|
||||||
|
'captured_at',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareFieldLabel(mixed $field): string
|
||||||
|
{
|
||||||
|
if (! is_scalar($field) || trim((string) $field) === '') {
|
||||||
|
return 'Changed field';
|
||||||
|
}
|
||||||
|
|
||||||
|
return str((string) $field)
|
||||||
|
->replace(['.', '_'], ' ')
|
||||||
|
->headline()
|
||||||
|
->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareToken(mixed $value, string $fallback): string
|
||||||
|
{
|
||||||
|
if (! is_scalar($value) || trim((string) $value) === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = str((string) $value)
|
||||||
|
->replaceMatches('/[^a-zA-Z0-9_]/', '')
|
||||||
|
->snake()
|
||||||
|
->toString();
|
||||||
|
|
||||||
|
return $token !== '' ? $token : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,320 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
final class EntraComparablePayloadNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const SUPPORTED_TYPES = [
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const CONDITIONAL_ACCESS_ROOT_FIELDS = [
|
||||||
|
'@odata.context',
|
||||||
|
'@odata.etag',
|
||||||
|
'@odata.type',
|
||||||
|
'conditions',
|
||||||
|
'createdDateTime',
|
||||||
|
'description',
|
||||||
|
'displayName',
|
||||||
|
'grantControls',
|
||||||
|
'id',
|
||||||
|
'modifiedDateTime',
|
||||||
|
'name',
|
||||||
|
'sessionControls',
|
||||||
|
'state',
|
||||||
|
'templateId',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const VOLATILE_ROOT_FIELDS = [
|
||||||
|
'@odata.context',
|
||||||
|
'@odata.etag',
|
||||||
|
'createdDateTime',
|
||||||
|
'modifiedDateTime',
|
||||||
|
];
|
||||||
|
|
||||||
|
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' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeConditionalAccessPolicy($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function volatileRootFields(): array
|
||||||
|
{
|
||||||
|
return self::VOLATILE_ROOT_FIELDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function normalizeConditionalAccessPolicy(array $payload): array
|
||||||
|
{
|
||||||
|
$redacted = $this->redactor->redact($payload);
|
||||||
|
$redacted = is_array($redacted) ? $redacted : [];
|
||||||
|
|
||||||
|
return $this->sortAssociative([
|
||||||
|
'canonical_type' => 'conditionalAccessPolicy',
|
||||||
|
'supported' => true,
|
||||||
|
'display_name' => $this->stringValue($redacted['displayName'] ?? $redacted['name'] ?? null),
|
||||||
|
'state' => $this->stringValue($redacted['state'] ?? null),
|
||||||
|
'targets' => [
|
||||||
|
'users' => [
|
||||||
|
'include_users' => $this->scalarList(data_get($redacted, 'conditions.users.includeUsers')),
|
||||||
|
'exclude_users' => $this->scalarList(data_get($redacted, 'conditions.users.excludeUsers')),
|
||||||
|
'include_groups' => $this->scalarList(data_get($redacted, 'conditions.users.includeGroups')),
|
||||||
|
'exclude_groups' => $this->scalarList(data_get($redacted, 'conditions.users.excludeGroups')),
|
||||||
|
'include_roles' => $this->scalarList(data_get($redacted, 'conditions.users.includeRoles')),
|
||||||
|
'exclude_roles' => $this->scalarList(data_get($redacted, 'conditions.users.excludeRoles')),
|
||||||
|
],
|
||||||
|
'applications' => [
|
||||||
|
'include_applications' => $this->scalarList(data_get($redacted, 'conditions.applications.includeApplications')),
|
||||||
|
'exclude_applications' => $this->scalarList(data_get($redacted, 'conditions.applications.excludeApplications')),
|
||||||
|
'include_user_actions' => $this->scalarList(data_get($redacted, 'conditions.applications.includeUserActions')),
|
||||||
|
'include_authentication_contexts' => $this->scalarList(data_get($redacted, 'conditions.applications.includeAuthenticationContextClassReferences')),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'conditions' => [
|
||||||
|
'client_app_types' => $this->scalarList(data_get($redacted, 'conditions.clientAppTypes')),
|
||||||
|
'platforms' => [
|
||||||
|
'include_platforms' => $this->scalarList(data_get($redacted, 'conditions.platforms.includePlatforms')),
|
||||||
|
'exclude_platforms' => $this->scalarList(data_get($redacted, 'conditions.platforms.excludePlatforms')),
|
||||||
|
],
|
||||||
|
'locations' => [
|
||||||
|
'include_locations' => $this->scalarList(data_get($redacted, 'conditions.locations.includeLocations')),
|
||||||
|
'exclude_locations' => $this->scalarList(data_get($redacted, 'conditions.locations.excludeLocations')),
|
||||||
|
],
|
||||||
|
'user_risk_levels' => $this->scalarList(data_get($redacted, 'conditions.userRiskLevels')),
|
||||||
|
'sign_in_risk_levels' => $this->scalarList(data_get($redacted, 'conditions.signInRiskLevels')),
|
||||||
|
],
|
||||||
|
'grant_controls' => [
|
||||||
|
'operator' => $this->stringValue(data_get($redacted, 'grantControls.operator')),
|
||||||
|
'built_in_controls' => $this->scalarList(data_get($redacted, 'grantControls.builtInControls')),
|
||||||
|
'custom_authentication_factors' => $this->scalarList(data_get($redacted, 'grantControls.customAuthenticationFactors')),
|
||||||
|
'terms_of_use' => $this->scalarList(data_get($redacted, 'grantControls.termsOfUse')),
|
||||||
|
],
|
||||||
|
'session_controls' => $this->normalizeNested(data_get($redacted, 'sessionControls', [])),
|
||||||
|
'diagnostics' => [
|
||||||
|
'unsupported_fields' => $this->unsupportedRootFields($redacted),
|
||||||
|
'redacted_fields' => $this->redactedPaths($redacted),
|
||||||
|
'volatile_fields' => $this->presentVolatileFields($payload),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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)) {
|
||||||
|
foreach ($this->scalarList($item) as $nested) {
|
||||||
|
$values[] = $nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stringValue(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_scalar($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
return $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeNested(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return is_scalar($value) ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_is_list($value)) {
|
||||||
|
$items = array_map(fn (mixed $item): mixed => $this->normalizeNested($item), $value);
|
||||||
|
|
||||||
|
if ($this->allScalar($items)) {
|
||||||
|
$items = array_map('strval', $items);
|
||||||
|
sort($items, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
} else {
|
||||||
|
usort($items, static fn (mixed $left, mixed $right): int => strcmp(
|
||||||
|
json_encode($left, JSON_THROW_ON_ERROR),
|
||||||
|
json_encode($right, JSON_THROW_ON_ERROR),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($value as $key => $nestedValue) {
|
||||||
|
$key = (string) $key;
|
||||||
|
|
||||||
|
if (in_array($key, self::VOLATILE_ROOT_FIELDS, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[$key] = $this->normalizeNested($nestedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($normalized, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<mixed> $items
|
||||||
|
*/
|
||||||
|
private function allScalar(array $items): bool
|
||||||
|
{
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (! is_scalar($item) && $item !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function unsupportedRootFields(array $payload): array
|
||||||
|
{
|
||||||
|
$fields = array_values(array_filter(
|
||||||
|
array_map('strval', array_keys($payload)),
|
||||||
|
static fn (string $key): bool => ! in_array($key, self::CONDITIONAL_ACCESS_ROOT_FIELDS, true),
|
||||||
|
));
|
||||||
|
|
||||||
|
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
|
||||||
|
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> $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,240 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
final class EntraCoverageComparator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const MATERIAL_CHANGE_TYPES = [
|
||||||
|
'added',
|
||||||
|
'removed',
|
||||||
|
'changed',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntraComparablePayloadNormalizer $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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
$hasMaterialChange = collect($changes)
|
||||||
|
->contains(fn (array $change): bool => in_array($change['classification'] ?? null, self::MATERIAL_CHANGE_TYPES, true));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'canonical_type' => $canonicalType,
|
||||||
|
'supported' => true,
|
||||||
|
'classification' => $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 = ''): 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) as $nestedChange) {
|
||||||
|
$changes[] = $nestedChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($beforeValue === $afterValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[] = [
|
||||||
|
'field' => $field,
|
||||||
|
'classification' => $this->changeClassification($beforeValue, $afterValue),
|
||||||
|
'importance' => $this->importance($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 importance(string $field): string
|
||||||
|
{
|
||||||
|
if ($field === 'state') {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($field, 'targets.')
|
||||||
|
|| str_starts_with($field, 'grant_controls.')
|
||||||
|
|| str_starts_with($field, 'session_controls.')
|
||||||
|
|| str_starts_with($field, 'conditions.')
|
||||||
|
) {
|
||||||
|
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 = [];
|
||||||
|
|
||||||
|
foreach ($this->diagnosticFieldUnion($before, $after, 'unsupported_fields') as $field) {
|
||||||
|
$changes[] = [
|
||||||
|
'field' => $field,
|
||||||
|
'classification' => 'unsupported_field',
|
||||||
|
'importance' => 'informational',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->diagnosticFieldUnion($before, $after, 'redacted_fields') as $field) {
|
||||||
|
$changes[] = [
|
||||||
|
'field' => $field,
|
||||||
|
'classification' => 'redacted',
|
||||||
|
'importance' => 'informational',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
final class EntraRenderableSummaryBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntraComparablePayloadNormalizer $normalizer,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'resource_type' => 'Conditional Access policy',
|
||||||
|
'display_name' => $normalized['display_name'] ?? 'Unnamed Conditional Access policy',
|
||||||
|
'state' => $normalized['state'] ?? 'unknown',
|
||||||
|
'targets' => [
|
||||||
|
['label' => 'Users', 'value' => $this->includeExcludeSummary(
|
||||||
|
data_get($normalized, 'targets.users.include_users', []),
|
||||||
|
data_get($normalized, 'targets.users.exclude_users', []),
|
||||||
|
'No users included',
|
||||||
|
)],
|
||||||
|
['label' => 'Groups', 'value' => $this->includeExcludeSummary(
|
||||||
|
data_get($normalized, 'targets.users.include_groups', []),
|
||||||
|
data_get($normalized, 'targets.users.exclude_groups', []),
|
||||||
|
'No groups included',
|
||||||
|
)],
|
||||||
|
['label' => 'Roles', 'value' => $this->includeExcludeSummary(
|
||||||
|
data_get($normalized, 'targets.users.include_roles', []),
|
||||||
|
data_get($normalized, 'targets.users.exclude_roles', []),
|
||||||
|
'No roles included',
|
||||||
|
)],
|
||||||
|
['label' => 'Applications', 'value' => $this->includeExcludeSummary(
|
||||||
|
data_get($normalized, 'targets.applications.include_applications', []),
|
||||||
|
data_get($normalized, 'targets.applications.exclude_applications', []),
|
||||||
|
'No applications included',
|
||||||
|
)],
|
||||||
|
],
|
||||||
|
'conditions' => [
|
||||||
|
['label' => 'Client apps', 'value' => $this->listSummary(data_get($normalized, 'conditions.client_app_types', []), 'Any client app')],
|
||||||
|
['label' => 'Platforms', 'value' => $this->includeExcludeSummary(
|
||||||
|
data_get($normalized, 'conditions.platforms.include_platforms', []),
|
||||||
|
data_get($normalized, 'conditions.platforms.exclude_platforms', []),
|
||||||
|
'Any platform',
|
||||||
|
)],
|
||||||
|
['label' => 'Locations', 'value' => $this->includeExcludeSummary(
|
||||||
|
data_get($normalized, 'conditions.locations.include_locations', []),
|
||||||
|
data_get($normalized, 'conditions.locations.exclude_locations', []),
|
||||||
|
'Any location',
|
||||||
|
)],
|
||||||
|
['label' => 'User risk', 'value' => $this->listSummary(data_get($normalized, 'conditions.user_risk_levels', []), 'Any user risk')],
|
||||||
|
['label' => 'Sign-in risk', 'value' => $this->listSummary(data_get($normalized, 'conditions.sign_in_risk_levels', []), 'Any sign-in risk')],
|
||||||
|
],
|
||||||
|
'grant_controls' => $this->grantControlsSummary(data_get($normalized, 'grant_controls', [])),
|
||||||
|
'session_controls' => $this->sessionControlsSummary(data_get($normalized, 'session_controls', [])),
|
||||||
|
'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> $include
|
||||||
|
* @param list<string> $exclude
|
||||||
|
*/
|
||||||
|
private function includeExcludeSummary(array $include, array $exclude, string $empty): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if ($include !== []) {
|
||||||
|
$parts[] = 'Include '.$this->listSummary($include, $empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exclude !== []) {
|
||||||
|
$parts[] = 'Exclude '.$this->listSummary($exclude, 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? $empty : implode('; ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $values
|
||||||
|
*/
|
||||||
|
private function listSummary(array $values, string $empty): 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 !== '',
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($values === []) {
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $grantControls
|
||||||
|
*/
|
||||||
|
private function grantControlsSummary(array $grantControls): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
$operator = $grantControls['operator'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($operator) && $operator !== '') {
|
||||||
|
$parts[] = 'Operator '.$operator;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'built_in_controls' => 'Built-in',
|
||||||
|
'custom_authentication_factors' => 'Custom factors',
|
||||||
|
'terms_of_use' => 'Terms of use',
|
||||||
|
] as $key => $label) {
|
||||||
|
$summary = $this->listSummary(is_array($grantControls[$key] ?? null) ? $grantControls[$key] : [], '');
|
||||||
|
|
||||||
|
if ($summary !== '') {
|
||||||
|
$parts[] = $label.': '.$summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? 'No grant controls' : implode('; ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sessionControlsSummary(mixed $sessionControls): string
|
||||||
|
{
|
||||||
|
if (! is_array($sessionControls) || $sessionControls === []) {
|
||||||
|
return 'No session controls';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
foreach ($sessionControls as $key => $value) {
|
||||||
|
$parts[] = str((string) $key)->headline()->toString().': '.$this->stringify($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('; ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stringify(mixed $value): string
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? 'yes' : 'no';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_scalar($value)) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,11 @@
|
|||||||
'Capture outcome' => $details['capture_outcome'] ?? null,
|
'Capture outcome' => $details['capture_outcome'] ?? null,
|
||||||
'Identity reason' => $details['identity_reason_code'] ?? null,
|
'Identity reason' => $details['identity_reason_code'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$typedSummary = $details['typed_render_summary'] ?? null;
|
||||||
|
$compareSummary = is_array($typedSummary) && is_array($typedSummary['compare_summary'] ?? null)
|
||||||
|
? $typedSummary['compare_summary']
|
||||||
|
: null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
@ -52,6 +57,121 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if (is_array($typedSummary))
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="text-sm font-medium text-gray-950 dark:text-white">
|
||||||
|
{{ $typedSummary['resource_type'] ?? 'Typed summary' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="grid gap-3 sm:grid-cols-2">
|
||||||
|
@foreach ([
|
||||||
|
'Display name' => $typedSummary['display_name'] ?? null,
|
||||||
|
'State' => $typedSummary['state'] ?? null,
|
||||||
|
'Grant controls' => $typedSummary['grant_controls'] ?? null,
|
||||||
|
'Session controls' => $typedSummary['session_controls'] ?? null,
|
||||||
|
'Claim state' => $typedSummary['claim_state'] ?? null,
|
||||||
|
'Identity state' => $typedSummary['identity_state'] ?? null,
|
||||||
|
'Last captured' => $typedSummary['last_captured'] ?? null,
|
||||||
|
] as $label => $value)
|
||||||
|
@if (filled($value))
|
||||||
|
<div class="min-w-0 border-l border-gray-200 pl-3 dark:border-white/10">
|
||||||
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $label }}
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 break-words text-sm text-gray-950 dark:text-white">
|
||||||
|
{{ $value }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@foreach (['targets' => 'Targets', 'conditions' => 'Conditions'] as $summaryKey => $heading)
|
||||||
|
@if (($typedSummary[$summaryKey] ?? []) !== [])
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $heading }}
|
||||||
|
</div>
|
||||||
|
<dl class="grid gap-2 sm:grid-cols-2">
|
||||||
|
@foreach ($typedSummary[$summaryKey] as $row)
|
||||||
|
@if (is_array($row) && filled($row['value'] ?? null))
|
||||||
|
<div class="min-w-0">
|
||||||
|
<dt class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $row['label'] ?? 'Summary' }}
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-0.5 break-words text-sm text-gray-950 dark:text-white">
|
||||||
|
{{ $row['value'] }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@if ($compareSummary !== null)
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Compare summary
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="($compareSummary['changed'] ?? false) ? 'warning' : 'gray'" icon="heroicon-m-arrows-right-left">
|
||||||
|
{{ $compareSummary['status'] ?? 'Compare summary unavailable' }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if (filled($compareSummary['previous_captured'] ?? null))
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Previous comparable evidence: {{ $compareSummary['previous_captured'] }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (($compareSummary['changes'] ?? []) !== [])
|
||||||
|
<dl class="grid gap-2 sm:grid-cols-2">
|
||||||
|
@foreach ($compareSummary['changes'] as $change)
|
||||||
|
@if (is_array($change))
|
||||||
|
<div class="min-w-0 border-l border-gray-200 pl-3 dark:border-white/10">
|
||||||
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $change['label'] ?? 'Changed field' }}
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 break-words text-sm text-gray-950 dark:text-white">
|
||||||
|
{{ str((string) ($change['classification'] ?? 'changed'))->replace('_', ' ')->headline() }}
|
||||||
|
·
|
||||||
|
{{ str((string) ($change['importance'] ?? 'informational'))->replace('_', ' ')->headline() }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@foreach ([
|
||||||
|
'unsupported_fields' => ['Unsupported fields', 'warning', 'heroicon-m-exclamation-triangle'],
|
||||||
|
'redacted_fields' => ['Redacted fields', 'gray', 'heroicon-m-shield-check'],
|
||||||
|
] as $summaryKey => [$heading, $color, $icon])
|
||||||
|
@if (($typedSummary[$summaryKey] ?? []) !== [])
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $heading }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach ($typedSummary[$summaryKey] as $field)
|
||||||
|
<x-filament::badge :color="$color" :icon="$icon">
|
||||||
|
{{ $field }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<dl class="grid gap-3 sm:grid-cols-2">
|
<dl class="grid gap-3 sm:grid-cols-2">
|
||||||
@foreach ($safeFields as $label => $value)
|
@foreach ($safeFields as $label => $value)
|
||||||
@if (filled($value))
|
@if (filled($value))
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-filament::badge :color="$readiness->color" :icon="$readiness->icon">
|
<x-filament::badge :color="$readiness->color" :icon="$readiness->icon" class="shrink-0 self-start whitespace-nowrap">
|
||||||
{{ $readiness->label }}
|
{{ $readiness->label }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,354 @@
|
|||||||
|
<?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('Spec421 smokes the Coverage v2 inspect surface for Entra comparable renderable evidence', function (): void {
|
||||||
|
[$user, $environment] = spec421CoverageV2BrowserFixture();
|
||||||
|
spec421AuthenticateCoverageV2Browser($this, $user, $environment);
|
||||||
|
|
||||||
|
$page = visit(CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin'))
|
||||||
|
->resize(768, 1100)
|
||||||
|
->waitForText('Coverage v2 Readiness')
|
||||||
|
->waitForText('Spec421 Browser Conditional Access policy')
|
||||||
|
->assertSee('Resource type registry')
|
||||||
|
->assertSee('Resource instances')
|
||||||
|
->assertSee('Conditional Access policy')
|
||||||
|
->assertSee('Coverage level')
|
||||||
|
->assertSee('Renderable')
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertDontSee('Entra covered')
|
||||||
|
->assertDontSee('certified')
|
||||||
|
->assertDontSee('restore-ready')
|
||||||
|
->assertDontSee('customer-ready')
|
||||||
|
->assertDontSee('100% Entra')
|
||||||
|
->assertDontSee('spec421-raw-secret')
|
||||||
|
->assertDontSee('spec421-normalized-secret')
|
||||||
|
->assertScript('typeof window.Livewire !== "undefined"', true)
|
||||||
|
->assertScript(<<<'JS'
|
||||||
|
(() => {
|
||||||
|
const badge = Array.from(document.querySelectorAll('span.fi-badge'))
|
||||||
|
.find((element) => element.textContent.trim() === 'Ready');
|
||||||
|
const section = badge?.closest('section');
|
||||||
|
|
||||||
|
if (! badge || ! section) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeRect = badge.getBoundingClientRect();
|
||||||
|
const sectionRect = section.getBoundingClientRect();
|
||||||
|
|
||||||
|
return badgeRect.width >= 60
|
||||||
|
&& badgeRect.right <= sectionRect.right
|
||||||
|
&& getComputedStyle(badge).whiteSpace === 'nowrap';
|
||||||
|
})()
|
||||||
|
JS, 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('Spec421 Browser Conditional Access policy'));
|
||||||
|
const resourceTypeCellText = row?.querySelectorAll('td')?.[1]?.innerText ?? '';
|
||||||
|
|
||||||
|
return resourceTypeCellText.includes('Conditional Access policy')
|
||||||
|
&& ! resourceTypeCellText.includes('conditionalAccessPolicy');
|
||||||
|
})()
|
||||||
|
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('Spec421 Browser Conditional Access policy'));
|
||||||
|
const inspect = Array.from(row?.querySelectorAll('button, a') ?? [])
|
||||||
|
.find((element) => element.textContent.includes('Spec421 Browser Conditional Access policy'));
|
||||||
|
|
||||||
|
inspect?.click();
|
||||||
|
})()
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page
|
||||||
|
->waitForText('Coverage: Renderable')
|
||||||
|
->assertSee('Conditional Access policy')
|
||||||
|
->assertSee('Display name')
|
||||||
|
->assertSee('Spec421 Browser Conditional Access policy')
|
||||||
|
->assertSee('State')
|
||||||
|
->assertSee('enabled')
|
||||||
|
->assertSee('Grant controls')
|
||||||
|
->assertSee('Built-in: mfa')
|
||||||
|
->assertSee('Compare summary')
|
||||||
|
->assertSee('Material changes detected')
|
||||||
|
->assertSee('Previous comparable evidence')
|
||||||
|
->assertSee('Grant Controls Built In Controls')
|
||||||
|
->assertSee('Users')
|
||||||
|
->assertSee('Include All')
|
||||||
|
->assertSee('Applications')
|
||||||
|
->assertSee('Include Office365')
|
||||||
|
->assertSee('Redacted fields')
|
||||||
|
->assertSee('clientSecret')
|
||||||
|
->assertSee('Evidence: Content backed')
|
||||||
|
->assertSee('Identity: Stable')
|
||||||
|
->assertSee('Claim: Internal only')
|
||||||
|
->assertDontSee('Entra covered')
|
||||||
|
->assertDontSee('certified')
|
||||||
|
->assertDontSee('restore-ready')
|
||||||
|
->assertDontSee('customer-ready')
|
||||||
|
->assertDontSee('100% Entra')
|
||||||
|
->assertDontSee('compliantDevice')
|
||||||
|
->assertDontSee('spec421-raw-secret')
|
||||||
|
->assertDontSee('spec421-normalized-secret')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs()
|
||||||
|
->screenshot(true, 'spec421-entra-comparable-renderable-operator-surface');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: User, 1: ManagedEnvironment}
|
||||||
|
*/
|
||||||
|
function spec421CoverageV2BrowserFixture(): array
|
||||||
|
{
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
|
||||||
|
$environment = ManagedEnvironment::factory()->active()->create([
|
||||||
|
'name' => 'Spec421 Browser Environment',
|
||||||
|
'external_id' => 'spec421-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' => 'Spec421 Browser Microsoft provider',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resourceType = TenantConfigurationResourceType::query()
|
||||||
|
->where('canonical_type', 'conditionalAccessPolicy')
|
||||||
|
->where('source_class', SourceClass::Tcm->value)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
TenantConfigurationSupportedScope::factory()->create([
|
||||||
|
'scope_key' => 'spec421_browser_internal_entra_scope',
|
||||||
|
'display_name' => 'Spec421 Browser internal Entra scope',
|
||||||
|
'minimum_coverage_level' => CoverageLevel::ContentBacked->value,
|
||||||
|
'included_resource_types' => ['conditionalAccessPolicy'],
|
||||||
|
'allow_graph_fallback' => false,
|
||||||
|
'allow_beta' => false,
|
||||||
|
'customer_claims_allowed' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$previousRun = 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' => ['conditionalAccessPolicy'],
|
||||||
|
'outcomes' => [
|
||||||
|
['canonical_type' => 'conditionalAccessPolicy', 'outcome' => CaptureOutcome::Captured->value],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'started_at' => now()->subMinutes(6),
|
||||||
|
'completed_at' => now()->subMinutes(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'initiator_name' => (string) $user->name,
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 1,
|
||||||
|
'processed' => 1,
|
||||||
|
'succeeded' => 1,
|
||||||
|
'skipped' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'errors_recorded' => 0,
|
||||||
|
],
|
||||||
|
'context' => [
|
||||||
|
'requested_resource_types' => ['conditionalAccessPolicy'],
|
||||||
|
'outcomes' => [
|
||||||
|
['canonical_type' => 'conditionalAccessPolicy', 'outcome' => CaptureOutcome::Captured->value],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'started_at' => now()->subMinute(),
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resource = TenantConfigurationResource::factory()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'canonical_type' => 'conditionalAccessPolicy',
|
||||||
|
'canonical_resource_key' => 'conditionalAccessPolicy:graph_object_id:cap-browser-421',
|
||||||
|
'canonical_key_kind' => CanonicalKeyKind::GraphObjectId->value,
|
||||||
|
'source_resource_id' => 'cap-browser-421',
|
||||||
|
'source_display_name' => 'Spec421 Browser Conditional Access policy',
|
||||||
|
'source_class' => SourceClass::Tcm->value,
|
||||||
|
'source_metadata' => [
|
||||||
|
'source_contract_key' => 'conditionalAccessPolicy',
|
||||||
|
'source_endpoint' => '/identity/conditionalAccess/policies',
|
||||||
|
'source_version' => 'v1.0',
|
||||||
|
'registry_source_class' => SourceClass::Tcm->value,
|
||||||
|
'registry_support_state' => 'out_of_scope',
|
||||||
|
],
|
||||||
|
'identity_strategy' => 'graph.conditional_access_policy.v1',
|
||||||
|
'source_identity' => [
|
||||||
|
'primary_field' => 'id',
|
||||||
|
'primary_value' => 'cap-browser-421',
|
||||||
|
],
|
||||||
|
'identity_diagnostics' => [
|
||||||
|
'reason_code' => 'graph_object_id',
|
||||||
|
],
|
||||||
|
'identity_evaluated_at' => now(),
|
||||||
|
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
|
'latest_identity_state' => IdentityState::Stable->value,
|
||||||
|
'latest_claim_state' => ClaimState::InternalOnly->value,
|
||||||
|
'latest_captured_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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' => 'conditionalAccessPolicy',
|
||||||
|
'source_endpoint' => '/identity/conditionalAccess/policies',
|
||||||
|
'source_version' => 'v1.0',
|
||||||
|
'source_schema_hash' => 'spec421-browser-previous-schema-hash',
|
||||||
|
'source_metadata' => [
|
||||||
|
'registry_source_class' => SourceClass::Tcm->value,
|
||||||
|
'registry_support_state' => 'out_of_scope',
|
||||||
|
],
|
||||||
|
'raw_payload' => ['id' => 'cap-browser-421'],
|
||||||
|
'normalized_payload' => [
|
||||||
|
'id' => 'cap-browser-421',
|
||||||
|
'displayName' => 'Spec421 Browser Conditional Access policy',
|
||||||
|
'state' => 'enabled',
|
||||||
|
'conditions' => [
|
||||||
|
'users' => ['includeUsers' => ['All']],
|
||||||
|
'applications' => ['includeApplications' => ['Office365']],
|
||||||
|
],
|
||||||
|
'grantControls' => ['builtInControls' => ['compliantDevice']],
|
||||||
|
],
|
||||||
|
'payload_hash' => str_repeat('e', 64),
|
||||||
|
'permission_context' => ['scopes_granted' => ['Policy.Read.All']],
|
||||||
|
'evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
|
'coverage_level' => CoverageLevel::Comparable->value,
|
||||||
|
'capture_outcome' => CaptureOutcome::Captured->value,
|
||||||
|
'captured_at' => now()->subMinutes(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$evidence = TenantConfigurationResourceEvidence::factory()->create([
|
||||||
|
'resource_id' => (int) $resource->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'source_contract_key' => 'conditionalAccessPolicy',
|
||||||
|
'source_endpoint' => '/identity/conditionalAccess/policies',
|
||||||
|
'source_version' => 'v1.0',
|
||||||
|
'source_schema_hash' => 'spec421-browser-schema-hash',
|
||||||
|
'source_metadata' => [
|
||||||
|
'registry_source_class' => SourceClass::Tcm->value,
|
||||||
|
'registry_support_state' => 'out_of_scope',
|
||||||
|
],
|
||||||
|
'raw_payload' => ['id' => 'cap-browser-421', 'secret' => 'spec421-raw-secret'],
|
||||||
|
'normalized_payload' => [
|
||||||
|
'id' => 'cap-browser-421',
|
||||||
|
'displayName' => 'Spec421 Browser Conditional Access policy',
|
||||||
|
'state' => 'enabled',
|
||||||
|
'conditions' => [
|
||||||
|
'users' => ['includeUsers' => ['All']],
|
||||||
|
'applications' => ['includeApplications' => ['Office365']],
|
||||||
|
],
|
||||||
|
'grantControls' => ['builtInControls' => ['mfa']],
|
||||||
|
'clientSecret' => '[redacted]',
|
||||||
|
],
|
||||||
|
'payload_hash' => str_repeat('f', 64),
|
||||||
|
'permission_context' => ['scopes_granted' => ['Policy.Read.All']],
|
||||||
|
'evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
|
'coverage_level' => CoverageLevel::Renderable->value,
|
||||||
|
'capture_outcome' => CaptureOutcome::Captured->value,
|
||||||
|
'captured_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resource->forceFill([
|
||||||
|
'latest_evidence_id' => (int) $evidence->getKey(),
|
||||||
|
'latest_payload_hash' => str_repeat('f', 64),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return [$user, $environment->refresh()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec421AuthenticateCoverageV2Browser(
|
||||||
|
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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@ -100,7 +100,7 @@
|
|||||||
->and($evidence->operation_run_id)->toBe((int) $run->getKey())
|
->and($evidence->operation_run_id)->toBe((int) $run->getKey())
|
||||||
->and($evidence->source_contract_key)->toBe('conditionalAccessPolicy')
|
->and($evidence->source_contract_key)->toBe('conditionalAccessPolicy')
|
||||||
->and($evidence->source_endpoint)->toBe('/identity/conditionalAccess/policies')
|
->and($evidence->source_endpoint)->toBe('/identity/conditionalAccess/policies')
|
||||||
->and($evidence->coverage_level)->toBe(CoverageLevel::ContentBacked)
|
->and($evidence->coverage_level)->toBe(CoverageLevel::Renderable)
|
||||||
->and($evidence->evidence_state)->toBe(EvidenceState::ContentBacked)
|
->and($evidence->evidence_state)->toBe(EvidenceState::ContentBacked)
|
||||||
->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured)
|
->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured)
|
||||||
->and($evidence->raw_payload['id'])->toBe('cap-1')
|
->and($evidence->raw_payload['id'])->toBe('cap-1')
|
||||||
|
|||||||
@ -0,0 +1,264 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
|
||||||
|
use App\Services\TenantConfiguration\GenericContentEvidenceCaptureService;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\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('Spec421 exposes typed Conditional Access summaries with material compare details without provider calls', 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(),
|
||||||
|
]);
|
||||||
|
app()->instance(GraphClientInterface::class, spec421InspectCaptureGraphClient([
|
||||||
|
spec421InspectConditionalAccessPayload([
|
||||||
|
'grantControls' => ['builtInControls' => ['compliantDevice']],
|
||||||
|
]),
|
||||||
|
spec421InspectConditionalAccessPayload(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
app(GenericContentEvidenceCaptureService::class)->capture(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: spec421InspectRun($user, $environment, $connection),
|
||||||
|
canonicalTypes: ['conditionalAccessPolicy'],
|
||||||
|
);
|
||||||
|
app(GenericContentEvidenceCaptureService::class)->capture(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: spec421InspectRun($user, $environment, $connection),
|
||||||
|
canonicalTypes: ['conditionalAccessPolicy'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$resource = TenantConfigurationResource::query()->sole();
|
||||||
|
app()->instance(GraphClientInterface::class, spec421FailingGraphClient());
|
||||||
|
|
||||||
|
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $environment, $user);
|
||||||
|
$summary = $details['typed_render_summary'] ?? null;
|
||||||
|
|
||||||
|
expect($summary)->toBeArray()
|
||||||
|
->and($summary['display_name'])->toBe('Require MFA')
|
||||||
|
->and($summary['state'])->toBe('enabled')
|
||||||
|
->and(json_encode($summary, JSON_THROW_ON_ERROR))->toContain('All')
|
||||||
|
->and(json_encode($summary, JSON_THROW_ON_ERROR))->not->toContain('raw_payload')
|
||||||
|
->and(json_encode($summary, JSON_THROW_ON_ERROR))->not->toContain('source_endpoint')
|
||||||
|
->and($summary['compare_summary']['status'])->toBe('Material changes detected')
|
||||||
|
->and($summary['compare_summary']['changed'])->toBeTrue()
|
||||||
|
->and(collect($summary['compare_summary']['changes'])->pluck('label'))
|
||||||
|
->toContain('Grant Controls Built In Controls')
|
||||||
|
->and(json_encode($summary['compare_summary'], JSON_THROW_ON_ERROR))
|
||||||
|
->not->toContain('compliantDevice')
|
||||||
|
->not->toContain('mfa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec421 does not render typed summaries for non-content-backed latest evidence', 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(),
|
||||||
|
]);
|
||||||
|
$resourceType = spec421ConditionalAccessResourceType();
|
||||||
|
$resource = TenantConfigurationResource::factory()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'canonical_type' => 'conditionalAccessPolicy',
|
||||||
|
'canonical_resource_key' => 'conditionalAccessPolicy:graph_object_id:cap-blocked-421',
|
||||||
|
'canonical_key_kind' => CanonicalKeyKind::GraphObjectId->value,
|
||||||
|
'source_resource_id' => 'cap-blocked-421',
|
||||||
|
'source_display_name' => 'Spec421 blocked Conditional Access policy',
|
||||||
|
'source_class' => SourceClass::Tcm->value,
|
||||||
|
'latest_evidence_state' => EvidenceState::PermissionBlocked->value,
|
||||||
|
'latest_identity_state' => IdentityState::Stable->value,
|
||||||
|
'latest_claim_state' => ClaimState::ClaimBlocked->value,
|
||||||
|
'latest_captured_at' => now(),
|
||||||
|
]);
|
||||||
|
$evidence = TenantConfigurationResourceEvidence::factory()->create([
|
||||||
|
'resource_id' => (int) $resource->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'source_contract_key' => 'conditionalAccessPolicy',
|
||||||
|
'source_endpoint' => '/identity/conditionalAccess/policies',
|
||||||
|
'normalized_payload' => spec421InspectConditionalAccessPayload(),
|
||||||
|
'evidence_state' => EvidenceState::PermissionBlocked->value,
|
||||||
|
'coverage_level' => CoverageLevel::Renderable->value,
|
||||||
|
'capture_outcome' => CaptureOutcome::BlockedPermission->value,
|
||||||
|
'captured_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resource->forceFill([
|
||||||
|
'latest_evidence_id' => (int) $evidence->getKey(),
|
||||||
|
'latest_payload_hash' => $evidence->payload_hash,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $environment, $user);
|
||||||
|
|
||||||
|
expect($details['typed_render_summary'] ?? null)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec421 returns no inspect details when the managed environment scope does not match', function (): void {
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
[, $foreignEnvironment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
]);
|
||||||
|
app()->instance(GraphClientInterface::class, spec421InspectCaptureGraphClient());
|
||||||
|
|
||||||
|
app(GenericContentEvidenceCaptureService::class)->capture(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: spec421InspectRun($user, $environment, $connection),
|
||||||
|
canonicalTypes: ['conditionalAccessPolicy'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(app(CoverageV2ReadinessReadModel::class)->inspectDetails(
|
||||||
|
TenantConfigurationResource::query()->sole(),
|
||||||
|
$foreignEnvironment,
|
||||||
|
$user,
|
||||||
|
))->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function spec421InspectRun($user, $environment, ProviderConnection $connection): OperationRun
|
||||||
|
{
|
||||||
|
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'context' => [
|
||||||
|
'target_scope' => [
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
'resource_types' => ['conditionalAccessPolicy'],
|
||||||
|
'required_capability' => 'evidence.manage',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec421InspectCaptureGraphClient(array $payloads = []): GraphClientInterface
|
||||||
|
{
|
||||||
|
return new class($payloads) implements GraphClientInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $payloads
|
||||||
|
*/
|
||||||
|
public function __construct(private array $payloads) {}
|
||||||
|
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, [array_shift($this->payloads) ?: spec421InspectConditionalAccessPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec421InspectConditionalAccessPayload(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_replace_recursive([
|
||||||
|
'id' => 'cap-1',
|
||||||
|
'displayName' => 'Require MFA',
|
||||||
|
'state' => 'enabled',
|
||||||
|
'conditions' => [
|
||||||
|
'users' => ['includeUsers' => ['All']],
|
||||||
|
'applications' => ['includeApplications' => ['Office365']],
|
||||||
|
],
|
||||||
|
'grantControls' => ['builtInControls' => ['mfa']],
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec421ConditionalAccessResourceType()
|
||||||
|
{
|
||||||
|
return \App\Models\TenantConfigurationResourceType::query()
|
||||||
|
->where('canonical_type', 'conditionalAccessPolicy')
|
||||||
|
->where('source_class', SourceClass::Tcm->value)
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec421FailingGraphClient(): GraphClientInterface
|
||||||
|
{
|
||||||
|
return new class implements GraphClientInterface
|
||||||
|
{
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\TenantConfiguration\GenericContentEvidenceCaptureService;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
|
||||||
|
it('Spec421 promotes only content-backed Conditional Access evidence to renderable coverage', function (): void {
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
config()->set('graph_contracts.types.conditionalAccessPolicy.volatile_fields', ['modifiedDateTime']);
|
||||||
|
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
]);
|
||||||
|
$graph = spec421PromotionGraphClient();
|
||||||
|
app()->instance(GraphClientInterface::class, $graph);
|
||||||
|
|
||||||
|
$result = app(GenericContentEvidenceCaptureService::class)->capture(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: spec421PromotionRun($user, $environment, $connection, [
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
'securityDefaults',
|
||||||
|
'application',
|
||||||
|
'servicePrincipal',
|
||||||
|
'roleDefinition',
|
||||||
|
'administrativeUnit',
|
||||||
|
]),
|
||||||
|
canonicalTypes: [
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
'securityDefaults',
|
||||||
|
'application',
|
||||||
|
'servicePrincipal',
|
||||||
|
'roleDefinition',
|
||||||
|
'administrativeUnit',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$outcomes = collect($result['outcomes'])->keyBy('canonical_type');
|
||||||
|
|
||||||
|
expect($graph->calls)->toBe(['conditionalAccessPolicy'])
|
||||||
|
->and(TenantConfigurationResource::query()->count())->toBe(1)
|
||||||
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(1)
|
||||||
|
->and(TenantConfigurationResourceEvidence::query()->sole()->coverage_level)->toBe(CoverageLevel::Renderable)
|
||||||
|
->and($outcomes['conditionalAccessPolicy']['outcome'])->toBe(CaptureOutcome::Captured->value)
|
||||||
|
->and($outcomes['securityDefaults']['outcome'])->toBe(CaptureOutcome::BlockedUnsupported->value)
|
||||||
|
->and($outcomes['application']['outcome'])->toBe(CaptureOutcome::BlockedUnsupported->value)
|
||||||
|
->and($outcomes['servicePrincipal']['outcome'])->toBe(CaptureOutcome::BlockedUnsupported->value)
|
||||||
|
->and($outcomes['roleDefinition']['outcome'])->toBe(CaptureOutcome::BlockedUnsupported->value)
|
||||||
|
->and($outcomes['administrativeUnit']['outcome'])->toBe(CaptureOutcome::BlockedUnsupported->value);
|
||||||
|
});
|
||||||
|
|
||||||
|
function spec421PromotionRun($user, $environment, ProviderConnection $connection, array $resourceTypes): OperationRun
|
||||||
|
{
|
||||||
|
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'context' => [
|
||||||
|
'target_scope' => [
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
'resource_types' => $resourceTypes,
|
||||||
|
'required_capability' => 'evidence.manage',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec421PromotionGraphClient(): GraphClientInterface
|
||||||
|
{
|
||||||
|
return new class implements GraphClientInterface
|
||||||
|
{
|
||||||
|
public array $calls = [];
|
||||||
|
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
$this->calls[] = $policyType;
|
||||||
|
|
||||||
|
return new GraphResponse(true, [[
|
||||||
|
'id' => 'cap-1',
|
||||||
|
'displayName' => 'Require MFA',
|
||||||
|
'state' => 'enabled',
|
||||||
|
'conditions' => ['users' => ['includeUsers' => ['All']]],
|
||||||
|
'grantControls' => ['builtInControls' => ['mfa']],
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('Spec421 does not add Entra-specific tables, models, routes, or Filament resources', function (): void {
|
||||||
|
$paths = [
|
||||||
|
'apps/platform/database/migrations',
|
||||||
|
'apps/platform/app/Models',
|
||||||
|
'apps/platform/app/Filament',
|
||||||
|
'apps/platform/routes',
|
||||||
|
];
|
||||||
|
$joined = collect($paths)
|
||||||
|
->flatMap(fn (string $path): array => glob(repo_path($path).'/**/*421*') ?: [])
|
||||||
|
->map(fn (string $path): string => str_replace(repo_path().DIRECTORY_SEPARATOR, '', $path))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($joined)->toBe([]);
|
||||||
|
});
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
use App\Support\TenantConfiguration\RestoreTier;
|
||||||
|
|
||||||
|
it('Spec421 keeps Conditional Access typed support separate from restore or certification claims', function (): void {
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
|
||||||
|
$resourceType = TenantConfigurationResourceType::query()
|
||||||
|
->where('canonical_type', 'conditionalAccessPolicy')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
expect($resourceType->restore_tier)->toBe(RestoreTier::NotRestorable)
|
||||||
|
->and($resourceType->allows_certified_claims)->toBeFalse()
|
||||||
|
->and($resourceType->default_claim_state)->toBe(ClaimState::InternalOnly)
|
||||||
|
->and($resourceType->metadata['customer_claims_allowed'] ?? null)->toBeFalse();
|
||||||
|
});
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('Spec421 does not introduce tenant_id as Coverage v2 ownership truth', function (): void {
|
||||||
|
foreach (spec421ChangedRuntimeFiles() as $path) {
|
||||||
|
expect(file_get_contents(repo_path($path)))->not->toContain('tenant_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function spec421ChangedRuntimeFiles(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php',
|
||||||
|
'apps/platform/app/Services/TenantConfiguration/EntraCoverageComparator.php',
|
||||||
|
'apps/platform/app/Services/TenantConfiguration/EntraRenderableSummaryBuilder.php',
|
||||||
|
'apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php',
|
||||||
|
'apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php',
|
||||||
|
'apps/platform/resources/views/filament/modals/tenant-configuration/coverage-v2-resource-inspect.blade.php',
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\ClaimGuard;
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
|
||||||
|
it('Spec421 allows scoped internal Entra comparable and renderable wording only as internal operator truth', function (string $claim): void {
|
||||||
|
expect(app(ClaimGuard::class)->evaluateStatement($claim, internalOperatorOnly: true))
|
||||||
|
->toBe(ClaimState::InternalOnly);
|
||||||
|
})->with([
|
||||||
|
'Selected Entra resources are comparable for internal operator review',
|
||||||
|
'Selected Entra resources are renderable for internal review',
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('Spec421 blocks unsafe Entra and M365 overclaims', function (string $claim): void {
|
||||||
|
expect(app(ClaimGuard::class)->evaluateStatement($claim, internalOperatorOnly: true))
|
||||||
|
->toBe(ClaimState::ClaimBlocked);
|
||||||
|
})->with([
|
||||||
|
'Entra certified coverage',
|
||||||
|
'Entra restore-ready coverage',
|
||||||
|
'All Entra resources are supported',
|
||||||
|
'100 percent Entra coverage',
|
||||||
|
'Microsoft 365 customer-ready evidence',
|
||||||
|
]);
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\EntraCoverageComparator;
|
||||||
|
|
||||||
|
it('Spec421 treats volatile-only Conditional Access differences as unchanged', function (): void {
|
||||||
|
$result = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
spec421ComparablePayload(['modifiedDateTime' => '2026-06-27T10:00:00Z']),
|
||||||
|
spec421ComparablePayload(['modifiedDateTime' => '2026-06-27T11:00:00Z']),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['changed'])->toBeFalse()
|
||||||
|
->and($result['classification'])->toBe('unchanged')
|
||||||
|
->and(collect($result['changes'])->pluck('classification'))->toContain('ignored_volatile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec421 detects material Conditional Access changes with bounded importance', function (array $after, string $field, string $importance): void {
|
||||||
|
$result = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
spec421ComparablePayload(),
|
||||||
|
spec421ComparablePayload($after),
|
||||||
|
);
|
||||||
|
$change = collect($result['changes'])->firstWhere('field', $field);
|
||||||
|
|
||||||
|
expect($result['changed'])->toBeTrue()
|
||||||
|
->and($result['classification'])->toBe('changed')
|
||||||
|
->and($change)->not->toBeNull()
|
||||||
|
->and($change['classification'])->toBe('changed')
|
||||||
|
->and($change['importance'])->toBe($importance);
|
||||||
|
})->with([
|
||||||
|
'state' => [['state' => 'disabled'], 'state', 'critical'],
|
||||||
|
'target users' => [['conditions' => ['users' => ['includeUsers' => ['All', 'group-a']]]], 'targets.users.include_users', 'important'],
|
||||||
|
'grant controls' => [['grantControls' => ['operator' => 'OR', 'builtInControls' => ['mfa', 'compliantDevice']]], 'grant_controls.built_in_controls', 'important'],
|
||||||
|
'session controls' => [['sessionControls' => ['signInFrequency' => ['value' => 4, 'type' => 'hours', 'isEnabled' => true]]], 'session_controls.signInFrequency.value', 'important'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('Spec421 records redacted and unsupported fields as non-material diagnostics', function (): void {
|
||||||
|
$result = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
spec421ComparablePayload(),
|
||||||
|
spec421ComparablePayload(['clientSecret' => 'spec421-client-secret']),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['changed'])->toBeFalse()
|
||||||
|
->and(collect($result['changes'])->pluck('classification'))->toContain('redacted', 'unsupported_field')
|
||||||
|
->and(json_encode($result, JSON_THROW_ON_ERROR))->not->toContain('spec421-client-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
function spec421ComparablePayload(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_replace_recursive([
|
||||||
|
'id' => 'cap-1',
|
||||||
|
'displayName' => 'Require MFA',
|
||||||
|
'state' => 'enabled',
|
||||||
|
'conditions' => [
|
||||||
|
'users' => ['includeUsers' => ['All']],
|
||||||
|
'applications' => ['includeApplications' => ['Office365']],
|
||||||
|
],
|
||||||
|
'grantControls' => [
|
||||||
|
'operator' => 'OR',
|
||||||
|
'builtInControls' => ['mfa'],
|
||||||
|
],
|
||||||
|
'sessionControls' => [
|
||||||
|
'signInFrequency' => ['value' => 8, 'type' => 'hours', 'isEnabled' => true],
|
||||||
|
],
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\EntraComparablePayloadNormalizer;
|
||||||
|
|
||||||
|
it('Spec421 normalizes Conditional Access payloads into deterministic typed shape', function (): void {
|
||||||
|
$normalizer = app(EntraComparablePayloadNormalizer::class);
|
||||||
|
|
||||||
|
$first = $normalizer->normalize('conditionalAccessPolicy', spec421ConditionalAccessPayload([
|
||||||
|
'modifiedDateTime' => '2026-06-27T10:00:00Z',
|
||||||
|
'conditions' => [
|
||||||
|
'users' => ['includeUsers' => ['group-b', 'group-a']],
|
||||||
|
'applications' => ['includeApplications' => ['Office365']],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
$second = $normalizer->normalize('conditionalAccessPolicy', spec421ConditionalAccessPayload([
|
||||||
|
'modifiedDateTime' => '2026-06-27T10:05:00Z',
|
||||||
|
'conditions' => [
|
||||||
|
'applications' => ['includeApplications' => ['Office365']],
|
||||||
|
'users' => ['includeUsers' => ['group-a', 'group-b']],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
expect($first['targets']['users']['include_users'])->toBe(['group-a', 'group-b'])
|
||||||
|
->and($first['diagnostics']['volatile_fields'])->toContain('modifiedDateTime')
|
||||||
|
->and($first)->toBe($second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec421 records unsupported and redacted Conditional Access diagnostics without exposing secret values', function (): void {
|
||||||
|
$normalizer = app(EntraComparablePayloadNormalizer::class);
|
||||||
|
$normalized = $normalizer->normalize('conditionalAccessPolicy', spec421ConditionalAccessPayload([
|
||||||
|
'clientSecret' => 'spec421-secret-value',
|
||||||
|
'tokenClaims' => ['access_token' => 'spec421-access-token'],
|
||||||
|
]));
|
||||||
|
$encoded = json_encode($normalized, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
expect($normalized['diagnostics']['unsupported_fields'])->toContain('clientSecret', 'tokenClaims')
|
||||||
|
->and($normalized['diagnostics']['redacted_fields'])->toContain('clientSecret', 'tokenClaims')
|
||||||
|
->and($encoded)->not->toContain('spec421-secret-value')
|
||||||
|
->and($encoded)->not->toContain('spec421-access-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
function spec421ConditionalAccessPayload(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_replace_recursive([
|
||||||
|
'id' => 'cap-1',
|
||||||
|
'displayName' => 'Require MFA',
|
||||||
|
'state' => 'enabled',
|
||||||
|
'conditions' => [
|
||||||
|
'users' => ['includeUsers' => ['All']],
|
||||||
|
'applications' => ['includeApplications' => ['Office365']],
|
||||||
|
],
|
||||||
|
'grantControls' => [
|
||||||
|
'operator' => 'OR',
|
||||||
|
'builtInControls' => ['mfa'],
|
||||||
|
],
|
||||||
|
'sessionControls' => [
|
||||||
|
'signInFrequency' => ['value' => 8, 'type' => 'hours', 'isEnabled' => true],
|
||||||
|
],
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\EntraCoverageComparator;
|
||||||
|
use App\Services\TenantConfiguration\EntraRenderableSummaryBuilder;
|
||||||
|
|
||||||
|
it('Spec421 keeps secret-bearing values out of render and compare output', function (): void {
|
||||||
|
$payload = [
|
||||||
|
'id' => 'cap-1',
|
||||||
|
'displayName' => 'Require MFA',
|
||||||
|
'state' => 'enabled',
|
||||||
|
'conditions' => ['users' => ['includeUsers' => ['All']]],
|
||||||
|
'grantControls' => ['builtInControls' => ['mfa']],
|
||||||
|
'clientSecret' => 'spec421-client-secret',
|
||||||
|
'privateKey' => 'spec421-private-key',
|
||||||
|
'headers' => ['Authorization' => 'Bearer spec421-token'],
|
||||||
|
'cookies' => ['set-cookie' => 'spec421-cookie'],
|
||||||
|
'auditMetadata' => ['raw_payload' => ['secret' => 'spec421-audit-secret']],
|
||||||
|
'operationRunContext' => ['access_token' => 'spec421-run-token'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$summary = app(EntraRenderableSummaryBuilder::class)->build('conditionalAccessPolicy', $payload);
|
||||||
|
$compare = app(EntraCoverageComparator::class)->compare('conditionalAccessPolicy', $payload, [
|
||||||
|
...$payload,
|
||||||
|
'modifiedDateTime' => '2026-06-27T12:00:00Z',
|
||||||
|
]);
|
||||||
|
$encoded = json_encode([$summary, $compare], JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
expect($encoded)->not->toContain('spec421-client-secret')
|
||||||
|
->and($encoded)->not->toContain('spec421-private-key')
|
||||||
|
->and($encoded)->not->toContain('spec421-token')
|
||||||
|
->and($encoded)->not->toContain('spec421-cookie')
|
||||||
|
->and($encoded)->not->toContain('spec421-audit-secret')
|
||||||
|
->and($encoded)->not->toContain('spec421-run-token')
|
||||||
|
->and($summary['redacted_fields'])->toContain('clientSecret', 'privateKey', 'headers.Authorization', 'cookies', 'auditMetadata.raw_payload.secret', 'operationRunContext.access_token');
|
||||||
|
});
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\EntraRenderableSummaryBuilder;
|
||||||
|
|
||||||
|
it('Spec421 renders operator-safe Conditional Access summaries without raw payload dependency', function (): void {
|
||||||
|
$summary = app(EntraRenderableSummaryBuilder::class)->build('conditionalAccessPolicy', [
|
||||||
|
'id' => 'cap-1',
|
||||||
|
'displayName' => 'Require MFA',
|
||||||
|
'state' => 'enabled',
|
||||||
|
'conditions' => [
|
||||||
|
'users' => [
|
||||||
|
'includeUsers' => ['All'],
|
||||||
|
'excludeUsers' => ['break-glass-user'],
|
||||||
|
],
|
||||||
|
'applications' => ['includeApplications' => ['Office365']],
|
||||||
|
'clientAppTypes' => ['browser', 'mobileAppsAndDesktopClients'],
|
||||||
|
],
|
||||||
|
'grantControls' => [
|
||||||
|
'operator' => 'OR',
|
||||||
|
'builtInControls' => ['mfa'],
|
||||||
|
],
|
||||||
|
'sessionControls' => [
|
||||||
|
'signInFrequency' => ['value' => 8, 'type' => 'hours', 'isEnabled' => true],
|
||||||
|
],
|
||||||
|
], [
|
||||||
|
'claim_state' => 'internal_only',
|
||||||
|
'identity_state' => 'stable',
|
||||||
|
'last_captured' => 'Jun 27, 2026 10:00 AM',
|
||||||
|
]);
|
||||||
|
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
expect($summary)->not->toBeNull()
|
||||||
|
->and($summary['resource_type'])->toBe('Conditional Access policy')
|
||||||
|
->and($summary['display_name'])->toBe('Require MFA')
|
||||||
|
->and($summary['state'])->toBe('enabled')
|
||||||
|
->and($summary['grant_controls'])->toContain('mfa')
|
||||||
|
->and($summary['session_controls'])->toContain('Sign In Frequency')
|
||||||
|
->and($encoded)->toContain('Office365')
|
||||||
|
->and($encoded)->not->toContain('raw_payload')
|
||||||
|
->and($encoded)->not->toContain('source_endpoint');
|
||||||
|
});
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
# Requirements Checklist: Spec 421 - Entra Core Comparable / Renderable Pack
|
||||||
|
|
||||||
|
**Purpose**: Validate preparation completeness and quality before implementation. This checklist validates `spec.md`, `plan.md`, and `tasks.md`; it does not mark implementation work complete.
|
||||||
|
**Created**: 2026-06-27
|
||||||
|
**Feature**: `specs/421-entra-core-comparable-renderable-pack/spec.md`
|
||||||
|
|
||||||
|
## Preparation Checklist
|
||||||
|
|
||||||
|
- [x] Candidate is user-provided, not auto-selected from the empty active candidate queue.
|
||||||
|
- [x] Spec 421 did not already exist in `specs/` before creation.
|
||||||
|
- [x] No existing local/remote `421-entra-core-comparable-renderable-pack` branch was found before creation.
|
||||||
|
- [x] Specs 414, 415, 417, 418, 419, and 420 are read-only dependency context only.
|
||||||
|
- [x] Current repo truth for Coverage v2 registry, generic evidence, canonical identity, Claim Guard, redaction, OperationRun, and existing operator surface was checked.
|
||||||
|
- [x] Draft-to-repo deviations are documented.
|
||||||
|
- [x] No application implementation was performed during preparation.
|
||||||
|
|
||||||
|
## Candidate Scope Checklist
|
||||||
|
|
||||||
|
- [x] Scope is bounded to selected Entra comparable/renderable support.
|
||||||
|
- [x] `conditionalAccessPolicy` is the mandatory evidence-backed first promotion path.
|
||||||
|
- [x] `securityDefaults` and optional Entra types are evidence-gated instead of assumed content-backed.
|
||||||
|
- [x] No capture expansion, source contract creation, restore, certification, customer output, report/download, or new UI start action is in scope.
|
||||||
|
- [x] No Entra-specific table family, persisted compare history, mini-platform, provider framework, or `tenant_id` is in scope.
|
||||||
|
|
||||||
|
## Product Surface Checklist
|
||||||
|
|
||||||
|
- [x] UI Surface Impact records existing Coverage v2 operator-surface rendering impact.
|
||||||
|
- [x] Product Surface Contract is referenced and applied.
|
||||||
|
- [x] Page archetype, primary question, primary action, surface budget, Technical Annex demotion, canonical vocabulary, visible complexity, and exceptions are recorded.
|
||||||
|
- [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 `none`.
|
||||||
|
- [x] Stop-and-amend rule exists for any new route, navigation, action, dashboard, customer output, report, download, restore/certify control, or broader UI scope.
|
||||||
|
|
||||||
|
## OperationRun / RBAC / Scope Checklist
|
||||||
|
|
||||||
|
- [x] No new OperationRun type or start/completion/link UX is planned.
|
||||||
|
- [x] Existing OperationRun references remain diagnostic only if rendered.
|
||||||
|
- [x] Existing Coverage v2 read authorization applies.
|
||||||
|
- [x] Non-member or wrong workspace/environment scope denies as not found.
|
||||||
|
- [x] Established member without capability denies as forbidden.
|
||||||
|
- [x] Provider connection scope must match workspace and managed environment.
|
||||||
|
|
||||||
|
## Evidence / Compare / Render Checklist
|
||||||
|
|
||||||
|
- [x] Promotion requires content-backed evidence and focused tests.
|
||||||
|
- [x] Missing-evidence types remain unpromoted with blockers/deferred reasons.
|
||||||
|
- [x] Compare classifications are explicit and deterministic.
|
||||||
|
- [x] Derived importance labels are non-persisted compare output only.
|
||||||
|
- [x] Volatile fields, null/empty handling, stable ordering, redaction, and unsupported fields are addressed.
|
||||||
|
- [x] Render output hides raw payloads and secrets by default.
|
||||||
|
- [x] Credential-related values render only as safe summaries if applicable.
|
||||||
|
|
||||||
|
## Claim / Customer Output Checklist
|
||||||
|
|
||||||
|
- [x] Scoped internal comparable/renderable claims are allowed only when proven.
|
||||||
|
- [x] Certified, restore-ready, customer-ready, full, all-resource, and 100 percent Entra/M365 claims are blocked.
|
||||||
|
- [x] No customer-facing route, Review Pack, management report, PDF, export, download, or customer-safe proof is in scope.
|
||||||
|
- [x] Customer output gate remains N/A/no output for this spec.
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] Unit tests are planned for normalization, compare, render, redaction, and Claim Guard.
|
||||||
|
- [x] Feature tests are planned for evidence-gated promotion, RBAC/scope, no restore/certification, no tenant_id, no mini-platform, and no overclaim.
|
||||||
|
- [x] Browser proof is conditional on rendered output changes.
|
||||||
|
- [x] No live Graph/TCM/provider call is required for tests.
|
||||||
|
- [x] Validation commands are listed.
|
||||||
|
|
||||||
|
## Spec Readiness Checklist
|
||||||
|
|
||||||
|
- [x] Problem statement, product value, user stories, requirements, acceptance criteria, success criteria, assumptions, risks, and open questions are present.
|
||||||
|
- [x] Plan identifies likely affected repo surfaces and does not require application implementation during preparation.
|
||||||
|
- [x] Tasks are ordered, bounded, verifiable, and include validation and close-out tasks.
|
||||||
|
- [x] RBAC, workspace/managed-environment isolation, OperationRun semantics, evidence/result truth, Product Surface, provider boundary, test governance, and proportionality are addressed.
|
||||||
|
- [x] No open question blocks the narrowed implementation-ready slice.
|
||||||
|
|
||||||
|
## Review Outcome
|
||||||
|
|
||||||
|
- [x] Review outcome class: acceptable-special-case for preparation.
|
||||||
|
- [x] Workflow outcome: keep.
|
||||||
|
- [x] Remaining condition: implementation must document any non-promoted Entra type as a blocker/deferred result instead of expanding capture scope.
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
# Implementation Report: Spec 421 - Entra Core Comparable / Renderable Pack
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- Result: implementation loop completed; manual review findings fixed; final validation passed.
|
||||||
|
- Active spec directory: `specs/421-entra-core-comparable-renderable-pack`.
|
||||||
|
- Branch: `421-entra-core-comparable-renderable-pack`.
|
||||||
|
- HEAD at implementation start: `a73a8f58 feat: complete m365 generic evidence coverage pack (#487)`.
|
||||||
|
- Initial dirty state: active spec directory was untracked; no unrelated runtime files were dirty.
|
||||||
|
- Final dirty state: expected active spec, runtime, test, and implementation-report changes only.
|
||||||
|
- Historical specs: Specs 414, 415, 417, 418, 419, and 420 were used as read-only dependency context only. No files under those completed spec directories were modified or stripped of validation history.
|
||||||
|
|
||||||
|
## Activated Skills And Gates
|
||||||
|
|
||||||
|
- Activated skills: `spec-kit-implementation-loop`, `pest-testing`, `browsertest`.
|
||||||
|
- Repo gates applied: spec readiness, workspace scope safety, RBAC/action safety, evidence anchor contract, Product Surface, TCM cutover guard, and Filament/Livewire v5 change loop.
|
||||||
|
- Gate result before code: pass with bounded conditions.
|
||||||
|
- Hard-gate stop conditions: none hit. No new capture/source contract, restore/apply, certification, customer output, OperationRun type, route/navigation/action, persisted compare table, Entra table family, provider mini-platform, or `tenant_id` ownership path was introduced.
|
||||||
|
- Analysis/fix iterations: one implementation/test/fix iteration plus one post-review fix iteration. The focused unit run exposed a camelCase redaction gap for `privateKey`; `CoveragePayloadRedactor` was fixed to match compacted key forms. Manual review then exposed that typed render summaries were not hard-gated on same-scope content-backed captured renderable evidence and that compare output was not visible at runtime; both findings were fixed. A related Spec 420 runtime test was updated because Conditional Access evidence now correctly promotes from `content_backed` to `renderable`. Browser review later exposed a narrow-viewport clipping issue on the existing Coverage v2 readiness badge; the badge was made non-shrinking/nowrap and the focused browser smoke now asserts it remains fully inside the section. A follow-up browser review exposed redundant `Resource type` cell content; the default table now shows the human label only, with canonical technical values remaining in inspect details.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- Runtime:
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/EntraCoverageComparator.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/EntraRenderableSummaryBuilder.php`
|
||||||
|
- `apps/platform/resources/views/filament/modals/tenant-configuration/coverage-v2-resource-inspect.blade.php`
|
||||||
|
- Existing test update:
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php`
|
||||||
|
- New Spec 421 tests:
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec421EntraConditionalAccessNormalizerTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec421EntraComparableDiffTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec421EntraRenderableSummaryTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec421EntraRedactionTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec421EntraClaimGuardTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec421EntraComparableRenderableTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec421EntraNoRestoreNoCertificationTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec421EntraNoTenantIdTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec421EntraNoMiniPlatformTest.php`
|
||||||
|
- `apps/platform/tests/Browser/Spec421EntraComparableRenderableOperatorSurfaceSmokeTest.php`
|
||||||
|
- Spec artifacts:
|
||||||
|
- `specs/421-entra-core-comparable-renderable-pack/tasks.md`
|
||||||
|
- `specs/421-entra-core-comparable-renderable-pack/implementation-report.md`
|
||||||
|
|
||||||
|
## Implementation Summary
|
||||||
|
|
||||||
|
- Added bounded Entra typed helpers under the existing Tenant Configuration service boundary:
|
||||||
|
- `EntraComparablePayloadNormalizer`
|
||||||
|
- `EntraCoverageComparator`
|
||||||
|
- `EntraRenderableSummaryBuilder`
|
||||||
|
- `conditionalAccessPolicy` is the only promoted type. It now receives deterministic typed normalization, compare output, operator-safe render summaries, and evidence-gated `CoverageLevel::Renderable` promotion when captured as content-backed evidence.
|
||||||
|
- `securityDefaults`, `application`, `servicePrincipal`, `roleDefinition`, and `administrativeUnit` remain unpromoted/deferred because current repo truth does not prove content-backed evidence for them without new capture/source-contract scope.
|
||||||
|
- Existing `CoveragePayloadRedactor` now also catches camelCase and separator-free sensitive key forms such as `privateKey`.
|
||||||
|
- `CoverageV2ReadinessReadModel::inspectDetails()` exposes a typed render summary only when latest evidence is same-scope, content-backed, captured, renderable, and supported by the bounded Entra summary builder.
|
||||||
|
- The existing Coverage v2 inspect slide-over now displays the safe Conditional Access summary and a bounded material-change compare summary while keeping raw payloads, previous/current raw compare values, source endpoints, provider response bodies, tokens, secrets, restore/certification/customer claims, and high-impact actions out of the default surface.
|
||||||
|
|
||||||
|
## Entra Evidence Matrix
|
||||||
|
|
||||||
|
| Canonical type | Current repo truth | Spec 421 result | Provider/render calls |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `conditionalAccessPolicy` | Explicit Spec 420 content-backed source contract path exists | promoted to typed comparable/renderable when captured; coverage level `renderable` | capture remains existing path; render/compare are DB-only |
|
||||||
|
| `securityDefaults` | registry planning row only; no proven content-backed evidence | deferred/unpromoted | no provider/render call |
|
||||||
|
| `application` | registry planning row only; no proven content-backed evidence | deferred/unpromoted | no provider/render call |
|
||||||
|
| `servicePrincipal` | registry planning row only; no proven content-backed evidence | deferred/unpromoted | no provider/render call |
|
||||||
|
| `roleDefinition` | registry planning row only; no proven content-backed evidence | deferred/unpromoted | no provider/render call |
|
||||||
|
| `administrativeUnit` | registry planning row only; no proven content-backed evidence | deferred/unpromoted | no provider/render call |
|
||||||
|
|
||||||
|
## Normalizer / Compare / Render Proof
|
||||||
|
|
||||||
|
- Normalization:
|
||||||
|
- Produces deterministic Conditional Access typed shape.
|
||||||
|
- Ignores volatile Graph context, etag, created, and modified timestamps.
|
||||||
|
- Sorts unordered scalar lists for stable compare.
|
||||||
|
- Tracks unsupported field names and redacted field paths as diagnostics.
|
||||||
|
- Compare:
|
||||||
|
- Emits classifications: `added`, `removed`, `changed`, `unchanged`, `ignored_volatile`, `redacted`, and `unsupported_field`.
|
||||||
|
- Uses derived non-persisted importance labels: `critical`, `important`, `informational`.
|
||||||
|
- Marks state changes critical; target/grant/session/condition changes important; volatile, unsupported, and redacted diagnostics informational.
|
||||||
|
- Render:
|
||||||
|
- Shows Conditional Access display name, state, user/group/role/application targets, client/platform/location/risk conditions, grant controls, session controls, claim state, identity state, capture time, material compare status/field labels, unsupported fields, and redacted field names.
|
||||||
|
- Does not render raw payload, raw Graph response, source endpoint, provider body, before/after compare values, tokens, credential values, private keys, authorization headers, cookies, or unneeded PII.
|
||||||
|
|
||||||
|
## Claim Guard, Redaction, And Safety Proof
|
||||||
|
|
||||||
|
- Claim Guard now permits only scoped internal operator wording for selected Entra comparable/renderable support.
|
||||||
|
- Claim Guard blocks certified, restore-ready, customer-ready, all-resource, full, and 100 percent Entra/M365 claims.
|
||||||
|
- Redaction tests prove render and compare output exclude secret values from credential-like keys, authorization headers, cookies, OperationRun diagnostic context, and audit metadata.
|
||||||
|
- `Spec421EntraNoRestoreNoCertificationTest` proves Conditional Access remains not restorable, not certified, and internal-only by registry default.
|
||||||
|
- `Spec421EntraNoTenantIdTest` proves the changed runtime files do not introduce `tenant_id`.
|
||||||
|
- `Spec421EntraNoMiniPlatformTest` proves this implementation did not add Spec 421 Entra-specific migrations, models, routes, or Filament resources.
|
||||||
|
|
||||||
|
## Product Surface Close-Out
|
||||||
|
|
||||||
|
- No-legacy posture: canonical Coverage v2 extension; no compatibility exception.
|
||||||
|
- UI Surface Impact: existing Coverage v2 technical annex / internal operator inspect surface changed.
|
||||||
|
- Product Surface Impact: existing rendered inspect slide-over now shows safe Conditional Access typed summary plus bounded material compare status.
|
||||||
|
- Page archetype: Technical Annex / internal operator evidence inspection surface.
|
||||||
|
- Surface budget: pass. No new page, route, navigation, action family, customer surface, report, export, or download was added.
|
||||||
|
- Technical Annex/deep-link demotion: raw payload, source endpoint, source keys, provider IDs, OperationRun context, and evidence internals remain secondary/diagnostic; raw payload values are not default-visible.
|
||||||
|
- Canonical status vocabulary: existing Coverage v2 labels only; no `Entra covered`, certified, restore-ready, customer-ready, full coverage, or 100 percent labels.
|
||||||
|
- Product Surface exceptions: none.
|
||||||
|
- Human Product Sanity: pass. The inspect modal answers what the selected Conditional Access policy is, whether it is enabled, who/apps it targets, what controls apply, whether material changes exist versus prior comparable evidence, and which diagnostics are unsupported/redacted without implying certification, restore, or customer proof.
|
||||||
|
- Visible complexity outcome: decreased for selected Conditional Access evidence because operators no longer need to infer meaning from raw payloads.
|
||||||
|
|
||||||
|
## Browser Proof
|
||||||
|
|
||||||
|
- Browser proof required: yes, rendered Coverage v2 output changed.
|
||||||
|
- Focused path: existing Coverage v2 readiness route for a seeded workspace and managed environment with prior comparable Conditional Access evidence and latest content-backed renderable evidence.
|
||||||
|
- Test: `apps/platform/tests/Browser/Spec421EntraComparableRenderableOperatorSurfaceSmokeTest.php`.
|
||||||
|
- Result: passed.
|
||||||
|
- Verified:
|
||||||
|
- page loads in `/admin` workspace/managed-environment context
|
||||||
|
- Conditional Access row is visible with `Renderable` and `Internal only`
|
||||||
|
- inspect slide-over opens from the existing primary link column
|
||||||
|
- typed summary shows display name, state, users, applications, grant controls, compare summary, material change field label, redacted fields
|
||||||
|
- no raw secrets, certified/restore-ready/customer-ready/full/100 percent Entra claims, high-impact actions, console errors, JavaScript errors, or Graph/TCM/provider network calls during render
|
||||||
|
|
||||||
|
## Filament v5 Output Contract
|
||||||
|
|
||||||
|
- Livewire v4.0+ compliance: unchanged; the existing Filament v5/Livewire v4 surface is exercised by the focused browser test.
|
||||||
|
- Provider registration location: no panel/provider registration changed. Laravel 12 providers remain under `apps/platform/bootstrap/providers.php`.
|
||||||
|
- Global search: no Filament Resource global-search behavior changed; no new Resource was added.
|
||||||
|
- Destructive/high-impact actions: none added or changed. No restore/apply/capture/export/certify action was introduced.
|
||||||
|
- Asset strategy: no assets registered and no frontend bundles changed. `php artisan filament:assets` is not newly required beyond existing deployment practice.
|
||||||
|
- Testing plan: unit, feature, related regression, and browser lanes were run.
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail php -l app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php` - passed.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail php -l app/Services/TenantConfiguration/EntraCoverageComparator.php` - passed.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail php -l app/Services/TenantConfiguration/EntraRenderableSummaryBuilder.php` - passed.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail php -l app/Services/TenantConfiguration/CoverageEvidenceWriter.php` - passed.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail php -l app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php` - passed.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec421EntraConditionalAccessNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraComparableDiffTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraRenderableSummaryTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraRedactionTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraClaimGuardTest.php tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php tests/Feature/TenantConfiguration/Spec421EntraComparableRenderableTest.php tests/Feature/TenantConfiguration/Spec421EntraNoRestoreNoCertificationTest.php tests/Feature/TenantConfiguration/Spec421EntraNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec421EntraNoMiniPlatformTest.php` - passed, 24 tests / 98 assertions.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec421EntraComparableRenderableOperatorSurfaceSmokeTest.php` - passed, 1 test / 52 assertions.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - passed.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec421EntraConditionalAccessNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraComparableDiffTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraRenderableSummaryTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraRedactionTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraClaimGuardTest.php tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php tests/Feature/TenantConfiguration/Spec421EntraComparableRenderableTest.php tests/Feature/TenantConfiguration/Spec421EntraNoRestoreNoCertificationTest.php tests/Feature/TenantConfiguration/Spec421EntraNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec421EntraNoMiniPlatformTest.php tests/Browser/Spec421EntraComparableRenderableOperatorSurfaceSmokeTest.php` - passed, 25 tests / 150 assertions.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec415CoverageRedactionTest.php tests/Unit/Support/TenantConfiguration/Spec415GenericPayloadNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php tests/Unit/Support/TenantConfiguration/Spec420M365GenericPayloadNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureRedactionTest.php tests/Feature/TenantConfiguration/Spec415GenericContentBackedCaptureTest.php tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php tests/Feature/TenantConfiguration/Spec420M365NoOverclaimTest.php tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php` - passed, 23 tests / 248 assertions.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php tests/Feature/Filament/CoverageV2ReadinessPageTest.php` - passed, 12 tests / 189 assertions.
|
||||||
|
- `git diff --check` - passed.
|
||||||
|
- Post-browser-comment validation: focused integrated-browser check at narrow viewport confirmed the `Ready` badge rendered as full text within the Activation readiness section; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec421EntraComparableRenderableOperatorSurfaceSmokeTest.php` passed, 1 test / 53 assertions.
|
||||||
|
- Post-resource-type-redundancy validation: the Resource instances table now omits the redundant canonical type line from the default `Resource type` cell while keeping canonical type available in inspect details; the focused browser smoke asserts this table-cell behavior.
|
||||||
|
|
||||||
|
## Deployment Impact
|
||||||
|
|
||||||
|
- Migrations: none.
|
||||||
|
- Environment variables: none.
|
||||||
|
- Queue/cron: no new queue or scheduled work; existing capture path remains unchanged.
|
||||||
|
- Storage/volumes: none.
|
||||||
|
- Assets: none.
|
||||||
|
- Dokploy/staging: deploy as ordinary app code/test/view change. Staging validation should include the focused Coverage v2 inspect path before production promotion.
|
||||||
|
- Rollback: revert code/view/test changes. No data migration or compatibility step required.
|
||||||
|
|
||||||
|
## Deferred Work And Residual Risk
|
||||||
|
|
||||||
|
- Deferred by spec: Security Defaults content-backed capture/source contract, application/service principal comparable/renderable pack, role definition and administrative unit comparable/renderable pack, certified compare, restore/apply, customer reports, Review Pack output, exports/downloads, and broad M365/Entra claims.
|
||||||
|
- Residual risk: this proves deterministic typed semantics for captured Conditional Access payloads in the existing internal Coverage v2 path; it does not certify real Microsoft tenant-wide Entra coverage.
|
||||||
|
- Remaining in-scope findings: none after the focused test/fix loop.
|
||||||
207
specs/421-entra-core-comparable-renderable-pack/plan.md
Normal file
207
specs/421-entra-core-comparable-renderable-pack/plan.md
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
# Implementation Plan: Spec 421 - Entra Core Comparable / Renderable Pack
|
||||||
|
|
||||||
|
**Branch**: `421-entra-core-comparable-renderable-pack` | **Date**: 2026-06-27 | **Spec**: `specs/421-entra-core-comparable-renderable-pack/spec.md`
|
||||||
|
**Input**: Feature specification from `/specs/421-entra-core-comparable-renderable-pack/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Promote selected Entra Coverage v2 evidence to comparable/renderable support without adding capture scope, restore, certification, customer output, or an Entra mini-platform. The mandatory implementation-ready slice is `conditionalAccessPolicy` because current repo truth already has a content-backed source contract path from Spec 420. `securityDefaults`, `application`, `servicePrincipal`, `roleDefinition`, and `administrativeUnit` remain evidence-gated and may be promoted only when preflight proves repo-real content-backed evidence already exists.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
|
||||||
|
**Primary Dependencies**: Existing Tenant Configuration / Coverage v2 services, `GraphClientInterface` only for existing evidence capture paths, `ClaimGuard`, `BadgeCatalog`/`BadgeRenderer`, Pest 4
|
||||||
|
**Storage**: Existing PostgreSQL tables for `tenant_configuration_resource_types`, `tenant_configuration_resources`, and `tenant_configuration_resource_evidence`; no new table by default
|
||||||
|
**Testing**: Pest 4 unit, feature, and focused browser if rendered UI changes
|
||||||
|
**Validation Lanes**: fast-feedback, confidence, browser if rendered output changes
|
||||||
|
**Target Platform**: Laravel Sail locally, Dokploy container deployment for staging/production
|
||||||
|
**Project Type**: web app under `apps/platform`
|
||||||
|
**Performance Goals**: render/compare from persisted evidence only; no remote/provider calls during UI render
|
||||||
|
**Constraints**: no restore, no certification, no customer-facing claims, no new capture contract, no `tenant_id`, no Entra mini-platform, no new OperationRun type
|
||||||
|
**Scale/Scope**: one mandatory evidence-backed resource type (`conditionalAccessPolicy`), optional Entra types only if already content-backed and testable
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: existing internal/operator Coverage v2 surface may change through rendered evidence/status summaries.
|
||||||
|
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: existing `CoverageV2Readiness` page, `CoverageV2ResourceTypesTable`, `CoverageV2ResourceInstancesTable`, inspect slide-over, and read model if rendered summaries are exposed. No new route, navigation, panel provider, action, report, download, or customer surface.
|
||||||
|
- **No-impact class, if applicable**: N/A - rendered data may change.
|
||||||
|
- **Native vs custom classification summary**: native Filament + existing widgets/read model.
|
||||||
|
- **Shared-family relevance**: evidence/status/read-only registry and inspect details.
|
||||||
|
- **State layers in scope**: page/detail evidence display; no shell/navigation state.
|
||||||
|
- **Audience modes in scope**: operator-MSP and support-platform; no customer/read-only output.
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: typed summary first, diagnostics second, raw/support evidence hidden or secondary.
|
||||||
|
- **Raw/support gating plan**: raw payload not default-visible; secrets never shown.
|
||||||
|
- **One-primary-action / duplicate-truth control**: keep existing inspect link as the only action; no capture/restore/certify/export action.
|
||||||
|
- **Handling modes by drift class or surface**: Product Surface and browser proof are review-mandatory if rendered output changes; runtime UI expansion is hard-stop until spec/plan/tasks are amended.
|
||||||
|
- **Repository-signal treatment**: report-only for no new UI files; review-mandatory for existing rendered output changes; exception-required for any new surface/action.
|
||||||
|
- **Special surface test profiles**: shared-detail-family / standard-native-filament.
|
||||||
|
- **Required tests or manual smoke**: focused browser if rendered output changes; feature tests for no raw/default overclaim.
|
||||||
|
- **Exception path and spread control**: none.
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
|
||||||
|
- **UI/Productization coverage decision**: existing internal surface only; no coverage artifact unless UI scope expands.
|
||||||
|
- **Coverage artifacts to update**: none by default.
|
||||||
|
- **No-impact rationale**: N/A.
|
||||||
|
- **Navigation / Filament provider-panel handling**: no provider/panel/navigation change.
|
||||||
|
- **Screenshot or page-report need**: no page report by default; browser proof is sufficient for existing internal surface rendering.
|
||||||
|
|
||||||
|
## Product Surface Contract Plan
|
||||||
|
|
||||||
|
- **Product Surface Contract reference**: `docs/product/standards/product-surface-contract.md`.
|
||||||
|
- **No-legacy posture**: canonical Coverage v2 extension; no compatibility exception.
|
||||||
|
- **Page archetype and surface budget plan**: Existing Technical Annex / internal operator evidence inspection surface; budgets pass because no new page/action family is added.
|
||||||
|
- **Technical Annex and deep-link demotion plan**: OperationRun links, evidence IDs, source keys, raw/normalized payloads, provider IDs, permission context, and unsupported fields remain secondary/diagnostic.
|
||||||
|
- **Canonical status vocabulary plan**: Use existing Coverage v2 labels and canonical status wording; no `Entra covered`, `certified`, `restore-ready`, `customer-ready`, or `100%` labels.
|
||||||
|
- **Product Surface exceptions**: none.
|
||||||
|
- **Browser verification plan**: focused Coverage v2 route/inspect flow if rendered output changes; otherwise `N/A - no rendered UI surface changed` with proof.
|
||||||
|
- **Human Product Sanity plan**: required if rendered output changes; result in implementation report.
|
||||||
|
- **Visible complexity outcome target**: neutral or decreased.
|
||||||
|
- **Implementation report target**: `specs/421-entra-core-comparable-renderable-pack/implementation-report.md`.
|
||||||
|
|
||||||
|
## Filament / Livewire / Deployment Posture
|
||||||
|
|
||||||
|
- **Livewire v4 compliance**: Livewire v4.x confirmed; no Livewire v3 APIs.
|
||||||
|
- **Panel provider registration location**: no panel provider change; Laravel 12 provider registration remains `apps/platform/bootstrap/providers.php`.
|
||||||
|
- **Global search posture**: no Filament Resource global-search behavior changed; no new Resource.
|
||||||
|
- **Destructive/high-impact action posture**: none. No restore/apply/capture/export/certify action is introduced.
|
||||||
|
- **Asset strategy**: no assets by default; `filament:assets` is not newly required beyond existing deployment practice.
|
||||||
|
- **Testing plan**: unit tests for typed behavior; feature tests for evidence-gated promotion, claims, redaction, scope, no overclaim; browser proof if rendered output changes.
|
||||||
|
- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, or assets expected.
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes.
|
||||||
|
- **Systems touched**: `ResourceTypeRegistry`, `TenantConfigurationResourceType`, `TenantConfigurationResource`, `TenantConfigurationResourceEvidence`, `GenericPayloadNormalizer`, `CoveragePayloadRedactor`, `CoverageIdentityStrategyRegistry`, `CanonicalIdentityResolver`, `ClaimGuard`, `CoverageV2ReadinessReadModel`, existing Coverage v2 widgets if rendered.
|
||||||
|
- **Shared abstractions reused**: Existing Coverage v2 registry, evidence, identity, redaction, Claim Guard, badges, and read-only operator surface.
|
||||||
|
- **New abstraction introduced? why?**: Bounded Entra typed normalizer/comparator/render summary helpers may be introduced because generic payload sorting/redaction is insufficient for operator-safe Conditional Access comparison.
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: Existing structures are sufficient for ownership, evidence, identity, claims, redaction, and rendering host; insufficient for typed Entra material-field semantics.
|
||||||
|
- **Bounded deviation / spread control**: Entra-specific field semantics stay inside bounded helpers/config for evidence-backed types and must not become a provider framework.
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no new start/completion/link UX.
|
||||||
|
- **Central contract reused**: N/A by default; existing OperationRun links remain diagnostics only if already present.
|
||||||
|
- **Delegated UX behaviors**: N/A.
|
||||||
|
- **Surface-owned behavior kept local**: read-only inspect only.
|
||||||
|
- **Queued DB-notification policy**: N/A.
|
||||||
|
- **Terminal notification path**: N/A.
|
||||||
|
- **Exception path**: none. If compare/render becomes long-running or persisted as an operation, stop and amend the spec.
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes.
|
||||||
|
- **Provider-owned seams**: Entra resource names, Graph field names, Conditional Access targeting/control semantics, optional Security Defaults fields.
|
||||||
|
- **Platform-core seams**: Coverage v2 evidence/resource state, coverage level, identity state, claim state, redaction boundary, workspace/managed-environment/provider connection ownership, Product Surface output.
|
||||||
|
- **Neutral platform terms / contracts preserved**: resource type, evidence, compare result, render summary, coverage level, claim state, identity state, provider connection, managed environment.
|
||||||
|
- **Retained provider-specific semantics and why**: Necessary to compare/render actual Entra evidence; bounded to current selected resource types.
|
||||||
|
- **Bounded extraction or follow-up path**: document-in-feature for optional type blockers; follow-up-spec for missing Security Defaults capture/source contract.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
- Inventory/evidence truth: PASS. Render/compare derives from existing last-observed evidence; it does not create customer proof.
|
||||||
|
- Read/write separation: PASS. No provider write, restore, apply, or mutating UI action.
|
||||||
|
- Graph contract path: PASS by default. No new capture or render-time Graph calls; any existing evidence remains from existing contract paths.
|
||||||
|
- Deterministic capabilities: PASS. Coverage-level promotion and compare behavior are testable.
|
||||||
|
- Workspace isolation: PASS with workspace + managed environment + provider connection scope requirements.
|
||||||
|
- RBAC-UX: PASS with existing Evidence View authorization and no action surface.
|
||||||
|
- OperationRun: PASS by default. No new OperationRun type or lifecycle; existing links remain diagnostics only.
|
||||||
|
- Evidence anchor/currentness: PASS if evidence is explicit and no fallback-to-latest is added.
|
||||||
|
- Customer output: PASS. No customer-facing output, report, download, or customer-ready claim.
|
||||||
|
- Provider boundary: PASS if Entra semantics stay in bounded typed helpers and provider-native IDs remain metadata.
|
||||||
|
- Product Surface: PASS with existing-surface proof and Product Surface exceptions `none`.
|
||||||
|
- Test governance: PASS with Unit/Feature/Browser-if-rendered lanes named.
|
||||||
|
- Proportionality: PASS. Typed helpers are justified by operator-safe compare/render and avoid new tables/frameworks.
|
||||||
|
- No premature abstraction: PASS if implementation avoids generic provider frameworks and separate Entra engines.
|
||||||
|
- Persisted truth: PASS by default. Existing evidence remains truth; compare/render derived unless spec amended.
|
||||||
|
- Behavioral state: PASS using existing coverage levels and derived non-persisted importance labels.
|
||||||
|
- No legacy / lean doctrine: PASS. No adapters, dual reads/writes, fallback readers, legacy aliases, or `tenant_id`.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Unit for normalization/compare/render/redaction/Claim Guard; Feature for evidence-gated promotion, RBAC/scope/no-overclaim/no-restore/no-certification/no-tenant-id/no-mini-platform; Browser if rendered Coverage v2 output changes.
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence, browser-if-rendered.
|
||||||
|
- **Fixture/helper/factory cost**: minimal workspace/managed-environment/provider/evidence setup; fake payloads only; no live Graph/TCM calls.
|
||||||
|
- **Heavy-family visibility**: none expected.
|
||||||
|
- **Reviewer handoff**: confirm optional type blockers are documented, no runtime capture expansion occurred, and no hidden customer output or UI action was introduced.
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec421EntraConditionalAccessNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraComparableDiffTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraRenderableSummaryTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraRedactionTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraClaimGuardTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec421EntraComparableRenderableTest.php tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php tests/Feature/TenantConfiguration/Spec421EntraNoRestoreNoCertificationTest.php tests/Feature/TenantConfiguration/Spec421EntraNoTenantIdTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec421EntraComparableRenderableOperatorSurfaceSmokeTest.php` if rendered output changes
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## Likely Repo Surfaces
|
||||||
|
|
||||||
|
Runtime implementation should verify current names before editing, but likely surfaces are:
|
||||||
|
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/TenantConfiguration/CoverageV2Readiness.php`
|
||||||
|
- `apps/platform/app/Filament/Widgets/TenantConfiguration/CoverageV2ResourceTypesTable.php`
|
||||||
|
- `apps/platform/app/Filament/Widgets/TenantConfiguration/CoverageV2ResourceInstancesTable.php`
|
||||||
|
- New bounded helper files under `apps/platform/app/Services/TenantConfiguration/` if needed, such as `EntraComparablePayloadNormalizer.php`, `EntraCoverageComparator.php`, and `EntraRenderableSummaryBuilder.php`.
|
||||||
|
- Focused Spec 421 tests under `apps/platform/tests/Unit/Support/TenantConfiguration/`, `apps/platform/tests/Feature/TenantConfiguration/`, and `apps/platform/tests/Browser/` if rendered output changes.
|
||||||
|
|
||||||
|
## Domain / Model Implications
|
||||||
|
|
||||||
|
- No new database table, migration, persisted compare result table, or model is expected.
|
||||||
|
- `TenantConfigurationResourceEvidence.coverage_level` and existing CoverageLevel values can represent `comparable` / `renderable`.
|
||||||
|
- Registry defaults for Entra types must not imply broad support or customer claims. Promotion should be evidence-gated and internal.
|
||||||
|
- Derived compare importance labels must not become a persisted taxonomy.
|
||||||
|
- Raw payload remains an internal evidence storage boundary; render summaries use redacted/normalized data.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 0 - Preflight And Evidence Matrix
|
||||||
|
|
||||||
|
Capture branch, HEAD, dirty state, activated skills, related completed-spec guardrail, and stop conditions. Verify current Coverage v2 service names and evidence availability for all draft Entra types. Record which types are content-backed, blocked, or deferred.
|
||||||
|
|
||||||
|
### Phase 1 - Tests First: Typed Semantics And Safety
|
||||||
|
|
||||||
|
Add focused Pest unit tests for Conditional Access normalization, deterministic compare, render summaries, volatile-field handling, redaction, and Claim Guard. Add evidence-gated tests for Security Defaults and optional types so missing evidence remains an explicit blocker.
|
||||||
|
|
||||||
|
### Phase 2 - Evidence-Gated Promotion
|
||||||
|
|
||||||
|
Implement or extend the narrow promotion path so only content-backed, typed-tested resource evidence can report comparable/renderable support. Do not promote any missing-evidence type.
|
||||||
|
|
||||||
|
### Phase 3 - Entra Typed Normalization
|
||||||
|
|
||||||
|
Add bounded typed normalization for Conditional Access and any evidence-backed optional type. Exclude volatile fields, preserve unsupported fields as diagnostics, and keep identity/source metadata separate.
|
||||||
|
|
||||||
|
### Phase 4 - Entra Compare
|
||||||
|
|
||||||
|
Add deterministic compare semantics for selected typed payloads. Classify changes, ignore volatile fields, handle redacted values, use stable ordering, and attach derived bounded importance labels.
|
||||||
|
|
||||||
|
### Phase 5 - Entra Render Summaries
|
||||||
|
|
||||||
|
Add operator-safe render summaries. Conditional Access summary must include state, targets, conditions, grant/session controls, claim/identity state, unsupported/redacted diagnostics, and last captured time without raw payload display.
|
||||||
|
|
||||||
|
### Phase 6 - Claim Guard, Product Surface, RBAC, And Evidence Boundaries
|
||||||
|
|
||||||
|
Extend Claim Guard tests for Entra wording, ensure read model/surface output stays internal and authorized, and confirm no customer output, restore/certification, direct Graph calls, or raw default payload rendering.
|
||||||
|
|
||||||
|
### Phase 7 - Browser Proof If Rendered
|
||||||
|
|
||||||
|
If rendered output changes, add focused browser proof for the existing Coverage v2 surface and inspect slide-over. Verify no console errors, no Livewire/Filament errors, no secrets/raw payload, and no restore/certify/customer-ready claim.
|
||||||
|
|
||||||
|
### Phase 8 - Validation And Implementation Report
|
||||||
|
|
||||||
|
Run focused validation, `git diff --check`, and complete the implementation report with matrices, Product Surface close-out, tests, browser/no-browser, deployment impact, no completed-spec rewrite assertion, and deferred work.
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
- Security Defaults or optional Entra types need new capture/source contracts to become content-backed.
|
||||||
|
- Any implementation proposes restore/apply, certification, customer-facing output, Review Pack/report/PDF output, export/download, or broad coverage claims.
|
||||||
|
- A new OperationRun type, new capture start action, new route/navigation/dashboard, or new persisted compare table is proposed without amending the spec.
|
||||||
|
- Raw payloads, secrets, credentials, tokens, provider response bodies, or provider IDs become default-visible.
|
||||||
|
- `tenant_id` appears as Coverage v2 ownership truth.
|
||||||
|
- A separate Entra table family, engine, dashboard, or provider mini-platform appears.
|
||||||
|
- Render/compare performs Graph/TCM/provider/HTTP work at render time.
|
||||||
|
|
||||||
|
## Draft-To-Repo Deviation Handling
|
||||||
|
|
||||||
|
- The user draft's minimum `securityDefaults` promotion is changed to evidence-gated promotion because current repo truth does not prove content-backed evidence for Security Defaults and the draft forbids faking typed support or adding missing capture.
|
||||||
|
- The user draft's optional initial resource list remains as preflight candidates, not mandatory implementation scope.
|
||||||
|
- The implementation report must include a matrix explaining promoted and deferred types.
|
||||||
382
specs/421-entra-core-comparable-renderable-pack/spec.md
Normal file
382
specs/421-entra-core-comparable-renderable-pack/spec.md
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
# Feature Specification: Spec 421 - Entra Core Comparable / Renderable Pack
|
||||||
|
|
||||||
|
**Feature Branch**: `421-entra-core-comparable-renderable-pack`
|
||||||
|
**Created**: 2026-06-27
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User-provided draft "Spec 421 - Entra Core Comparable / Renderable Pack", prepared through `spec-kit-next-best-prep`.
|
||||||
|
|
||||||
|
## Preparation Selection Summary
|
||||||
|
|
||||||
|
- **Selected candidate**: Spec 421 - Entra Core Comparable / Renderable Pack.
|
||||||
|
- **Source location**: User-provided attachment `/Users/ahmeddarrazi/.codex/attachments/892e3d80-bea6-44dd-b846-36a03d43c2e2/pasted-text.txt`.
|
||||||
|
- **Why selected**: Spec 420 completed generic M365 evidence capture for Conditional Access through the existing Coverage v2 path. The next safe product slice is bounded compare/render support for selected Entra evidence without restore, certification, or customer-facing claims.
|
||||||
|
- **Roadmap relationship**: Extends the Coverage v2 / M365 path after Specs 414, 415, 417, 418, 419, and 420; aligns with the roadmap's anti-drift and Microsoft governance expansion direction.
|
||||||
|
- **Close alternatives deferred**: Exchange/Teams comparable packs, Security/Compliance readiness, certified compare packs, customer reporting claim guards, and restore/apply remain separate later specs because they require different source contracts, claims, product surfaces, or risk controls.
|
||||||
|
- **Completed-spec guardrail result**: Specs 414, 415, 417, 418, 419, and 420 are completed dependency context only. This package must not patch, normalize, reopen, or strip their implementation reports, completed tasks, validation results, or browser proof.
|
||||||
|
- **Smallest viable implementation slice**: Add evidence-gated typed compare/render support for content-backed Entra Coverage v2 evidence, with `conditionalAccessPolicy` as the required first concrete resource. `securityDefaults` and the remaining initial Entra types may be promoted only when repo-real content-backed evidence exists; otherwise they must remain explicitly blocked/deferred.
|
||||||
|
- **Candidate Selection Gate**: PASS for a user-provided candidate, with repo-truth scope reduction documented below.
|
||||||
|
|
||||||
|
## Draft-To-Repo Deviations
|
||||||
|
|
||||||
|
The attached draft names Conditional Access and Security Defaults as the minimum promotion pair. Current repo evidence shows:
|
||||||
|
|
||||||
|
- `conditionalAccessPolicy` has a repo-real explicit source contract path through Spec 420.
|
||||||
|
- `securityDefaults`, `application`, `servicePrincipal`, `roleDefinition`, and `administrativeUnit` are registered as Entra planning resource types, but are not proven content-backed by the current Coverage v2 capture path.
|
||||||
|
- The draft also says not to fake typed support and not to add missing Graph/TCM capture in this spec.
|
||||||
|
|
||||||
|
Therefore this spec keeps the draft intent but narrows the implementable mandatory promotion to `conditionalAccessPolicy`. All other Entra core types are evidence-gated: they may be promoted only if implementation preflight proves content-backed evidence already exists without adding new capture scope.
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: TenantPilot can capture selected Entra generic evidence, but operators still need safe, deterministic compare and render semantics before Entra evidence can be useful without reading raw payloads.
|
||||||
|
- **Today's failure**: A captured Conditional Access payload can exist as internal Coverage v2 evidence, but operators cannot safely answer what changed, which fields matter, whether volatile metadata is noise, or whether the product is overclaiming restore/certified readiness.
|
||||||
|
- **User-visible improvement**: Operators reviewing the existing Coverage v2 surface can understand selected Entra evidence through typed summaries and compare outcomes while broad Entra, restore, certification, and customer-ready claims remain blocked.
|
||||||
|
- **Smallest enterprise-capable version**: Promote `conditionalAccessPolicy` to comparable/renderable when content-backed evidence exists; preflight all other draft Entra types and promote only those already content-backed with tests. No capture expansion, restore, certification, customer output, or new Entra dashboard.
|
||||||
|
- **Explicit non-goals**: No Entra restore/apply, no certification, no full Entra catalog, no new Entra-specific table family, no broad M365 coverage claims, no customer-facing report/review pack output, no new capture start action, no direct Graph calls during render/compare, no `tenant_id`, no v1 compatibility.
|
||||||
|
- **Permanent complexity imported**: Bounded typed normalization/compare/render helpers for selected Entra types, derived compare importance labels, Claim Guard tests, redaction tests, and focused browser proof if rendered output changes. No new persisted entity, status family, runtime Product Surface framework, or mini-platform by default.
|
||||||
|
- **Why now**: Spec 420 created the first content-backed M365/Entra evidence path. Compare/render semantics are the next safety gate before later packs can discuss broader Entra or M365 readiness.
|
||||||
|
- **Why not local**: A one-off Entra UI parser would bypass Coverage v2 registry/evidence/identity/redaction/Claim Guard truth. The existing Coverage v2 path is the correct shared path.
|
||||||
|
- **Approval class**: Core Enterprise.
|
||||||
|
- **Red flags triggered**: New typed compare/render helpers and derived importance labels. Defense: the helpers are bounded to current evidence-backed Entra types, use existing Coverage v2 truth, and directly prevent unsafe operator interpretation and overclaiming.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve as a narrowed, evidence-gated comparable/renderable pack.
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: Workspace + managed-environment scoped Coverage v2 evidence and read-model behavior for selected Entra resource types.
|
||||||
|
- **Primary Routes**: Existing internal/operator Coverage v2 readiness route `workspaces/{workspace}/environments/{environment}/tenant-configuration/coverage-v2`; no new route, navigation entry, customer route, report, download, or dashboard.
|
||||||
|
- **Data Ownership**: Existing `TenantConfigurationResourceType`, `TenantConfigurationResource`, and `TenantConfigurationResourceEvidence` records. Environment-owned rows remain scoped by `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id` where provider-sourced.
|
||||||
|
- **RBAC**: Existing Coverage v2 read authorization applies. Non-member workspace or missing managed-environment entitlement returns 404. Established member without evidence view capability returns 403. No mutating action is introduced.
|
||||||
|
|
||||||
|
For canonical-view specs:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: N/A - no new canonical route or route filter.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing page, widgets, read model, and any new compare/render service must resolve records through workspace + managed environment scope and must not use provider-native tenant IDs as ownership.
|
||||||
|
|
||||||
|
## No Legacy / No Backward Compatibility Constraint *(mandatory)*
|
||||||
|
|
||||||
|
TenantPilot is pre-production unless this spec explicitly records a compatibility exception.
|
||||||
|
|
||||||
|
- **Compatibility posture**: canonical Coverage v2 extension; no compatibility exception.
|
||||||
|
- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no.
|
||||||
|
- **Why clean replacement is safe now**: This is a new internal Coverage v2 compare/render slice over existing evidence. No production data or external contract requires legacy Coverage v1 adapters, fallback readers, dual writes, or old customer-facing coverage vocabulary.
|
||||||
|
|
||||||
|
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||||
|
|
||||||
|
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||||
|
|
||||||
|
- [ ] No UI surface impact
|
||||||
|
- [x] Existing page changed
|
||||||
|
- [ ] New page/route added
|
||||||
|
- [ ] Navigation changed
|
||||||
|
- [ ] Filament panel/provider surface changed
|
||||||
|
- [ ] New modal/drawer/wizard/action added
|
||||||
|
- [ ] New table/form/state added
|
||||||
|
- [ ] Customer-facing surface changed
|
||||||
|
- [ ] Dangerous action changed
|
||||||
|
- [x] Status/evidence/review presentation changed
|
||||||
|
- [ ] Workspace/environment context presentation changed
|
||||||
|
|
||||||
|
Impact is limited to data/rendering on the existing Coverage v2 internal/operator surface if implementation exposes comparable/renderable Entra summaries or coverage-level rows. No new route, navigation, action, dashboard, customer output, report, download, restore, certify, or capture UI is allowed.
|
||||||
|
|
||||||
|
If implementation requires runtime UI edits beyond existing surface rendering, the implementation must keep this spec/plan/tasks aligned before editing runtime UI files.
|
||||||
|
|
||||||
|
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
|
||||||
|
|
||||||
|
- **Route/page/surface**: Existing Coverage v2 readiness page and inspect slide-over under `apps/platform/app/Filament/Pages/TenantConfiguration/CoverageV2Readiness.php` and `apps/platform/app/Filament/Widgets/TenantConfiguration/*`.
|
||||||
|
- **Current or new page archetype**: Technical Annex / internal operator registry and evidence inspection surface.
|
||||||
|
- **Design depth**: Internal/Hidden with Product Surface evidence/status guardrails.
|
||||||
|
- **Repo-truth level**: repo-verified existing surface.
|
||||||
|
- **Existing pattern reused**: Existing read-only Coverage v2 page, widgets, badge catalog, inspect slide-over, and read model.
|
||||||
|
- **New pattern required**: none by default.
|
||||||
|
- **Screenshot required**: focused browser proof required if rendered output changes.
|
||||||
|
- **Page audit required**: no new page audit unless a new route/navigation/surface is introduced, which is out of scope.
|
||||||
|
- **Customer-safe review required**: no customer-facing surface; customer-output gate must remain N/A/no output.
|
||||||
|
- **Dangerous-action review required**: no dangerous action.
|
||||||
|
- **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] Existing internal page only; no new coverage artifact unless runtime UI scope expands.
|
||||||
|
- **No-impact rationale when applicable**: N/A.
|
||||||
|
|
||||||
|
## Product Surface Impact *(mandatory for UI-affecting specs; otherwise write `N/A - no rendered product surface changed` plus rationale)*
|
||||||
|
|
||||||
|
Reference: `docs/product/standards/product-surface-contract.md`.
|
||||||
|
|
||||||
|
- **Product Surface Contract applies?**: yes, because evidence/status presentation may change on an existing rendered operator surface.
|
||||||
|
- **Page archetype**: Technical Annex / internal operator evidence inspection surface.
|
||||||
|
- **Primary user question**: Which selected Entra resources are safely comparable/renderable, and what material changes or blockers require operator attention?
|
||||||
|
- **Primary action**: Inspect selected existing Coverage v2 evidence rows. No start, restore, certify, publish, export, or customer-output action.
|
||||||
|
- **Surface budget result**: pass by reusing the existing internal surface and not adding new page/action families.
|
||||||
|
- **Technical Annex / deep-link demotion**: OperationRun links, raw evidence IDs, source keys, payloads, provider IDs, identity diagnostics, permission context, and unsupported fields remain hidden, diagnostic, or secondary.
|
||||||
|
- **Canonical status vocabulary**: Product-facing labels must map to existing canonical wording or internal Coverage v2 labels. Do not display "Entra covered", "certified", "restore-ready", "customer-ready", "100% Entra coverage", or "full M365 coverage".
|
||||||
|
- **Visible complexity impact**: neutral or decreased. Typed summaries should reduce raw-payload interpretation burden without adding a new surface.
|
||||||
|
- **Product Surface exceptions**: none.
|
||||||
|
|
||||||
|
## Browser Verification Plan *(mandatory)*
|
||||||
|
|
||||||
|
- **Browser proof required?**: yes if rendered Coverage v2 output changes.
|
||||||
|
- **No-browser rationale**: `N/A - no rendered UI surface changed` is allowed only if implementation proves no rendered output changes.
|
||||||
|
- **Focused path when required**: Existing Coverage v2 readiness route for a seeded workspace/managed environment with a content-backed Conditional Access evidence row.
|
||||||
|
- **Primary interaction to execute**: Load the page, inspect the Conditional Access row, verify comparable/renderable state and operator summary, and verify no raw payload, secrets, restore/certify/customer-ready claim, or console/Livewire error.
|
||||||
|
- **Console, Livewire, Filament, network, and 500-error checks**: required for focused path when rendered data changes.
|
||||||
|
- **Full-suite failure triage**: unrelated failures may be documented only after focused proof is green.
|
||||||
|
|
||||||
|
## Human Product Sanity Check *(mandatory)*
|
||||||
|
|
||||||
|
- **Required?**: yes if rendered output changes.
|
||||||
|
- **No-human-sanity rationale**: N/A only when no rendered product surface changes.
|
||||||
|
- **Reviewer questions**: Is the summary understandable without raw payload? Is it clear this is selected Entra comparable/renderable support, not certification or restore readiness? Are diagnostics demoted? Is there one obvious inspect path and no high-impact action?
|
||||||
|
- **Planned result location**: `specs/421-entra-core-comparable-renderable-pack/implementation-report.md`.
|
||||||
|
|
||||||
|
## Product Surface Merge Gate Checklist *(mandatory)*
|
||||||
|
|
||||||
|
- [x] No-legacy posture or approved exception recorded.
|
||||||
|
- [x] Product Surface Impact is completed for existing-surface evidence/status rendering.
|
||||||
|
- [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 as `none`.
|
||||||
|
- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, visible complexity outcome, and completed-spec rewrite assertion.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes, evidence/status/rendering and Claim Guard behavior.
|
||||||
|
- **Interaction class(es)**: evidence inspection, coverage-level/status display, compare summaries, diagnostic detail demotion.
|
||||||
|
- **Systems touched**: Existing Coverage v2 registry/resource/evidence models, generic normalizer/redactor, identity resolver, Claim Guard, badge catalog/read model if rendered, and focused tests.
|
||||||
|
- **Existing pattern(s) to extend**: Coverage v2 resource/evidence/read model, `BadgeCatalog`/`BadgeRenderer`, `ClaimGuard`, and existing read-only Filament surface.
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: Existing Coverage v2 paths. Any Entra typed helpers must remain bounded adapters under the existing Tenant Configuration service boundary.
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: It already owns evidence, source metadata, identity, claim state, redaction, read authorization, and the existing operator surface. It lacks typed compare/render behavior for selected Entra payloads.
|
||||||
|
- **Allowed deviation and why**: Bounded Entra typed helpers are allowed because raw generic payloads cannot safely support operator comparison. They must not become a generic provider framework or separate Entra engine.
|
||||||
|
- **Consistency impact**: Compare/render state must align with existing coverage levels, evidence states, identity states, claim states, badge labels, RBAC behavior, and Product Surface demotion.
|
||||||
|
- **Review focus**: No raw-payload default display, no customer claims, no restore/certify action, no mini-platform, no new tables, no `tenant_id`, no endpoint guessing.
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no new start/completion/link UX.
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: N/A by default. Existing evidence rows may retain existing OperationRun references as diagnostics only.
|
||||||
|
- **Delegated start/completion UX behaviors**: N/A - no queued operation is introduced.
|
||||||
|
- **Local surface-owned behavior that remains**: inspect existing evidence only.
|
||||||
|
- **Queued DB-notification policy**: N/A - no new queued notification.
|
||||||
|
- **Terminal notification path**: N/A - no new OperationRun lifecycle.
|
||||||
|
- **Exception required?**: none. If compare/render becomes long-running or persisted as an operation, stop and amend this spec first.
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes.
|
||||||
|
- **Boundary classification**: mixed. Coverage v2 evidence/resource/claim/read-model truth is platform-core; Entra resource semantics and Graph field names are provider-owned typed adapters.
|
||||||
|
- **Seams affected**: compare strategy selection, typed normalization/render fields, redaction rules, coverage-level promotion, Claim Guard wording, and existing operator surface rendering.
|
||||||
|
- **Neutral platform terms preserved or introduced**: workspace, managed environment, provider connection, resource type, evidence state, coverage level, identity state, claim state, compare result, render summary.
|
||||||
|
- **Provider-specific semantics retained and why**: Entra resource names and fields are necessary for current operator meaning, but must stay inside bounded Entra typed mapping/helpers and source metadata.
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: No provider-native tenant ID ownership, no Entra table family, no Entra dashboard, no provider framework, no customer claim activation, and no restore/certify support.
|
||||||
|
- **Follow-up path**: additional Entra types, Exchange/Teams/Security comparable packs, certification, restore, and customer reporting remain later specs.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| 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 | yes, if rendered summaries or coverage levels change | Native Filament + existing widgets/read model | evidence/status/read-only registry | page/detail | no | Existing internal surface only; no new navigation/action. |
|
||||||
|
|
||||||
|
## 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 Surface | Verify selected Entra evidence can be interpreted safely | Resource name, coverage level, evidence/identity/claim state, typed summary if rendered | raw/normalized payload, source metadata, unsupported fields, evidence hash, OperationRun link | Not primary; it supports evidence inspection and release review | Follows existing Coverage v2 internal review flow | Reduces raw-payload reading for selected Entra 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Existing Coverage v2 readiness / inspect surface | operator-MSP, support-platform | selected resource summary, material changes, blockers, claim state | unsupported fields, source metadata, identity diagnostics | raw payload stays hidden or secondary/internal; secrets never shown | Inspect | raw payload, provider IDs, OperationRun details, source keys | Coverage level and summary state appear once and do not duplicate as broad Entra 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 | Primary link column | not required | none | none | existing route | inspect slide-over | workspace + managed environment | Coverage v2 resources | coverage level, evidence state, identity state, claim state | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Coverage v2 readiness / inspect | Tenant operator / release reviewer | Verify selected Entra evidence interpretation without unsafe claims | Technical Annex / read-only evidence inspection | What selected Entra evidence is comparable/renderable, and what changed materially? | typed summary, coverage/evidence/identity/claim state, last captured | raw payload, unsupported fields, source metadata, evidence hash, OperationRun | coverage level, evidence state, identity state, claim state, 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 table by default. Compare/render should derive from existing evidence unless implementation proves persistence is required and this spec is amended.
|
||||||
|
- **New abstraction?**: yes, bounded typed compare/render helpers may be introduced or existing services extended.
|
||||||
|
- **New enum/state/reason family?**: no persisted enum/status family. Derived importance labels (`critical`, `important`, `informational`) may exist only inside compare output/tests.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no.
|
||||||
|
- **Current operator problem**: Operators cannot safely understand material Entra changes from generic payloads and can over-read evidence as certification or restore readiness.
|
||||||
|
- **Existing structure is insufficient because**: Generic normalization sorts/redacts payloads but does not express Entra material fields, volatile-field exclusions, target/control summaries, or safe render summaries.
|
||||||
|
- **Narrowest correct implementation**: Bounded typed helpers for evidence-backed Entra types, mandatory first for Conditional Access, with Security Defaults and other draft types evidence-gated.
|
||||||
|
- **Ownership cost**: Focused tests for each promoted type, maintenance of field mappings as Graph payloads evolve, and Product Surface/browser proof if rendered output changes.
|
||||||
|
- **Alternative intentionally rejected**: Raw-payload display and a separate Entra policy engine/dashboard were rejected because they increase operator risk and provider coupling.
|
||||||
|
- **Release truth**: Current-release truth over existing Coverage v2 evidence, not future certification or restore 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.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Unit for typed normalization/compare/render/redaction/Claim Guard; Feature for evidence-gated promotion, RBAC/scope, no overclaim/no restore/no certification/no `tenant_id`; Browser if rendered Coverage v2 output changes.
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence, browser if rendered UI changes.
|
||||||
|
- **Why this classification and these lanes are sufficient**: The core behavior is deterministic pure transformation over existing evidence plus internal read-model rendering. No schema or remote provider path is added by default.
|
||||||
|
- **New or expanded test families**: Spec 421 focused unit/feature tests; one focused browser smoke only if rendered output changes.
|
||||||
|
- **Fixture / helper cost impact**: Use minimal workspace/managed-environment/provider/evidence factories and fake evidence payloads. No live Graph/TCM calls.
|
||||||
|
- **Heavy-family visibility / justification**: none by default.
|
||||||
|
- **Special surface test profile**: shared-detail-family / standard-native-filament if existing inspect slide-over changes.
|
||||||
|
- **Standard-native relief or required special coverage**: focused browser proof validates the existing read-only page and inspect summary when rendered.
|
||||||
|
- **Reviewer handoff**: Confirm lane fit, no hidden capture/OperationRun/customer-output scope, and no new heavy fixtures.
|
||||||
|
- **Budget / baseline / trend impact**: none expected.
|
||||||
|
- **Escalation needed**: document-in-feature if optional Entra types remain blocked; follow-up-spec if securityDefaults needs new capture/source contract work.
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact <focused Spec421 unit tests>`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact <focused Spec421 feature tests>`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact <focused Spec421 browser test>` if rendered output changes
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Understand Conditional Access evidence safely (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator or release reviewer, I need captured Conditional Access evidence to render as a concise operator summary and deterministic compare result so I can understand material policy changes without reading raw Graph payload.
|
||||||
|
|
||||||
|
**Independent Test**: Given two content-backed Conditional Access evidence payloads, compare output identifies material state, target, grant control, and session control changes; render output summarizes the current policy without raw payload or secrets.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** content-backed Conditional Access evidence, **When** the resource is rendered, **Then** the operator sees display name, state, target summaries, controls, claim/identity state, and last captured time.
|
||||||
|
2. **Given** two Conditional Access payloads that differ only in volatile fields, **When** they are compared, **Then** the result is unchanged or marks volatile fields as ignored.
|
||||||
|
3. **Given** included users/groups or grant controls change, **When** they are compared, **Then** the result marks the change as material with bounded importance.
|
||||||
|
|
||||||
|
### User Story 2 - Keep non-evidence-backed Entra types honest (Priority: P1)
|
||||||
|
|
||||||
|
As a release reviewer, I need Security Defaults and other draft Entra types to remain unpromoted unless content-backed evidence exists so TenantPilot does not claim typed support for data it cannot prove.
|
||||||
|
|
||||||
|
**Independent Test**: Given registered Entra resource types without content-backed evidence, the promotion path leaves them detected/content-backed-only as appropriate and records a blocker/deferred reason rather than comparable/renderable support.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** `securityDefaults` has no content-backed evidence, **When** Spec 421 promotion is evaluated, **Then** it remains unpromoted and the implementation report records the blocker.
|
||||||
|
2. **Given** an optional Entra type has content-backed evidence and typed tests, **When** it is promoted, **Then** it receives comparable/renderable support without restore/certification/customer claims.
|
||||||
|
|
||||||
|
### User Story 3 - Prevent Entra overclaiming (Priority: P1)
|
||||||
|
|
||||||
|
As a product/release owner, I need Claim Guard and Product Surface output to block certification, restore readiness, full Entra coverage, and customer-ready claims so internal compare/render support cannot become unsafe customer proof.
|
||||||
|
|
||||||
|
**Independent Test**: Claim Guard tests block forbidden wording while allowing scoped internal comparable/renderable wording, and rendered surfaces contain no restore/certified/customer-ready claims.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a claim says "Entra certified" or "Entra restore-ready", **When** Claim Guard evaluates it, **Then** it is blocked.
|
||||||
|
2. **Given** a claim says selected Entra resources are comparable/renderable for internal review, **When** Claim Guard evaluates it, **Then** it remains scoped/internal and does not become customer-facing proof.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
- **FR-421-001**: The implementation MUST use existing Coverage v2 resource type, resource, evidence, identity, redaction, read-model, and Claim Guard boundaries.
|
||||||
|
- **FR-421-002**: A resource type MUST be promoted to comparable/renderable only when content-backed evidence exists and typed normalization, compare, render, redaction, and tests exist.
|
||||||
|
- **FR-421-003**: `conditionalAccessPolicy` MUST be promoted to comparable/renderable when content-backed evidence exists.
|
||||||
|
- **FR-421-004**: `securityDefaults` MUST be preflighted and promoted only if repo-real content-backed evidence already exists; otherwise it MUST remain unpromoted with a documented blocker.
|
||||||
|
- **FR-421-005**: `application`, `servicePrincipal`, `roleDefinition`, and `administrativeUnit` MAY be promoted only if implementation preflight proves content-backed evidence and scope stays bounded.
|
||||||
|
- **FR-421-006**: Compare output MUST classify changes as `added`, `removed`, `changed`, `unchanged`, `ignored_volatile`, `redacted`, or `unsupported_field`.
|
||||||
|
- **FR-421-007**: Compare importance labels MUST remain derived compare-output labels only: `critical`, `important`, or `informational`.
|
||||||
|
- **FR-421-008**: Compare MUST ignore or label volatile fields including Graph context, etags, created/modified timestamps, and source metadata that is not business-relevant.
|
||||||
|
- **FR-421-009**: Compare MUST be deterministic, including stable array ordering where order is not semantically meaningful and explicit null/empty handling.
|
||||||
|
- **FR-421-010**: Render output MUST answer what the resource is, enabled/active state, targets/controls/settings where applicable, material change summary, blockers, redaction, unsupported fields, claim state, identity state, and capture time.
|
||||||
|
- **FR-421-011**: Render output MUST NOT show raw payloads, raw Graph responses, tokens, secrets, credential values, private keys, certificate material, authorization headers, cookies, or unneeded PII by default.
|
||||||
|
- **FR-421-012**: Credential-related Entra fields MAY render only safe summaries such as presence, count, expiration date if safe, or partial key ID if already allowed by repo convention.
|
||||||
|
- **FR-421-013**: Claim Guard MUST allow only scoped internal comparable/renderable wording and MUST block certified, restore-ready, customer-ready, full, all-resource, 100 percent, or broad Entra/M365 coverage claims.
|
||||||
|
- **FR-421-014**: No restore/apply action, certified state, customer-facing route, customer report, Review Pack output, management PDF output, export, or download is in scope.
|
||||||
|
- **FR-421-015**: Render/compare MUST be DB-only from existing evidence at render time and MUST NOT call Graph, TCM, provider clients, HTTP, or remote APIs.
|
||||||
|
- **FR-421-016**: No new Entra-specific table family, persisted compare result table, provider mini-platform, or `tenant_id` ownership path may be introduced.
|
||||||
|
- **FR-421-017**: Existing Coverage v2 read authorization MUST apply: non-member/wrong scope deny as not found, established member missing capability forbidden, and provider connection scope must match workspace/managed environment.
|
||||||
|
- **FR-421-018**: Existing Coverage v2 operator surface MAY show comparable/renderable Entra summaries; any rendered change requires focused browser proof and Human Product Sanity.
|
||||||
|
- **FR-421-019**: If implementation requires capture expansion, a new OperationRun type, new persisted truth, a new UI action, or customer output, it MUST stop and amend/split the spec first.
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
- **NFR-421-001**: Compare/render behavior must be deterministic across repeated runs for the same normalized evidence.
|
||||||
|
- **NFR-421-002**: Redaction must be applied before any operator render or compare summary can expose sensitive values.
|
||||||
|
- **NFR-421-003**: Existing read-only Coverage v2 surface performance must remain DB-only and must not introduce remote calls or expensive per-row service calls.
|
||||||
|
- **NFR-421-004**: Tests must prove business truth rather than thin presentation internals: material change detection, redaction, claims, scope, and no overclaim.
|
||||||
|
- **NFR-421-005**: No UI label may imply restore, certification, full coverage, customer proof, or production readiness.
|
||||||
|
|
||||||
|
## Key Entities / Data
|
||||||
|
|
||||||
|
- **TenantConfigurationResourceType**: Existing Coverage v2 registry definition for selected Entra resource types.
|
||||||
|
- **TenantConfigurationResource**: Existing scoped observed resource row tied to workspace, managed environment, provider connection, identity, and latest evidence.
|
||||||
|
- **TenantConfigurationResourceEvidence**: Existing append-only evidence row with raw payload boundary, normalized payload, coverage level, evidence state, capture outcome, permission context, and optional OperationRun link.
|
||||||
|
- **Compare result**: Derived in-memory output for selected evidence comparisons; not persisted by default.
|
||||||
|
- **Render summary**: Derived operator-safe view model; not persisted by default.
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
- Entra restore/apply.
|
||||||
|
- Entra certification or certified compare.
|
||||||
|
- Full Entra or M365 catalog support.
|
||||||
|
- New source capture contracts for Security Defaults or optional types if missing from current repo truth.
|
||||||
|
- Customer-facing Entra reports, Review Pack output, management PDF output, or public export.
|
||||||
|
- New Coverage v2 start/capture action, route, navigation item, dashboard, wizard, or customer workspace surface.
|
||||||
|
- New Entra tables, mini-platform services, provider framework, or persisted compare history.
|
||||||
|
- Coverage v1 compatibility, fallback readers, dual writes, old gap taxonomy adapters, or `tenant_id`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- **AC-421-001**: Conditional Access evidence can be normalized, compared, and rendered deterministically without raw payload display.
|
||||||
|
- **AC-421-002**: Security Defaults and optional Entra types are either promoted with repo-real evidence and focused tests or remain explicitly blocked/deferred.
|
||||||
|
- **AC-421-003**: Compare output detects material Conditional Access state, target, condition, grant control, and session control changes while ignoring volatile fields.
|
||||||
|
- **AC-421-004**: Render output hides secrets and raw payloads and shows unsupported/redacted fields only as diagnostics.
|
||||||
|
- **AC-421-005**: Claim Guard blocks certified, restore-ready, full/100 percent Entra or M365, customer-ready, and broad coverage claims.
|
||||||
|
- **AC-421-006**: No restore/apply/customer-output UI, action, route, report, download, or certification state is introduced.
|
||||||
|
- **AC-421-007**: No `tenant_id`, Entra-specific table family, persisted compare result table, or mini-platform appears.
|
||||||
|
- **AC-421-008**: Focused unit and feature tests pass; focused browser proof passes if rendered output changes.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- **SC-421-001**: A reviewer can inspect the implementation report and see an Entra evidence matrix naming promoted and deferred types.
|
||||||
|
- **SC-421-002**: For the mandatory promoted Conditional Access path, focused tests prove at least one volatile-only no-change comparison and at least three material-change comparisons.
|
||||||
|
- **SC-421-003**: Redaction tests prove no secret/credential/token/raw payload value appears in render summaries, compare summaries, OperationRun context, audit metadata, or default UI.
|
||||||
|
- **SC-421-004**: Product Surface close-out confirms existing-surface impact only, Product Surface exceptions `none`, and no broad Entra/M365 claims.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
|---|---:|---|
|
||||||
|
| Compare/render is mistaken for certification | High | Claim Guard, Product Surface wording, no certified coverage level. |
|
||||||
|
| Security Defaults is promoted without evidence | High | Evidence-gated requirement and blocker task. |
|
||||||
|
| Raw payload or secrets render by default | High | Redaction tests, browser proof, diagnostics demotion. |
|
||||||
|
| Entra-specific mini-platform appears | High | Reuse existing Coverage v2 services; no new tables/dashboard. |
|
||||||
|
| Remote calls happen during render | High | DB-only requirement and fail-hard tests. |
|
||||||
|
| Scope leaks across workspace/environment/provider connection | High | Existing authorization plus feature tests. |
|
||||||
|
| Optional Entra types expand scope too far | Medium | Conditional promotion only with evidence and tests; otherwise defer. |
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Spec 420's Conditional Access content-backed evidence path is available as repo truth.
|
||||||
|
- Security Defaults and other optional draft Entra types are registered but not assumed content-backed.
|
||||||
|
- Existing Coverage v2 surface is internal/operator only and read-only.
|
||||||
|
- Existing Coverage v2 models and coverage-level enum can represent comparable/renderable without schema changes.
|
||||||
|
- Any implementation report will be created during the later implementation loop, not during preparation.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None blocking for the narrowed implementation-ready slice. Security Defaults promotion depends on implementation preflight evidence; missing evidence is an expected blocker/deferred result, not an open product question.
|
||||||
|
|
||||||
|
## Follow-Up Spec Candidates
|
||||||
|
|
||||||
|
- Security Defaults content-backed capture/source contract if missing and still product-critical.
|
||||||
|
- Entra application and service principal comparable/renderable pack.
|
||||||
|
- Entra role definition and administrative unit comparable/renderable pack.
|
||||||
|
- Exchange and Teams comparable/renderable pack.
|
||||||
|
- Security and Compliance readiness/comparable pack.
|
||||||
|
- Entra certified compare pack.
|
||||||
|
- M365 customer reporting Claim Guard pack.
|
||||||
|
- Entra restore/apply feasibility and safety review.
|
||||||
134
specs/421-entra-core-comparable-renderable-pack/tasks.md
Normal file
134
specs/421-entra-core-comparable-renderable-pack/tasks.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# Tasks: Spec 421 - Entra Core Comparable / Renderable Pack
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/421-entra-core-comparable-renderable-pack/`
|
||||||
|
**Prerequisites**: `spec.md`, `plan.md`, `checklists/requirements.md`, completed Specs 414, 415, 417, 418, 419, and 420 as read-only context
|
||||||
|
**Tests**: Required. Runtime compare/render behavior must be covered with focused Pest unit and feature tests. Browser proof is required if rendered Coverage v2 output changes.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment is named and is the narrowest sufficient proof for typed normalization, compare/render, redaction, claims, scope, and Product Surface behavior.
|
||||||
|
- [x] New or changed tests stay in the smallest honest family; browser coverage is explicit because rendered output changed.
|
||||||
|
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
|
||||||
|
- [x] Planned validation commands cover the change without pulling unrelated lane cost.
|
||||||
|
- [x] Browser proof is completed with focused rendered UI coverage because rendered output changed.
|
||||||
|
- [x] Human Product Sanity and Product Surface implementation-report close-out are planned where applicable.
|
||||||
|
- [x] Any optional Entra type blocker is documented in the active spec or implementation report.
|
||||||
|
|
||||||
|
## Phase 1: Preflight And Repo Truth
|
||||||
|
|
||||||
|
**Purpose**: Confirm current repo truth before implementation and prevent completed-spec rewrite.
|
||||||
|
|
||||||
|
- [x] T001 Capture branch, HEAD, dirty state, activated skills, and hard-gate stop conditions in `specs/421-entra-core-comparable-renderable-pack/implementation-report.md`.
|
||||||
|
- [x] T002 Verify Specs 414, 415, 417, 418, 419, and 420 are completed dependency context only and do not edit any files under their spec directories.
|
||||||
|
- [x] T003 Inspect `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php`, `CoverageSourceContractResolver.php`, `GenericContentEvidenceCaptureService.php`, `CoverageIdentityStrategyRegistry.php`, `CanonicalIdentityResolver.php`, `ClaimGuard.php`, and `CoverageV2ReadinessReadModel.php` to confirm current Coverage v2 service names before editing.
|
||||||
|
- [x] T004 Build the Entra evidence matrix in `specs/421-entra-core-comparable-renderable-pack/implementation-report.md` for `conditionalAccessPolicy`, `securityDefaults`, `application`, `servicePrincipal`, `roleDefinition`, and `administrativeUnit`, classifying each as content-backed, missing-contract, unsupported, identity-blocked, or deferred.
|
||||||
|
- [x] T005 Confirm no runtime task needs new capture/source contracts, restore/apply, certification, customer output, new OperationRun type, new route/navigation/action, new table, or `tenant_id`; stop and amend the spec if any is required.
|
||||||
|
|
||||||
|
## Phase 2: Tests First - Typed Semantics And Claim Safety
|
||||||
|
|
||||||
|
**Purpose**: Lock the business truth before implementation.
|
||||||
|
|
||||||
|
- [x] T006 [P] Add Conditional Access typed normalization tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec421EntraConditionalAccessNormalizerTest.php`.
|
||||||
|
- [x] T007 [P] Add deterministic compare tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec421EntraComparableDiffTest.php` covering volatile-only no-change, state change, target change, grant control change, session control change, stable ordering, null/empty handling, redacted values, and unsupported fields.
|
||||||
|
- [x] T008 [P] Add render summary tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec421EntraRenderableSummaryTest.php` covering operator-safe Conditional Access summaries and no raw payload dependency.
|
||||||
|
- [x] T009 [P] Add redaction tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec421EntraRedactionTest.php` proving secrets, credentials, tokens, authorization headers, cookies, raw payload, provider response bodies, unsafe OperationRun diagnostic context, and unsafe audit metadata do not appear in render/compare summaries or any default-visible diagnostic output.
|
||||||
|
- [x] T010 [P] Add Claim Guard tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec421EntraClaimGuardTest.php` allowing scoped internal comparable/renderable wording and blocking certified, restore-ready, customer-ready, full, all-resource, and 100 percent Entra/M365 claims.
|
||||||
|
- [x] T011 [P] Add evidence-gated promotion tests in `apps/platform/tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php` proving `conditionalAccessPolicy` can promote only with content-backed evidence and missing-evidence Entra types remain unpromoted.
|
||||||
|
- [x] T012 [P] Add no-restore/no-certification tests in `apps/platform/tests/Feature/TenantConfiguration/Spec421EntraNoRestoreNoCertificationTest.php`.
|
||||||
|
- [x] T013 [P] Add no-tenant-id/no-mini-platform tests or static guards in `apps/platform/tests/Feature/TenantConfiguration/Spec421EntraNoTenantIdTest.php` and `apps/platform/tests/Feature/TenantConfiguration/Spec421EntraNoMiniPlatformTest.php`.
|
||||||
|
- [x] T014 [P] Add read authorization, provider-scope, and no-remote-render tests in `apps/platform/tests/Feature/TenantConfiguration/Spec421EntraComparableRenderableTest.php` proving non-member/wrong-scope denial, member-missing-capability denial, same-scope provider connection requirements, and no Graph/TCM/provider calls during render/compare.
|
||||||
|
|
||||||
|
## Phase 3: Evidence-Gated Promotion Path
|
||||||
|
|
||||||
|
**Purpose**: Promote only proven evidence-backed types.
|
||||||
|
|
||||||
|
- [x] T015 Update or extend `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php` only if needed to keep selected Entra comparable/renderable support internal and claim-safe; do not set restore/certified/customer defaults.
|
||||||
|
- [x] T016 Update or extend the existing Coverage v2 promotion/read path in `apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php` or repo-equivalent service so comparable/renderable state is derived only from content-backed typed evidence.
|
||||||
|
- [x] T017 Ensure `securityDefaults`, `application`, `servicePrincipal`, `roleDefinition`, and `administrativeUnit` stay unpromoted unless Phase 1 proves content-backed evidence and corresponding tests exist; record blockers in `specs/421-entra-core-comparable-renderable-pack/implementation-report.md`.
|
||||||
|
- [x] T018 Confirm `apps/platform/app/Support/TenantConfiguration/CoverageLevel.php` existing values are reused and no new persisted coverage/status/importance enum is added.
|
||||||
|
|
||||||
|
## Phase 4: Typed Entra Normalization
|
||||||
|
|
||||||
|
**Purpose**: Produce deterministic typed payloads for evidence-backed Entra types.
|
||||||
|
|
||||||
|
- [x] T019 Add or extend a bounded typed normalizer in `apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php` or the repo-equivalent Tenant Configuration service path.
|
||||||
|
- [x] T020 [P] Implement Conditional Access normalization for display name, state, included/excluded users/groups/roles, included apps/resources, conditions, grant controls, session controls, source version/schema, redacted diagnostics, and unsupported fields in the normalizer path from T019.
|
||||||
|
- [x] T021 [P] Implement Security Defaults normalization only if Phase 1 proves content-backed evidence; otherwise keep a blocker path and corresponding tests. Deferred because current repo evidence does not prove a content-backed source contract.
|
||||||
|
- [x] T022 [P] Implement optional `application`, `servicePrincipal`, `roleDefinition`, and `administrativeUnit` normalization only if Phase 1 proves content-backed evidence and the scope remains bounded; otherwise defer them in the implementation report. Deferred because current repo evidence does not prove content-backed source contracts.
|
||||||
|
- [x] T023 Ensure typed normalization reuses `apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php` or its repo-equivalent redaction path before render/compare output.
|
||||||
|
|
||||||
|
## Phase 5: Deterministic Compare
|
||||||
|
|
||||||
|
**Purpose**: Compare selected Entra evidence without volatile noise or unsafe claims.
|
||||||
|
|
||||||
|
- [x] T024 Add or extend a bounded comparator in `apps/platform/app/Services/TenantConfiguration/EntraCoverageComparator.php` or the repo-equivalent Tenant Configuration service path.
|
||||||
|
- [x] T025 Implement change classification `added`, `removed`, `changed`, `unchanged`, `ignored_volatile`, `redacted`, and `unsupported_field` in the comparator path from T024.
|
||||||
|
- [x] T026 Implement Conditional Access material change rules for enabled/state, included/excluded actors, app/resource targeting, conditions, grant controls, and session controls in the comparator path from T024.
|
||||||
|
- [x] T027 Implement derived importance labels `critical`, `important`, and `informational` only inside compare output; do not add a persisted enum/status family.
|
||||||
|
- [x] T028 Ensure compare ordering is deterministic for arrays where order is not semantically meaningful and null/empty handling is explicit.
|
||||||
|
- [x] T029 Implement Security Defaults and optional type compare rules only when corresponding evidence-backed normalization exists; otherwise leave documented blockers.
|
||||||
|
|
||||||
|
## Phase 6: Operator-Safe Render Summaries
|
||||||
|
|
||||||
|
**Purpose**: Let operators understand selected Entra resources without raw payloads.
|
||||||
|
|
||||||
|
- [x] T030 Add or extend a render summary builder in `apps/platform/app/Services/TenantConfiguration/EntraRenderableSummaryBuilder.php` or the repo-equivalent Tenant Configuration service path.
|
||||||
|
- [x] T031 Implement Conditional Access render summary fields: display name, state, included/excluded actor summary, included app/resource summary, conditions summary, grant/session control summary, claim state, identity state, last captured, unsupported fields, and redaction markers.
|
||||||
|
- [x] T032 Implement Security Defaults render summary only if Phase 1 proves content-backed evidence; otherwise keep a blocker/deferred summary in the implementation report.
|
||||||
|
- [x] T033 Ensure render summaries never expose raw payload, raw Graph response, tokens, credential values, private keys, certificate material, authorization headers, cookies, or unneeded PII.
|
||||||
|
- [x] T034 If application/service principal rendering is promoted, summarize credentials only as safe presence/count/expiration/partial-key metadata according to repo convention. Not promoted; deferred in the implementation report.
|
||||||
|
|
||||||
|
## Phase 7: Existing Surface Integration And Product Safety
|
||||||
|
|
||||||
|
**Purpose**: Reuse the existing read-only Coverage v2 surface without adding product-surface risk.
|
||||||
|
|
||||||
|
- [x] T035 If rendered output changes, update `apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php` to expose typed summaries through existing inspect details while keeping raw/technical evidence demoted.
|
||||||
|
- [x] T036 If rendered output changes, update existing inspect modal views under `apps/platform/resources/views/filament/modals/tenant-configuration/` only as needed to display typed summaries with native/shared Filament semantics.
|
||||||
|
- [x] T037 Confirm `apps/platform/app/Filament/Pages/TenantConfiguration/CoverageV2Readiness.php`, `CoverageV2ResourceTypesTable.php`, and `CoverageV2ResourceInstancesTable.php` expose no new action, route, navigation, start/capture, restore, certify, export, report, or customer output.
|
||||||
|
- [x] T038 Confirm global search posture is unchanged because no Filament Resource is added or changed for global search.
|
||||||
|
- [x] T039 Confirm no new assets are registered and no `filament:assets` requirement is introduced beyond existing deployment practice.
|
||||||
|
- [x] T040 Ensure rendered labels do not include `Entra covered`, `certified`, `restore-ready`, `customer-ready`, `full Entra coverage`, `100% Entra`, or broad M365 readiness wording.
|
||||||
|
|
||||||
|
## Phase 8: Browser Proof If Rendered Output Changes
|
||||||
|
|
||||||
|
**Purpose**: Prove the existing surface remains safe when summaries render.
|
||||||
|
|
||||||
|
- [x] T041 Add `apps/platform/tests/Browser/Spec421EntraComparableRenderableOperatorSurfaceSmokeTest.php` if rendered output changes.
|
||||||
|
- [x] T042 In the browser smoke, seed a workspace, managed environment, provider connection, and Conditional Access content-backed evidence row with comparable/renderable summary data.
|
||||||
|
- [x] T043 In the browser smoke, load the existing Coverage v2 readiness route, open the inspect flow, and assert comparable/renderable state, operator-readable Conditional Access summary, no raw payload, no secrets, no unsafe OperationRun/audit diagnostic metadata if diagnostics render, no restore/certified/customer-ready claim, no new high-impact action, no provider/network call during render, and no console/Livewire/Filament errors.
|
||||||
|
- [x] T044 If no rendered output changes, document `N/A - no rendered UI surface changed` proof in `specs/421-entra-core-comparable-renderable-pack/implementation-report.md` instead of adding a browser test. Not applicable because rendered output changed and focused browser proof was added.
|
||||||
|
|
||||||
|
## Phase 9: Validation And Close-Out
|
||||||
|
|
||||||
|
**Purpose**: Complete the implementation loop with explicit proof.
|
||||||
|
|
||||||
|
- [x] T045 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
|
- [x] T046 Run focused Spec 421 unit tests for normalization, compare, render, redaction, and Claim Guard.
|
||||||
|
- [x] T047 Run focused Spec 421 feature tests for promotion, RBAC/scope, no restore/certification, no tenant_id, no mini-platform, and no overclaim.
|
||||||
|
- [x] T048 Run focused Spec 421 browser test if rendered output changed, or record no-browser proof if not.
|
||||||
|
- [x] T049 Run `git diff --check`.
|
||||||
|
- [x] T050 Complete `specs/421-entra-core-comparable-renderable-pack/implementation-report.md` with candidate gate, dirty state before/after, files changed, Entra evidence matrix, promoted/deferred types, normalizer matrix, compare matrix, render matrix, Claim Guard proof, redaction proof including OperationRun diagnostic context and audit metadata posture, no restore/certification proof, no tenant_id proof, no mini-platform proof, Product Surface proof, tests run, browser/no-browser, deployment impact, and deferred work.
|
||||||
|
- [x] T051 Confirm no completed historical spec was rewritten, normalized, reopened, or stripped of validation/task/browser/review history.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Phase 1 blocks all implementation.
|
||||||
|
- Phase 2 tests should be written before or alongside Phases 3-7.
|
||||||
|
- Phase 3 promotion path depends on Phase 1 evidence matrix.
|
||||||
|
- Phase 4 typed normalization blocks Phases 5 and 6.
|
||||||
|
- Phase 7 depends on Phases 3-6 only if rendered output changes.
|
||||||
|
- Phase 8 depends on Phase 7 rendered output changes.
|
||||||
|
- Phase 9 closes after all relevant implementation and validation tasks.
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
- New capture/source contract work is needed for Security Defaults or optional types.
|
||||||
|
- Restore/apply, certification, customer output, report/download/export, or broad Entra/M365 claim is proposed.
|
||||||
|
- A new route, navigation entry, dashboard, action, OperationRun type, persisted compare table, or Entra-specific table family is proposed without amending this spec.
|
||||||
|
- Raw payloads, secrets, credentials, tokens, provider response bodies, source keys, or provider IDs become default-visible.
|
||||||
|
- `tenant_id` appears as Coverage v2 ownership truth.
|
||||||
|
- Render/compare performs provider/Graph/TCM/HTTP work during page render.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
Deliver the MVP first: Conditional Access content-backed evidence comparable/renderable, plus Claim Guard/redaction/no-overclaim proof. Treat every other Entra type as evidence-gated follow-through, not required scope. Stop and split if the implementation needs new capture contracts or broader product-surface work.
|
||||||
Loading…
Reference in New Issue
Block a user