TenantAtlas/apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php
ahmido 6a5b8a3a11
Some checks failed
Main Confidence / confidence (push) Failing after 50s
feat: canonical control catalog foundation (#272)
## Summary
- add a config-seeded canonical control catalog plus shared resolution primitives and Microsoft subject bindings
- propagate canonical control references into findings-derived evidence snapshots and tenant review composition
- add the feature spec artifacts and focused Pest coverage, plus the supporting workspace and Sail helper adjustments included in this branch

## Testing
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php tests/Feature/PlatformRelocation/CommandModelSmokeTest.php
- cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #272
2026-04-24 12:26:02 +00:00

132 lines
5.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use InvalidArgumentException;
final readonly class CanonicalControlDefinition
{
/**
* @param list<EvidenceArchetype> $evidenceArchetypes
*/
public function __construct(
public string $controlKey,
public string $name,
public string $domainKey,
public string $subdomainKey,
public string $controlClass,
public string $summary,
public string $operatorDescription,
public DetectabilityClass $detectabilityClass,
public EvaluationStrategy $evaluationStrategy,
public array $evidenceArchetypes,
public ArtifactSuitability $artifactSuitability,
public string $historicalStatus = 'active',
) {
foreach ([
'control key' => $this->controlKey,
'name' => $this->name,
'domain key' => $this->domainKey,
'subdomain key' => $this->subdomainKey,
'control class' => $this->controlClass,
'summary' => $this->summary,
'operator description' => $this->operatorDescription,
'historical status' => $this->historicalStatus,
] as $label => $value) {
if (trim($value) === '') {
throw new InvalidArgumentException(sprintf('Canonical control definitions require a non-empty %s.', $label));
}
}
if ($this->controlKey !== mb_strtolower($this->controlKey) || preg_match('/^[a-z][a-z0-9_]*$/', $this->controlKey) !== 1) {
throw new InvalidArgumentException(sprintf('Canonical control key [%s] must be a lowercase provider-neutral slug.', $this->controlKey));
}
if (! in_array($this->historicalStatus, ['active', 'retired'], true)) {
throw new InvalidArgumentException(sprintf('Canonical control [%s] has an unsupported historical status.', $this->controlKey));
}
if ($this->evidenceArchetypes === []) {
throw new InvalidArgumentException(sprintf('Canonical control [%s] must declare at least one evidence archetype.', $this->controlKey));
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
controlKey: (string) ($data['control_key'] ?? ''),
name: (string) ($data['name'] ?? ''),
domainKey: (string) ($data['domain_key'] ?? ''),
subdomainKey: (string) ($data['subdomain_key'] ?? ''),
controlClass: (string) ($data['control_class'] ?? ''),
summary: (string) ($data['summary'] ?? ''),
operatorDescription: (string) ($data['operator_description'] ?? ''),
detectabilityClass: DetectabilityClass::from((string) ($data['detectability_class'] ?? '')),
evaluationStrategy: EvaluationStrategy::from((string) ($data['evaluation_strategy'] ?? '')),
evidenceArchetypes: self::evidenceArchetypes($data['evidence_archetypes'] ?? []),
artifactSuitability: ArtifactSuitability::fromArray(is_array($data['artifact_suitability'] ?? null) ? $data['artifact_suitability'] : []),
historicalStatus: (string) ($data['historical_status'] ?? 'active'),
);
}
/**
* @return array{
* control_key: string,
* name: string,
* domain_key: string,
* subdomain_key: string,
* control_class: string,
* summary: string,
* operator_description: string,
* detectability_class: string,
* evaluation_strategy: string,
* evidence_archetypes: list<string>,
* artifact_suitability: array{baseline: bool, drift: bool, finding: bool, exception: bool, evidence: bool, review: bool, report: bool},
* historical_status: string
* }
*/
public function toArray(): array
{
return [
'control_key' => $this->controlKey,
'name' => $this->name,
'domain_key' => $this->domainKey,
'subdomain_key' => $this->subdomainKey,
'control_class' => $this->controlClass,
'summary' => $this->summary,
'operator_description' => $this->operatorDescription,
'detectability_class' => $this->detectabilityClass->value,
'evaluation_strategy' => $this->evaluationStrategy->value,
'evidence_archetypes' => array_map(
static fn (EvidenceArchetype $archetype): string => $archetype->value,
$this->evidenceArchetypes,
),
'artifact_suitability' => $this->artifactSuitability->toArray(),
'historical_status' => $this->historicalStatus,
];
}
public function isRetired(): bool
{
return $this->historicalStatus === 'retired';
}
/**
* @param iterable<mixed> $values
* @return list<EvidenceArchetype>
*/
private static function evidenceArchetypes(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): EvidenceArchetype => EvidenceArchetype::from(trim($value)))
->values()
->all();
}
}