TenantAtlas/apps/platform/app/Support/Governance/Controls/MicrosoftSubjectBinding.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

133 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use InvalidArgumentException;
final readonly class MicrosoftSubjectBinding
{
/**
* @param list<string> $signalKeys
* @param list<string> $supportedContexts
*/
public function __construct(
public string $controlKey,
public ?string $subjectFamilyKey,
public ?string $workload,
public array $signalKeys,
public array $supportedContexts,
public bool $primary = false,
public ?string $notes = null,
) {
if (trim($this->controlKey) === '') {
throw new InvalidArgumentException('Microsoft subject bindings require a canonical control key.');
}
if ($this->subjectFamilyKey === null && $this->workload === null && $this->signalKeys === []) {
throw new InvalidArgumentException(sprintf('Microsoft subject binding for [%s] requires at least one discriminator.', $this->controlKey));
}
if ($this->supportedContexts === []) {
throw new InvalidArgumentException(sprintf('Microsoft subject binding for [%s] requires at least one supported context.', $this->controlKey));
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(string $controlKey, array $data): self
{
return new self(
controlKey: $controlKey,
subjectFamilyKey: self::optionalString($data['subject_family_key'] ?? null),
workload: self::optionalString($data['workload'] ?? null),
signalKeys: self::stringList($data['signal_keys'] ?? []),
supportedContexts: self::stringList($data['supported_contexts'] ?? []),
primary: (bool) ($data['primary'] ?? false),
notes: self::optionalString($data['notes'] ?? null),
);
}
public function supportsContext(string $consumerContext): bool
{
return in_array(trim($consumerContext), $this->supportedContexts, true);
}
public function matches(CanonicalControlResolutionRequest $request): bool
{
if ($request->provider !== 'microsoft') {
return false;
}
if (! $this->supportsContext($request->consumerContext)) {
return false;
}
if ($request->subjectFamilyKey !== null && $this->subjectFamilyKey !== $request->subjectFamilyKey) {
return false;
}
if ($request->workload !== null && $this->workload !== $request->workload) {
return false;
}
if ($request->signalKey !== null && ! in_array($request->signalKey, $this->signalKeys, true)) {
return false;
}
return true;
}
/**
* @return array{
* control_key: string,
* provider: string,
* subject_family_key: ?string,
* workload: ?string,
* signal_keys: list<string>,
* supported_contexts: list<string>,
* primary: bool,
* notes: ?string
* }
*/
public function toArray(): array
{
return [
'control_key' => $this->controlKey,
'provider' => 'microsoft',
'subject_family_key' => $this->subjectFamilyKey,
'workload' => $this->workload,
'signal_keys' => $this->signalKeys,
'supported_contexts' => $this->supportedContexts,
'primary' => $this->primary,
'notes' => $this->notes,
];
}
private static function optionalString(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
/**
* @param iterable<mixed> $values
* @return list<string>
*/
private static function stringList(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): string => trim($value))
->values()
->all();
}
}