feat: complete spec 425 enta certified compare pack (#492)
Implements spec 425 with Entra certified compare pack support, coverage, guards, evaluator, fixtures, and tests. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #492
This commit is contained in:
parent
2cd512915a
commit
33e496c182
@ -12,6 +12,12 @@
|
|||||||
|
|
||||||
final class ClaimGuard
|
final class ClaimGuard
|
||||||
{
|
{
|
||||||
|
private const ENTRA_CERTIFIED_COMPARE_PACK_CLAIMS = [
|
||||||
|
'certified entra core compare pack conditional access and security defaults',
|
||||||
|
'certified compare support for conditional access and security defaults',
|
||||||
|
'certified compare render support for the entra core denominator conditional access and security defaults',
|
||||||
|
];
|
||||||
|
|
||||||
public function evaluateStatement(string $claim, bool $internalOperatorOnly = false): ClaimState
|
public function evaluateStatement(string $claim, bool $internalOperatorOnly = false): ClaimState
|
||||||
{
|
{
|
||||||
$tokens = $this->claimTokens($claim);
|
$tokens = $this->claimTokens($claim);
|
||||||
@ -32,6 +38,30 @@ public function evaluateStatement(string $claim, bool $internalOperatorOnly = fa
|
|||||||
return $registryScoped ? ClaimState::ClaimBlocked : ClaimState::ClaimLimited;
|
return $registryScoped ? ClaimState::ClaimBlocked : ClaimState::ClaimLimited;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function evaluateCertifiedComparePackStatement(
|
||||||
|
string $claim,
|
||||||
|
bool $packPassed,
|
||||||
|
bool $internalOperatorOnly,
|
||||||
|
): ClaimState {
|
||||||
|
$tokens = $this->claimTokens($claim);
|
||||||
|
$exactDenominatorClaim = $this->hasExactEntraCertifiedComparePackDenominator($tokens)
|
||||||
|
&& in_array(implode(' ', $tokens), self::ENTRA_CERTIFIED_COMPARE_PACK_CLAIMS, true);
|
||||||
|
|
||||||
|
if ($packPassed && $internalOperatorOnly && $exactDenominatorClaim) {
|
||||||
|
return ClaimState::InternalOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasUnsafeBroadCoverageClaim($tokens, registryScoped: false)) {
|
||||||
|
return ClaimState::ClaimBlocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $packPassed || ! $internalOperatorOnly) {
|
||||||
|
return ClaimState::ClaimBlocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClaimState::ClaimBlocked;
|
||||||
|
}
|
||||||
|
|
||||||
public function evaluate(
|
public function evaluate(
|
||||||
?string $scopeKey,
|
?string $scopeKey,
|
||||||
CoverageLevel|string $requestedLevel,
|
CoverageLevel|string $requestedLevel,
|
||||||
@ -264,6 +294,17 @@ private function hasScopedWorkloadReference(array $tokens): bool
|
|||||||
|| $this->hasAnyToken($tokens, ['label', 'labels']);
|
|| $this->hasAnyToken($tokens, ['label', 'labels']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $tokens
|
||||||
|
*/
|
||||||
|
private function hasExactEntraCertifiedComparePackDenominator(array $tokens): bool
|
||||||
|
{
|
||||||
|
return $this->hasToken($tokens, 'conditional')
|
||||||
|
&& $this->hasToken($tokens, 'access')
|
||||||
|
&& $this->hasToken($tokens, 'security')
|
||||||
|
&& $this->hasToken($tokens, 'defaults');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $tokens
|
* @param list<string> $tokens
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -196,7 +196,7 @@ public function providerConnectionOptions(ManagedEnvironment $environment): arra
|
|||||||
public function supportedScopeOptions(): array
|
public function supportedScopeOptions(): array
|
||||||
{
|
{
|
||||||
return app(SupportedScopeResolver::class)
|
return app(SupportedScopeResolver::class)
|
||||||
->activeScopes()
|
->readinessVisibleScopes()
|
||||||
->mapWithKeys(fn (TenantConfigurationSupportedScope $scope): array => [
|
->mapWithKeys(fn (TenantConfigurationSupportedScope $scope): array => [
|
||||||
(string) $scope->scope_key => (string) $scope->display_name,
|
(string) $scope->scope_key => (string) $scope->display_name,
|
||||||
])
|
])
|
||||||
@ -208,7 +208,7 @@ public function supportedScopeOptions(): array
|
|||||||
*/
|
*/
|
||||||
public function includedCanonicalTypesForScope(string $scopeKey): array
|
public function includedCanonicalTypesForScope(string $scopeKey): array
|
||||||
{
|
{
|
||||||
$scope = app(SupportedScopeResolver::class)->findActive($scopeKey);
|
$scope = app(SupportedScopeResolver::class)->findReadinessVisible($scopeKey);
|
||||||
|
|
||||||
if (! $scope instanceof TenantConfigurationSupportedScope) {
|
if (! $scope instanceof TenantConfigurationSupportedScope) {
|
||||||
return [];
|
return [];
|
||||||
@ -230,22 +230,20 @@ public function includedCanonicalTypesForScope(string $scopeKey): array
|
|||||||
|
|
||||||
public function scopeInclusionLabel(TenantConfigurationResourceType $resourceType, ?string $scopeKey = null): string
|
public function scopeInclusionLabel(TenantConfigurationResourceType $resourceType, ?string $scopeKey = null): string
|
||||||
{
|
{
|
||||||
$scopeKey = $scopeKey ?: $this->defaultScopeKey();
|
$scope = $this->readinessVisibleScope($scopeKey);
|
||||||
|
|
||||||
if (! is_string($scopeKey) || $scopeKey === '') {
|
if (! $scope instanceof TenantConfigurationSupportedScope) {
|
||||||
return 'No active scope';
|
return 'No active scope';
|
||||||
}
|
}
|
||||||
|
|
||||||
return in_array((string) $resourceType->canonical_type, $this->includedCanonicalTypesForScope($scopeKey), true)
|
return in_array((string) $resourceType->canonical_type, $this->includedCanonicalTypesForScope((string) $scope->scope_key), true)
|
||||||
? 'Included'
|
? 'Included'
|
||||||
: 'Not included';
|
: 'Not included';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function defaultScopeKey(): ?string
|
public function defaultScopeKey(): ?string
|
||||||
{
|
{
|
||||||
$scope = app(SupportedScopeResolver::class)
|
$scope = $this->readinessVisibleScope();
|
||||||
->activeScopes()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $scope instanceof TenantConfigurationSupportedScope
|
return $scope instanceof TenantConfigurationSupportedScope
|
||||||
? (string) $scope->scope_key
|
? (string) $scope->scope_key
|
||||||
@ -538,9 +536,9 @@ private static function compareToken(mixed $value, string $fallback): string
|
|||||||
*/
|
*/
|
||||||
public function resourceTypeInspectDetails(TenantConfigurationResourceType $resourceType, ?string $scopeKey = null): array
|
public function resourceTypeInspectDetails(TenantConfigurationResourceType $resourceType, ?string $scopeKey = null): array
|
||||||
{
|
{
|
||||||
$scopeKey = $scopeKey ?: $this->defaultScopeKey();
|
$scope = $this->readinessVisibleScope($scopeKey);
|
||||||
$scope = is_string($scopeKey) && $scopeKey !== ''
|
$scopeKey = $scope instanceof TenantConfigurationSupportedScope
|
||||||
? app(SupportedScopeResolver::class)->findActive($scopeKey)
|
? (string) $scope->scope_key
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -555,7 +553,9 @@ public function resourceTypeInspectDetails(TenantConfigurationResourceType $reso
|
|||||||
'default_identity_state' => self::safeStateValue($resourceType->default_identity_state),
|
'default_identity_state' => self::safeStateValue($resourceType->default_identity_state),
|
||||||
'default_claim_state' => self::safeStateValue($resourceType->default_claim_state),
|
'default_claim_state' => self::safeStateValue($resourceType->default_claim_state),
|
||||||
'restore_tier' => self::humanize(self::safeStateValue($resourceType->restore_tier)),
|
'restore_tier' => self::humanize(self::safeStateValue($resourceType->restore_tier)),
|
||||||
'supported_scope' => $this->scopeInclusionLabel($resourceType, $scopeKey),
|
'supported_scope' => $scope instanceof TenantConfigurationSupportedScope
|
||||||
|
? $this->scopeInclusionLabel($resourceType, $scopeKey)
|
||||||
|
: 'No active scope',
|
||||||
'scope' => $scope instanceof TenantConfigurationSupportedScope ? (string) $scope->display_name : null,
|
'scope' => $scope instanceof TenantConfigurationSupportedScope ? (string) $scope->display_name : null,
|
||||||
'scope_key' => $scopeKey,
|
'scope_key' => $scopeKey,
|
||||||
'allows_beta_claims' => (bool) $resourceType->allows_beta_claims,
|
'allows_beta_claims' => (bool) $resourceType->allows_beta_claims,
|
||||||
@ -563,6 +563,17 @@ public function resourceTypeInspectDetails(TenantConfigurationResourceType $reso
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function readinessVisibleScope(?string $scopeKey = null): ?TenantConfigurationSupportedScope
|
||||||
|
{
|
||||||
|
$resolver = app(SupportedScopeResolver::class);
|
||||||
|
|
||||||
|
if (is_string($scopeKey) && $scopeKey !== '') {
|
||||||
|
return $resolver->findReadinessVisible($scopeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->readinessVisibleScopes()->first();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,647 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Models\TenantConfigurationSupportedScope;
|
||||||
|
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 Carbon\CarbonInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class EntraCertifiedComparePackEvaluator
|
||||||
|
{
|
||||||
|
public const SCOPE_KEY = 'entra_core_compare_certified';
|
||||||
|
|
||||||
|
public const CLAIM_LABEL = 'Certified Entra Core Compare Pack: Conditional Access and Security Defaults';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const DENOMINATOR = [
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
'securityDefaults',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const EXPECTED_CONTRACT_KEYS = [
|
||||||
|
'conditionalAccessPolicy' => 'conditionalAccessPolicy',
|
||||||
|
'securityDefaults' => 'securityDefaults',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const BLOCKER_PRIORITY = [
|
||||||
|
EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE,
|
||||||
|
EntraCertifiedComparePackResult::BLOCKED_IDENTITY,
|
||||||
|
EntraCertifiedComparePackResult::BLOCKED_COMPARE,
|
||||||
|
EntraCertifiedComparePackResult::BLOCKED_RENDER,
|
||||||
|
EntraCertifiedComparePackResult::BLOCKED_REDACTION,
|
||||||
|
EntraCertifiedComparePackResult::BLOCKED_CLAIM_GUARD,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const SENSITIVE_KEY_PARTS = [
|
||||||
|
'access_token',
|
||||||
|
'authorization',
|
||||||
|
'bearer',
|
||||||
|
'certificate',
|
||||||
|
'client_secret',
|
||||||
|
'cookie',
|
||||||
|
'credential',
|
||||||
|
'id_token',
|
||||||
|
'password',
|
||||||
|
'private_key',
|
||||||
|
'refresh_token',
|
||||||
|
'secret',
|
||||||
|
'set-cookie',
|
||||||
|
'token',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SupportedScopeResolver $supportedScopes,
|
||||||
|
private readonly EntraCoverageComparator $comparator,
|
||||||
|
private readonly EntraRenderableSummaryBuilder $summaryBuilder,
|
||||||
|
private readonly CoveragePayloadRedactor $redactor,
|
||||||
|
private readonly ClaimGuard $claimGuard,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function evaluate(
|
||||||
|
ManagedEnvironment $environment,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
): EntraCertifiedComparePackResult {
|
||||||
|
$this->assertSameScope($environment, $providerConnection);
|
||||||
|
|
||||||
|
$scope = $this->supportedScopes->findActive(self::SCOPE_KEY);
|
||||||
|
|
||||||
|
if (! $scope instanceof TenantConfigurationSupportedScope) {
|
||||||
|
return new EntraCertifiedComparePackResult(
|
||||||
|
scopeKey: self::SCOPE_KEY,
|
||||||
|
denominator: self::DENOMINATOR,
|
||||||
|
state: EntraCertifiedComparePackResult::NOT_EVALUATED,
|
||||||
|
blockers: ['supported_scope_missing'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeBlockers = $this->scopeBlockers($scope);
|
||||||
|
|
||||||
|
if ($scopeBlockers !== []) {
|
||||||
|
return new EntraCertifiedComparePackResult(
|
||||||
|
scopeKey: self::SCOPE_KEY,
|
||||||
|
denominator: self::DENOMINATOR,
|
||||||
|
state: EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE,
|
||||||
|
blockers: $scopeBlockers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resourceResults = [];
|
||||||
|
$blockers = [];
|
||||||
|
|
||||||
|
foreach (self::DENOMINATOR as $canonicalType) {
|
||||||
|
$typeResult = $this->evaluateCanonicalType($canonicalType, $environment, $providerConnection);
|
||||||
|
$resourceResults[] = $typeResult;
|
||||||
|
$blockers = [...$blockers, ...$typeResult['blockers']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$blockers = $this->uniqueStrings($blockers);
|
||||||
|
$claimState = null;
|
||||||
|
|
||||||
|
if ($blockers === []) {
|
||||||
|
$claimState = $this->claimGuard->evaluateCertifiedComparePackStatement(
|
||||||
|
claim: self::CLAIM_LABEL,
|
||||||
|
packPassed: true,
|
||||||
|
internalOperatorOnly: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($claimState !== ClaimState::InternalOnly) {
|
||||||
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_CLAIM_GUARD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EntraCertifiedComparePackResult(
|
||||||
|
scopeKey: self::SCOPE_KEY,
|
||||||
|
denominator: self::DENOMINATOR,
|
||||||
|
state: $this->overallState($blockers),
|
||||||
|
resourceResults: $resourceResults,
|
||||||
|
blockers: $this->uniqueStrings($blockers),
|
||||||
|
claimState: $claimState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function evaluateCanonicalType(
|
||||||
|
string $canonicalType,
|
||||||
|
ManagedEnvironment $environment,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
): array {
|
||||||
|
$resourceType = TenantConfigurationResourceType::query()
|
||||||
|
->active()
|
||||||
|
->where('canonical_type', $canonicalType)
|
||||||
|
->orderBy('source_class')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $resourceType instanceof TenantConfigurationResourceType) {
|
||||||
|
return $this->blockedTypeResult($canonicalType, ['resource_type_missing']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resources = TenantConfigurationResource::query()
|
||||||
|
->where('workspace_id', (int) $environment->workspace_id)
|
||||||
|
->where('managed_environment_id', (int) $environment->getKey())
|
||||||
|
->where('provider_connection_id', (int) $providerConnection->getKey())
|
||||||
|
->where('resource_type_id', (int) $resourceType->getKey())
|
||||||
|
->where('canonical_type', $canonicalType)
|
||||||
|
->with([
|
||||||
|
'latestEvidence.operationRun:id,workspace_id,managed_environment_id',
|
||||||
|
])
|
||||||
|
->orderBy('canonical_resource_key')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($resources->isEmpty()) {
|
||||||
|
return $this->blockedTypeResult($canonicalType, ['current_same_scope_resource_missing']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resourceResults = $resources
|
||||||
|
->map(fn (TenantConfigurationResource $resource): array => $this->evaluateResource($resource, $canonicalType))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$blockers = $this->uniqueStrings($resourceResults
|
||||||
|
->flatMap(fn (array $result): array => $result['blockers'])
|
||||||
|
->all());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'canonical_type' => $canonicalType,
|
||||||
|
'resource_count' => $resources->count(),
|
||||||
|
'criteria' => [
|
||||||
|
'evidence' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['evidence']),
|
||||||
|
'identity' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['identity']),
|
||||||
|
'compare' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['compare']),
|
||||||
|
'render' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['render']),
|
||||||
|
'redaction' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['redaction']),
|
||||||
|
],
|
||||||
|
'certified' => $blockers === [],
|
||||||
|
'blockers' => $blockers,
|
||||||
|
'resources' => $resourceResults->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function evaluateResource(TenantConfigurationResource $resource, string $canonicalType): array
|
||||||
|
{
|
||||||
|
$evidence = $resource->latestEvidence;
|
||||||
|
$blockers = [];
|
||||||
|
$criteria = [
|
||||||
|
'evidence' => false,
|
||||||
|
'identity' => false,
|
||||||
|
'compare' => false,
|
||||||
|
'render' => false,
|
||||||
|
'redaction' => false,
|
||||||
|
];
|
||||||
|
$reasons = [];
|
||||||
|
|
||||||
|
[$criteria['evidence'], $evidenceReasons] = $this->evidencePasses($resource, $evidence, $canonicalType);
|
||||||
|
$reasons = [...$reasons, ...$evidenceReasons];
|
||||||
|
|
||||||
|
if (! $criteria['evidence']) {
|
||||||
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$criteria['identity'], $identityReasons] = $this->identityPasses($resource);
|
||||||
|
$reasons = [...$reasons, ...$identityReasons];
|
||||||
|
|
||||||
|
if (! $criteria['identity']) {
|
||||||
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_IDENTITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderSummary = null;
|
||||||
|
|
||||||
|
if ($evidence instanceof TenantConfigurationResourceEvidence && is_array($evidence->normalized_payload)) {
|
||||||
|
[$criteria['compare'], $compareReasons] = $this->comparePasses($canonicalType, $evidence);
|
||||||
|
[$criteria['render'], $renderSummary, $renderReasons] = $this->renderPasses($resource, $canonicalType, $evidence);
|
||||||
|
[$criteria['redaction'], $redactionReasons] = $this->redactionPasses($evidence, $renderSummary);
|
||||||
|
$reasons = [...$reasons, ...$compareReasons, ...$renderReasons, ...$redactionReasons];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $criteria['compare']) {
|
||||||
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_COMPARE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $criteria['render']) {
|
||||||
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_RENDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $criteria['redaction']) {
|
||||||
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_REDACTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'resource_id' => (int) $resource->getKey(),
|
||||||
|
'canonical_resource_key' => (string) $resource->canonical_resource_key,
|
||||||
|
'criteria' => $criteria,
|
||||||
|
'certified' => $blockers === [],
|
||||||
|
'blockers' => $this->uniqueStrings($blockers),
|
||||||
|
'reasons' => $this->uniqueStrings($reasons),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: bool, 1: list<string>}
|
||||||
|
*/
|
||||||
|
private function evidencePasses(
|
||||||
|
TenantConfigurationResource $resource,
|
||||||
|
mixed $evidence,
|
||||||
|
string $canonicalType,
|
||||||
|
): array {
|
||||||
|
$reasons = [];
|
||||||
|
|
||||||
|
if (! $evidence instanceof TenantConfigurationResourceEvidence) {
|
||||||
|
return [false, ['latest_evidence_missing']];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $resource->latest_evidence_id !== (int) $evidence->getKey()) {
|
||||||
|
$reasons[] = 'latest_evidence_pointer_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'resource_id' => (int) $resource->getKey(),
|
||||||
|
'workspace_id' => (int) $resource->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $resource->managed_environment_id,
|
||||||
|
'provider_connection_id' => (int) $resource->provider_connection_id,
|
||||||
|
'resource_type_id' => (int) $resource->resource_type_id,
|
||||||
|
] as $column => $expected) {
|
||||||
|
if ((int) $evidence->{$column} !== $expected) {
|
||||||
|
$reasons[] = $column.'_scope_mismatch';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evidence->evidence_state !== EvidenceState::ContentBacked) {
|
||||||
|
$reasons[] = 'evidence_not_content_backed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evidence->capture_outcome !== CaptureOutcome::Captured) {
|
||||||
|
$reasons[] = 'evidence_not_captured';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($evidence->raw_payload) || $evidence->raw_payload === []) {
|
||||||
|
$reasons[] = 'raw_payload_missing';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($evidence->normalized_payload) || $evidence->normalized_payload === []) {
|
||||||
|
$reasons[] = 'normalized_payload_missing';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($evidence->payload_hash) || ! preg_match('/^[a-f0-9]{64}$/', $evidence->payload_hash)) {
|
||||||
|
$reasons[] = 'payload_hash_invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $resource->latest_payload_hash !== (string) $evidence->payload_hash) {
|
||||||
|
$reasons[] = 'latest_payload_hash_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $evidence->source_contract_key !== self::EXPECTED_CONTRACT_KEYS[$canonicalType]) {
|
||||||
|
$reasons[] = 'source_contract_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['source_endpoint', 'source_version'] as $field) {
|
||||||
|
if (! is_string($evidence->{$field}) || trim($evidence->{$field}) === '') {
|
||||||
|
$reasons[] = $field.'_missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $evidence->captured_at instanceof CarbonInterface) {
|
||||||
|
$reasons[] = 'captured_at_missing';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $evidence->operation_run_id <= 0 || ! $evidence->operationRun) {
|
||||||
|
$reasons[] = 'operation_run_link_missing';
|
||||||
|
} elseif ((int) $evidence->operationRun->workspace_id !== (int) $resource->workspace_id
|
||||||
|
|| (int) $evidence->operationRun->managed_environment_id !== (int) $resource->managed_environment_id
|
||||||
|
) {
|
||||||
|
$reasons[] = 'operation_run_scope_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasNewerEvidence($resource, $evidence)) {
|
||||||
|
$reasons[] = 'latest_evidence_not_current';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$reasons === [], $reasons];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: bool, 1: list<string>}
|
||||||
|
*/
|
||||||
|
private function identityPasses(TenantConfigurationResource $resource): array
|
||||||
|
{
|
||||||
|
return $resource->latest_identity_state === IdentityState::Stable
|
||||||
|
? [true, []]
|
||||||
|
: [false, ['identity_state_'.$this->stateValue($resource->latest_identity_state)]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: bool, 1: list<string>}
|
||||||
|
*/
|
||||||
|
private function comparePasses(string $canonicalType, TenantConfigurationResourceEvidence $evidence): array
|
||||||
|
{
|
||||||
|
$normalizedPayload = is_array($evidence->normalized_payload) ? $evidence->normalized_payload : [];
|
||||||
|
$result = $this->comparator->compare($canonicalType, $normalizedPayload, $normalizedPayload);
|
||||||
|
$changes = collect(is_array($result['changes'] ?? null) ? $result['changes'] : []);
|
||||||
|
$unsupportedFields = data_get($normalizedPayload, 'diagnostics.unsupported_fields', []);
|
||||||
|
$reasons = [];
|
||||||
|
|
||||||
|
if (($result['supported'] ?? false) !== true) {
|
||||||
|
$reasons[] = 'compare_not_supported';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($result['classification'] ?? null) !== 'unchanged' || ($result['changed'] ?? true) !== false) {
|
||||||
|
$reasons[] = 'compare_not_deterministic';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes->contains(fn (mixed $change): bool => is_array($change) && ($change['classification'] ?? null) === 'unsupported_field')
|
||||||
|
|| (is_array($unsupportedFields) && $unsupportedFields !== [])
|
||||||
|
) {
|
||||||
|
$reasons[] = 'unsupported_fields_present';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$reasons === [], $reasons];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: bool, 1: array<string, mixed>|null, 2: list<string>}
|
||||||
|
*/
|
||||||
|
private function renderPasses(
|
||||||
|
TenantConfigurationResource $resource,
|
||||||
|
string $canonicalType,
|
||||||
|
TenantConfigurationResourceEvidence $evidence,
|
||||||
|
): array {
|
||||||
|
$reasons = [];
|
||||||
|
$coverageLevel = $this->coverageLevel($evidence->coverage_level);
|
||||||
|
|
||||||
|
if (! $coverageLevel?->meets(CoverageLevel::Renderable)) {
|
||||||
|
$reasons[] = 'coverage_not_renderable';
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = $this->summaryBuilder->build($canonicalType, $evidence->normalized_payload, [
|
||||||
|
'claim_state' => $resource->latest_claim_state,
|
||||||
|
'identity_state' => $resource->latest_identity_state,
|
||||||
|
'evidence_state' => $resource->latest_evidence_state,
|
||||||
|
'coverage_level' => $evidence->coverage_level,
|
||||||
|
'last_captured' => $resource->latest_captured_at?->toDayDateTimeString(),
|
||||||
|
'source_version' => $evidence->source_version,
|
||||||
|
'source_schema_hash' => $evidence->source_schema_hash,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! is_array($summary) || $summary === []) {
|
||||||
|
$reasons[] = 'render_summary_missing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$reasons === [], $summary, $reasons];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $summary
|
||||||
|
* @return array{0: bool, 1: list<string>}
|
||||||
|
*/
|
||||||
|
private function redactionPasses(TenantConfigurationResourceEvidence $evidence, ?array $summary): array
|
||||||
|
{
|
||||||
|
$reasons = [];
|
||||||
|
$redactedRaw = $this->redactor->redact($evidence->raw_payload);
|
||||||
|
$sensitiveValues = $this->sensitiveValues($evidence->raw_payload, $redactedRaw);
|
||||||
|
$safeOutput = [
|
||||||
|
'normalized_payload' => $evidence->normalized_payload,
|
||||||
|
'render_summary' => $summary,
|
||||||
|
'claim_label' => self::CLAIM_LABEL,
|
||||||
|
];
|
||||||
|
$encoded = json_encode($safeOutput, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
|
$lowerEncoded = strtolower($encoded);
|
||||||
|
|
||||||
|
foreach (['raw_payload', 'raw graph response', 'raw_graph_response', 'permission_context'] as $forbiddenToken) {
|
||||||
|
if (str_contains($lowerEncoded, $forbiddenToken)) {
|
||||||
|
$reasons[] = 'forbidden_output_token_'.$forbiddenToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($sensitiveValues as $value) {
|
||||||
|
if ($value !== '' && str_contains($encoded, $value)) {
|
||||||
|
$reasons[] = 'sensitive_value_leaked';
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$reasons === [], $reasons];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function scopeBlockers(TenantConfigurationSupportedScope $scope): array
|
||||||
|
{
|
||||||
|
$metadata = is_array($scope->metadata) ? $scope->metadata : [];
|
||||||
|
$reasons = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resolved = $this->supportedScopes->resolveDefinition(
|
||||||
|
$scope,
|
||||||
|
TenantConfigurationResourceType::query()
|
||||||
|
->active()
|
||||||
|
->get(['canonical_type', 'source_class']),
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return ['supported_scope_unresolvable'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolved['included_resource_types'] !== self::DENOMINATOR) {
|
||||||
|
$reasons[] = 'supported_scope_denominator_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolved['minimum_coverage_level'] !== CoverageLevel::Certified) {
|
||||||
|
$reasons[] = 'supported_scope_minimum_not_certified';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolved['allow_beta'] !== false) {
|
||||||
|
$reasons[] = 'supported_scope_beta_allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolved['customer_claims_allowed'] !== false) {
|
||||||
|
$reasons[] = 'supported_scope_customer_claims_allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($metadata['graph_fallback_allowlist'] ?? null) !== ['securityDefaults']) {
|
||||||
|
$reasons[] = 'supported_scope_graph_fallback_allowlist_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($metadata['resource_type_denominator'] ?? null) !== self::DENOMINATOR) {
|
||||||
|
$reasons[] = 'supported_scope_metadata_denominator_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $reasons
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function blockedTypeResult(string $canonicalType, array $reasons): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'canonical_type' => $canonicalType,
|
||||||
|
'resource_count' => 0,
|
||||||
|
'criteria' => [
|
||||||
|
'evidence' => false,
|
||||||
|
'identity' => false,
|
||||||
|
'compare' => false,
|
||||||
|
'render' => false,
|
||||||
|
'redaction' => false,
|
||||||
|
],
|
||||||
|
'certified' => false,
|
||||||
|
'blockers' => [EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE],
|
||||||
|
'resources' => [],
|
||||||
|
'reasons' => $reasons,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertSameScope(ManagedEnvironment $environment, ProviderConnection $providerConnection): void
|
||||||
|
{
|
||||||
|
if ((int) $providerConnection->workspace_id !== (int) $environment->workspace_id
|
||||||
|
|| (int) $providerConnection->managed_environment_id !== (int) $environment->getKey()
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('Provider connection scope mismatch while evaluating Entra certified compare pack.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasNewerEvidence(
|
||||||
|
TenantConfigurationResource $resource,
|
||||||
|
TenantConfigurationResourceEvidence $evidence,
|
||||||
|
): bool {
|
||||||
|
if (! $evidence->captured_at instanceof CarbonInterface) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) $evidence->getKey())
|
||||||
|
->where('evidence_state', EvidenceState::ContentBacked->value)
|
||||||
|
->where('capture_outcome', CaptureOutcome::Captured->value)
|
||||||
|
->where(function ($query) use ($evidence): void {
|
||||||
|
$query->where('captured_at', '>', $evidence->captured_at)
|
||||||
|
->orWhere(function ($query) use ($evidence): void {
|
||||||
|
$query->where('captured_at', '=', $evidence->captured_at)
|
||||||
|
->where('id', '>', (int) $evidence->getKey());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function overallState(array $blockers): string
|
||||||
|
{
|
||||||
|
$blockers = $this->uniqueStrings($blockers);
|
||||||
|
|
||||||
|
if ($blockers === []) {
|
||||||
|
return EntraCertifiedComparePackResult::PASSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::BLOCKER_PRIORITY as $blocker) {
|
||||||
|
if (in_array($blocker, $blockers, true)) {
|
||||||
|
return $blocker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EntraCertifiedComparePackResult::NOT_EVALUATED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function coverageLevel(mixed $value): ?CoverageLevel
|
||||||
|
{
|
||||||
|
if ($value instanceof CoverageLevel) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($value) ? CoverageLevel::tryFrom($value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function uniqueStrings(array $values): array
|
||||||
|
{
|
||||||
|
return array_values(array_unique(array_filter(
|
||||||
|
array_map(static fn (mixed $value): string => is_scalar($value) ? trim((string) $value) : '', $values),
|
||||||
|
static fn (string $value): bool => $value !== '',
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stateValue(mixed $state): string
|
||||||
|
{
|
||||||
|
return $state instanceof \BackedEnum ? (string) $state->value : (string) $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function sensitiveValues(mixed $raw, mixed $redacted): array
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
if (! is_array($raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($raw as $key => $value) {
|
||||||
|
$key = (string) $key;
|
||||||
|
$redactedValue = is_array($redacted) ? ($redacted[$key] ?? null) : null;
|
||||||
|
|
||||||
|
if ($redactedValue === '[redacted]' && is_scalar($value)) {
|
||||||
|
$values[] = (string) $value;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSensitiveKey($key) && is_scalar($value)) {
|
||||||
|
$values[] = (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$values = [...$values, ...$this->sensitiveValues($value, $redactedValue)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->uniqueStrings($values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSensitiveKey(string $key): bool
|
||||||
|
{
|
||||||
|
$normalized = strtolower($key);
|
||||||
|
$compact = str_replace(['_', '-', ' '], '', $normalized);
|
||||||
|
|
||||||
|
foreach (self::SENSITIVE_KEY_PARTS as $part) {
|
||||||
|
$compactPart = str_replace(['_', '-', ' '], '', $part);
|
||||||
|
|
||||||
|
if (str_contains($normalized, $part) || str_contains($compact, $compactPart)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
|
||||||
|
final class EntraCertifiedComparePackResult
|
||||||
|
{
|
||||||
|
public const NOT_EVALUATED = 'certification_not_evaluated';
|
||||||
|
|
||||||
|
public const PASSED = 'certification_passed';
|
||||||
|
|
||||||
|
public const BLOCKED_MISSING_EVIDENCE = 'certification_blocked_missing_evidence';
|
||||||
|
|
||||||
|
public const BLOCKED_IDENTITY = 'certification_blocked_identity';
|
||||||
|
|
||||||
|
public const BLOCKED_COMPARE = 'certification_blocked_compare';
|
||||||
|
|
||||||
|
public const BLOCKED_RENDER = 'certification_blocked_render';
|
||||||
|
|
||||||
|
public const BLOCKED_REDACTION = 'certification_blocked_redaction';
|
||||||
|
|
||||||
|
public const BLOCKED_CLAIM_GUARD = 'certification_blocked_claim_guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $denominator
|
||||||
|
* @param list<array<string, mixed>> $resourceResults
|
||||||
|
* @param list<string> $blockers
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $scopeKey,
|
||||||
|
private readonly array $denominator,
|
||||||
|
private readonly string $state,
|
||||||
|
private readonly array $resourceResults = [],
|
||||||
|
private readonly array $blockers = [],
|
||||||
|
private readonly ?ClaimState $claimState = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function scopeKey(): string
|
||||||
|
{
|
||||||
|
return $this->scopeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function denominator(): array
|
||||||
|
{
|
||||||
|
return $this->denominator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function state(): string
|
||||||
|
{
|
||||||
|
return $this->state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function certified(): bool
|
||||||
|
{
|
||||||
|
return $this->state === self::PASSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function claimState(): ?ClaimState
|
||||||
|
{
|
||||||
|
return $this->claimState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function blockers(): array
|
||||||
|
{
|
||||||
|
return $this->blockers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function resourceResults(): array
|
||||||
|
{
|
||||||
|
return $this->resourceResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'scope_key' => $this->scopeKey,
|
||||||
|
'denominator' => $this->denominator,
|
||||||
|
'state' => $this->state,
|
||||||
|
'certified' => $this->certified(),
|
||||||
|
'claim_state' => $this->claimState?->value,
|
||||||
|
'blockers' => $this->blockers,
|
||||||
|
'resource_results' => $this->resourceResults,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -140,6 +140,7 @@ private function normalizeConditionalAccessPolicy(array $payload): array
|
|||||||
'include_locations' => $this->scalarList(data_get($redacted, 'conditions.locations.includeLocations')),
|
'include_locations' => $this->scalarList(data_get($redacted, 'conditions.locations.includeLocations')),
|
||||||
'exclude_locations' => $this->scalarList(data_get($redacted, 'conditions.locations.excludeLocations')),
|
'exclude_locations' => $this->scalarList(data_get($redacted, 'conditions.locations.excludeLocations')),
|
||||||
],
|
],
|
||||||
|
'devices' => $this->conditionalAccessDevices(data_get($redacted, 'conditions.devices', [])),
|
||||||
'user_risk_levels' => $this->scalarList(data_get($redacted, 'conditions.userRiskLevels')),
|
'user_risk_levels' => $this->scalarList(data_get($redacted, 'conditions.userRiskLevels')),
|
||||||
'sign_in_risk_levels' => $this->scalarList(data_get($redacted, 'conditions.signInRiskLevels')),
|
'sign_in_risk_levels' => $this->scalarList(data_get($redacted, 'conditions.signInRiskLevels')),
|
||||||
],
|
],
|
||||||
@ -189,6 +190,27 @@ private function normalizeSecurityDefaults(array $payload): array
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function conditionalAccessDevices(mixed $value): array
|
||||||
|
{
|
||||||
|
$devices = is_array($value) ? $value : [];
|
||||||
|
$deviceFilter = data_get($devices, 'deviceFilter', []);
|
||||||
|
$deviceFilter = is_array($deviceFilter) ? $deviceFilter : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'include_device_states' => $this->scalarList(data_get($devices, 'includeDeviceStates')),
|
||||||
|
'exclude_device_states' => $this->scalarList(data_get($devices, 'excludeDeviceStates')),
|
||||||
|
'include_devices' => $this->scalarList(data_get($devices, 'includeDevices')),
|
||||||
|
'exclude_devices' => $this->scalarList(data_get($devices, 'excludeDevices')),
|
||||||
|
'device_filter' => [
|
||||||
|
'mode' => $this->stringValue(data_get($deviceFilter, 'mode')),
|
||||||
|
'rule' => $this->stringValue(data_get($deviceFilter, 'rule')),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@ -323,12 +345,91 @@ private function unsupportedRootFields(array $payload, string $canonicalType): a
|
|||||||
array_map('strval', array_keys($payload)),
|
array_map('strval', array_keys($payload)),
|
||||||
static fn (string $key): bool => ! in_array($key, $allowedFields, true),
|
static fn (string $key): bool => ! in_array($key, $allowedFields, true),
|
||||||
));
|
));
|
||||||
|
$fields = [
|
||||||
|
...$fields,
|
||||||
|
...$this->unsupportedConditionalAccessConditionFields($payload, $canonicalType),
|
||||||
|
];
|
||||||
|
$fields = array_values(array_unique($fields));
|
||||||
|
|
||||||
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
|
||||||
return $fields;
|
return $fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function unsupportedConditionalAccessConditionFields(array $payload, string $canonicalType): array
|
||||||
|
{
|
||||||
|
if ($canonicalType !== 'conditionalAccessPolicy') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$conditions = $payload['conditions'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($conditions)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$fields = [
|
||||||
|
...$fields,
|
||||||
|
...$this->unsupportedNestedFields($conditions, 'conditions', [
|
||||||
|
'applications',
|
||||||
|
'clientAppTypes',
|
||||||
|
'devices',
|
||||||
|
'locations',
|
||||||
|
'platforms',
|
||||||
|
'signInRiskLevels',
|
||||||
|
'userRiskLevels',
|
||||||
|
'users',
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'users' => ['includeUsers', 'excludeUsers', 'includeGroups', 'excludeGroups', 'includeRoles', 'excludeRoles'],
|
||||||
|
'applications' => ['includeApplications', 'excludeApplications', 'includeUserActions', 'includeAuthenticationContextClassReferences'],
|
||||||
|
'platforms' => ['includePlatforms', 'excludePlatforms'],
|
||||||
|
'locations' => ['includeLocations', 'excludeLocations'],
|
||||||
|
'devices' => ['includeDeviceStates', 'excludeDeviceStates', 'includeDevices', 'excludeDevices', 'deviceFilter'],
|
||||||
|
] as $key => $allowed) {
|
||||||
|
$nested = $conditions[$key] ?? null;
|
||||||
|
|
||||||
|
if (is_array($nested)) {
|
||||||
|
$fields = [
|
||||||
|
...$fields,
|
||||||
|
...$this->unsupportedNestedFields($nested, 'conditions.'.$key, $allowed),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceFilter = data_get($conditions, 'devices.deviceFilter');
|
||||||
|
|
||||||
|
if (is_array($deviceFilter)) {
|
||||||
|
$fields = [
|
||||||
|
...$fields,
|
||||||
|
...$this->unsupportedNestedFields($deviceFilter, 'conditions.devices.deviceFilter', ['mode', 'rule']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $allowedFields
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function unsupportedNestedFields(array $payload, string $prefix, array $allowedFields): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (string $field): string => $prefix.'.'.$field,
|
||||||
|
array_filter(
|
||||||
|
array_map('strval', array_keys($payload)),
|
||||||
|
static fn (string $key): bool => ! in_array($key, $allowedFields, true),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $payload
|
* @param array<string, mixed> $payload
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
|
|||||||
@ -88,6 +88,7 @@ public function build(string $canonicalType, array $payload, array $context = []
|
|||||||
data_get($normalized, 'conditions.locations.exclude_locations', []),
|
data_get($normalized, 'conditions.locations.exclude_locations', []),
|
||||||
'Any location',
|
'Any location',
|
||||||
)],
|
)],
|
||||||
|
['label' => 'Devices', 'value' => $this->deviceConditionsSummary(data_get($normalized, 'conditions.devices', []))],
|
||||||
['label' => 'User risk', 'value' => $this->listSummary(data_get($normalized, 'conditions.user_risk_levels', []), 'Any user risk')],
|
['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')],
|
['label' => 'Sign-in risk', 'value' => $this->listSummary(data_get($normalized, 'conditions.sign_in_risk_levels', []), 'Any sign-in risk')],
|
||||||
],
|
],
|
||||||
@ -174,6 +175,42 @@ private function listSummary(array $values, string $empty): string
|
|||||||
return implode(', ', $values);
|
return implode(', ', $values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $devices
|
||||||
|
*/
|
||||||
|
private function deviceConditionsSummary(array $devices): ?string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
$stateSummary = $this->includeExcludeSummary(
|
||||||
|
is_array($devices['include_device_states'] ?? null) ? $devices['include_device_states'] : [],
|
||||||
|
is_array($devices['exclude_device_states'] ?? null) ? $devices['exclude_device_states'] : [],
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
$deviceSummary = $this->includeExcludeSummary(
|
||||||
|
is_array($devices['include_devices'] ?? null) ? $devices['include_devices'] : [],
|
||||||
|
is_array($devices['exclude_devices'] ?? null) ? $devices['exclude_devices'] : [],
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
$deviceFilter = is_array($devices['device_filter'] ?? null) ? $devices['device_filter'] : [];
|
||||||
|
$filterMode = $this->humanValue($deviceFilter['mode'] ?? null);
|
||||||
|
$filterRule = is_scalar($deviceFilter['rule'] ?? null) ? trim((string) $deviceFilter['rule']) : null;
|
||||||
|
$filterRule = $filterRule !== '' ? $filterRule : null;
|
||||||
|
|
||||||
|
if ($stateSummary !== '') {
|
||||||
|
$parts[] = 'States: '.$stateSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deviceSummary !== '') {
|
||||||
|
$parts[] = 'Devices: '.$deviceSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filterMode !== null || $filterRule !== null) {
|
||||||
|
$parts[] = trim('Filter: '.implode(' ', array_filter([$filterMode, $filterRule])));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? null : implode('; ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $grantControls
|
* @param array<string, mixed> $grantControls
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -56,6 +56,29 @@ public static function defaultDefinitions(): array
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'metadata' => ['kernel' => 'coverage_v2', 'claim_surface' => 'future_activation'],
|
'metadata' => ['kernel' => 'coverage_v2', 'claim_surface' => 'future_activation'],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'scope_key' => EntraCertifiedComparePackEvaluator::SCOPE_KEY,
|
||||||
|
'display_name' => 'Certified Entra Core Compare Pack',
|
||||||
|
'description' => 'Internal/operator certified compare/render pack for exactly Conditional Access and Security Defaults.',
|
||||||
|
'minimum_coverage_level' => CoverageLevel::Certified->value,
|
||||||
|
'included_resource_types' => EntraCertifiedComparePackEvaluator::DENOMINATOR,
|
||||||
|
'allow_beta' => false,
|
||||||
|
'allow_graph_fallback' => true,
|
||||||
|
'customer_claims_allowed' => false,
|
||||||
|
'is_active' => true,
|
||||||
|
'metadata' => [
|
||||||
|
'kernel' => 'coverage_v2',
|
||||||
|
'workload' => 'entra',
|
||||||
|
'claim_label' => EntraCertifiedComparePackEvaluator::CLAIM_LABEL,
|
||||||
|
'claim_surface' => 'internal_operator_only',
|
||||||
|
'customer_claims_allowed' => false,
|
||||||
|
'internal_operator_only' => true,
|
||||||
|
'graph_fallback_allowlist' => ['securityDefaults'],
|
||||||
|
'restore_allowed' => false,
|
||||||
|
'resource_type_denominator' => EntraCertifiedComparePackEvaluator::DENOMINATOR,
|
||||||
|
'visible_in_coverage_readiness' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
...self::m365PlanningScopes(),
|
...self::m365PlanningScopes(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -91,6 +114,26 @@ public function activeScopes(): Collection
|
|||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, TenantConfigurationSupportedScope>
|
||||||
|
*/
|
||||||
|
public function readinessVisibleScopes(): Collection
|
||||||
|
{
|
||||||
|
return $this->activeScopes()
|
||||||
|
->reject(function (TenantConfigurationSupportedScope $scope): bool {
|
||||||
|
$metadata = is_array($scope->metadata) ? $scope->metadata : [];
|
||||||
|
|
||||||
|
return ($metadata['visible_in_coverage_readiness'] ?? true) === false;
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findReadinessVisible(string $scopeKey): ?TenantConfigurationSupportedScope
|
||||||
|
{
|
||||||
|
return $this->readinessVisibleScopes()
|
||||||
|
->first(fn (TenantConfigurationSupportedScope $scope): bool => (string) $scope->scope_key === $scopeKey);
|
||||||
|
}
|
||||||
|
|
||||||
public function findActive(string $scopeKey): ?TenantConfigurationSupportedScope
|
public function findActive(string $scopeKey): ?TenantConfigurationSupportedScope
|
||||||
{
|
{
|
||||||
return TenantConfigurationSupportedScope::query()
|
return TenantConfigurationSupportedScope::query()
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\ClaimGuard;
|
||||||
|
use App\Services\TenantConfiguration\EntraCertifiedComparePackEvaluator;
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
|
||||||
|
it('Spec425 treats exact pack certification claims as internal operator only', function (): void {
|
||||||
|
expect(app(ClaimGuard::class)->evaluateCertifiedComparePackStatement(
|
||||||
|
EntraCertifiedComparePackEvaluator::CLAIM_LABEL,
|
||||||
|
packPassed: true,
|
||||||
|
internalOperatorOnly: true,
|
||||||
|
))->toBe(ClaimState::InternalOnly);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 blocks forbidden customer broad restore and Microsoft 365 claims in feature context', function (string $claim): void {
|
||||||
|
expect(app(ClaimGuard::class)->evaluateCertifiedComparePackStatement(
|
||||||
|
claim: $claim,
|
||||||
|
packPassed: true,
|
||||||
|
internalOperatorOnly: true,
|
||||||
|
))->toBe(ClaimState::ClaimBlocked);
|
||||||
|
})->with([
|
||||||
|
'100% Entra coverage',
|
||||||
|
'Entra restore-ready',
|
||||||
|
'Certified Microsoft 365 coverage',
|
||||||
|
'Customer-ready Entra proof',
|
||||||
|
]);
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Services\TenantConfiguration\EntraCertifiedComparePackEvaluator;
|
||||||
|
use App\Services\TenantConfiguration\EntraCertifiedComparePackResult;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use App\Support\TenantConfiguration\IdentityState;
|
||||||
|
use Tests\Support\TenantConfiguration\Spec425Fixtures as Spec425;
|
||||||
|
|
||||||
|
it('Spec425 certifies only when both mandatory denominator types pass every criterion', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'scopes_granted' => ['Policy.Read.All'],
|
||||||
|
]);
|
||||||
|
Spec425::createResourceWithEvidence($environment, $connection, 'conditionalAccessPolicy', Spec425::fixture('conditional-access', 'no-change'));
|
||||||
|
Spec425::createResourceWithEvidence($environment, $connection, 'securityDefaults', Spec425::fixture('security-defaults', 'no-change'));
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
$result = assertNoOutboundHttp(fn (): EntraCertifiedComparePackResult => app(EntraCertifiedComparePackEvaluator::class)
|
||||||
|
->evaluate($environment, $connection));
|
||||||
|
|
||||||
|
expect($result->state())->toBe(EntraCertifiedComparePackResult::PASSED)
|
||||||
|
->and($result->certified())->toBeTrue()
|
||||||
|
->and($result->denominator())->toBe(['conditionalAccessPolicy', 'securityDefaults'])
|
||||||
|
->and($result->blockers())->toBe([])
|
||||||
|
->and(json_encode($result->toArray(), JSON_THROW_ON_ERROR))
|
||||||
|
->not->toContain('raw_payload')
|
||||||
|
->not->toContain('permission_context')
|
||||||
|
->not->toContain('Policy.Read.All');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 blocks certification when either denominator item is missing and does not fallback to wrong-scope evidence', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
[, $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(),
|
||||||
|
]);
|
||||||
|
$foreignConnection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $foreignEnvironment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $foreignEnvironment->getKey(),
|
||||||
|
]);
|
||||||
|
Spec425::createResourceWithEvidence($environment, $connection, 'conditionalAccessPolicy', Spec425::fixture('conditional-access', 'no-change'));
|
||||||
|
Spec425::createResourceWithEvidence($foreignEnvironment, $foreignConnection, 'securityDefaults', Spec425::fixture('security-defaults', 'no-change'));
|
||||||
|
|
||||||
|
$result = app(EntraCertifiedComparePackEvaluator::class)->evaluate($environment, $connection);
|
||||||
|
|
||||||
|
expect($result->state())->toBe(EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE)
|
||||||
|
->and($result->certified())->toBeFalse()
|
||||||
|
->and($result->blockers())->toContain(EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 blocks stale or superseded latest evidence instead of falling back to first or latest implicitly', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
]);
|
||||||
|
$resource = Spec425::createResourceWithEvidence($environment, $connection, 'conditionalAccessPolicy', Spec425::fixture('conditional-access', 'no-change'));
|
||||||
|
Spec425::createResourceWithEvidence($environment, $connection, 'securityDefaults', Spec425::fixture('security-defaults', 'no-change'));
|
||||||
|
$latestEvidence = $resource->latestEvidence()->firstOrFail();
|
||||||
|
|
||||||
|
TenantConfigurationResourceEvidence::factory()->create([
|
||||||
|
'resource_id' => (int) $resource->getKey(),
|
||||||
|
'workspace_id' => (int) $resource->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $resource->managed_environment_id,
|
||||||
|
'provider_connection_id' => (int) $resource->provider_connection_id,
|
||||||
|
'resource_type_id' => (int) $resource->resource_type_id,
|
||||||
|
'operation_run_id' => (int) $latestEvidence->operation_run_id,
|
||||||
|
'source_contract_key' => 'conditionalAccessPolicy',
|
||||||
|
'source_endpoint' => '/identity/conditionalAccess/policies',
|
||||||
|
'source_version' => 'v1.0',
|
||||||
|
'raw_payload' => Spec425::fixture('conditional-access', 'state-change'),
|
||||||
|
'normalized_payload' => Spec425::normalizedPayload(Spec425::fixture('conditional-access', 'state-change')),
|
||||||
|
'payload_hash' => Spec425::payloadHash(Spec425::normalizedPayload(Spec425::fixture('conditional-access', 'state-change'))),
|
||||||
|
'evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
|
'coverage_level' => CoverageLevel::Renderable->value,
|
||||||
|
'capture_outcome' => CaptureOutcome::Captured->value,
|
||||||
|
'captured_at' => $latestEvidence->captured_at->copy()->addMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(EntraCertifiedComparePackEvaluator::class)->evaluate($environment, $connection);
|
||||||
|
$conditionalAccess = collect($result->resourceResults())->firstWhere('canonical_type', 'conditionalAccessPolicy');
|
||||||
|
$firstResource = collect($conditionalAccess['resources'])->first();
|
||||||
|
|
||||||
|
expect($result->state())->toBe(EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE)
|
||||||
|
->and($firstResource['reasons'])->toContain('latest_evidence_not_current');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 blocks identity states that are not stable', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
]);
|
||||||
|
Spec425::createResourceWithEvidence(
|
||||||
|
$environment,
|
||||||
|
$connection,
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
Spec425::fixture('conditional-access', 'no-change'),
|
||||||
|
['latest_identity_state' => IdentityState::Derived->value],
|
||||||
|
);
|
||||||
|
Spec425::createResourceWithEvidence($environment, $connection, 'securityDefaults', Spec425::fixture('security-defaults', 'no-change'));
|
||||||
|
|
||||||
|
$result = app(EntraCertifiedComparePackEvaluator::class)->evaluate($environment, $connection);
|
||||||
|
|
||||||
|
expect($result->state())->toBe(EntraCertifiedComparePackResult::BLOCKED_IDENTITY)
|
||||||
|
->and($result->blockers())->toContain(EntraCertifiedComparePackResult::BLOCKED_IDENTITY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 blocks unsupported fields because they make certification ambiguous', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
]);
|
||||||
|
Spec425::createResourceWithEvidence($environment, $connection, 'conditionalAccessPolicy', Spec425::fixture('conditional-access', 'unsupported-field'));
|
||||||
|
Spec425::createResourceWithEvidence($environment, $connection, 'securityDefaults', Spec425::fixture('security-defaults', 'no-change'));
|
||||||
|
|
||||||
|
$result = app(EntraCertifiedComparePackEvaluator::class)->evaluate($environment, $connection);
|
||||||
|
|
||||||
|
expect($result->state())->toBe(EntraCertifiedComparePackResult::BLOCKED_COMPARE)
|
||||||
|
->and($result->blockers())->toContain(EntraCertifiedComparePackResult::BLOCKED_COMPARE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 blocks non-renderable denominator evidence', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
]);
|
||||||
|
Spec425::createResourceWithEvidence(
|
||||||
|
$environment,
|
||||||
|
$connection,
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
Spec425::fixture('conditional-access', 'no-change'),
|
||||||
|
evidenceOverrides: ['coverage_level' => CoverageLevel::ContentBacked->value],
|
||||||
|
);
|
||||||
|
Spec425::createResourceWithEvidence($environment, $connection, 'securityDefaults', Spec425::fixture('security-defaults', 'no-change'));
|
||||||
|
|
||||||
|
$result = app(EntraCertifiedComparePackEvaluator::class)->evaluate($environment, $connection);
|
||||||
|
|
||||||
|
expect($result->state())->toBe(EntraCertifiedComparePackResult::BLOCKED_RENDER)
|
||||||
|
->and($result->blockers())->toContain(EntraCertifiedComparePackResult::BLOCKED_RENDER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 blocks certification output when redaction would leak a sensitive value', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
]);
|
||||||
|
$leakingPayload = array_replace(Spec425::fixture('security-defaults', 'no-change'), [
|
||||||
|
'clientSecret' => 'spec425-redaction-leak',
|
||||||
|
]);
|
||||||
|
$leakingNormalized = array_replace(Spec425::fixture('security-defaults', 'no-change'), [
|
||||||
|
'displayName' => 'spec425-redaction-leak',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Spec425::createResourceWithEvidence($environment, $connection, 'conditionalAccessPolicy', Spec425::fixture('conditional-access', 'no-change'));
|
||||||
|
Spec425::createResourceWithEvidence(
|
||||||
|
$environment,
|
||||||
|
$connection,
|
||||||
|
'securityDefaults',
|
||||||
|
$leakingPayload,
|
||||||
|
evidenceOverrides: [
|
||||||
|
'normalized_payload' => $leakingNormalized,
|
||||||
|
'payload_hash' => Spec425::payloadHash($leakingNormalized),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = app(EntraCertifiedComparePackEvaluator::class)->evaluate($environment, $connection);
|
||||||
|
|
||||||
|
expect($result->state())->toBe(EntraCertifiedComparePackResult::BLOCKED_REDACTION)
|
||||||
|
->and($result->blockers())->toContain(EntraCertifiedComparePackResult::BLOCKED_REDACTION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 rejects provider connections outside the managed environment scope', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
[, $foreignEnvironment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$foreignConnection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $foreignEnvironment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $foreignEnvironment->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => app(EntraCertifiedComparePackEvaluator::class)->evaluate($environment, $foreignConnection))
|
||||||
|
->toThrow(InvalidArgumentException::class, 'Provider connection scope mismatch');
|
||||||
|
});
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\TenantConfigurationSupportedScope;
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
|
||||||
|
use App\Services\TenantConfiguration\EntraCertifiedComparePackEvaluator;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
use Tests\Support\TenantConfiguration\Spec425Fixtures as Spec425;
|
||||||
|
|
||||||
|
it('Spec425 syncs the certified supported scope with exact metadata and Graph fallback allowlist', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
$scope = TenantConfigurationSupportedScope::query()
|
||||||
|
->where('scope_key', EntraCertifiedComparePackEvaluator::SCOPE_KEY)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
expect($scope->display_name)->toBe('Certified Entra Core Compare Pack')
|
||||||
|
->and($scope->minimum_coverage_level)->toBe(CoverageLevel::Certified)
|
||||||
|
->and($scope->included_resource_types)->toBe(['conditionalAccessPolicy', 'securityDefaults'])
|
||||||
|
->and($scope->allow_beta)->toBeFalse()
|
||||||
|
->and($scope->allow_graph_fallback)->toBeTrue()
|
||||||
|
->and($scope->customer_claims_allowed)->toBeFalse()
|
||||||
|
->and($scope->metadata['graph_fallback_allowlist'])->toBe(['securityDefaults'])
|
||||||
|
->and($scope->metadata['resource_type_denominator'])->toBe(['conditionalAccessPolicy', 'securityDefaults'])
|
||||||
|
->and($scope->metadata['customer_claims_allowed'])->toBeFalse()
|
||||||
|
->and($scope->metadata['restore_allowed'])->toBeFalse()
|
||||||
|
->and($scope->metadata['visible_in_coverage_readiness'])->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 does not certify non-denominator Entra resource types', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
$scope = TenantConfigurationSupportedScope::query()
|
||||||
|
->where('scope_key', EntraCertifiedComparePackEvaluator::SCOPE_KEY)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
expect($scope->included_resource_types)->not->toContain(
|
||||||
|
'application',
|
||||||
|
'servicePrincipal',
|
||||||
|
'roleDefinition',
|
||||||
|
'administrativeUnit',
|
||||||
|
'authenticationMethodsPolicy',
|
||||||
|
'identityProtectionPolicy',
|
||||||
|
'authorizationPolicy',
|
||||||
|
'crossTenantAccessPolicy',
|
||||||
|
'accessReview',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 keeps the internal certified scope out of existing Coverage v2 readiness options', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
$readModel = app(CoverageV2ReadinessReadModel::class);
|
||||||
|
|
||||||
|
expect($readModel->supportedScopeOptions())
|
||||||
|
->not->toHaveKey(EntraCertifiedComparePackEvaluator::SCOPE_KEY)
|
||||||
|
->and($readModel->defaultScopeKey())->toBe('intune_tcm_core')
|
||||||
|
->and($readModel->includedCanonicalTypesForScope(EntraCertifiedComparePackEvaluator::SCOPE_KEY))->toBe([]);
|
||||||
|
|
||||||
|
$resourceType = TenantConfigurationResourceType::query()
|
||||||
|
->where('canonical_type', 'conditionalAccessPolicy')
|
||||||
|
->firstOrFail();
|
||||||
|
$details = $readModel->resourceTypeInspectDetails($resourceType, EntraCertifiedComparePackEvaluator::SCOPE_KEY);
|
||||||
|
|
||||||
|
expect($details['scope'])->toBeNull()
|
||||||
|
->and($details['scope_key'])->toBeNull()
|
||||||
|
->and($details['supported_scope'])->toBe('No active scope');
|
||||||
|
});
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\EntraCertifiedComparePackEvaluator;
|
||||||
|
use Tests\Support\TenantConfiguration\Spec425Fixtures as Spec425;
|
||||||
|
|
||||||
|
it('Spec425 does not activate customer-facing claims or customer output paths', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
$runtimeFiles = [
|
||||||
|
app_path('Services/TenantConfiguration/EntraCertifiedComparePackEvaluator.php'),
|
||||||
|
app_path('Services/TenantConfiguration/EntraCertifiedComparePackResult.php'),
|
||||||
|
app_path('Services/TenantConfiguration/SupportedScopeResolver.php'),
|
||||||
|
app_path('Services/TenantConfiguration/ClaimGuard.php'),
|
||||||
|
];
|
||||||
|
$runtime = implode("\n", array_map(static fn (string $path): string => file_get_contents($path) ?: '', $runtimeFiles));
|
||||||
|
$scope = collect(\App\Services\TenantConfiguration\SupportedScopeResolver::defaultDefinitions())
|
||||||
|
->firstWhere('scope_key', EntraCertifiedComparePackEvaluator::SCOPE_KEY);
|
||||||
|
|
||||||
|
expect($scope['customer_claims_allowed'])->toBeFalse()
|
||||||
|
->and($scope['metadata']['claim_surface'])->toBe('internal_operator_only')
|
||||||
|
->and($runtime)->not->toContain('ReviewPack')
|
||||||
|
->and($runtime)->not->toContain('ManagementReport')
|
||||||
|
->and($runtime)->not->toContain('CustomerOutputGate')
|
||||||
|
->and($runtime)->not->toContain('customer-ready');
|
||||||
|
});
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('Spec425 does not add an Entra mini platform table model route dashboard or Filament surface', function (): void {
|
||||||
|
$newPlatformFiles = collect([
|
||||||
|
'apps/platform/database/migrations',
|
||||||
|
'apps/platform/app/Models',
|
||||||
|
'apps/platform/app/Filament',
|
||||||
|
'apps/platform/routes',
|
||||||
|
'apps/platform/resources/views',
|
||||||
|
])
|
||||||
|
->flatMap(fn (string $path): array => glob(repo_path($path).'/**/*425*') ?: [])
|
||||||
|
->map(fn (string $path): string => str_replace(repo_path().DIRECTORY_SEPARATOR, '', $path))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($newPlatformFiles)->toBe([]);
|
||||||
|
});
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\Support\TenantConfiguration\RestoreTier;
|
||||||
|
use Tests\Support\TenantConfiguration\Spec425Fixtures as Spec425;
|
||||||
|
|
||||||
|
it('Spec425 introduces no restore apply action restore-ready state or restorable tier', function (): void {
|
||||||
|
Spec425::syncDefaults();
|
||||||
|
|
||||||
|
$types = TenantConfigurationResourceType::query()
|
||||||
|
->whereIn('canonical_type', ['conditionalAccessPolicy', 'securityDefaults'])
|
||||||
|
->get()
|
||||||
|
->keyBy('canonical_type');
|
||||||
|
$runtime = collect([
|
||||||
|
app_path('Services/TenantConfiguration/EntraCertifiedComparePackEvaluator.php'),
|
||||||
|
app_path('Services/TenantConfiguration/EntraCertifiedComparePackResult.php'),
|
||||||
|
app_path('Services/TenantConfiguration/SupportedScopeResolver.php'),
|
||||||
|
app_path('Services/TenantConfiguration/ClaimGuard.php'),
|
||||||
|
app_path('Services/TenantConfiguration/ResourceTypeRegistry.php'),
|
||||||
|
])->map(fn (string $path): string => file_get_contents($path) ?: '')->implode("\n");
|
||||||
|
|
||||||
|
expect($types['conditionalAccessPolicy']->restore_tier)->toBe(RestoreTier::NotRestorable)
|
||||||
|
->and($types['securityDefaults']->restore_tier)->toBe(RestoreTier::NotRestorable)
|
||||||
|
->and($types['conditionalAccessPolicy']->allows_certified_claims)->toBeFalse()
|
||||||
|
->and($types['securityDefaults']->allows_certified_claims)->toBeFalse()
|
||||||
|
->and($runtime)->not->toContain('restore-ready')
|
||||||
|
->and($runtime)->not->toContain('apply-ready');
|
||||||
|
});
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('Spec425 runtime changes do not introduce tenant id as Coverage v2 ownership truth', function (): void {
|
||||||
|
foreach ([
|
||||||
|
app_path('Services/TenantConfiguration/EntraCertifiedComparePackEvaluator.php'),
|
||||||
|
app_path('Services/TenantConfiguration/EntraCertifiedComparePackResult.php'),
|
||||||
|
app_path('Services/TenantConfiguration/SupportedScopeResolver.php'),
|
||||||
|
app_path('Services/TenantConfiguration/ClaimGuard.php'),
|
||||||
|
] as $path) {
|
||||||
|
expect(file_get_contents($path))->not->toContain('tenant_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -60,7 +60,7 @@
|
|||||||
$resolver->syncDefaults();
|
$resolver->syncDefaults();
|
||||||
$resolver->syncDefaults();
|
$resolver->syncDefaults();
|
||||||
|
|
||||||
expect(TenantConfigurationSupportedScope::query()->count())->toBe(9)
|
expect(TenantConfigurationSupportedScope::query()->count())->toBe(count(SupportedScopeResolver::defaultDefinitions()))
|
||||||
->and(TenantConfigurationSupportedScope::query()
|
->and(TenantConfigurationSupportedScope::query()
|
||||||
->where('scope_key', 'intune_tcm_core')
|
->where('scope_key', 'intune_tcm_core')
|
||||||
->count())->toBe(1);
|
->count())->toBe(1);
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365", "app-a"],
|
||||||
|
"excludeApplications": ["legacy-app"],
|
||||||
|
"includeUserActions": ["urn:user:registersecurityinfo"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"]
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser", "mobileAppsAndDesktopClients"],
|
||||||
|
"platforms": {
|
||||||
|
"includePlatforms": ["windows", "macOS"]
|
||||||
|
},
|
||||||
|
"locations": {
|
||||||
|
"excludeLocations": ["trusted-location"]
|
||||||
|
},
|
||||||
|
"userRiskLevels": ["high"],
|
||||||
|
"signInRiskLevels": ["medium"]
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"],
|
||||||
|
"devices": {
|
||||||
|
"includeDeviceStates": ["compliant"],
|
||||||
|
"excludeDeviceStates": ["domainJoined"],
|
||||||
|
"deviceFilter": {
|
||||||
|
"mode": "include",
|
||||||
|
"rule": "device.trustType -eq \"AzureAD\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"],
|
||||||
|
"excludeUsers": ["break-glass-user"],
|
||||||
|
"excludeGroups": ["break-glass-group"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"]
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"]
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "AND",
|
||||||
|
"builtInControls": ["mfa", "compliantDevice"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All", "user-a"],
|
||||||
|
"includeGroups": ["group-a"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"]
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"],
|
||||||
|
"excludeUsers": []
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"],
|
||||||
|
"excludeApplications": []
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"]
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"]
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clientSecret": "spec425-ca-secret",
|
||||||
|
"authorization": "Bearer spec425-ca-token"
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"]
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
},
|
||||||
|
"persistentBrowser": {
|
||||||
|
"mode": "never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "disabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"]
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"],
|
||||||
|
"devices": {
|
||||||
|
"deviceFilter": {
|
||||||
|
"previewRuleId": "unsupported-nested-device-field"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unsupportedPreviewField": "manual-review-required"
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"id": "cap-spec425",
|
||||||
|
"displayName": "Require MFA",
|
||||||
|
"state": "enabled",
|
||||||
|
"createdDateTime": "2026-07-01T10:00:00Z",
|
||||||
|
"modifiedDateTime": "2026-07-01T11:00:00Z",
|
||||||
|
"conditions": {
|
||||||
|
"users": {
|
||||||
|
"includeUsers": ["All"]
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"includeApplications": ["Office365"]
|
||||||
|
},
|
||||||
|
"clientAppTypes": ["browser"]
|
||||||
|
},
|
||||||
|
"grantControls": {
|
||||||
|
"operator": "OR",
|
||||||
|
"builtInControls": ["mfa"]
|
||||||
|
},
|
||||||
|
"sessionControls": {
|
||||||
|
"signInFrequency": {
|
||||||
|
"value": 8,
|
||||||
|
"type": "hours",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "securityDefaults",
|
||||||
|
"displayName": "Security Defaults",
|
||||||
|
"description": "Tenant-wide Security Defaults policy.",
|
||||||
|
"isEnabled": false
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "securityDefaults",
|
||||||
|
"displayName": "Security Defaults",
|
||||||
|
"description": "Tenant-wide Security Defaults policy.",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "securityDefaults",
|
||||||
|
"displayName": "Security Defaults identity blocked marker",
|
||||||
|
"description": "Fixture marker only; tests create the identity-blocked state through resource identity state.",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "securityDefaults",
|
||||||
|
"displayName": "Security Defaults missing evidence marker",
|
||||||
|
"description": "Fixture marker only; tests create the missing-evidence state by omitting evidence rows.",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "securityDefaults",
|
||||||
|
"displayName": "Security Defaults",
|
||||||
|
"description": "Tenant-wide Security Defaults policy.",
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "securityDefaults",
|
||||||
|
"displayName": "Security Defaults",
|
||||||
|
"description": "Tenant-wide Security Defaults policy.",
|
||||||
|
"isEnabled": true,
|
||||||
|
"clientSecret": "spec425-security-defaults-secret",
|
||||||
|
"authorization": "Bearer spec425-security-defaults-token",
|
||||||
|
"cookie": "spec425-cookie",
|
||||||
|
"privateKey": "spec425-private-key",
|
||||||
|
"certificate": "spec425-certificate"
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "securityDefaults",
|
||||||
|
"displayName": "Security Defaults",
|
||||||
|
"description": "Tenant-wide Security Defaults policy.",
|
||||||
|
"isEnabled": true,
|
||||||
|
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#policies/identitySecurityDefaultsEnforcementPolicy/$entity",
|
||||||
|
"@odata.etag": "W/\"spec425\""
|
||||||
|
}
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Support\TenantConfiguration;
|
||||||
|
|
||||||
|
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\Services\TenantConfiguration\CoveragePayloadRedactor;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\Services\TenantConfiguration\SupportedScopeResolver;
|
||||||
|
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;
|
||||||
|
|
||||||
|
final class Spec425Fixtures
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function fixture(string $family, string $name): array
|
||||||
|
{
|
||||||
|
$path = base_path("tests/Fixtures/TenantConfiguration/Spec425/{$family}/{$name}.json");
|
||||||
|
$json = file_get_contents($path);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
throw new \RuntimeException("Missing Spec425 fixture [{$family}/{$name}].");
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
throw new \RuntimeException("Invalid Spec425 fixture [{$family}/{$name}].");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function syncDefaults(): void
|
||||||
|
{
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
app(SupportedScopeResolver::class)->syncDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resourceType(string $canonicalType): TenantConfigurationResourceType
|
||||||
|
{
|
||||||
|
return TenantConfigurationResourceType::query()
|
||||||
|
->active()
|
||||||
|
->where('canonical_type', $canonicalType)
|
||||||
|
->orderBy('source_class')
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function createRun(ManagedEnvironment $environment, ProviderConnection $connection): OperationRun
|
||||||
|
{
|
||||||
|
return OperationRun::factory()
|
||||||
|
->forTenant($environment)
|
||||||
|
->create([
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'target_scope' => [
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
'resource_types' => ['conditionalAccessPolicy', 'securityDefaults'],
|
||||||
|
'required_capability' => 'evidence.manage',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, mixed> $resourceOverrides
|
||||||
|
* @param array<string, mixed> $evidenceOverrides
|
||||||
|
*/
|
||||||
|
public static function createResourceWithEvidence(
|
||||||
|
ManagedEnvironment $environment,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
string $canonicalType,
|
||||||
|
array $payload,
|
||||||
|
array $resourceOverrides = [],
|
||||||
|
array $evidenceOverrides = [],
|
||||||
|
): TenantConfigurationResource {
|
||||||
|
$resourceType = self::resourceType($canonicalType);
|
||||||
|
$normalizedPayload = self::normalizedPayload($payload);
|
||||||
|
$payloadHash = self::payloadHash($normalizedPayload);
|
||||||
|
$run = self::createRun($environment, $connection);
|
||||||
|
$sourceResourceId = (string) ($payload['id'] ?? $canonicalType);
|
||||||
|
|
||||||
|
/** @var TenantConfigurationResource $resource */
|
||||||
|
$resource = TenantConfigurationResource::factory()->create(array_replace([
|
||||||
|
'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_class' => $resourceType->source_class,
|
||||||
|
'canonical_type' => $canonicalType,
|
||||||
|
'canonical_resource_key' => "{$canonicalType}:graph_object_id:{$sourceResourceId}",
|
||||||
|
'canonical_key_kind' => CanonicalKeyKind::GraphObjectId->value,
|
||||||
|
'source_resource_id' => $sourceResourceId,
|
||||||
|
'source_display_name' => (string) ($payload['displayName'] ?? $payload['name'] ?? $canonicalType),
|
||||||
|
'source_metadata' => [
|
||||||
|
'source_contract_key' => self::sourceContractKey($canonicalType),
|
||||||
|
'source_endpoint' => self::sourceEndpoint($canonicalType),
|
||||||
|
'source_version' => 'v1.0',
|
||||||
|
],
|
||||||
|
'identity_strategy' => $canonicalType === 'securityDefaults'
|
||||||
|
? 'graph.security_defaults.v1'
|
||||||
|
: 'graph.conditional_access_policy.v1',
|
||||||
|
'source_identity' => [
|
||||||
|
'primary_value' => $sourceResourceId,
|
||||||
|
'strategy_identifier' => $canonicalType === 'securityDefaults'
|
||||||
|
? 'graph.security_defaults.v1'
|
||||||
|
: 'graph.conditional_access_policy.v1',
|
||||||
|
],
|
||||||
|
'secondary_identity_keys' => (object) [],
|
||||||
|
'identity_diagnostics' => (object) [],
|
||||||
|
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
|
'latest_identity_state' => IdentityState::Stable->value,
|
||||||
|
'latest_claim_state' => ClaimState::InternalOnly->value,
|
||||||
|
'latest_payload_hash' => $payloadHash,
|
||||||
|
'latest_captured_at' => now(),
|
||||||
|
], $resourceOverrides));
|
||||||
|
|
||||||
|
/** @var TenantConfigurationResourceEvidence $evidence */
|
||||||
|
$evidence = TenantConfigurationResourceEvidence::factory()->create(array_replace([
|
||||||
|
'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' => self::sourceContractKey($canonicalType),
|
||||||
|
'source_endpoint' => self::sourceEndpoint($canonicalType),
|
||||||
|
'source_version' => 'v1.0',
|
||||||
|
'source_schema_hash' => hash('sha256', $canonicalType),
|
||||||
|
'source_metadata' => [
|
||||||
|
'source_contract_key' => self::sourceContractKey($canonicalType),
|
||||||
|
'source_endpoint' => self::sourceEndpoint($canonicalType),
|
||||||
|
'source_version' => 'v1.0',
|
||||||
|
],
|
||||||
|
'raw_payload' => $payload,
|
||||||
|
'normalized_payload' => $normalizedPayload,
|
||||||
|
'payload_hash' => $payloadHash,
|
||||||
|
'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(),
|
||||||
|
], $evidenceOverrides));
|
||||||
|
|
||||||
|
$resource->forceFill([
|
||||||
|
'latest_evidence_id' => (int) $evidence->getKey(),
|
||||||
|
'latest_payload_hash' => (string) $evidence->payload_hash,
|
||||||
|
'latest_captured_at' => $evidence->captured_at,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $resource->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function normalizedPayload(array $payload): array
|
||||||
|
{
|
||||||
|
$redacted = app(CoveragePayloadRedactor::class)->redact($payload);
|
||||||
|
|
||||||
|
return is_array($redacted) ? $redacted : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public static function payloadHash(array $payload): string
|
||||||
|
{
|
||||||
|
return hash('sha256', json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sourceContractKey(string $canonicalType): string
|
||||||
|
{
|
||||||
|
return $canonicalType === 'securityDefaults' ? 'securityDefaults' : 'conditionalAccessPolicy';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sourceEndpoint(string $canonicalType): string
|
||||||
|
{
|
||||||
|
return $canonicalType === 'securityDefaults'
|
||||||
|
? '/policies/identitySecurityDefaultsEnforcementPolicy'
|
||||||
|
: '/identity/conditionalAccess/policies';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\EntraCoverageComparator;
|
||||||
|
use Tests\Support\TenantConfiguration\Spec425Fixtures as Spec425;
|
||||||
|
|
||||||
|
it('Spec425 proves Conditional Access material certified compare dimensions', function (string $fixture, string $field, string $importance): void {
|
||||||
|
$result = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
Spec425::fixture('conditional-access', 'no-change'),
|
||||||
|
Spec425::fixture('conditional-access', $fixture),
|
||||||
|
);
|
||||||
|
$change = collect($result['changes'])->firstWhere('field', $field);
|
||||||
|
|
||||||
|
expect($result['changed'])->toBeTrue()
|
||||||
|
->and($result['classification'])->toBe('changed')
|
||||||
|
->and($change)->not->toBeNull()
|
||||||
|
->and($change['importance'])->toBe($importance);
|
||||||
|
})->with([
|
||||||
|
'state' => ['state-change', 'state', 'critical'],
|
||||||
|
'grant controls' => ['grant-controls-change', 'grant_controls.built_in_controls', 'important'],
|
||||||
|
'included actors' => ['included-actor-change', 'targets.users.include_groups', 'important'],
|
||||||
|
'excluded actors' => ['excluded-actor-change', 'targets.users.exclude_groups', 'important'],
|
||||||
|
'app targeting' => ['app-targeting-change', 'targets.applications.exclude_applications', 'important'],
|
||||||
|
'conditions' => ['condition-change', 'conditions.sign_in_risk_levels', 'important'],
|
||||||
|
'device conditions' => ['device-condition-change', 'conditions.devices.device_filter.rule', 'important'],
|
||||||
|
'session controls' => ['session-control-change', 'session_controls.persistentBrowser', 'important'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('Spec425 treats Conditional Access volatile-only differences as non-material', function (): void {
|
||||||
|
$result = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
Spec425::fixture('conditional-access', 'no-change'),
|
||||||
|
Spec425::fixture('conditional-access', 'volatile-only-change'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['changed'])->toBeFalse()
|
||||||
|
->and($result['classification'])->toBe('unchanged')
|
||||||
|
->and(collect($result['changes'])->pluck('classification'))->toContain('ignored_volatile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 keeps Conditional Access unsupported and redacted fields diagnostic and secret-free', function (): void {
|
||||||
|
$unsupported = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
Spec425::fixture('conditional-access', 'no-change'),
|
||||||
|
Spec425::fixture('conditional-access', 'unsupported-field'),
|
||||||
|
);
|
||||||
|
$redacted = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
Spec425::fixture('conditional-access', 'no-change'),
|
||||||
|
Spec425::fixture('conditional-access', 'redaction'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collect($unsupported['changes'])->pluck('classification'))->toContain('unsupported_field')
|
||||||
|
->and(collect($unsupported['changes'])->pluck('field'))->toContain('conditions.devices.deviceFilter.previewRuleId')
|
||||||
|
->and($unsupported['changed'])->toBeFalse()
|
||||||
|
->and(collect($redacted['changes'])->pluck('classification'))->toContain('redacted', 'unsupported_field')
|
||||||
|
->and(json_encode($redacted, JSON_THROW_ON_ERROR))
|
||||||
|
->not->toContain('spec425-ca-secret')
|
||||||
|
->not->toContain('spec425-ca-token');
|
||||||
|
});
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\EntraCertifiedComparePackEvaluator;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\Services\TenantConfiguration\SupportedScopeResolver;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
|
||||||
|
it('Spec425 defines the certified denominator as exactly Conditional Access and Security Defaults', function (): void {
|
||||||
|
$scope = collect(SupportedScopeResolver::defaultDefinitions())
|
||||||
|
->firstWhere('scope_key', EntraCertifiedComparePackEvaluator::SCOPE_KEY);
|
||||||
|
|
||||||
|
expect($scope)->toBeArray()
|
||||||
|
->and($scope['display_name'])->toBe('Certified Entra Core Compare Pack')
|
||||||
|
->and($scope['minimum_coverage_level'])->toBe(CoverageLevel::Certified->value)
|
||||||
|
->and($scope['included_resource_types'])->toBe([
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
'securityDefaults',
|
||||||
|
])
|
||||||
|
->and($scope['allow_beta'])->toBeFalse()
|
||||||
|
->and($scope['allow_graph_fallback'])->toBeTrue()
|
||||||
|
->and($scope['customer_claims_allowed'])->toBeFalse()
|
||||||
|
->and($scope['metadata']['graph_fallback_allowlist'])->toBe(['securityDefaults'])
|
||||||
|
->and($scope['metadata']['resource_type_denominator'])->toBe([
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
'securityDefaults',
|
||||||
|
])
|
||||||
|
->and($scope['metadata']['claim_label'])->toBe(EntraCertifiedComparePackEvaluator::CLAIM_LABEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 excludes optional Entra resource types from the certified denominator', function (): void {
|
||||||
|
$resolver = new SupportedScopeResolver;
|
||||||
|
$scope = collect(SupportedScopeResolver::defaultDefinitions())
|
||||||
|
->firstWhere('scope_key', EntraCertifiedComparePackEvaluator::SCOPE_KEY);
|
||||||
|
$resolved = $resolver->resolveDefinition($scope, ResourceTypeRegistry::defaultDefinitions());
|
||||||
|
|
||||||
|
expect($resolved['included_resource_types'])->toBe(EntraCertifiedComparePackEvaluator::DENOMINATOR)
|
||||||
|
->and($resolved['included_resource_types'])->not->toContain(
|
||||||
|
'application',
|
||||||
|
'servicePrincipal',
|
||||||
|
'roleDefinition',
|
||||||
|
'administrativeUnit',
|
||||||
|
'authenticationMethodsPolicy',
|
||||||
|
'identityProtectionPolicy',
|
||||||
|
'authorizationPolicy',
|
||||||
|
'crossTenantAccessPolicy',
|
||||||
|
'accessReview',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 cannot ignore a missing denominator item', function (): void {
|
||||||
|
$resolver = new SupportedScopeResolver;
|
||||||
|
$scope = [
|
||||||
|
'scope_key' => EntraCertifiedComparePackEvaluator::SCOPE_KEY,
|
||||||
|
'minimum_coverage_level' => CoverageLevel::Certified->value,
|
||||||
|
'included_resource_types' => ['conditionalAccessPolicy', 'securityDefaults'],
|
||||||
|
'allow_beta' => false,
|
||||||
|
'allow_graph_fallback' => true,
|
||||||
|
'customer_claims_allowed' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($resolver->isScopeComplete(['conditionalAccessPolicy'], $scope, ResourceTypeRegistry::defaultDefinitions()))
|
||||||
|
->toBeFalse()
|
||||||
|
->and($resolver->isScopeComplete(['conditionalAccessPolicy', 'securityDefaults'], $scope, ResourceTypeRegistry::defaultDefinitions()))
|
||||||
|
->toBeTrue();
|
||||||
|
});
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\ClaimGuard;
|
||||||
|
use App\Services\TenantConfiguration\EntraCertifiedComparePackEvaluator;
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
|
||||||
|
it('Spec425 allows exact denominator-visible certified pack wording only after the evaluator passes', function (): void {
|
||||||
|
$guard = app(ClaimGuard::class);
|
||||||
|
|
||||||
|
expect($guard->evaluateCertifiedComparePackStatement(
|
||||||
|
claim: EntraCertifiedComparePackEvaluator::CLAIM_LABEL,
|
||||||
|
packPassed: true,
|
||||||
|
internalOperatorOnly: true,
|
||||||
|
))->toBe(ClaimState::InternalOnly)
|
||||||
|
->and($guard->evaluateCertifiedComparePackStatement(
|
||||||
|
claim: EntraCertifiedComparePackEvaluator::CLAIM_LABEL,
|
||||||
|
packPassed: false,
|
||||||
|
internalOperatorOnly: true,
|
||||||
|
))->toBe(ClaimState::ClaimBlocked)
|
||||||
|
->and($guard->evaluateCertifiedComparePackStatement(
|
||||||
|
claim: EntraCertifiedComparePackEvaluator::CLAIM_LABEL,
|
||||||
|
packPassed: true,
|
||||||
|
internalOperatorOnly: false,
|
||||||
|
))->toBe(ClaimState::ClaimBlocked);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 blocks certified pack wording when the denominator is omitted', function (): void {
|
||||||
|
expect(app(ClaimGuard::class)->evaluateCertifiedComparePackStatement(
|
||||||
|
claim: 'Certified Entra Core Compare Pack',
|
||||||
|
packPassed: true,
|
||||||
|
internalOperatorOnly: true,
|
||||||
|
))->toBe(ClaimState::ClaimBlocked);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 blocks broad restore customer Microsoft 365 and legal certification overclaims', function (string $claim): void {
|
||||||
|
expect(app(ClaimGuard::class)->evaluateCertifiedComparePackStatement(
|
||||||
|
claim: $claim,
|
||||||
|
packPassed: true,
|
||||||
|
internalOperatorOnly: true,
|
||||||
|
))->toBe(ClaimState::ClaimBlocked);
|
||||||
|
})->with([
|
||||||
|
'Certified Entra coverage',
|
||||||
|
'100% Entra coverage',
|
||||||
|
'Full Entra coverage',
|
||||||
|
'Entra restore-ready',
|
||||||
|
'Certified Microsoft 365 coverage',
|
||||||
|
'Customer-ready Entra proof',
|
||||||
|
'Full tenant security proof',
|
||||||
|
'Legal attestation for Entra coverage',
|
||||||
|
'Review Pack proof for Entra',
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('Spec425 keeps existing generic certification claims conservative by default', function (): void {
|
||||||
|
expect(app(ClaimGuard::class)->evaluateStatement(
|
||||||
|
EntraCertifiedComparePackEvaluator::CLAIM_LABEL,
|
||||||
|
internalOperatorOnly: true,
|
||||||
|
))->toBe(ClaimState::ClaimBlocked);
|
||||||
|
});
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\EntraCertifiedComparePackEvaluator;
|
||||||
|
use App\Services\TenantConfiguration\EntraCertifiedComparePackResult;
|
||||||
|
|
||||||
|
it('Spec425 keeps certification states local and derived on the result object', function (): void {
|
||||||
|
$result = new EntraCertifiedComparePackResult(
|
||||||
|
scopeKey: EntraCertifiedComparePackEvaluator::SCOPE_KEY,
|
||||||
|
denominator: EntraCertifiedComparePackEvaluator::DENOMINATOR,
|
||||||
|
state: EntraCertifiedComparePackResult::BLOCKED_COMPARE,
|
||||||
|
blockers: [EntraCertifiedComparePackResult::BLOCKED_COMPARE],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result->scopeKey())->toBe('entra_core_compare_certified')
|
||||||
|
->and($result->denominator())->toBe(['conditionalAccessPolicy', 'securityDefaults'])
|
||||||
|
->and($result->state())->toBe('certification_blocked_compare')
|
||||||
|
->and($result->certified())->toBeFalse()
|
||||||
|
->and($result->toArray())->toMatchArray([
|
||||||
|
'scope_key' => 'entra_core_compare_certified',
|
||||||
|
'state' => 'certification_blocked_compare',
|
||||||
|
'certified' => false,
|
||||||
|
'blockers' => ['certification_blocked_compare'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\EntraRenderableSummaryBuilder;
|
||||||
|
use Tests\Support\TenantConfiguration\Spec425Fixtures as Spec425;
|
||||||
|
|
||||||
|
it('Spec425 renders Conditional Access certification summaries without raw or secret output', function (): void {
|
||||||
|
$summary = app(EntraRenderableSummaryBuilder::class)->build(
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
Spec425::fixture('conditional-access', 'redaction'),
|
||||||
|
[
|
||||||
|
'claim_state' => 'internal_only',
|
||||||
|
'identity_state' => 'stable',
|
||||||
|
'last_captured' => 'Jul 1, 2026 10:00 AM',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
expect($summary)->toBeArray()
|
||||||
|
->and($summary['resource_type'])->toBe('Conditional Access policy')
|
||||||
|
->and($encoded)->not->toContain('raw_payload')
|
||||||
|
->not->toContain('raw Graph response')
|
||||||
|
->not->toContain('permission_context')
|
||||||
|
->not->toContain('spec425-ca-secret')
|
||||||
|
->not->toContain('spec425-ca-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 renders Security Defaults certification summaries without raw or secret output', function (): void {
|
||||||
|
$summary = app(EntraRenderableSummaryBuilder::class)->build(
|
||||||
|
'securityDefaults',
|
||||||
|
Spec425::fixture('security-defaults', 'redaction'),
|
||||||
|
[
|
||||||
|
'claim_state' => 'internal_only',
|
||||||
|
'identity_state' => 'stable',
|
||||||
|
'evidence_state' => 'content_backed',
|
||||||
|
'last_captured' => 'Jul 1, 2026 10:00 AM',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
expect($summary)->toBeArray()
|
||||||
|
->and($summary['resource_type'])->toBe('Security Defaults')
|
||||||
|
->and($encoded)->not->toContain('raw_payload')
|
||||||
|
->not->toContain('raw Graph response')
|
||||||
|
->not->toContain('permission_context')
|
||||||
|
->not->toContain('spec425-security-defaults-secret')
|
||||||
|
->not->toContain('spec425-security-defaults-token')
|
||||||
|
->not->toContain('spec425-cookie')
|
||||||
|
->not->toContain('spec425-private-key')
|
||||||
|
->not->toContain('spec425-certificate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 renders Conditional Access device conditions for certified summaries', function (): void {
|
||||||
|
$summary = app(EntraRenderableSummaryBuilder::class)->build(
|
||||||
|
'conditionalAccessPolicy',
|
||||||
|
Spec425::fixture('conditional-access', 'device-condition-change'),
|
||||||
|
[
|
||||||
|
'claim_state' => 'internal_only',
|
||||||
|
'identity_state' => 'stable',
|
||||||
|
'last_captured' => 'Jul 1, 2026 10:00 AM',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$devices = collect($summary['conditions'] ?? [])->firstWhere('label', 'Devices');
|
||||||
|
|
||||||
|
expect($devices)->toBeArray()
|
||||||
|
->and($devices['value'])->toContain('States: Include compliant; Exclude domainJoined')
|
||||||
|
->and($devices['value'])->toContain('Filter: Include device.trustType -eq "AzureAD"');
|
||||||
|
});
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\EntraCoverageComparator;
|
||||||
|
use Tests\Support\TenantConfiguration\Spec425Fixtures as Spec425;
|
||||||
|
|
||||||
|
it('Spec425 proves Security Defaults enabled state changes are critical material changes', function (): void {
|
||||||
|
$result = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'securityDefaults',
|
||||||
|
Spec425::fixture('security-defaults', 'enabled-false'),
|
||||||
|
Spec425::fixture('security-defaults', 'enabled-true'),
|
||||||
|
);
|
||||||
|
$fields = collect($result['changes'])->keyBy('field');
|
||||||
|
|
||||||
|
expect($result['changed'])->toBeTrue()
|
||||||
|
->and($result['classification'])->toBe('changed')
|
||||||
|
->and($fields['enabled']['importance'])->toBe('critical')
|
||||||
|
->and($fields['enabled_state']['importance'])->toBe('critical');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 treats Security Defaults no-change and volatile-only changes as non-material', function (): void {
|
||||||
|
$unchanged = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'securityDefaults',
|
||||||
|
Spec425::fixture('security-defaults', 'no-change'),
|
||||||
|
Spec425::fixture('security-defaults', 'no-change'),
|
||||||
|
);
|
||||||
|
$volatile = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'securityDefaults',
|
||||||
|
Spec425::fixture('security-defaults', 'no-change'),
|
||||||
|
Spec425::fixture('security-defaults', 'volatile-only-change'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($unchanged['changed'])->toBeFalse()
|
||||||
|
->and($unchanged['classification'])->toBe('unchanged')
|
||||||
|
->and($volatile['changed'])->toBeFalse()
|
||||||
|
->and(collect($volatile['changes'])->pluck('classification'))->toContain('ignored_volatile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec425 keeps Security Defaults redaction diagnostic and secret-free', function (): void {
|
||||||
|
$result = app(EntraCoverageComparator::class)->compare(
|
||||||
|
'securityDefaults',
|
||||||
|
Spec425::fixture('security-defaults', 'no-change'),
|
||||||
|
Spec425::fixture('security-defaults', 'redaction'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['changed'])->toBeFalse()
|
||||||
|
->and(collect($result['changes'])->pluck('classification'))->toContain('redacted', 'unsupported_field')
|
||||||
|
->and(json_encode($result, JSON_THROW_ON_ERROR))
|
||||||
|
->not->toContain('spec425-security-defaults-secret')
|
||||||
|
->not->toContain('spec425-security-defaults-token')
|
||||||
|
->not->toContain('spec425-cookie')
|
||||||
|
->not->toContain('spec425-private-key')
|
||||||
|
->not->toContain('spec425-certificate');
|
||||||
|
});
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
# Requirements Checklist: Spec 425 - Entra Certified Compare Pack
|
||||||
|
|
||||||
|
**Purpose**: Validate preparation readiness for the user-provided Spec 425 candidate before implementation.
|
||||||
|
**Created**: 2026-07-01
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Candidate And Scope
|
||||||
|
|
||||||
|
- [x] Candidate is directly user-provided and does not depend on the empty auto-prep queue.
|
||||||
|
- [x] Completed historical specs are treated as read-only dependency evidence, not artifacts to rewrite.
|
||||||
|
- [x] Scope is limited to `entra_core_compare_certified`.
|
||||||
|
- [x] Certified denominator is exactly `conditionalAccessPolicy` plus `securityDefaults`.
|
||||||
|
- [x] Optional Entra candidates are explicitly excluded.
|
||||||
|
- [x] Full Entra certification is excluded.
|
||||||
|
- [x] Microsoft 365 certification is excluded.
|
||||||
|
- [x] Restore/apply certification is excluded.
|
||||||
|
- [x] Customer-facing proof or report activation is excluded.
|
||||||
|
|
||||||
|
## Repo Truth Alignment
|
||||||
|
|
||||||
|
- [x] Spec 421 is recorded as the source of Conditional Access comparable/renderable support.
|
||||||
|
- [x] Spec 424 is recorded as the source of Security Defaults content-backed comparable/renderable support.
|
||||||
|
- [x] Current source preflight checked source contracts for both mandatory denominator types.
|
||||||
|
- [x] Current source preflight checked identity strategy for both mandatory denominator types.
|
||||||
|
- [x] Current source preflight checked compare/render/redaction helpers for both mandatory denominator types.
|
||||||
|
- [x] Current source preflight found no existing `425` spec directory before creation.
|
||||||
|
- [x] Current source preflight found no existing local `425` branch before creation.
|
||||||
|
- [x] `entra_core_compare_certified` is not assumed to already exist; implementation tasks require adding or confirming it.
|
||||||
|
|
||||||
|
## Constitution And Product Surface
|
||||||
|
|
||||||
|
- [x] Spec states no `tenant_id` as Coverage v2 ownership truth.
|
||||||
|
- [x] Spec preserves workspace, managed-environment, and provider-connection scope.
|
||||||
|
- [x] Spec requires DB-only certification evaluation with no Graph/TCM/provider remote calls.
|
||||||
|
- [x] Proportionality review rejects a new persisted certification table.
|
||||||
|
- [x] Proportionality review allows only a narrow derived evaluator/result if existing supported-scope evaluation is insufficient.
|
||||||
|
- [x] Product Surface impact is conditional and bounded to the existing Coverage v2 operator surface if needed.
|
||||||
|
- [x] Browser proof is required if rendered UI changes.
|
||||||
|
- [x] Browser proof is explicitly `N/A - no rendered UI surface changed` if no UI files change.
|
||||||
|
- [x] No new primary navigation, dashboard, route, customer output, report, export, Review Pack, or PDF is allowed.
|
||||||
|
- [x] Completed historical spec artifacts remain read-only.
|
||||||
|
|
||||||
|
## Requirement Coverage
|
||||||
|
|
||||||
|
- [x] Supported scope metadata requirements are defined.
|
||||||
|
- [x] Exact denominator integrity requirements are defined.
|
||||||
|
- [x] Evidence criteria are defined.
|
||||||
|
- [x] Evidence currentness and no fallback-to-first/latest behavior are defined.
|
||||||
|
- [x] Stable identity criteria are defined, and derived identity is blocked for certification.
|
||||||
|
- [x] Compare criteria are defined.
|
||||||
|
- [x] Render criteria are defined.
|
||||||
|
- [x] Redaction criteria are defined.
|
||||||
|
- [x] Claim Guard criteria are defined.
|
||||||
|
- [x] Explicit certification pass, not-evaluated, and blocker states are defined as derived outcomes.
|
||||||
|
- [x] Conditional Access certified compare fixture coverage is defined.
|
||||||
|
- [x] Security Defaults certified compare fixture coverage is defined.
|
||||||
|
- [x] Broad/full/restore/M365/customer claims are blocked.
|
||||||
|
- [x] No-restore and no-customer activation requirements are explicit.
|
||||||
|
- [x] No Entra mini-platform and no Entra-specific table family requirements are explicit.
|
||||||
|
- [x] RBAC/isolation expectations are explicit.
|
||||||
|
- [x] RBAC/isolation proof is tied to concrete service/command/route/UI invocation boundaries.
|
||||||
|
|
||||||
|
## Task Readiness
|
||||||
|
|
||||||
|
- [x] Preflight tasks block runtime implementation if mandatory evidence, identity, compare, render, redaction, or claim posture fails.
|
||||||
|
- [x] Tests and fixtures are planned before or alongside implementation.
|
||||||
|
- [x] Unit tests cover evaluator, denominator, compare, redaction, and Claim Guard behavior.
|
||||||
|
- [x] Feature tests cover supported scope, denominator, certification, no restore, no customer claim, no `tenant_id`, and no mini-platform.
|
||||||
|
- [x] Browser test task is conditional on rendered UI changes.
|
||||||
|
- [x] Validation commands include Pint, focused unit tests, focused feature tests, conditional browser test, and `git diff --check`.
|
||||||
|
- [x] Implementation report requirements include candidate gate, dirty state, files, matrices, redaction, no-restore, no-customer, no-tenant-id, no-mini-platform, Product Surface, tests, and deferred work.
|
||||||
|
|
||||||
|
## Review Outcome
|
||||||
|
|
||||||
|
- [x] Candidate Selection Gate: PASS.
|
||||||
|
- [x] Spec Readiness Gate: PASS for preparation artifacts.
|
||||||
|
- [x] Open questions: none that block implementation planning.
|
||||||
|
- [x] Hard implementation preflight remains required at T001-T006 before runtime code changes.
|
||||||
|
- [x] Preparation scope stops before application implementation.
|
||||||
112
specs/425-entra-certified-compare-pack/implementation-report.md
Normal file
112
specs/425-entra-certified-compare-pack/implementation-report.md
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# Implementation Report: Spec 425 - Entra Certified Compare Pack
|
||||||
|
|
||||||
|
## Preflight
|
||||||
|
|
||||||
|
- **Branch**: `425-entra-certified-compare-pack`
|
||||||
|
- **HEAD before implementation**: `2cd51291 feat: complete spec 424 security defaults content-backed comparable support (#491)`
|
||||||
|
- **Dirty state before implementation**: untracked active spec artifacts under `specs/425-entra-certified-compare-pack/`
|
||||||
|
- **Dirty state after implementation**: modified `ClaimGuard.php`, `CoverageV2ReadinessReadModel.php`, `EntraComparablePayloadNormalizer.php`, `EntraRenderableSummaryBuilder.php`, `SupportedScopeResolver.php`, `TenantConfigurationSupportedScopeTest.php`; untracked Spec 425 evaluator/result classes, Spec 425 tests/fixtures/support helper, and active spec artifacts under `specs/425-entra-certified-compare-pack/`.
|
||||||
|
- **Activated skills/gates**: `spec-kit-implementation-loop`, `pest-testing`, `workflows/spec-readiness-gate`, `repo-contracts/workspace-scope-safety`, `repo-contracts/rbac-action-safety`, `repo-contracts/evidence-anchor-contract`, `repo-contracts/provider-freshness-semantics`, `repo-contracts/customer-output-gate`, `repo-contracts/product-surface-gate`, `temporary-migrations/tcm-cutover-guard`
|
||||||
|
- **Candidate gate result**: PASS. Scope remains the exact internal/operator `entra_core_compare_certified` pack.
|
||||||
|
- **Completed-spec rewrite assertion**: Specs 414, 415, 417, 418, 419, 420, 421, and 424 were used as read-only dependency evidence only.
|
||||||
|
|
||||||
|
## Hard Preflight Result
|
||||||
|
|
||||||
|
| Check | Result | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| Conditional Access source contract | PASS | `CoverageSourceContractResolver`, `config/graph_contracts.php` |
|
||||||
|
| Conditional Access stable identity | PASS | `CoverageIdentityStrategyRegistry` uses `graph.conditional_access_policy.v1`, no derived identity |
|
||||||
|
| Conditional Access compare/render/redaction | PASS | `EntraComparablePayloadNormalizer`, `EntraCoverageComparator`, `EntraRenderableSummaryBuilder`, Spec421 tests |
|
||||||
|
| Security Defaults source contract | PASS | `CoverageSourceContractResolver`, `config/graph_contracts.php` |
|
||||||
|
| Security Defaults stable identity | PASS | `CoverageIdentityStrategyRegistry` uses `graph.security_defaults.v1`, no derived identity |
|
||||||
|
| Security Defaults compare/render/redaction | PASS | `EntraComparablePayloadNormalizer`, `EntraCoverageComparator`, `EntraRenderableSummaryBuilder`, Spec424 tests |
|
||||||
|
| Ownership fields | PASS | Coverage v2 schema uses `workspace_id`, `managed_environment_id`, `provider_connection_id`; no `tenant_id` ownership path |
|
||||||
|
|
||||||
|
## Product Surface Decision
|
||||||
|
|
||||||
|
- **Runtime UI files changed**: no Filament, Blade, route, navigation, action, dashboard, report, export, or PDF files changed. `CoverageV2ReadinessReadModel` now filters the internal certified scope out of existing Coverage v2 readiness options/defaults.
|
||||||
|
- **Browser proof**: N/A - no new rendered route/page/action/widget/view surface was introduced; focused service/feature tests cover the existing option source, direct hidden-scope key rejection, and operator-safe render-summary behavior without browser-heavy coverage.
|
||||||
|
- **Human Product Sanity**: N/A - no new product surface to inspect; visible complexity remains bounded because the internal scope is hidden from existing UI option sources, rejected when passed directly to readiness UI helpers, and Device-condition render output appears only when the underlying Conditional Access payload contains device conditions.
|
||||||
|
- **Visible complexity outcome**: neutral; derived proof stays internal/service-first, the existing Coverage v2 filter/default cannot select the certified-pack scope, and Conditional Access device data does not add an always-visible row when absent.
|
||||||
|
- **Product Surface exceptions**: none
|
||||||
|
- **Livewire v4 compliance**: unchanged; platform remains Filament v5 on Livewire v4.
|
||||||
|
- **Panel provider registration location**: unchanged; Laravel provider registration remains `apps/platform/bootstrap/providers.php`.
|
||||||
|
- **Global search posture**: unchanged; no Resource/global search behavior changed.
|
||||||
|
- **Destructive/high-impact actions**: none introduced.
|
||||||
|
- **Asset strategy**: no assets registered; `filament:assets` is not newly required.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- Runtime services: `SupportedScopeResolver`, `ClaimGuard`, `CoverageV2ReadinessReadModel`, `EntraComparablePayloadNormalizer`, `EntraRenderableSummaryBuilder`, `EntraCertifiedComparePackEvaluator`, `EntraCertifiedComparePackResult`.
|
||||||
|
- Tests and fixtures: focused Spec 425 Unit/Feature tests, `Spec425Fixtures`, and golden fixtures for Conditional Access and Security Defaults, including Conditional Access device-condition coverage.
|
||||||
|
- Existing regression test update: `TenantConfigurationSupportedScopeTest` now derives the default supported-scope count from `SupportedScopeResolver::defaultDefinitions()`.
|
||||||
|
- Spec artifacts: `spec.md`, `plan.md`, `tasks.md`, `checklists/requirements.md`, and this implementation report.
|
||||||
|
- No migrations, routes, Filament resources/pages/widgets, views, browser tests, jobs, commands, assets, config secrets, or provider clients were added.
|
||||||
|
|
||||||
|
## Certification Matrix
|
||||||
|
|
||||||
|
| Resource Type | Evidence | Identity | Compare | Render | Redaction | Certified? | Blocker |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `conditionalAccessPolicy` | PASS | PASS | PASS | PASS | PASS | Yes | none |
|
||||||
|
| `securityDefaults` | PASS | PASS | PASS | PASS | PASS | Yes | none |
|
||||||
|
|
||||||
|
## Claim Matrix
|
||||||
|
|
||||||
|
| Claim | Allowed? | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| Certified Entra Core Compare Pack: Conditional Access and Security Defaults | Yes, internal/operator only | Exact denominator-visible pack claim after all criteria pass |
|
||||||
|
| 100% Entra coverage | No | Broad overclaim |
|
||||||
|
| Entra restore-ready | No | Restore out of scope |
|
||||||
|
| Certified Microsoft 365 coverage | No | Broad overclaim |
|
||||||
|
| Customer-ready Entra proof | No | Customer output deferred |
|
||||||
|
|
||||||
|
## Safety Proof
|
||||||
|
|
||||||
|
- **No restore proof**: PASS via `Spec425EntraCertifiedNoRestoreTest`; no restore/apply path, restore-ready state, or restorable tier introduced.
|
||||||
|
- **No customer-claim proof**: PASS via `Spec425EntraCertifiedNoCustomerClaimTest`; no Review Pack/report/export/PDF/customer-ready proof activation.
|
||||||
|
- **No `tenant_id` proof**: PASS via `Spec425EntraCertifiedNoTenantIdTest`; evaluator and supported-scope changes stay on `workspace_id`, `managed_environment_id`, and `provider_connection_id`.
|
||||||
|
- **No mini-platform proof**: PASS via `Spec425EntraCertifiedNoMiniPlatformTest`; no Entra-specific migration, route, navigation, Filament surface, dashboard, or table family.
|
||||||
|
- **No remote-call proof**: PASS via fail-hard Graph binding and `assertNoOutboundHttp` in `Spec425EntraCertifiedComparePackTest`; evaluator is DB-only.
|
||||||
|
- **Provider scope proof**: PASS; evaluator rejects provider connections outside the managed environment scope.
|
||||||
|
- **Route/command 404/403 proof**: N/A; Spec 425 adds no route, command, job, or UI invocation boundary. The pure service requires explicit managed-environment and provider-connection inputs and still proves same-scope rejection for wrong provider connections.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `php -l` on modified Spec 425 runtime/test files - PASS.
|
||||||
|
- `find apps/platform/tests/Fixtures/TenantConfiguration/Spec425 -name '*.json' -print0 | xargs -0 -n 1 php -r '...'` - PASS; 19 fixtures decoded.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - PASS.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/EntraCertifiedDenominatorTest.php tests/Unit/Support/TenantConfiguration/EntraCertifiedPackClaimGuardTest.php tests/Unit/Support/TenantConfiguration/ConditionalAccessCertifiedCompareTest.php tests/Unit/Support/TenantConfiguration/SecurityDefaultsCertifiedCompareTest.php tests/Unit/Support/TenantConfiguration/EntraCertifiedRenderRedactionTest.php tests/Unit/Support/TenantConfiguration/EntraCertifiedPackEvaluatorTest.php` - PASS, 32 tests / 118 assertions.
|
||||||
|
- Earlier combined Spec 425 feature command from `tasks.md` - FAILED by environment signal 9 before result output; no test failure details were produced. Fix-up validation keeps the same split strategy to avoid aggregate signal-9 noise.
|
||||||
|
- Split Spec 425 feature validation:
|
||||||
|
- `Spec425EntraCertifiedComparePackTest.php` - PASS, 8 tests / 23 assertions.
|
||||||
|
- `Spec425EntraCertifiedClaimGuardFeatureTest.php`, `Spec425EntraCertifiedNoRestoreTest.php`, `Spec425EntraCertifiedNoCustomerClaimTest.php` - PASS, 7 tests / 17 assertions.
|
||||||
|
- `Spec425EntraCertifiedNoTenantIdTest.php`, `Spec425EntraCertifiedNoMiniPlatformTest.php`, `Spec425EntraCertifiedDenominatorFeatureTest.php` - PASS, 5 tests / 23 assertions.
|
||||||
|
- Total focused Spec 425 feature split - PASS, 20 tests / 63 assertions.
|
||||||
|
- Related resolver/readiness regressions: `SupportedScopeResolverTest.php`, `TenantConfigurationSupportedScopeTest.php`, `CoverageV2ReadinessPageTest.php` - PASS, 19 tests / 156 assertions.
|
||||||
|
- Related ClaimGuard/Entra/SecurityDefaults regressions: `ClaimGuardTest.php`, `Spec421EntraClaimGuardTest.php`, `Spec421EntraComparableDiffTest.php`, `Spec424SecurityDefaultsTypedSemanticsTest.php`, `Spec424SecurityDefaultsSourceContractTest.php`, `TenantConfigurationClaimGuardFeatureTest.php`, `Spec421EntraComparableRenderableTest.php`, `Spec421EntraCoverageLevelPromotionTest.php`, `Spec421EntraNoRestoreNoCertificationTest.php`, `Spec424SecurityDefaultsCaptureReadinessTest.php` - PASS, 61 tests / 285 assertions.
|
||||||
|
- Earlier combined related feature regression command - FAILED by environment signal 9 before complete output; isolated failure was the expected supported-scope default count increase.
|
||||||
|
- Split related feature regression validation:
|
||||||
|
- `TenantConfigurationSupportedScopeTest.php` - PASS, 4 tests / 13 assertions after deriving the default count from the resolver.
|
||||||
|
- `TenantConfigurationClaimGuardFeatureTest.php`, `Spec421EntraComparableRenderableTest.php` - PASS, 5 tests / 20 assertions.
|
||||||
|
- `Spec421EntraCoverageLevelPromotionTest.php`, `Spec421EntraNoRestoreNoCertificationTest.php`, `Spec424SecurityDefaultsCaptureReadinessTest.php` - PASS, 12 tests / 108 assertions.
|
||||||
|
- `git diff --check` - PASS.
|
||||||
|
- Browser validation: N/A - no new rendered route/page/action/widget/view surface; no browser-heavy coverage added.
|
||||||
|
|
||||||
|
## Deployment Impact
|
||||||
|
|
||||||
|
- **Staging/production validation**: required gate remains Staging before Production.
|
||||||
|
- **Migrations**: none.
|
||||||
|
- **Environment variables/secrets**: none.
|
||||||
|
- **Queues/scheduler/workers**: none.
|
||||||
|
- **Storage/volumes**: none.
|
||||||
|
- **Assets**: none; no new `filament:assets` requirement beyond existing deployment process.
|
||||||
|
- **Operational command**: deploy/release should run the existing idempotent `tenant-configuration:sync-defaults` path so the new `entra_core_compare_certified` supported scope is present outside tests.
|
||||||
|
- **Rollback/forward**: rollback removes only derived evaluator availability and the supported-scope default from code; no schema rollback needed.
|
||||||
|
|
||||||
|
## Final Gate Result
|
||||||
|
|
||||||
|
PASS. Spec 425 remains exact-denominator, internal/operator-only, DB-only, non-restorable, non-customer-facing, workspace-scoped, and free of new routes, actions, dashboards, reports, exports, PDFs, jobs, commands, migrations, and customer output. Conditional Access device conditions are now covered by compare/render proof, the exact `resource_type_denominator` metadata key is present, and the internal certified scope is hidden from and rejected by existing Coverage v2 readiness option/default/inspect paths.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
- Broader Entra, Microsoft 365, restore/apply, customer output, report/PDF/review-pack claims remain separate-spec candidates.
|
||||||
356
specs/425-entra-certified-compare-pack/plan.md
Normal file
356
specs/425-entra-certified-compare-pack/plan.md
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
# Implementation Plan: Spec 425 - Entra Certified Compare Pack
|
||||||
|
|
||||||
|
**Branch**: `425-entra-certified-compare-pack` | **Date**: 2026-07-01 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `specs/425-entra-certified-compare-pack/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Prepare the first internal/operator certified Coverage v2 pack: `entra_core_compare_certified`. The implementation should evaluate exactly two resource types, `conditionalAccessPolicy` and `securityDefaults`, against evidence, stable identity, deterministic compare, operator-safe render, redaction, and Claim Guard criteria. Certification is derived and DB-only by default. No restore/apply, customer output, full Entra claim, Microsoft 365 claim, new Entra dashboard, new route/navigation, `tenant_id`, or Entra-specific table family is in scope.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4
|
||||||
|
**Primary Dependencies**: Existing Coverage v2 Tenant Configuration services: `ResourceTypeRegistry`, `SupportedScopeResolver`, `CoverageSourceContractResolver`, `CoverageIdentityStrategyRegistry`, `CoverageV2ReadinessReadModel`, `CoveragePayloadRedactor`, `ClaimGuard`, `EntraComparablePayloadNormalizer`, `EntraCoverageComparator`, `EntraRenderableSummaryBuilder`
|
||||||
|
**Storage**: PostgreSQL through existing Coverage v2 resource/evidence/supported-scope tables; no new table planned
|
||||||
|
**Testing**: Pest 4, PHPUnit 12, focused Unit/Feature, Browser only if UI changes
|
||||||
|
**Validation Lanes**: fast-feedback for unit/feature; browser conditional; Pint dirty; diff check
|
||||||
|
**Target Platform**: Laravel Sail locally, Dokploy container deployment
|
||||||
|
**Project Type**: Laravel web monolith under `apps/platform`
|
||||||
|
**Performance Goals**: Certification evaluation is DB-only and bounded to the exact two-item denominator
|
||||||
|
**Constraints**: no remote calls during evaluation, no restore/apply, no customer claim activation, no full Entra/M365 claim, no `tenant_id`, no new Entra table family, no v1 compatibility, no completed-spec rewrites
|
||||||
|
**Scale/Scope**: exactly one certified compare/render pack with two mandatory resource types
|
||||||
|
|
||||||
|
## Preparation Preflight Result
|
||||||
|
|
||||||
|
- Current branch before creation: `platform-dev`.
|
||||||
|
- Current HEAD before creation: `2cd51291 feat: complete spec 424 security defaults content-backed comparable support (#491)`.
|
||||||
|
- Initial dirty state: clean.
|
||||||
|
- Spec 424 direct prerequisite: complete at current HEAD.
|
||||||
|
- Current source evidence:
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` maps both denominator types.
|
||||||
|
- `apps/platform/config/graph_contracts.php` defines both denominator contracts.
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php` requires stable non-derived identity for both types.
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/EntraComparablePayloadNormalizer.php`, `EntraCoverageComparator.php`, and `EntraRenderableSummaryBuilder.php` support both types.
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php` promotes renderable content-backed evidence through existing typed render builders.
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php` currently blocks broad certification/restore/customer/M365 claims and must be extended for exact pack wording.
|
||||||
|
- Direct related specs completed/read-only: 414, 415, 417, 418, 419, 420, 421, 424.
|
||||||
|
- Implementation must re-run hard preflight before runtime edits and stop if current source/tests contradict these findings.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: possible existing operator technical-annex status/evidence presentation change; no new surface.
|
||||||
|
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: existing Coverage v2 readiness/operator surface only if implementation needs rendered certification display.
|
||||||
|
- **No-impact class, if applicable**: service/config/test-only if no runtime UI file changes are needed.
|
||||||
|
- **Native vs custom classification summary**: existing Filament/Coverage v2 surface; no custom UI pattern planned.
|
||||||
|
- **Shared-family relevance**: status messaging, evidence inspection, claim display, redaction.
|
||||||
|
- **State layers in scope**: read model / existing inspect details only if UI changes.
|
||||||
|
- **Audience modes in scope**: operator-MSP and support-platform; no customer/read-only output.
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: pack state and denominator first; blockers second; raw/support evidence hidden or omitted.
|
||||||
|
- **Raw/support gating plan**: raw payloads, raw Graph response, raw permission context, and secrets remain absent from default output.
|
||||||
|
- **One-primary-action / duplicate-truth control**: one read-only inspect/blocker path; no mutation action.
|
||||||
|
- **Handling modes by drift class or surface**: UI changes require Product Surface proof and browser smoke; new route/navigation/customer output is a hard stop.
|
||||||
|
- **Repository-signal treatment**: review-mandatory for status/evidence presentation; hard-stop for new route/navigation/customer/restore scope.
|
||||||
|
- **Special surface test profiles**: technical-annex Coverage v2 surface if UI changes; N/A if service/config/test-only.
|
||||||
|
- **Required tests or manual smoke**: functional core tests always; browser smoke only if rendered UI changes.
|
||||||
|
- **Exception path and spread control**: none.
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
|
||||||
|
- **UI/Productization coverage decision**: `N/A - no rendered UI surface changed` by default; existing surface only after active artifacts are amended.
|
||||||
|
- **Coverage artifacts to update**: none unless implementation adds a new reachable surface, which is currently forbidden.
|
||||||
|
- **No-impact rationale**: Certification can be proven by services, supported-scope metadata, fixtures, and tests without adding a new page.
|
||||||
|
- **Navigation / Filament provider-panel handling**: no panel/provider/navigation change.
|
||||||
|
- **Screenshot or page-report need**: browser screenshot/proof only if rendered UI files change.
|
||||||
|
|
||||||
|
## 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**: Technical Annex if existing surface changes; pass because no new page/action/navigation and one read-only inspect path.
|
||||||
|
- **Technical Annex and deep-link demotion plan**: OperationRun, evidence IDs, source endpoint, source keys, raw payloads, permission context, provider diagnostics, and raw compare values remain hidden/collapsed/internal-only.
|
||||||
|
- **Canonical status vocabulary plan**: Product labels map to `Ready`, `Blocked`, `Needs attention`, or `Unknown` if rendered. Internal derived blocker states remain diagnostics.
|
||||||
|
- **Product Surface exceptions**: none.
|
||||||
|
- **Browser verification plan**: `N/A - no rendered UI surface changed` unless UI files change; otherwise focused existing Coverage v2 route smoke.
|
||||||
|
- **Human Product Sanity plan**: N/A unless UI changes; otherwise record in implementation report.
|
||||||
|
- **Visible complexity outcome target**: neutral or decreased.
|
||||||
|
- **Implementation report target**: `specs/425-entra-certified-compare-pack/implementation-report.md`.
|
||||||
|
|
||||||
|
## Filament / Livewire / Deployment Posture
|
||||||
|
|
||||||
|
- **Livewire v4 compliance**: unchanged; platform remains Filament v5 on Livewire v4. Must be stated in close-out.
|
||||||
|
- **Panel provider registration location**: no panel change planned. Laravel 12 provider registration remains `apps/platform/bootstrap/providers.php`.
|
||||||
|
- **Global search posture**: no Resource or global search change planned.
|
||||||
|
- **Destructive/high-impact action posture**: none. No restore/apply/certify action may be introduced.
|
||||||
|
- **Asset strategy**: no new assets planned. `filament:assets` is not newly required unless implementation unexpectedly registers assets, which would require spec amendment.
|
||||||
|
- **Testing plan**: Unit/Feature for evaluator, denominator, claim guard, redaction, no restore/customer/tenant_id/mini-platform; Browser only if UI changes.
|
||||||
|
- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, or assets expected. If supported-scope defaults change, existing `tenant-configuration:sync-defaults` deployment step may be needed and must be documented.
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes.
|
||||||
|
- **Systems touched**: Coverage v2 resource type registry, supported scopes, evidence/read model, Entra compare/render helpers, redaction, Claim Guard.
|
||||||
|
- **Shared abstractions reused**: `SupportedScopeResolver`, `ClaimGuard`, `CoveragePayloadRedactor`, `CoverageV2ReadinessReadModel`, `EntraComparablePayloadNormalizer`, `EntraCoverageComparator`, `EntraRenderableSummaryBuilder`.
|
||||||
|
- **New abstraction introduced? why?**: A narrow derived evaluator service may be introduced if existing supported-scope evaluation cannot compose pack-level criteria. No persisted truth or generic certification framework.
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: Existing helpers prove row-level evidence/identity/compare/render/redaction. They do not yet compose exact two-type denominator certification and exact claim wording.
|
||||||
|
- **Bounded deviation / spread control**: evaluator is pack-specific, internal/operator-only, and must not become an Entra mini-platform or cross-domain certification framework in this spec.
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no.
|
||||||
|
- **Central contract reused**: N/A.
|
||||||
|
- **Delegated UX behaviors**: N/A.
|
||||||
|
- **Surface-owned behavior kept local**: N/A.
|
||||||
|
- **Queued DB-notification policy**: N/A.
|
||||||
|
- **Terminal notification path**: N/A.
|
||||||
|
- **Exception path**: none.
|
||||||
|
|
||||||
|
Certification evaluation must not create or mutate `OperationRun`. It may read existing operation-backed evidence linkage. If long-running/batch behavior becomes necessary, stop and amend the spec.
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes.
|
||||||
|
- **Provider-owned seams**: Microsoft Entra canonical types, Graph/TCM source contracts, provider source identifiers.
|
||||||
|
- **Platform-core seams**: Coverage level, supported scope, evidence state, identity state, claim guard, redaction, workspace/managed-environment/provider scope.
|
||||||
|
- **Neutral platform terms / contracts preserved**: supported scope, resource type, evidence, identity, claim, provider connection, managed environment.
|
||||||
|
- **Retained provider-specific semantics and why**: The denominator is deliberately Microsoft Entra-specific and exact.
|
||||||
|
- **Bounded extraction or follow-up path**: document-in-feature for this pack; future workload packs require separate specs.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
- Inventory-first / evidence truth: PASS. Certification derives from existing Coverage v2 evidence, not provider live state.
|
||||||
|
- Read/write separation: PASS. Read/evaluate/render only; no restore/apply or mutation action.
|
||||||
|
- Graph contract path: PASS. No new evaluation Graph calls; existing source contracts remain read-only dependency truth.
|
||||||
|
- Deterministic capabilities: PASS. Certification criteria and denominator are testable by fixtures.
|
||||||
|
- RBAC-UX: PASS with implementation requirement. Any invocation path must enforce non-member 404 and member missing capability 403.
|
||||||
|
- Workspace isolation: PASS with implementation requirement. Evaluation must remain same workspace/managed environment/provider connection scoped.
|
||||||
|
- Tenant isolation: PASS in current repo vocabulary; no `tenant_id` ownership truth.
|
||||||
|
- Run observability: PASS. Evaluation is DB-only and skips OperationRun by design.
|
||||||
|
- OperationRun start UX: N/A.
|
||||||
|
- Data minimization: PASS. Raw and permission payloads must not render.
|
||||||
|
- Test governance: PASS. Unit/Feature lane is sufficient unless UI changes.
|
||||||
|
- Proportionality: PASS. Derived evaluator avoids persistence and broad frameworkization.
|
||||||
|
- No premature abstraction: PASS only if evaluator stays pack-specific and no generic certification platform appears.
|
||||||
|
- Persisted truth: PASS. No new table.
|
||||||
|
- Behavioral state: PASS if certification blocker states remain derived and have clear reviewer consequences.
|
||||||
|
- UI semantics: PASS if existing Coverage v2 surface is reused and no runtime Product Surface framework is introduced.
|
||||||
|
- Shared pattern first: PASS. Existing Coverage v2/Claim Guard/redaction paths are reused.
|
||||||
|
- Provider boundary: PASS with bounded provider-owned denominator.
|
||||||
|
- V1 explicitness / few layers: PASS. Direct pack implementation only.
|
||||||
|
- Spec discipline / bloat check: PASS. Scope groups the certification semantics into one coherent spec.
|
||||||
|
- Filament-native UI: N/A unless existing UI changes.
|
||||||
|
- Product Surface Contract: PASS with conditional browser/Human Product Sanity requirements.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Unit for pure evaluator/claim/compare/render/redaction; Feature for DB/scope/supported scope/no-overreach/no-tenant_id/no-mini-platform; Browser only if UI changes.
|
||||||
|
- **Affected validation lanes**: fast-feedback; browser conditional.
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: Certification is derived from deterministic services and persisted evidence rows. Browser is only needed for rendered UI proof.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/EntraCertifiedPackEvaluatorTest.php tests/Unit/Support/TenantConfiguration/EntraCertifiedPackClaimGuardTest.php tests/Unit/Support/TenantConfiguration/ConditionalAccessCertifiedCompareTest.php tests/Unit/Support/TenantConfiguration/SecurityDefaultsCertifiedCompareTest.php tests/Unit/Support/TenantConfiguration/EntraCertifiedRenderRedactionTest.php tests/Unit/Support/TenantConfiguration/EntraCertifiedDenominatorTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec425EntraCertifiedComparePackTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedClaimGuardFeatureTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoRestoreTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoCustomerClaimTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoMiniPlatformTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedDenominatorFeatureTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: focused golden fixtures for two resource types; avoid broad default fixture setup.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no.
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none unless browser proof is triggered.
|
||||||
|
- **Surface-class relief / special coverage rule**: N/A if no rendered UI change; technical-annex focused browser if UI changes.
|
||||||
|
- **Closing validation and reviewer handoff**: verify denominator, blocker states, exact claim allowance/blocking, redaction, no restore/customer/tenant_id/mini-platform, and no remote calls.
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected.
|
||||||
|
- **Review-stop questions**: Does any task create a generic certification framework, persisted table, customer output, restore action, or broad claim? If yes, split/stop.
|
||||||
|
- **Escalation path**: reject-or-split for restore/customer/full-workload scope; document-in-feature for bounded existing-surface UI proof.
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
|
||||||
|
- **Why no dedicated follow-up spec is needed**: This is the dedicated exact-denominator certification slice; broader packs are deferred.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/425-entra-certified-compare-pack/
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
├── plan.md
|
||||||
|
├── spec.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
Likely affected runtime/test paths for later implementation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/app/Services/TenantConfiguration/
|
||||||
|
├── ClaimGuard.php
|
||||||
|
├── SupportedScopeResolver.php
|
||||||
|
├── EntraComparablePayloadNormalizer.php
|
||||||
|
├── EntraCoverageComparator.php
|
||||||
|
├── EntraRenderableSummaryBuilder.php
|
||||||
|
└── EntraCertifiedComparePackEvaluator.php # only if needed
|
||||||
|
|
||||||
|
apps/platform/tests/Fixtures/TenantConfiguration/Spec425/
|
||||||
|
├── conditional-access/
|
||||||
|
└── security-defaults/
|
||||||
|
|
||||||
|
apps/platform/tests/Unit/Support/TenantConfiguration/
|
||||||
|
└── *Certified*.php
|
||||||
|
|
||||||
|
apps/platform/tests/Feature/TenantConfiguration/
|
||||||
|
└── Spec425*.php
|
||||||
|
|
||||||
|
apps/platform/tests/Browser/
|
||||||
|
└── Spec425EntraCertifiedComparePackOperatorSurfaceSmokeTest.php # only if UI changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Use existing TenantConfiguration/Coverage v2 service and test directories. Do not create new base folders outside test fixtures unless implementation proves an existing location is insufficient.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|---|---|---|
|
||||||
|
| Derived certification evaluator | Pack-level certification needs denominator-wide evidence/identity/compare/render/redaction/claim composition | Resource-level `renderable` checks would allow one mandatory type or claim criterion to fail silently |
|
||||||
|
| Derived certification blocker states | Reviewers need actionable blocker reasons for withheld certification | A boolean pass/fail would hide whether evidence, identity, compare, render, redaction, or claim guard failed |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Internal operators need safe exact certification wording after Specs 421 and 424 without implying restore, full Entra, Microsoft 365, or customer proof.
|
||||||
|
- **Existing structure is insufficient because**: Existing helpers prove individual resource behavior, not exact denominator integrity or pack-level claims.
|
||||||
|
- **Narrowest correct implementation**: Add exact supported-scope metadata and one derived evaluator or equivalent existing-scope evaluation; no persistence, no dashboard, no generic certification framework.
|
||||||
|
- **Ownership cost created**: A small fixture/test set must be maintained when Conditional Access or Security Defaults compare/render changes.
|
||||||
|
- **Alternative intentionally rejected**: Setting `allows_certified_claims` on each resource type and relying on current Claim Guard. That misses denominator integrity and exact wording controls.
|
||||||
|
- **Release truth**: Current-release internal/operator certification proof.
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Phase 0 - Hard Preflight
|
||||||
|
|
||||||
|
- Confirm branch, HEAD, dirty state, and active spec path.
|
||||||
|
- Confirm Spec 424 is present in current history and the Security Defaults runtime support still exists.
|
||||||
|
- Confirm `conditionalAccessPolicy` and `securityDefaults` have source contracts, content-backed evidence paths, stable identity strategies, deterministic compare, render builder support, redaction, and no-restore/customer/certified default posture.
|
||||||
|
- Stop before implementation if either type fails the hard prerequisites.
|
||||||
|
|
||||||
|
### Phase 1 - Certified Denominator And Supported Scope
|
||||||
|
|
||||||
|
- Add or confirm the supported scope key `entra_core_compare_certified` in the existing Coverage v2 supported-scope mechanism.
|
||||||
|
- Include exactly `conditionalAccessPolicy` and `securityDefaults`.
|
||||||
|
- Set required minimum level to `certified`.
|
||||||
|
- Set `customer_claims_allowed = false`.
|
||||||
|
- Set `allow_beta = false`.
|
||||||
|
- Allow Graph fallback only explicitly for `securityDefaults`, preferably through metadata allowlist plus evaluator enforcement because the existing scope model has a boolean `allow_graph_fallback`.
|
||||||
|
- Add tests proving no optional Entra resource can enter the denominator.
|
||||||
|
|
||||||
|
### Phase 2 - Certification Evaluator
|
||||||
|
|
||||||
|
- Implement a derived evaluator only if existing supported-scope evaluation cannot produce the certification matrix.
|
||||||
|
- Evaluate:
|
||||||
|
- evidence criteria, including current same-scope evidence and no fallback-to-first/latest behavior
|
||||||
|
- identity criteria
|
||||||
|
- compare criteria
|
||||||
|
- render criteria
|
||||||
|
- redaction criteria
|
||||||
|
- Claim Guard criteria
|
||||||
|
- Return derived blocker states:
|
||||||
|
- `certification_not_evaluated`
|
||||||
|
- `certification_passed`
|
||||||
|
- `certification_blocked_missing_evidence`
|
||||||
|
- `certification_blocked_identity`
|
||||||
|
- `certification_blocked_compare`
|
||||||
|
- `certification_blocked_render`
|
||||||
|
- `certification_blocked_redaction`
|
||||||
|
- `certification_blocked_claim_guard`
|
||||||
|
- Keep states local/derived unless the spec is amended.
|
||||||
|
|
||||||
|
### Phase 3 - Golden Fixtures
|
||||||
|
|
||||||
|
- Add focused golden fixtures for Conditional Access:
|
||||||
|
- no change
|
||||||
|
- state change
|
||||||
|
- grant control change
|
||||||
|
- included actor change
|
||||||
|
- excluded actor change
|
||||||
|
- app/resource targeting change
|
||||||
|
- condition change
|
||||||
|
- session control change
|
||||||
|
- volatile ignored
|
||||||
|
- unsupported field diagnostics
|
||||||
|
- Add focused golden fixtures for Security Defaults:
|
||||||
|
- no change
|
||||||
|
- enabled true/false change
|
||||||
|
- volatile ignored
|
||||||
|
- missing evidence
|
||||||
|
- identity blocked
|
||||||
|
- redaction proof
|
||||||
|
|
||||||
|
### Phase 4 - Claim Guard
|
||||||
|
|
||||||
|
- Allow exact internal/operator visible wording only when the denominator is included:
|
||||||
|
- `Certified Entra Core Compare Pack: Conditional Access and Security Defaults`
|
||||||
|
- `Certified compare support for Conditional Access and Security Defaults`
|
||||||
|
- `Certified compare/render support for the Entra Core denominator: Conditional Access and Security Defaults`
|
||||||
|
- The bare label `Certified Entra Core Compare Pack` may exist as an internal scope label or diagnostic row heading only when the same visible context includes the denominator.
|
||||||
|
- Require exact denominator visibility when a certification claim is shown.
|
||||||
|
- Block broad/full Entra, 100 percent, Microsoft 365, restore-ready, customer-ready, legal/regulatory, full tenant proof, and Review Pack/report wording.
|
||||||
|
|
||||||
|
### Phase 5 - Product Surface Decision
|
||||||
|
|
||||||
|
- Prefer no runtime UI file changes if evaluator tests prove certification.
|
||||||
|
- If implementation changes rendered UI, amend active artifacts before editing UI files and add focused browser proof.
|
||||||
|
- Do not add route, navigation, dashboard, customer output, restore/apply, report, export, or Review Pack output.
|
||||||
|
|
||||||
|
### Phase 6 - Tests And Validation
|
||||||
|
|
||||||
|
- Add focused unit tests first.
|
||||||
|
- Add focused feature/static tests.
|
||||||
|
- Add browser test only if UI changes.
|
||||||
|
- Run Pint dirty, focused test lanes, and `git diff --check`.
|
||||||
|
|
||||||
|
### Phase 7 - Implementation Report
|
||||||
|
|
||||||
|
- Complete `specs/425-entra-certified-compare-pack/implementation-report.md`.
|
||||||
|
- Include required certification and claim matrices.
|
||||||
|
- Record no restore, no customer output, no `tenant_id`, no mini-platform, no remote calls, Product Surface result, tests, and deferred work.
|
||||||
|
|
||||||
|
## Data Model Impact
|
||||||
|
|
||||||
|
- No new persisted table.
|
||||||
|
- No new Entra-specific table family.
|
||||||
|
- No `tenant_id`.
|
||||||
|
- Existing supported-scope metadata may be updated.
|
||||||
|
- Certification result should remain derived unless an existing supported-scope evaluation summary already persists such results. If persistence is needed outside existing Coverage v2 supported-scope evaluation, stop and amend the spec.
|
||||||
|
|
||||||
|
## RBAC / Isolation Plan
|
||||||
|
|
||||||
|
- Any service/command/UI invocation must receive or resolve workspace, managed environment, and provider connection scope explicitly.
|
||||||
|
- Non-member workspace or managed-environment access returns 404.
|
||||||
|
- Member without view/evaluate capability returns 403.
|
||||||
|
- Provider connection must belong to the same workspace and managed environment.
|
||||||
|
- Evaluation must not fallback to first/latest records outside scope.
|
||||||
|
- Feature or service-level tests must cover wrong-scope and missing-capability behavior for any service, command, route, or UI invocation boundary. If no callable boundary is added beyond a pure injected service, the implementation report must record why route/command 404/403 proof is N/A and still prove explicit same-scope service inputs.
|
||||||
|
|
||||||
|
## OperationRun / Observability Plan
|
||||||
|
|
||||||
|
- No new `OperationRun`.
|
||||||
|
- No new job.
|
||||||
|
- No new queue/scheduler.
|
||||||
|
- Existing operation-backed evidence links may be read.
|
||||||
|
- No remote/provider call is allowed during certification evaluation.
|
||||||
|
|
||||||
|
## Claim And Redaction Plan
|
||||||
|
|
||||||
|
- Claim Guard is the authoritative claim safety layer.
|
||||||
|
- Raw payload, raw Graph response, raw permission context, secrets, tokens, cookies, authorization headers, private keys, certificate material, and credential values must not appear in render output, Claim Guard proof, UI, implementation report snippets, logs, notifications, or OperationRun context.
|
||||||
|
- Tests must fail if sensitive values appear in certification matrix or render summary output.
|
||||||
|
|
||||||
|
## Rollout Considerations
|
||||||
|
|
||||||
|
- Staging validation is required before production if runtime code changes.
|
||||||
|
- No environment variable changes expected.
|
||||||
|
- No migration expected.
|
||||||
|
- If supported scope defaults are updated, deployment notes must mention whether `cd apps/platform && php artisan tenant-configuration:sync-defaults` is required for existing environments.
|
||||||
|
- Rollback should be code/config/test only unless the implementation amends the spec to persist summaries.
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
- Either denominator type lacks content-backed evidence, stable identity, deterministic compare, safe render, or redaction proof.
|
||||||
|
- Denominator changes from exactly two resource types.
|
||||||
|
- Restore/apply, customer output, full Entra, M365, Review Pack/report/PDF/export, or legal attestation scope appears.
|
||||||
|
- A new Entra dashboard, route, navigation item, primary surface, table family, or mini-platform appears.
|
||||||
|
- `tenant_id` is introduced as platform-core ownership truth or compatibility/fallback path.
|
||||||
|
- Evaluation requires remote calls or a long-running job.
|
||||||
|
- Raw payloads, provider response bodies, permission context, credentials, or secrets become default-visible.
|
||||||
|
- Implementation requires persistence outside existing Coverage v2 supported-scope/evaluation paths.
|
||||||
421
specs/425-entra-certified-compare-pack/spec.md
Normal file
421
specs/425-entra-certified-compare-pack/spec.md
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
# Feature Specification: Spec 425 - Entra Certified Compare Pack
|
||||||
|
|
||||||
|
**Feature Branch**: `425-entra-certified-compare-pack`
|
||||||
|
**Created**: 2026-07-01
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User-provided candidate: "Spec 425 - Entra Certified Compare Pack"
|
||||||
|
|
||||||
|
## Selection And Preflight
|
||||||
|
|
||||||
|
- **Selected candidate**: Certified Entra Core Compare Pack.
|
||||||
|
- **Source**: Direct user-provided Spec 425 draft in the 2026-07-01 session.
|
||||||
|
- **Why selected**: Spec 424 is merged at current HEAD (`2cd51291 feat: complete spec 424 security defaults content-backed comparable support (#491)`) and closes the direct `securityDefaults` blocker that Spec 421 deferred. This makes the exact two-type Entra denominator eligible for a certification-prep slice.
|
||||||
|
- **Close alternatives deferred**: Exchange/Teams certified compare, Security and Compliance certified compare, customer reporting claim guard, pilot readiness, Entra restore/apply, Application, Service Principal, Role Definition, Administrative Unit, Review Pack output, and management PDF output all require separate denominators or customer/output risk decisions.
|
||||||
|
- **Completed-spec guardrail result**: Specs 414, 415, 417, 418, 419, 420, 421, and 424 are completed dependency context only. Their close-out history, validation results, browser proof, and task completion markers must not be rewritten by this spec.
|
||||||
|
- **Current preflight evidence**:
|
||||||
|
- Spec 421 implementation report records Conditional Access as content-backed, comparable, renderable, redacted, browser-proven, no-restore, no-certification, and no-customer-claim.
|
||||||
|
- Spec 424 implementation report records Security Defaults as content-backed, comparable, renderable, redacted, browser-proven, no-restore, no-certification, and no-customer-claim.
|
||||||
|
- Current source shows `conditionalAccessPolicy` and `securityDefaults` in `CoverageSourceContractResolver`, `CoverageIdentityStrategyRegistry`, `EntraComparablePayloadNormalizer`, `EntraCoverageComparator`, `EntraRenderableSummaryBuilder`, and `config/graph_contracts.php`.
|
||||||
|
- Current source shows no existing `entra_core_compare_certified` supported scope.
|
||||||
|
- **Hard implementation preflight**: Before runtime code changes, implementation must re-check current source/tests and stop if either denominator item lacks content-backed evidence, stable identity, deterministic compare, operator-safe render, redaction proof, or Claim Guard safety.
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: TenantPilot can internally prove Conditional Access and Security Defaults are comparable/renderable, but it cannot safely say the exact Entra core compare denominator is certified without stronger pack-level proof and claim controls.
|
||||||
|
- **Today's failure**: Operators or release reviewers could over-read comparable/renderable support as full Entra coverage, Microsoft 365 coverage, restore readiness, customer-ready proof, or certification without denominator integrity.
|
||||||
|
- **User-visible improvement**: Internal operators get one exact certification result for Conditional Access plus Security Defaults, with blocker reasons and Claim Guard protection that prevent broad or restore/customer claims.
|
||||||
|
- **Smallest enterprise-capable version**: A DB-only, internal/operator-only certification evaluator and supported scope for exactly `conditionalAccessPolicy` and `securityDefaults`, with golden fixtures, tests, and no new customer output.
|
||||||
|
- **Explicit non-goals**: No restore/apply, no full Entra certification, no Microsoft 365 certification, no customer claim activation, no new Entra dashboard, no new Entra table family, no Application/Service Principal/Role Definition/Administrative Unit certification, no v1 compatibility, no `tenant_id`.
|
||||||
|
- **Permanent complexity imported**: One narrow derived certification evaluator or equivalent existing-scope evaluation, exact supported-scope metadata, focused fixture/test family, and Claim Guard exact wording rules. No new persisted table is allowed.
|
||||||
|
- **Why now**: Spec 424 unblocked Security Defaults, leaving certification claim safety as the next P0 trust gate before later customer or pilot readiness work.
|
||||||
|
- **Why not local**: Certification is a cross-resource pack claim. It cannot be safely represented by one row-level compare/render helper or ad hoc wording without denominator, evidence, identity, redaction, and claim guard checks.
|
||||||
|
- **Approval class**: Core Enterprise.
|
||||||
|
- **Red flags triggered**: New claim/status semantics and evaluator logic. Defense: they directly prevent unsafe certification, restore, customer, full-Entra, and M365 overclaims; scope is exactly two resource types and derived by default.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve.
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace + managed-environment scoped internal/operator evaluation over existing Coverage v2 evidence.
|
||||||
|
- **Primary Routes**: Existing Coverage v2 readiness/operator surface only if implementation needs rendered certification display. No new route is planned.
|
||||||
|
- **Data Ownership**: Environment-owned Coverage v2 resource/evidence rows remain scoped by `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id`.
|
||||||
|
- **RBAC**: Non-member workspace or managed-environment access is 404. Member without view/evaluate capability is 403. No customer-facing route.
|
||||||
|
|
||||||
|
## No Legacy / No Backward Compatibility Constraint *(mandatory)*
|
||||||
|
|
||||||
|
TenantPilot is pre-production unless this spec explicitly records a compatibility exception.
|
||||||
|
|
||||||
|
- **Compatibility posture**: canonical extension of Coverage v2; 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 spec adds a new internal certified pack concept and does not migrate legacy data or customer-facing contracts.
|
||||||
|
|
||||||
|
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||||
|
|
||||||
|
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||||
|
|
||||||
|
- [x] No UI surface impact
|
||||||
|
- [ ] Existing page changed
|
||||||
|
- [ ] New page/route added
|
||||||
|
- [ ] Navigation changed
|
||||||
|
- [ ] Filament panel/provider surface changed
|
||||||
|
- [ ] New modal/drawer/wizard/action added
|
||||||
|
- [ ] New table/form/state added
|
||||||
|
- [ ] Customer-facing surface changed
|
||||||
|
- [ ] Dangerous action changed
|
||||||
|
- [ ] Status/evidence/review presentation changed
|
||||||
|
- [ ] Workspace/environment context presentation changed
|
||||||
|
|
||||||
|
Default planned impact is service/config/test-only with `N/A - no rendered UI surface changed`. If implementation discovers that rendered certification output is necessary, amend this spec, plan, and tasks before editing UI files, then limit impact to the existing Coverage v2 internal/operator readiness or inspect surface. No new route, navigation item, dashboard, action, report, download, Review Pack output, or customer-facing surface may be added.
|
||||||
|
|
||||||
|
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
|
||||||
|
|
||||||
|
- **Route/page/surface**: N/A unless the active artifacts are amended for rendered UI changes; then existing Coverage v2 readiness/operator surface and inspect details only.
|
||||||
|
- **Current or new page archetype**: Technical Annex / internal operator evidence inspection.
|
||||||
|
- **Design depth**: Internal/Hidden, because this is internal/operator certification evidence and not a customer output surface.
|
||||||
|
- **Repo-truth level**: repo-verified through Specs 418, 421, and 424.
|
||||||
|
- **Existing pattern reused**: Existing Coverage v2 readiness read model and inspect surface.
|
||||||
|
- **New pattern required**: none.
|
||||||
|
- **Screenshot required**: only if runtime UI files change; otherwise no.
|
||||||
|
- **Page audit required**: no, unless implementation adds a materially new surface contrary to this spec.
|
||||||
|
- **Customer-safe review required**: yes as a negative proof only: no customer-facing output or customer-ready label may appear.
|
||||||
|
- **Dangerous-action review required**: no destructive/high-impact action in scope.
|
||||||
|
- **Coverage files updated or explicitly not needed**:
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/page-reports/...`
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
|
||||||
|
- [x] `N/A - no new reachable UI surface`
|
||||||
|
- **No-impact rationale when applicable**: Existing surface reuse is expected; no new page, route, navigation, customer output, or action is allowed.
|
||||||
|
|
||||||
|
## Product Surface Impact *(mandatory for UI-affecting specs)*
|
||||||
|
|
||||||
|
Reference: `docs/product/standards/product-surface-contract.md`.
|
||||||
|
|
||||||
|
- **Product Surface Contract applies?**: conditional. The active decision is `N/A - no rendered UI surface changed`; the contract fields below define the only allowed amendment path if UI changes become necessary.
|
||||||
|
- **Page archetype**: Technical Annex.
|
||||||
|
- **Primary user question**: Is the exact Entra Core Compare denominator certified for internal compare/render claims?
|
||||||
|
- **Primary action**: Inspect blockers or evidence; read-only.
|
||||||
|
- **Surface budget result**: pass if reused on existing Coverage v2 surface; no new page/action/route.
|
||||||
|
- **Technical Annex / deep-link demotion**: Raw payload, raw Graph response, permission context, source endpoints, operation details, IDs, source keys, unsupported field internals, and raw compare values remain hidden, collapsed, or internal-only.
|
||||||
|
- **Canonical status vocabulary**: Certification state may use derived internal blocker values. Product-facing labels, if rendered, must map to `Ready`, `Blocked`, `Needs attention`, or `Unknown` and must not expose `Certified Entra` without the exact denominator.
|
||||||
|
- **Visible complexity impact**: neutral or decreased. The pack should reduce interpretation work by presenting one exact denominator and blocker set.
|
||||||
|
- **Product Surface exceptions**: none.
|
||||||
|
|
||||||
|
## Browser Verification Plan *(mandatory)*
|
||||||
|
|
||||||
|
- **Browser proof required?**: yes if runtime UI files or rendered certification output change; no otherwise.
|
||||||
|
- **No-browser rationale**: `N/A - no rendered UI surface changed` if implementation is service/config/test only.
|
||||||
|
- **Focused path when required**: Existing Coverage v2 readiness/operator route with seeded workspace, managed environment, provider connection, Conditional Access evidence, and Security Defaults evidence.
|
||||||
|
- **Primary interaction to execute**: Load the existing route as an authorized internal operator, inspect the certified pack state, verify exact denominator, blockers/no blockers, no restore-ready claim, no full Entra/M365 claim, internal/operator-only label, raw payload absence, and no console/Livewire/Filament errors.
|
||||||
|
- **Console, Livewire, Filament, network, and 500-error checks**: planned if browser proof is required.
|
||||||
|
- **Full-suite failure triage**: Unrelated failures must be documented separately if focused proof is green.
|
||||||
|
|
||||||
|
## Human Product Sanity Check *(mandatory)*
|
||||||
|
|
||||||
|
- **Required?**: yes if rendered UI changes; otherwise no.
|
||||||
|
- **No-human-sanity rationale**: `N/A - no rendered UI surface changed` if service/config/test only.
|
||||||
|
- **Reviewer questions**: Is the exact denominator visible? Is it impossible to confuse this with full Entra, M365, restore-ready, or customer-ready proof? Are raw/technical details demoted? Is there one read-only next action? Is visible complexity not worse?
|
||||||
|
- **Planned result location**: implementation-report.
|
||||||
|
|
||||||
|
## Product Surface Merge Gate Checklist *(mandatory)*
|
||||||
|
|
||||||
|
- [x] No-legacy posture or approved exception recorded.
|
||||||
|
- [x] Product Surface Impact is completed or `N/A` is justified.
|
||||||
|
- [x] Browser proof is completed or `N/A - no rendered UI surface changed` is justified.
|
||||||
|
- [x] Human Product Sanity is completed or not applicable with rationale.
|
||||||
|
- [x] Product Surface exceptions are documented or `none`.
|
||||||
|
- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, and visible complexity outcome.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes.
|
||||||
|
- **Interaction class(es)**: status messaging, evidence inspection, claim wording, redaction.
|
||||||
|
- **Systems touched**: Coverage v2 resource/evidence/read model, supported scopes, Claim Guard, redactor, typed Entra compare/render helpers.
|
||||||
|
- **Existing pattern(s) to extend**: `SupportedScopeResolver`, `ClaimGuard`, `CoveragePayloadRedactor`, `CoverageV2ReadinessReadModel`, `EntraComparablePayloadNormalizer`, `EntraCoverageComparator`, `EntraRenderableSummaryBuilder`.
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: Existing Coverage v2 and Entra helper family. Do not create an Entra mini-platform.
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: Existing paths already prove evidence, identity, compare, render, and claim safety for the two resource types. A narrow pack evaluator is needed only to compose the exact denominator and certification criteria.
|
||||||
|
- **Allowed deviation and why**: A small derived `EntraCertifiedComparePackEvaluator` or equivalent existing-scope evaluator is allowed if existing classes do not already produce pack-level certification state.
|
||||||
|
- **Consistency impact**: Claim and blocker wording must remain aligned with Coverage v2 states and Claim Guard.
|
||||||
|
- **Review focus**: Prevent broad claims, restore implications, raw payload exposure, `tenant_id`, and Entra-specific table/platform drift.
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when touched)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no.
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: N/A.
|
||||||
|
- **Delegated start/completion UX behaviors**: N/A.
|
||||||
|
- **Local surface-owned behavior that remains**: N/A.
|
||||||
|
- **Queued DB-notification policy**: N/A.
|
||||||
|
- **Terminal notification path**: N/A.
|
||||||
|
- **Exception required?**: none.
|
||||||
|
|
||||||
|
Certification evaluation is DB-only and must not create a new `OperationRun`. If implementation discovers that evaluation must become a batch job, stop and amend this spec before continuing.
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when touched)*
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes.
|
||||||
|
- **Boundary classification**: mixed. Resource identifiers and Graph/TCM source contracts are provider-owned; certification denominator, coverage level, claim guard, redaction, and workspace/environment/provider scope are platform-core Coverage v2 concerns.
|
||||||
|
- **Seams affected**: supported scopes, resource-type registry metadata, evidence rows, identity strategy, typed Entra compare/render helpers, Claim Guard.
|
||||||
|
- **Neutral platform terms preserved or introduced**: coverage, evidence, identity, claim, supported scope, resource type, managed environment, provider connection.
|
||||||
|
- **Provider-specific semantics retained and why**: `conditionalAccessPolicy` and `securityDefaults` are the exact Microsoft Entra resource types in the denominator.
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: No provider-native tenant ID ownership, no new Entra table family, no Entra dashboard, no provider framework, no customer output, and no restore/apply support.
|
||||||
|
- **Follow-up path**: Additional Entra resource types, Entra restore, customer reporting, and M365-wide certification require separate specs.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Existing Coverage v2 readiness / inspect surface | conditional | Existing Filament/Coverage v2 surface | status/evidence/claim display | read model, inspect details | no | Browser proof only if rendered UI files change |
|
||||||
|
|
||||||
|
## 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 | Release reviewer verifies a certification claim is safe | Pack state, exact denominator, blocker summary, no-restore/customer scope | source contract, evidence hash, unsupported/redacted fields | Not primary; supports internal proof review | Extends Coverage v2 technical annex | One pack result replaces manual row-by-row inference |
|
||||||
|
|
||||||
|
## 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 | Certified pack state, exact denominator, blocker/no blocker, no-restore warning | evidence/identity/claim state, source contract, redaction result | raw payload, raw Graph response, permission context, source endpoints | Inspect blockers | raw payload and provider diagnostics | Certification claim appears once with denominator |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Existing Coverage v2 readiness / inspect surface | List / Table / Detail | Technical Annex | Inspect blockers | Existing inspect affordance | existing behavior | Existing details/technical disclosure | none | existing Coverage v2 route | existing inspect/modal | workspace + managed environment | Certified Entra Core Compare Pack | exact denominator and blocker 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Existing Coverage v2 readiness / inspect surface | internal operator / release reviewer | Decide whether internal certified pack wording is safe | Technical Annex | Is the exact pack certified for compare/render? | pack state, denominator, blockers, internal-only/no-restore labels | raw payload, source metadata, OperationRun links, permission context | certification, evidence, identity, compare, render, redaction, claim | read-only | Inspect blockers | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no. Certification is derived from existing Coverage v2 evidence, identity, compare, render, redaction, supported scope, and Claim Guard truth.
|
||||||
|
- **New persisted entity/table/artifact?**: no.
|
||||||
|
- **New abstraction?**: yes, if implementation adds a narrow pack evaluator service or value object.
|
||||||
|
- **New enum/state/reason family?**: derived blocker states may be introduced as service-local values. Do not add a persisted enum/status family unless the spec is amended.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no.
|
||||||
|
- **Current operator problem**: Release reviewers need a safe internal certification claim for the exact Entra denominator without overclaiming restore, customer proof, or full workload coverage.
|
||||||
|
- **Existing structure is insufficient because**: Row-level compare/render helpers do not prove denominator integrity, exact pack success/failure, or claim wording safety across both mandatory resource types.
|
||||||
|
- **Narrowest correct implementation**: One exact pack definition plus derived evaluator and tests; reuse existing Coverage v2/Claim Guard/redaction helpers; no persistence and no new UI framework.
|
||||||
|
- **Ownership cost**: Focused evaluator, fixtures, and tests must be maintained when either denominator type changes.
|
||||||
|
- **Alternative intentionally rejected**: Marking each resource type `certified` independently without a pack evaluator; that would allow one denominator item to fail silently or claims to overreach.
|
||||||
|
- **Release truth**: Current-release internal/operator certification proof only.
|
||||||
|
|
||||||
|
### 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 evaluator, denominator, compare fixtures, render/redaction, and Claim Guard. Feature for supported scope, exact denominator, RBAC/scope, no restore/customer/tenant_id/mini-platform, and non-denominator exclusions. Browser only if rendered UI changes.
|
||||||
|
- **Validation lane(s)**: fast-feedback for unit/feature; browser only if UI changes.
|
||||||
|
- **Why this classification and these lanes are sufficient**: Certification behavior is derived, deterministic, and DB-only. Browser proof is required only for rendered product-surface changes.
|
||||||
|
- **New or expanded test families**: Spec425 TenantConfiguration tests and optional browser smoke if UI changes.
|
||||||
|
- **Fixture / helper cost impact**: Add focused golden fixtures for Conditional Access and Security Defaults only; avoid broad workspace/provider default helpers.
|
||||||
|
- **Heavy-family visibility / justification**: none unless browser proof is triggered.
|
||||||
|
- **Special surface test profile**: technical-annex Coverage v2 surface if UI changes; otherwise N/A.
|
||||||
|
- **Standard-native relief or required special coverage**: ordinary service/feature coverage unless UI changes.
|
||||||
|
- **Reviewer handoff**: verify exact denominator, no broad claims, no restore/customer output, no raw payloads, stable identity, no `tenant_id`, no Entra mini-platform, and narrow validation commands.
|
||||||
|
- **Budget / baseline / trend impact**: none expected.
|
||||||
|
- **Escalation needed**: document-in-feature if a bounded UI no-impact decision is used; reject-or-split if restore/customer/full-Entra scope appears.
|
||||||
|
- **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 Spec425 unit tests>`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact <focused Spec425 feature tests>`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact <focused Spec425 browser test>` if UI changes
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Certify the Exact Entra Core Compare Denominator (Priority: P1)
|
||||||
|
|
||||||
|
As an internal release reviewer, I need one derived certification result for the exact Entra Core Compare Pack so I can safely approve only exact pack wording when both mandatory types meet evidence, identity, compare, render, redaction, and claim criteria.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core certification gate and prevents false certification when either mandatory type fails.
|
||||||
|
|
||||||
|
**Independent Test**: A focused evaluator test can seed valid Conditional Access and Security Defaults evidence and prove the pack passes only when both mandatory types pass.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** both denominator types have content-backed evidence, stable identity, deterministic compare, safe render, redaction proof, and allowed exact internal wording, **When** the pack is evaluated, **Then** the pack returns `certification_passed`.
|
||||||
|
2. **Given** either `conditionalAccessPolicy` or `securityDefaults` is missing, blocked, identity-unstable, non-renderable, or redaction-unsafe, **When** the pack is evaluated, **Then** the pack returns the matching blocker state and does not certify.
|
||||||
|
3. **Given** optional Entra types exist in the registry, **When** the pack denominator is evaluated, **Then** only `conditionalAccessPolicy` and `securityDefaults` are included.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Block Unsafe Certification Claims (Priority: P1)
|
||||||
|
|
||||||
|
As a product/release reviewer, I need Claim Guard to allow only exact internal/operator pack wording and block broad Entra, M365, restore, customer-ready, legal/regulatory, and full-tenant proof wording.
|
||||||
|
|
||||||
|
**Why this priority**: Certification wording is the biggest risk in this spec.
|
||||||
|
|
||||||
|
**Independent Test**: Claim Guard tests can evaluate allowed and forbidden statements without UI or provider calls.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the exact wording "Certified Entra Core Compare Pack: Conditional Access and Security Defaults", **When** Claim Guard evaluates it for internal/operator use and the evaluator has passed, **Then** the wording is allowed as internal/operator-only.
|
||||||
|
2. **Given** "Certified Entra coverage", "100% Entra coverage", "Entra restore-ready", "Certified Microsoft 365 coverage", or "Customer-ready Entra proof", **When** Claim Guard evaluates the claim, **Then** the claim is blocked.
|
||||||
|
3. **Given** exact pack wording without explicit denominator, **When** Claim Guard evaluates the claim, **Then** the claim is blocked or limited until the denominator is included.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Preserve Safety Boundaries (Priority: P2)
|
||||||
|
|
||||||
|
As a platform reviewer, I need proof that certification does not introduce restore/apply behavior, customer output, raw payload display, `tenant_id`, remote calls, or an Entra-specific mini-platform.
|
||||||
|
|
||||||
|
**Why this priority**: The pack is high risk if certification creates hidden product or architecture expansion.
|
||||||
|
|
||||||
|
**Independent Test**: Feature/static tests can assert no restore/customer/tenant_id/mini-platform artifacts and no provider calls during evaluation.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** certification evaluation runs, **When** the evaluator executes, **Then** no Graph, TCM, provider, or remote call is made.
|
||||||
|
2. **Given** runtime files are scanned, **When** Spec 425 changes are inspected, **Then** no `tenant_id`, Entra-specific table family, restore/apply action, new route/navigation/dashboard, or customer output path is introduced.
|
||||||
|
3. **Given** render output is evaluated, **When** summaries are inspected, **Then** raw payload, raw Graph response, credentials, secrets, authorization headers, cookies, certificate material, private keys, and raw permission context are absent.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- One denominator type has no current same-scope evidence for the evaluated workspace, managed environment, provider connection, and resource type.
|
||||||
|
- One denominator type has current same-scope evidence but `identity_state` is not `stable`.
|
||||||
|
- One denominator type has evidence from the wrong workspace, managed environment, or provider connection.
|
||||||
|
- Evidence is stale, superseded, missing an expected anchor/currentness marker, or would require fallback to first/latest outside the explicit evaluation scope.
|
||||||
|
- Conditional Access compare detects only volatile changes.
|
||||||
|
- Conditional Access compare detects unsupported fields.
|
||||||
|
- Security Defaults `enabled` changes from `null` to a boolean.
|
||||||
|
- Claim Guard exact pack wording is attempted before evaluator pass.
|
||||||
|
- A non-denominator Entra type is renderable but must remain non-certified.
|
||||||
|
- UI implementation becomes necessary after the initial no-new-surface plan.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-425-001**: The system MUST define the canonical internal pack name `entra_core_compare_certified`.
|
||||||
|
- **FR-425-002**: The system MUST define the human label `Certified Entra Core Compare Pack`.
|
||||||
|
- **FR-425-003**: The certified denominator MUST be exactly `conditionalAccessPolicy` and `securityDefaults`.
|
||||||
|
- **FR-425-004**: The denominator MUST fail certification if either mandatory type is missing, blocked, incomplete, or downgraded to warning.
|
||||||
|
- **FR-425-005**: The pack MUST exclude `application`, `servicePrincipal`, `roleDefinition`, `administrativeUnit`, `authenticationMethodsPolicy`, `identityProtectionPolicy`, `authorizationPolicy`, `crossTenantAccessPolicy`, `accessReview`, PIM resources, and all optional candidates.
|
||||||
|
- **FR-425-006**: The evaluator MUST require append-only current same-scope content-backed evidence with `raw_payload`, `normalized_payload`, deterministic `payload_hash`, source class, source contract, `captured_at`, and operation run linkage where capture was operation-backed.
|
||||||
|
- **FR-425-007**: The evaluator MUST require `identity_state = stable`.
|
||||||
|
- **FR-425-008**: The evaluator MUST block certification for `identity_conflict`, `missing_external_id`, `unsupported_identity`, and `derived`.
|
||||||
|
- **FR-425-009**: The evaluator MUST require deterministic typed compare support for both denominator types.
|
||||||
|
- **FR-425-010**: Conditional Access compare proof MUST cover policy state, included/excluded users/groups/roles, included/excluded apps/resources, conditions, grant controls, session controls, named locations when present, client app/device/platform conditions when present, volatile fields, and unsupported fields.
|
||||||
|
- **FR-425-011**: Security Defaults compare proof MUST cover enabled state, source identity, captured timestamp/evidence state, claim state, volatile fields, missing evidence, and identity blockers.
|
||||||
|
- **FR-425-012**: The evaluator MUST require operator-safe render summaries for both denominator types.
|
||||||
|
- **FR-425-013**: Render output MUST be understandable without default raw provider JSON.
|
||||||
|
- **FR-425-014**: Render/redaction output MUST hide tokens, secrets, password credential values, private keys, certificate material, authorization headers, cookies, raw payload, raw Graph response, raw permission context, and unredacted credentials.
|
||||||
|
- **FR-425-015**: Unsupported fields MUST remain diagnostics-only and MUST NOT silently produce certification.
|
||||||
|
- **FR-425-016**: Claim Guard MUST allow exact internal/operator certification wording only after the exact pack passes.
|
||||||
|
- **FR-425-017**: Claim Guard MUST block full Entra, 100 percent Entra, M365 certified, restore-ready, customer-ready proof, full tenant security proof, legal/regulatory attestation, and Review Pack/report output claims.
|
||||||
|
- **FR-425-018**: Any visible certified pack claim MUST include the exact denominator.
|
||||||
|
- **FR-425-019**: The supported scope `entra_core_compare_certified` MUST be internal/operator-only by default and customer-claim disabled.
|
||||||
|
- **FR-425-020**: The supported scope MUST use `required_minimum_coverage_level = certified`.
|
||||||
|
- **FR-425-021**: Because `securityDefaults` is currently Graph v1 fallback, any graph fallback allowance MUST be explicit, type-bounded to `securityDefaults`, and non-customer-claimable.
|
||||||
|
- **FR-425-022**: Certification state SHOULD be derived from existing Coverage v2 truth. New persisted certification tables are forbidden.
|
||||||
|
- **FR-425-023**: No Graph, TCM, provider, Microsoft docs, or remote call may occur during certification evaluation.
|
||||||
|
- **FR-425-024**: Evaluation through any route, command, service, or UI path MUST enforce workspace, managed-environment, provider-connection, and capability scope.
|
||||||
|
- **FR-425-025**: No restore/apply action, restore readiness, customer output, Review Pack/report/export/PDF output, new dashboard, new primary navigation, or new Entra mini-platform may be introduced.
|
||||||
|
- **FR-425-026**: No Coverage v2 ownership field, new persistence path, compatibility alias, dual-write target, fallback reader, or parallel scope key may use `tenant_id`.
|
||||||
|
- **FR-425-027**: Implementation close-out MUST include candidate gate result, dirty state before/after, files changed, certified denominator, certification matrix, claim matrix, redaction proof, no-restore proof, no-customer-claim proof, no-tenant_id proof, no-mini-platform proof, Product Surface decision, tests run, and deferred work.
|
||||||
|
- **FR-425-028**: The supported scope metadata MUST include description, `workload = entra`, exact `resource_type_denominator`, `allow_beta = false`, explicit graph fallback policy, claim label, and `customer_claims_allowed = false`.
|
||||||
|
- **FR-425-029**: Derived certification state output MUST include or map to `certification_not_evaluated`, `certification_passed`, `certification_blocked_missing_evidence`, `certification_blocked_identity`, `certification_blocked_compare`, `certification_blocked_render`, `certification_blocked_redaction`, and `certification_blocked_claim_guard`.
|
||||||
|
- **FR-425-030**: Certification evidence selection MUST NOT fallback to first/latest evidence outside the explicit workspace, managed-environment, provider-connection, resource-type, and currentness scope. Missing, stale, superseded, wrong-scope, or ambiguous evidence MUST block certification.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
N/A. No new Filament Resource, RelationManager, Page, action, bulk action, destructive action, or global search behavior is planned. If implementation changes Filament/UI runtime files, this spec and plan must be amended before those edits.
|
||||||
|
|
||||||
|
### Key Entities / Concepts *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Certified pack**: Derived internal/operator pack result for `entra_core_compare_certified`.
|
||||||
|
- **Denominator**: Exact two-resource set: `conditionalAccessPolicy`, `securityDefaults`.
|
||||||
|
- **Certification evaluator**: Derived DB-only evaluator that composes evidence, identity, compare, render, redaction, and claim criteria.
|
||||||
|
- **Certification blocker**: Derived reason such as missing evidence, identity blocked, compare blocked, render blocked, redaction blocked, or Claim Guard blocked.
|
||||||
|
- **Supported scope**: Existing Coverage v2 supported-scope mechanism carrying the pack key and denominator metadata.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-425-001**: Focused tests prove the denominator contains exactly two resource types and fails when either is absent.
|
||||||
|
- **SC-425-002**: Focused tests prove certification passes only when both denominator types meet all evidence, identity, compare, render, redaction, and claim criteria.
|
||||||
|
- **SC-425-003**: Focused tests prove all forbidden broad, restore, M365, customer, and legal/regulatory claims are blocked.
|
||||||
|
- **SC-425-004**: Focused tests prove raw payloads and sensitive values are absent from render/claim output.
|
||||||
|
- **SC-425-005**: Focused feature/static tests prove no restore/apply, customer output, `tenant_id`, Entra-specific table family, new route/navigation/dashboard, or mini-platform is introduced.
|
||||||
|
- **SC-425-006**: Focused validation commands and `git diff --check` pass, or exact failures are documented.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- **AC-425-001**: `entra_core_compare_certified` exists as an internal/operator supported scope or equivalent existing scope metadata.
|
||||||
|
- **AC-425-002**: The certified denominator is exactly `conditionalAccessPolicy` and `securityDefaults`.
|
||||||
|
- **AC-425-003**: Certification passes only when both mandatory denominator types pass all required criteria.
|
||||||
|
- **AC-425-004**: Certification produces explicit blocker states and does not downgrade mandatory failures to warnings.
|
||||||
|
- **AC-425-005**: Claim Guard allows exact internal/operator pack wording only and blocks full Entra, M365, restore, customer-ready, and broad proof claims.
|
||||||
|
- **AC-425-006**: Raw payload, secrets, credential values, authorization headers, cookies, certificate/private-key material, and raw permission context are hidden.
|
||||||
|
- **AC-425-007**: Certification evaluation is DB-only and makes no provider/Graph/TCM calls.
|
||||||
|
- **AC-425-008**: No restore/apply, customer output, full Entra/M365 certification, new dashboard, new navigation, new route, `tenant_id`, or Entra mini-platform is introduced.
|
||||||
|
- **AC-425-009**: If UI changes are made, focused browser proof and Human Product Sanity pass.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Current HEAD includes Spec 424 completion and its Security Defaults support.
|
||||||
|
- Conditional Access and Security Defaults evidence remains represented by existing Coverage v2 resource/evidence rows.
|
||||||
|
- `coverage_level = certified` may be derived for pack evaluation; resource evidence rows do not need to be mutated to `certified` unless the implementation proves existing supported-scope evaluation already persists such summaries.
|
||||||
|
- `allow_graph_fallback = true` for this pack is acceptable only because `securityDefaults` is explicitly listed, Graph v1-backed, non-beta, and internal/operator-only.
|
||||||
|
- Existing fixtures or inline payloads from Specs 421 and 424 can be reused as the basis for golden fixtures.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None blocking. If implementation discovers that certification must be persisted, queued, customer-rendered, or restore-aware, stop and amend the spec before continuing.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
|---|---:|---|
|
||||||
|
| Certified compare misunderstood as restore-ready | High | Claim Guard, no-restore proof, visible no-restore wording if rendered |
|
||||||
|
| Certified pack misunderstood as full Entra coverage | High | Exact denominator required everywhere |
|
||||||
|
| Security Defaults regression reopens blocker | High | Mandatory denominator and missing-evidence tests |
|
||||||
|
| Raw/secrets leak through render | High | Redaction unit/feature tests |
|
||||||
|
| Certification becomes separate mini-platform | High | No Entra table family, no dashboard, no new route/navigation |
|
||||||
|
| Customer-facing claims sneak in | High | Claim Guard and no-customer-output tests |
|
||||||
|
| `tenant_id` returns | High | Static/schema tests and implementation report proof |
|
||||||
|
| Remote calls during evaluation | High | Fail-hard provider client tests |
|
||||||
|
|
||||||
|
## Follow-Up Spec Candidates
|
||||||
|
|
||||||
|
- Spec 426 - Exchange/Teams Certified Compare Pack.
|
||||||
|
- Spec 427 - Security and Compliance Compare Pack.
|
||||||
|
- Spec 428 - M365 Customer Reporting Claim Guard Pack.
|
||||||
|
- Spec 429 - M365 Pilot Readiness Gate.
|
||||||
|
- Entra restore/apply.
|
||||||
|
- Full Entra certified coverage.
|
||||||
|
- Application/ServicePrincipal/RoleDefinition/AdministrativeUnit certification.
|
||||||
|
- Customer-facing Entra reports.
|
||||||
|
- Review Pack output.
|
||||||
|
- Management PDF output.
|
||||||
|
- M365-wide certification.
|
||||||
|
|
||||||
|
## Required Implementation Report Matrices
|
||||||
|
|
||||||
|
### Certification Matrix
|
||||||
|
|
||||||
|
| Resource Type | Evidence | Identity | Compare | Render | Redaction | Certified? | Blocker |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `conditionalAccessPolicy` | | | | | | | |
|
||||||
|
| `securityDefaults` | | | | | | | |
|
||||||
|
|
||||||
|
### Claim Matrix
|
||||||
|
|
||||||
|
| Claim | Allowed? | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| Certified Entra Core Compare Pack: Conditional Access and Security Defaults | Yes, internal/operator only | Exact denominator |
|
||||||
|
| 100% Entra coverage | No | Broad overclaim |
|
||||||
|
| Entra restore-ready | No | Restore out of scope |
|
||||||
|
| Certified Microsoft 365 coverage | No | Broad overclaim |
|
||||||
|
| Customer-ready Entra proof | No | Customer output deferred |
|
||||||
160
specs/425-entra-certified-compare-pack/tasks.md
Normal file
160
specs/425-entra-certified-compare-pack/tasks.md
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
# Tasks: Spec 425 - Entra Certified Compare Pack
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/425-entra-certified-compare-pack/`
|
||||||
|
**Prerequisites**: [spec.md](./spec.md), [plan.md](./plan.md), [checklists/requirements.md](./checklists/requirements.md)
|
||||||
|
|
||||||
|
**Tests**: Required. This spec changes runtime certification behavior and claim safety. Use focused Pest Unit/Feature tests first. Browser proof is required only if rendered UI changes.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
|
||||||
|
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
|
||||||
|
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||||
|
- [x] Browser proof is explicitly `N/A - no rendered UI surface changed` unless rendered UI changes.
|
||||||
|
- [x] Human Product Sanity and Product Surface implementation-report close-out are planned if UI changes.
|
||||||
|
- [x] Any material budget, baseline, trend, or escalation note is recorded in the implementation report.
|
||||||
|
|
||||||
|
## Phase 1: Hard Preflight
|
||||||
|
|
||||||
|
**Purpose**: Re-check the user-provided prerequisite gate before runtime implementation. Stop before code changes if this phase fails.
|
||||||
|
|
||||||
|
- [x] T001 Capture current branch, HEAD, and `git status --short` in `specs/425-entra-certified-compare-pack/implementation-report.md`.
|
||||||
|
- [x] T002 Confirm Specs 414, 415, 417, 418, 419, 420, 421, and 424 remain completed/read-only dependency context; do not edit their artifacts.
|
||||||
|
- [x] T003 Confirm `conditionalAccessPolicy` is content-backed, comparable, renderable, redacted, non-restorable, internal/operator-only, and stable-identity backed in current source/tests.
|
||||||
|
- [x] T004 Confirm `securityDefaults` is content-backed, comparable, renderable, redacted, non-restorable, internal/operator-only, and stable-identity backed in current source/tests.
|
||||||
|
- [x] T005 Confirm current Coverage v2 ownership paths use `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id`, not `tenant_id`.
|
||||||
|
- [x] T006 Stop and report the blocker before implementation if either mandatory denominator type lacks evidence, stable identity, compare, render, redaction, or safe claim posture.
|
||||||
|
|
||||||
|
**Checkpoint**: Mandatory denominator preflight passes or implementation stops.
|
||||||
|
|
||||||
|
## Phase 2: Fixtures And Failing Tests
|
||||||
|
|
||||||
|
**Purpose**: Add focused proof before runtime changes.
|
||||||
|
|
||||||
|
- [x] T007 [P] Add Conditional Access golden fixture payloads under `apps/platform/tests/Fixtures/TenantConfiguration/Spec425/conditional-access/` for no change, state change, grant controls, included actor, excluded actor, app/resource targeting, condition, session control, volatile-only change, unsupported field, and redaction cases.
|
||||||
|
- [x] T008 [P] Add Security Defaults golden fixture payloads under `apps/platform/tests/Fixtures/TenantConfiguration/Spec425/security-defaults/` for no change, enabled true/false change, volatile-only change, missing evidence, identity blocked, and redaction cases.
|
||||||
|
- [x] T009 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/EntraCertifiedDenominatorTest.php` proving the denominator is exactly `conditionalAccessPolicy` and `securityDefaults`, excludes optional Entra types, and cannot ignore a missing denominator item.
|
||||||
|
- [x] T010 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/EntraCertifiedPackEvaluatorTest.php` proving not-evaluated, pass, missing evidence blockers, stale/superseded evidence blockers, wrong-scope evidence blockers, no fallback-to-first/latest behavior, identity blockers, compare blockers, render blockers, redaction blockers, and Claim Guard blockers.
|
||||||
|
- [x] T011 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/ConditionalAccessCertifiedCompareTest.php` proving Conditional Access no-change, state, grant controls, included/excluded actors, app/resource targeting, conditions, session controls, volatile fields, unsupported fields, and raw payload hiding behavior.
|
||||||
|
- [x] T012 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/SecurityDefaultsCertifiedCompareTest.php` proving enabled changes, no-change, volatile fields, missing evidence, identity blocked, raw payload hiding, and exact claim gating.
|
||||||
|
- [x] T013 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/EntraCertifiedRenderRedactionTest.php` proving tokens, secrets, credential values, private keys, certificate material, authorization headers, cookies, raw payload, raw Graph response, and raw permission context are absent from certification output.
|
||||||
|
- [x] T014 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/EntraCertifiedPackClaimGuardTest.php` proving exact internal/operator wording is allowed only with the explicit denominator and broad/full/restore/M365/customer claims are blocked.
|
||||||
|
- [x] T015 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec425EntraCertifiedComparePackTest.php` proving the certified pack passes only when both mandatory resource types pass every criterion.
|
||||||
|
- [x] T016 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec425EntraCertifiedDenominatorFeatureTest.php` proving supported-scope denominator integrity, exact two-type denominator, graph fallback allowlist for `securityDefaults`, and non-denominator exclusions.
|
||||||
|
- [x] T017 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec425EntraCertifiedClaimGuardFeatureTest.php` proving exact pack claims are internal/operator-only and broad claims remain blocked.
|
||||||
|
- [x] T018 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoRestoreTest.php` proving no restore/apply action, restore-ready state, or restorable tier is introduced.
|
||||||
|
- [x] T019 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoCustomerClaimTest.php` proving no customer-facing claim, Review Pack/report/export/PDF output, or customer-ready proof activation.
|
||||||
|
- [x] T020 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoTenantIdTest.php` proving Spec 425 runtime changes do not introduce `tenant_id`.
|
||||||
|
- [x] T021 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoMiniPlatformTest.php` proving no Entra-specific migration, table family, model, route, navigation item, Filament Resource/Page, dashboard, or mini-platform is added.
|
||||||
|
- [x] T022 [P] Add a fail-hard provider/Graph assertion in the focused evaluator/read-model tests proving certification evaluation makes no Graph, TCM, provider, Microsoft docs, or other remote call.
|
||||||
|
|
||||||
|
**Checkpoint**: New focused tests fail for missing implementation and pass after later phases.
|
||||||
|
|
||||||
|
## Phase 3: Certified Scope And Denominator
|
||||||
|
|
||||||
|
**Purpose**: Define the exact internal/operator certified pack scope without broad claims.
|
||||||
|
|
||||||
|
- [x] T023 Update `apps/platform/app/Services/TenantConfiguration/SupportedScopeResolver.php` to add `entra_core_compare_certified` with description, workload `entra`, display name `Certified Entra Core Compare Pack`, denominator `conditionalAccessPolicy` and `securityDefaults`, minimum coverage level `certified`, `allow_beta = false`, claim label, `customer_claims_allowed = false`, and metadata documenting internal/operator-only posture.
|
||||||
|
- [x] T024 In `SupportedScopeResolver.php`, encode the `securityDefaults` Graph v1 fallback allowance explicitly, preferably with metadata allowlist such as `graph_fallback_allowlist = ["securityDefaults"]`; do not make broad graph fallback claims customer-claimable.
|
||||||
|
- [x] T025 Ensure `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php` does not mark optional Entra resource types as certified, customer-claimable, or restore-ready.
|
||||||
|
- [x] T026 Ensure the denominator definition cannot silently include `application`, `servicePrincipal`, `roleDefinition`, `administrativeUnit`, `authenticationMethodsPolicy`, `identityProtectionPolicy`, `authorizationPolicy`, `crossTenantAccessPolicy`, `accessReview`, or PIM resources.
|
||||||
|
|
||||||
|
**Checkpoint**: Supported scope exists and denominator integrity tests pass.
|
||||||
|
|
||||||
|
## Phase 4: Certification Evaluator
|
||||||
|
|
||||||
|
**Purpose**: Derive certification from existing Coverage v2 truth without new persistence.
|
||||||
|
|
||||||
|
- [x] T027 Add `apps/platform/app/Services/TenantConfiguration/EntraCertifiedComparePackEvaluator.php` only if existing supported-scope evaluation cannot produce the required certification matrix.
|
||||||
|
- [x] T028 If a result carrier is needed, add a narrow non-persisted result class under `apps/platform/app/Services/TenantConfiguration/` and keep certification states derived strings rather than a persisted enum/status family, including `certification_not_evaluated`, `certification_passed`, `certification_blocked_missing_evidence`, `certification_blocked_identity`, `certification_blocked_compare`, `certification_blocked_render`, `certification_blocked_redaction`, and `certification_blocked_claim_guard`.
|
||||||
|
- [x] T029 Implement exact denominator loading in the evaluator with same workspace, managed-environment, and provider-connection scope checks.
|
||||||
|
- [x] T030 Implement evidence criteria checks: current same-scope content-backed evidence, append-only evidence row, raw payload present, normalized payload present, deterministic payload hash, source class, source contract, captured timestamp, operation run linkage when capture was operation-backed, stale/superseded/missing-currentness blockers, and no fallback to first/latest or wrong-scope evidence.
|
||||||
|
- [x] T031 Implement identity criteria checks requiring `IdentityState::Stable` and blocking `derived`, `identity_conflict`, `missing_external_id`, and `unsupported_identity`.
|
||||||
|
- [x] T032 Implement compare criteria checks by reusing `EntraCoverageComparator` and proving material, volatile, unsupported, and redacted paths are classified deterministically.
|
||||||
|
- [x] T033 Implement render criteria checks by reusing `EntraRenderableSummaryBuilder` and requiring operator-safe summaries for both denominator types.
|
||||||
|
- [x] T034 Implement redaction criteria checks by reusing `CoveragePayloadRedactor` and asserting no sensitive raw values appear in evaluator/render/claim output.
|
||||||
|
- [x] T035 Implement Claim Guard criteria checks by requiring exact internal/operator pack wording and explicit denominator visibility.
|
||||||
|
- [x] T036 Ensure missing mandatory denominator items, failed mandatory criteria, unsupported fields that would make certification ambiguous, and non-deterministic compare output produce explicit blocker states rather than warnings.
|
||||||
|
- [x] T037 Ensure evaluator execution is DB-only and does not call `ProviderGateway`, `GraphClientInterface`, TCM, Microsoft docs, HTTP, queued jobs, or OperationRun creation.
|
||||||
|
|
||||||
|
**Checkpoint**: Evaluator unit and feature tests pass.
|
||||||
|
|
||||||
|
## Phase 5: Claim Guard Exact Wording
|
||||||
|
|
||||||
|
**Purpose**: Allow exact internal/operator certification wording while blocking overclaims.
|
||||||
|
|
||||||
|
- [x] T038 Update `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php` to allow exact internal/operator visible wording only for `Certified Entra Core Compare Pack: Conditional Access and Security Defaults`; the bare pack label may exist only as internal scope metadata or a diagnostic row heading when the same visible context includes the denominator.
|
||||||
|
- [x] T039 Require exact denominator visibility for any certified pack wording; block or limit certification wording that omits the denominator.
|
||||||
|
- [x] T040 Block forbidden wording: `Certified Entra coverage`, `100% Entra coverage`, `Full Entra coverage`, `Entra restore-ready`, `Certified Microsoft 365 coverage`, `Customer-ready Entra proof`, `Full tenant security proof`, legal/regulatory attestation claims, and Review Pack/report proof claims.
|
||||||
|
- [x] T041 Keep Claim Guard default behavior conservative for all non-425 claims; do not weaken existing Spec 421, 422, 423, or 424 claim-blocking behavior.
|
||||||
|
|
||||||
|
**Checkpoint**: Unit and feature Claim Guard tests pass.
|
||||||
|
|
||||||
|
## Phase 6: Product Surface Decision
|
||||||
|
|
||||||
|
**Purpose**: Keep UI scope bounded and browser-proof only if rendered UI changes.
|
||||||
|
|
||||||
|
- [x] T042 Determine whether the certification pack result can remain service/config/test-only. If yes, record `N/A - no rendered UI surface changed` in `implementation-report.md`.
|
||||||
|
- [x] T043 If rendered UI changes are necessary, amend `spec.md`, `plan.md`, and this `tasks.md` before editing UI files with exact affected surfaces, Product Surface decisions, browser proof path, and Human Product Sanity criteria. N/A - no rendered UI surface changed.
|
||||||
|
- [x] T044 If UI changes proceed after amendment, update only the existing Coverage v2 readiness/read-model/inspect path; do not add a new route, navigation item, dashboard, customer output, report/export/PDF, restore action, or primary Entra surface. N/A - no rendered UI surface changed.
|
||||||
|
- [x] T045 If UI changes proceed after amendment, add `apps/platform/tests/Browser/Spec425EntraCertifiedComparePackOperatorSurfaceSmokeTest.php` proving certified pack state, exact denominator, internal/operator-only label, no restore-ready/full-Entra/M365/customer claim, no raw payload/secrets, and no console/Livewire/Filament errors. N/A - no rendered UI surface changed.
|
||||||
|
|
||||||
|
**Checkpoint**: Product Surface decision is explicit and not contradicted by changed files.
|
||||||
|
|
||||||
|
## Phase 7: Architecture And Safety Guards
|
||||||
|
|
||||||
|
**Purpose**: Prove no hidden scope expansion or ownership drift.
|
||||||
|
|
||||||
|
- [x] T046 Ensure no migration creates `entra_certifications`, `certified_entra_resources`, or any Entra-specific certification table family.
|
||||||
|
- [x] T047 Ensure no code introduces `tenant_id` as Coverage v2 ownership truth, compatibility alias, fallback reader, dual-write target, or parallel scope key.
|
||||||
|
- [x] T048 Ensure no restore/apply, preview restore, assisted restore, or restore-readiness code path is introduced.
|
||||||
|
- [x] T049 Ensure no customer output, Review Pack, rendered report, management PDF, export/download, legal/regulatory attestation, or customer-ready proof path is introduced.
|
||||||
|
- [x] T050 Ensure no new Filament Resource/Page/Widget, route, navigation item, dashboard, or primary Entra surface is introduced.
|
||||||
|
- [x] T051 Add or extend focused feature/service tests proving non-member access remains deny-as-not-found (404), member without capability remains 403, provider connection scope remains same workspace/environment, and pure service-only evaluation uses explicit same-scope inputs where any service, command, route, or UI invocation boundary exists.
|
||||||
|
|
||||||
|
**Checkpoint**: No-overreach feature/static tests pass.
|
||||||
|
|
||||||
|
## Phase 8: Implementation Report And Validation
|
||||||
|
|
||||||
|
**Purpose**: Close the prep-defined evidence contract for implementation.
|
||||||
|
|
||||||
|
- [x] T052 Create `specs/425-entra-certified-compare-pack/implementation-report.md` with candidate gate result, dirty state before/after, files changed, certified denominator, evaluator matrix, claim matrix, redaction proof, no-restore proof, no-customer-claim proof, no-tenant_id proof, no-mini-platform proof, Product Surface decision, tests run, deferred work, and final gate result.
|
||||||
|
- [x] T053 Complete the certification matrix in `implementation-report.md` for `conditionalAccessPolicy` and `securityDefaults`.
|
||||||
|
- [x] T054 Complete the claim matrix in `implementation-report.md` for exact denominator-visible pack claim, 100 percent Entra, restore-ready, Microsoft 365 certified, and customer-ready proof.
|
||||||
|
- [x] T055 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
|
- [x] T056 Run focused unit tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/EntraCertifiedPackEvaluatorTest.php tests/Unit/Support/TenantConfiguration/EntraCertifiedPackClaimGuardTest.php tests/Unit/Support/TenantConfiguration/ConditionalAccessCertifiedCompareTest.php tests/Unit/Support/TenantConfiguration/SecurityDefaultsCertifiedCompareTest.php tests/Unit/Support/TenantConfiguration/EntraCertifiedRenderRedactionTest.php tests/Unit/Support/TenantConfiguration/EntraCertifiedDenominatorTest.php`.
|
||||||
|
- [x] T057 Run focused feature tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec425EntraCertifiedComparePackTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedClaimGuardFeatureTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoRestoreTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoCustomerClaimTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedNoMiniPlatformTest.php tests/Feature/TenantConfiguration/Spec425EntraCertifiedDenominatorFeatureTest.php`.
|
||||||
|
- [x] T058 If UI changed, run focused browser test: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec425EntraCertifiedComparePackOperatorSurfaceSmokeTest.php`. N/A - no rendered UI surface changed.
|
||||||
|
- [x] T059 Run `git diff --check`.
|
||||||
|
- [x] T060 Record any failed validation exactly in `implementation-report.md`; do not weaken certification, denominator, claim, redaction, ownership, no-restore, or no-mini-platform criteria to make tests pass.
|
||||||
|
|
||||||
|
**Checkpoint**: Focused validation passes or exact failures are documented.
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
- Phase 1 blocks all runtime implementation.
|
||||||
|
- Phase 2 tests should be added before or alongside Phases 3-5 implementation.
|
||||||
|
- Phase 3 scope definition blocks evaluator pass behavior.
|
||||||
|
- Phase 4 evaluator depends on existing Coverage v2 evidence/identity/compare/render helpers.
|
||||||
|
- Phase 5 Claim Guard updates depend on exact pack wording from the spec.
|
||||||
|
- Phase 6 must complete before any runtime UI edits.
|
||||||
|
- Phase 8 completes after all implementation tasks and validation.
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
- T007-T014 can run in parallel after preflight because they touch different fixture/test files.
|
||||||
|
- T015-T022 can run in parallel after preflight because they touch different feature test files.
|
||||||
|
- T023-T026 should be coordinated because they share supported-scope/registry behavior.
|
||||||
|
- T027-T037 should be sequential within evaluator implementation.
|
||||||
|
- T046-T051 can run in parallel with final static/feature guard hardening once implementation files stabilize.
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
- A mandatory denominator type cannot satisfy evidence, stable identity, compare, render, redaction, or claim criteria.
|
||||||
|
- The denominator changes from exactly `conditionalAccessPolicy` plus `securityDefaults`.
|
||||||
|
- Any restore/apply, customer output, Review Pack/report/PDF/export, full Entra/M365 certification, or legal/regulatory attestation scope appears.
|
||||||
|
- A new Entra-specific table family, dashboard, route, navigation item, primary surface, or mini-platform appears.
|
||||||
|
- `tenant_id` is introduced as platform-core ownership truth or compatibility/fallback path.
|
||||||
|
- Certification evaluation requires remote calls, queues, or a new OperationRun.
|
||||||
|
- Raw payloads or sensitive values become default-visible or leak into reports/logs/notifications.
|
||||||
Loading…
Reference in New Issue
Block a user