Implements the bounded Spec 421 Entra comparable/renderable pack on the existing Coverage v2 operator surface. - Adds typed Conditional Access normalization, comparison, and render summaries - Keeps Security Defaults and other optional Entra types deferred until evidence-backed - Preserves the existing Coverage v2 surface with claim-guard and redaction hardening - Includes focused unit, feature, and browser coverage already recorded in the implementation report Validation is documented in `specs/421-entra-core-comparable-renderable-pack/implementation-report.md`. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #488
265 lines
11 KiB
PHP
265 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\TenantConfigurationResource;
|
|
use App\Models\TenantConfigurationResourceEvidence;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\Graph\GraphResponse;
|
|
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
|
|
use App\Services\TenantConfiguration\GenericContentEvidenceCaptureService;
|
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\TenantConfiguration\CanonicalKeyKind;
|
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
|
use App\Support\TenantConfiguration\ClaimState;
|
|
use App\Support\TenantConfiguration\CoverageLevel;
|
|
use App\Support\TenantConfiguration\EvidenceState;
|
|
use App\Support\TenantConfiguration\IdentityState;
|
|
use App\Support\TenantConfiguration\SourceClass;
|
|
|
|
it('Spec421 exposes typed Conditional Access summaries with material compare details without provider calls', function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
]);
|
|
app()->instance(GraphClientInterface::class, spec421InspectCaptureGraphClient([
|
|
spec421InspectConditionalAccessPayload([
|
|
'grantControls' => ['builtInControls' => ['compliantDevice']],
|
|
]),
|
|
spec421InspectConditionalAccessPayload(),
|
|
]));
|
|
|
|
app(GenericContentEvidenceCaptureService::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: spec421InspectRun($user, $environment, $connection),
|
|
canonicalTypes: ['conditionalAccessPolicy'],
|
|
);
|
|
app(GenericContentEvidenceCaptureService::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: spec421InspectRun($user, $environment, $connection),
|
|
canonicalTypes: ['conditionalAccessPolicy'],
|
|
);
|
|
|
|
$resource = TenantConfigurationResource::query()->sole();
|
|
app()->instance(GraphClientInterface::class, spec421FailingGraphClient());
|
|
|
|
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $environment, $user);
|
|
$summary = $details['typed_render_summary'] ?? null;
|
|
|
|
expect($summary)->toBeArray()
|
|
->and($summary['display_name'])->toBe('Require MFA')
|
|
->and($summary['state'])->toBe('enabled')
|
|
->and(json_encode($summary, JSON_THROW_ON_ERROR))->toContain('All')
|
|
->and(json_encode($summary, JSON_THROW_ON_ERROR))->not->toContain('raw_payload')
|
|
->and(json_encode($summary, JSON_THROW_ON_ERROR))->not->toContain('source_endpoint')
|
|
->and($summary['compare_summary']['status'])->toBe('Material changes detected')
|
|
->and($summary['compare_summary']['changed'])->toBeTrue()
|
|
->and(collect($summary['compare_summary']['changes'])->pluck('label'))
|
|
->toContain('Grant Controls Built In Controls')
|
|
->and(json_encode($summary['compare_summary'], JSON_THROW_ON_ERROR))
|
|
->not->toContain('compliantDevice')
|
|
->not->toContain('mfa');
|
|
});
|
|
|
|
it('Spec421 does not render typed summaries for non-content-backed latest evidence', function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
]);
|
|
$resourceType = spec421ConditionalAccessResourceType();
|
|
$resource = TenantConfigurationResource::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $resourceType->getKey(),
|
|
'canonical_type' => 'conditionalAccessPolicy',
|
|
'canonical_resource_key' => 'conditionalAccessPolicy:graph_object_id:cap-blocked-421',
|
|
'canonical_key_kind' => CanonicalKeyKind::GraphObjectId->value,
|
|
'source_resource_id' => 'cap-blocked-421',
|
|
'source_display_name' => 'Spec421 blocked Conditional Access policy',
|
|
'source_class' => SourceClass::Tcm->value,
|
|
'latest_evidence_state' => EvidenceState::PermissionBlocked->value,
|
|
'latest_identity_state' => IdentityState::Stable->value,
|
|
'latest_claim_state' => ClaimState::ClaimBlocked->value,
|
|
'latest_captured_at' => now(),
|
|
]);
|
|
$evidence = TenantConfigurationResourceEvidence::factory()->create([
|
|
'resource_id' => (int) $resource->getKey(),
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $resourceType->getKey(),
|
|
'source_contract_key' => 'conditionalAccessPolicy',
|
|
'source_endpoint' => '/identity/conditionalAccess/policies',
|
|
'normalized_payload' => spec421InspectConditionalAccessPayload(),
|
|
'evidence_state' => EvidenceState::PermissionBlocked->value,
|
|
'coverage_level' => CoverageLevel::Renderable->value,
|
|
'capture_outcome' => CaptureOutcome::BlockedPermission->value,
|
|
'captured_at' => now(),
|
|
]);
|
|
|
|
$resource->forceFill([
|
|
'latest_evidence_id' => (int) $evidence->getKey(),
|
|
'latest_payload_hash' => $evidence->payload_hash,
|
|
])->save();
|
|
|
|
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $environment, $user);
|
|
|
|
expect($details['typed_render_summary'] ?? null)->toBeNull();
|
|
});
|
|
|
|
it('Spec421 returns no inspect details when the managed environment scope does not match', function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
[, $foreignEnvironment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
]);
|
|
app()->instance(GraphClientInterface::class, spec421InspectCaptureGraphClient());
|
|
|
|
app(GenericContentEvidenceCaptureService::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: spec421InspectRun($user, $environment, $connection),
|
|
canonicalTypes: ['conditionalAccessPolicy'],
|
|
);
|
|
|
|
expect(app(CoverageV2ReadinessReadModel::class)->inspectDetails(
|
|
TenantConfigurationResource::query()->sole(),
|
|
$foreignEnvironment,
|
|
$user,
|
|
))->toBe([]);
|
|
});
|
|
|
|
function spec421InspectRun($user, $environment, ProviderConnection $connection): OperationRun
|
|
{
|
|
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'context' => [
|
|
'target_scope' => [
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
'resource_types' => ['conditionalAccessPolicy'],
|
|
'required_capability' => 'evidence.manage',
|
|
],
|
|
]);
|
|
}
|
|
|
|
function spec421InspectCaptureGraphClient(array $payloads = []): GraphClientInterface
|
|
{
|
|
return new class($payloads) implements GraphClientInterface
|
|
{
|
|
/**
|
|
* @param list<array<string, mixed>> $payloads
|
|
*/
|
|
public function __construct(private array $payloads) {}
|
|
|
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(true, [array_shift($this->payloads) ?: spec421InspectConditionalAccessPayload()]);
|
|
}
|
|
|
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function getOrganization(array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
};
|
|
}
|
|
|
|
function spec421InspectConditionalAccessPayload(array $overrides = []): array
|
|
{
|
|
return array_replace_recursive([
|
|
'id' => 'cap-1',
|
|
'displayName' => 'Require MFA',
|
|
'state' => 'enabled',
|
|
'conditions' => [
|
|
'users' => ['includeUsers' => ['All']],
|
|
'applications' => ['includeApplications' => ['Office365']],
|
|
],
|
|
'grantControls' => ['builtInControls' => ['mfa']],
|
|
], $overrides);
|
|
}
|
|
|
|
function spec421ConditionalAccessResourceType()
|
|
{
|
|
return \App\Models\TenantConfigurationResourceType::query()
|
|
->where('canonical_type', 'conditionalAccessPolicy')
|
|
->where('source_class', SourceClass::Tcm->value)
|
|
->firstOrFail();
|
|
}
|
|
|
|
function spec421FailingGraphClient(): GraphClientInterface
|
|
{
|
|
return new class implements GraphClientInterface
|
|
{
|
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
|
}
|
|
|
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
|
}
|
|
|
|
public function getOrganization(array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
|
}
|
|
|
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
|
}
|
|
|
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
|
}
|
|
|
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec421 render path must not call provider clients.');
|
|
}
|
|
};
|
|
}
|