Committing and publishing the current Spec 420 package changes. Includes updated services, coverage tests, browser smoke coverage, and the spec/plan/tasks artifacts for the package. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #487
213 lines
9.5 KiB
PHP
213 lines
9.5 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\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;
|
|
|
|
it('Spec420 captures Conditional Access evidence and blocks selected missing-contract types', function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
config()->set('graph_contracts.types.conditionalAccessPolicy.volatile_fields', ['modifiedDateTime']);
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'scopes_granted' => ['Policy.Read.All', 'Directory.Read.All'],
|
|
]);
|
|
$graph = spec420CaptureGraphClient([
|
|
'conditionalAccessPolicy' => [
|
|
[
|
|
'id' => 'cap-1',
|
|
'displayName' => 'Require MFA',
|
|
'state' => 'enabled',
|
|
'modifiedDateTime' => '2026-06-27T10:00:00Z',
|
|
'clientSecret' => 'spec420-provider-client-secret',
|
|
'tokenClaims' => [
|
|
'access_token' => 'spec420-provider-access-token',
|
|
],
|
|
'conditions' => ['users' => ['includeUsers' => ['All']]],
|
|
],
|
|
],
|
|
]);
|
|
app()->instance(GraphClientInterface::class, $graph);
|
|
|
|
$run = spec420CaptureRun($user, $environment, $connection, [
|
|
'conditionalAccessPolicy',
|
|
'acceptedDomain',
|
|
'appPermissionPolicy',
|
|
'dlpCompliancePolicy',
|
|
]);
|
|
|
|
$result = app(GenericContentEvidenceCaptureService::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalTypes: ['conditionalAccessPolicy', 'acceptedDomain', 'appPermissionPolicy', 'dlpCompliancePolicy'],
|
|
);
|
|
|
|
expect($graph->calls)->toHaveCount(1)
|
|
->and($graph->calls[0]['policy_type'])->toBe('conditionalAccessPolicy')
|
|
->and($result['summary_counts'])->toMatchArray([
|
|
'total' => 4,
|
|
'processed' => 4,
|
|
'succeeded' => 1,
|
|
'skipped' => 3,
|
|
'failed' => 0,
|
|
'errors_recorded' => 0,
|
|
])
|
|
->and($result['run_outcome'])->toBe(OperationRunOutcome::Succeeded->value);
|
|
|
|
$outcomes = collect($result['outcomes'])->keyBy('canonical_type');
|
|
|
|
expect($outcomes['conditionalAccessPolicy']['outcome'])->toBe(CaptureOutcome::Captured->value)
|
|
->and($outcomes['conditionalAccessPolicy']['source_contract_key'])->toBe('conditionalAccessPolicy')
|
|
->and($outcomes['acceptedDomain']['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value)
|
|
->and($outcomes['appPermissionPolicy']['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value)
|
|
->and($outcomes['dlpCompliancePolicy']['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value);
|
|
|
|
$resource = TenantConfigurationResource::query()->sole();
|
|
$evidence = TenantConfigurationResourceEvidence::query()->sole();
|
|
|
|
expect($resource->canonical_type)->toBe('conditionalAccessPolicy')
|
|
->and($resource->workspace_id)->toBe((int) $environment->workspace_id)
|
|
->and($resource->managed_environment_id)->toBe((int) $environment->getKey())
|
|
->and($resource->provider_connection_id)->toBe((int) $connection->getKey())
|
|
->and($resource->canonical_key_kind)->toBe(CanonicalKeyKind::GraphObjectId)
|
|
->and($resource->latest_identity_state)->toBe(IdentityState::Stable)
|
|
->and($resource->latest_claim_state)->toBe(ClaimState::InternalOnly)
|
|
->and($resource->source_identity['strategy_identifier'])->toBe('graph.conditional_access_policy.v1')
|
|
->and($resource->source_metadata['source_contract_key'])->toBe('conditionalAccessPolicy')
|
|
->and($resource->source_metadata['registry_source_class'])->toBe('tcm')
|
|
->and($resource->source_metadata['registry_support_state'])->toBe('out_of_scope');
|
|
|
|
expect($evidence->resource_id)->toBe((int) $resource->getKey())
|
|
->and($evidence->operation_run_id)->toBe((int) $run->getKey())
|
|
->and($evidence->source_contract_key)->toBe('conditionalAccessPolicy')
|
|
->and($evidence->source_endpoint)->toBe('/identity/conditionalAccess/policies')
|
|
->and($evidence->coverage_level)->toBe(CoverageLevel::ContentBacked)
|
|
->and($evidence->evidence_state)->toBe(EvidenceState::ContentBacked)
|
|
->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured)
|
|
->and($evidence->raw_payload['id'])->toBe('cap-1')
|
|
->and($evidence->raw_payload['clientSecret'])->toBe('spec420-provider-client-secret')
|
|
->and($evidence->normalized_payload)->not->toHaveKey('modifiedDateTime')
|
|
->and($evidence->normalized_payload['clientSecret'])->toBe('[redacted]')
|
|
->and($evidence->normalized_payload['tokenClaims'])->toBe('[redacted]')
|
|
->and(json_encode($evidence->normalized_payload, JSON_THROW_ON_ERROR))->not->toContain('spec420-provider-client-secret')
|
|
->and(json_encode($evidence->normalized_payload, JSON_THROW_ON_ERROR))->not->toContain('spec420-provider-access-token')
|
|
->and($evidence->payload_hash)->toBeString()->toHaveLength(64)
|
|
->and($evidence->permission_context['scopes_granted'])->toBe(['Policy.Read.All', 'Directory.Read.All']);
|
|
});
|
|
|
|
it('Spec420 creates no fake evidence rows for selected missing-contract types', function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
]);
|
|
$graph = spec420CaptureGraphClient([]);
|
|
app()->instance(GraphClientInterface::class, $graph);
|
|
|
|
$run = spec420CaptureRun($user, $environment, $connection, [
|
|
'acceptedDomain',
|
|
'appPermissionPolicy',
|
|
'dlpCompliancePolicy',
|
|
]);
|
|
|
|
$result = app(GenericContentEvidenceCaptureService::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalTypes: ['acceptedDomain', 'appPermissionPolicy', 'dlpCompliancePolicy'],
|
|
);
|
|
|
|
expect($graph->calls)->toBe([])
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0)
|
|
->and($result['summary_counts'])->toMatchArray([
|
|
'total' => 3,
|
|
'processed' => 3,
|
|
'succeeded' => 0,
|
|
'skipped' => 3,
|
|
'failed' => 0,
|
|
])
|
|
->and($result['run_outcome'])->toBe(OperationRunOutcome::Blocked->value);
|
|
});
|
|
|
|
function spec420CaptureRun($user, $environment, ProviderConnection $connection, array $resourceTypes): OperationRun
|
|
{
|
|
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'context' => [
|
|
'target_scope' => [
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
'resource_types' => $resourceTypes,
|
|
'required_capability' => 'evidence.manage',
|
|
],
|
|
]);
|
|
}
|
|
|
|
function spec420CaptureGraphClient(array $responses): GraphClientInterface
|
|
{
|
|
return new class($responses) implements GraphClientInterface
|
|
{
|
|
public array $calls = [];
|
|
|
|
public function __construct(private readonly array $responses) {}
|
|
|
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
{
|
|
$this->calls[] = ['policy_type' => $policyType, 'options' => $options];
|
|
|
|
return new GraphResponse(true, $this->responses[$policyType] ?? []);
|
|
}
|
|
|
|
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);
|
|
}
|
|
};
|
|
}
|