TenantAtlas/apps/platform/app/Services/Resources/ProviderResourceBindingService.php
Ahmed Darrazi fb2642e941
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m9s
feat(resources): implement provider resource identity binding
Added ProviderResourceBinding model, migrations, policies, and supporting framework for canonical resource identity mapping as defined in Spec 381.
2026-06-15 17:37:06 +02:00

447 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Resources;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\InventoryItem;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Models\ProviderConnection;
use App\Models\ProviderResourceBinding;
use App\Models\User;
use App\Services\Audit\AuditRecorder;
use App\Support\Audit\AuditActionId;
use App\Support\Audit\AuditActorSnapshot;
use App\Support\Audit\AuditOutcome;
use App\Support\Audit\AuditTargetSnapshot;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\SubjectClass;
use App\Support\Resources\ProviderResourceBindingStatus;
use App\Support\Resources\ProviderResourceResolutionMode;
use App\Support\Resources\ResourceIdentity;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use InvalidArgumentException;
final class ProviderResourceBindingService
{
public function __construct(
private readonly AuditRecorder $auditRecorder,
) {}
/**
* @param array<string, mixed> $attributes
*/
public function createExactProviderIdentity(User $actor, ManagedEnvironment|int $environment, ResourceIdentity $identity, array $attributes): ProviderResourceBinding
{
return $this->createDecision($actor, $environment, ProviderResourceResolutionMode::ExactProviderIdentity, $identity, $attributes);
}
/**
* @param array<string, mixed> $attributes
*/
public function createCanonicalBuiltin(User $actor, ManagedEnvironment|int $environment, ResourceIdentity $identity, array $attributes): ProviderResourceBinding
{
return $this->createDecision($actor, $environment, ProviderResourceResolutionMode::CanonicalBuiltin, $identity, $attributes);
}
/**
* @param array<string, mixed> $attributes
*/
public function createCanonicalVirtualTarget(User $actor, ManagedEnvironment|int $environment, ResourceIdentity $identity, array $attributes): ProviderResourceBinding
{
return $this->createDecision($actor, $environment, ProviderResourceResolutionMode::CanonicalVirtualTarget, $identity, $attributes);
}
/**
* @param array<string, mixed> $attributes
*/
public function createManualBinding(User $actor, ManagedEnvironment|int $environment, ResourceIdentity $identity, array $attributes): ProviderResourceBinding
{
return $this->createDecision($actor, $environment, ProviderResourceResolutionMode::ManualBinding, $identity, $attributes);
}
/**
* @param array<string, mixed> $attributes
*/
public function createExclusion(User $actor, ManagedEnvironment|int $environment, ResourceIdentity $identity, array $attributes): ProviderResourceBinding
{
return $this->createDecision($actor, $environment, ProviderResourceResolutionMode::ExcludedNonGoverned, $identity, $attributes);
}
/**
* @param array<string, mixed> $attributes
*/
public function createAcceptedLimitation(User $actor, ManagedEnvironment|int $environment, ResourceIdentity $identity, array $attributes): ProviderResourceBinding
{
return $this->createDecision($actor, $environment, ProviderResourceResolutionMode::AcceptedLimitation, $identity, $attributes);
}
/**
* @param array<string, mixed> $attributes
*/
public function markUnsupported(User $actor, ManagedEnvironment|int $environment, ResourceIdentity $identity, array $attributes): ProviderResourceBinding
{
return $this->createDecision($actor, $environment, ProviderResourceResolutionMode::UnsupportedCoverage, $identity, $attributes);
}
/**
* @param array<string, mixed> $attributes
*/
public function markMissingExpected(User $actor, ManagedEnvironment|int $environment, ResourceIdentity $identity, array $attributes): ProviderResourceBinding
{
return $this->createDecision($actor, $environment, ProviderResourceResolutionMode::MissingExpected, $identity, $attributes);
}
public function revoke(User $actor, ProviderResourceBinding|int $binding, string $operatorNote, ?string $resolutionReason = null): ProviderResourceBinding
{
$binding = $binding instanceof ProviderResourceBinding
? $binding
: ProviderResourceBinding::query()->findOrFail($binding);
Gate::forUser($actor)->authorize('revoke', $binding);
$note = $this->requiredNote($operatorNote);
return DB::transaction(function () use ($binding, $actor, $note, $resolutionReason): ProviderResourceBinding {
/** @var ProviderResourceBinding $locked */
$locked = ProviderResourceBinding::query()->lockForUpdate()->findOrFail((int) $binding->getKey());
if (! $locked->isActive()) {
return $locked->refresh();
}
$locked->forceFill([
'binding_status' => ProviderResourceBindingStatus::Revoked->value,
'operator_note' => $note,
'resolution_reason' => $this->nullableString($resolutionReason),
'ended_at' => now(),
])->save();
$this->audit(
action: AuditActionId::ProviderResourceBindingRevoked,
actor: $actor,
binding: $locked->refresh(),
oldBinding: $locked,
operatorNote: $note,
);
return $locked;
});
}
/**
* @param array<string, mixed> $attributes
*/
private function createDecision(
User $actor,
ManagedEnvironment|int $environment,
ProviderResourceResolutionMode $resolutionMode,
ResourceIdentity $identity,
array $attributes,
): ProviderResourceBinding {
$tenant = $this->resolveEnvironment($environment);
Gate::forUser($actor)->authorize('createForEnvironment', [ProviderResourceBinding::class, $tenant]);
$operatorNote = $this->requiredNote($attributes['operator_note'] ?? null);
$subjectDomain = $this->requiredString($attributes, 'subject_domain');
$subjectClass = $this->requiredSubjectClass($attributes['subject_class'] ?? null);
$subjectTypeKey = $this->requiredString($attributes, 'subject_type_key');
$canonicalSubjectKey = $this->nullableString($attributes['canonical_subject_key'] ?? null)
?? BaselineSubjectKey::forProviderResourceIdentity($subjectDomain, $subjectClass, $subjectTypeKey, $identity);
if ($canonicalSubjectKey === null) {
throw new InvalidArgumentException('Provider resource binding requires a canonical subject key.');
}
$scope = [
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
];
$providerConnectionId = $this->validatedProviderConnectionId($attributes['provider_connection_id'] ?? null, $tenant, $identity->providerKey);
$sourceAttributes = $this->validatedSourceAttributes($attributes, $tenant);
return DB::transaction(function () use (
$actor,
$tenant,
$identity,
$resolutionMode,
$attributes,
$operatorNote,
$subjectDomain,
$subjectClass,
$subjectTypeKey,
$canonicalSubjectKey,
$scope,
$providerConnectionId,
$sourceAttributes,
): ProviderResourceBinding {
$oldBinding = ProviderResourceBinding::query()
->where($scope)
->where('provider_key', $identity->providerKey)
->where('canonical_subject_key', $canonicalSubjectKey)
->active()
->lockForUpdate()
->first();
if ($oldBinding instanceof ProviderResourceBinding) {
$oldBinding->forceFill([
'binding_status' => ProviderResourceBindingStatus::Superseded->value,
'ended_at' => now(),
])->save();
}
$binding = ProviderResourceBinding::query()->create([
...$scope,
'provider_key' => $identity->providerKey,
'provider_connection_id' => $providerConnectionId,
'subject_domain' => $subjectDomain,
'subject_class' => $subjectClass instanceof SubjectClass ? $subjectClass->value : $subjectClass,
'subject_type_key' => $subjectTypeKey,
'legacy_subject_key' => $this->nullableString($attributes['legacy_subject_key'] ?? null),
'canonical_subject_key' => $canonicalSubjectKey,
'provider_resource_type' => $identity->providerResourceType,
'provider_resource_id' => $identity->providerResourceId,
'provider_resource_discriminator' => $identity->canonicalDiscriminator,
'provider_resource_identity_kind' => $identity->identityKind,
'provider_resource_fingerprint' => $identity->fingerprint(),
'display_label' => $this->nullableString($attributes['display_label'] ?? null),
'binding_status' => ProviderResourceBindingStatus::Active->value,
'resolution_mode' => $resolutionMode->value,
'resolution_reason' => $this->nullableString($attributes['resolution_reason'] ?? null),
'operator_note' => $operatorNote,
...$sourceAttributes,
'decided_by_user_id' => (int) $actor->getKey(),
'decided_at' => now(),
'ended_at' => null,
]);
$this->audit(
action: $oldBinding instanceof ProviderResourceBinding
? AuditActionId::ProviderResourceBindingSuperseded
: AuditActionId::ProviderResourceBindingCreated,
actor: $actor,
binding: $binding,
oldBinding: $oldBinding,
operatorNote: $operatorNote,
);
return $binding->refresh();
});
}
private function resolveEnvironment(ManagedEnvironment|int $environment): ManagedEnvironment
{
if ($environment instanceof ManagedEnvironment) {
$environment->refresh();
return $environment;
}
return ManagedEnvironment::query()->whereKey($environment)->firstOrFail();
}
private function requiredNote(mixed $value): string
{
if (! is_string($value) || trim($value) === '') {
throw new InvalidArgumentException('Provider resource binding decisions require an operator note.');
}
return trim($value);
}
/**
* @param array<string, mixed> $attributes
*/
private function requiredString(array $attributes, string $key): string
{
$value = $attributes[$key] ?? null;
if (! is_string($value) || trim($value) === '') {
throw new InvalidArgumentException(sprintf('Provider resource binding requires [%s].', $key));
}
return trim($value);
}
private function requiredSubjectClass(mixed $value): SubjectClass|string
{
if ($value instanceof SubjectClass) {
return $value;
}
if (! is_string($value) || trim($value) === '') {
throw new InvalidArgumentException('Provider resource binding requires [subject_class].');
}
return trim($value);
}
private function validatedProviderConnectionId(mixed $providerConnectionId, ManagedEnvironment $tenant, string $providerKey): ?int
{
if ($providerConnectionId === null || $providerConnectionId === '') {
return null;
}
if (! is_numeric($providerConnectionId)) {
throw new InvalidArgumentException('Provider connection reference must be numeric.');
}
$connection = ProviderConnection::query()
->whereKey((int) $providerConnectionId)
->where('workspace_id', (int) $tenant->workspace_id)
->where('managed_environment_id', (int) $tenant->getKey())
->where('provider', $providerKey)
->first();
if (! $connection instanceof ProviderConnection) {
$this->throwNotFound(ProviderConnection::class, (int) $providerConnectionId);
}
return (int) $connection->getKey();
}
/**
* @param array<string, mixed> $attributes
* @return array<string, int|null>
*/
private function validatedSourceAttributes(array $attributes, ManagedEnvironment $tenant): array
{
return [
'source_operation_run_id' => $this->validatedScopedId(OperationRun::class, $attributes['source_operation_run_id'] ?? null, $tenant, true),
'source_baseline_snapshot_id' => $this->validatedBaselineSnapshotId($attributes['source_baseline_snapshot_id'] ?? null, $tenant),
'source_inventory_item_id' => $this->validatedScopedId(InventoryItem::class, $attributes['source_inventory_item_id'] ?? null, $tenant, true),
'source_policy_version_id' => $this->validatedScopedId(PolicyVersion::class, $attributes['source_policy_version_id'] ?? null, $tenant, true),
];
}
private function validatedBaselineSnapshotId(mixed $id, ManagedEnvironment $tenant): ?int
{
if ($id === null || $id === '') {
return null;
}
if (! is_numeric($id)) {
throw new InvalidArgumentException('BaselineSnapshot reference must be numeric.');
}
$snapshot = BaselineSnapshot::query()
->whereKey((int) $id)
->where('workspace_id', (int) $tenant->workspace_id)
->first();
if (! $snapshot instanceof BaselineSnapshot) {
$this->throwNotFound(BaselineSnapshot::class, (int) $id);
}
$assignedToTenant = BaselineTenantAssignment::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('managed_environment_id', (int) $tenant->getKey())
->where('baseline_profile_id', (int) $snapshot->baseline_profile_id)
->exists();
if (! $assignedToTenant) {
$this->throwNotFound(BaselineSnapshot::class, (int) $id);
}
return (int) $snapshot->getKey();
}
/**
* @param class-string<Model> $modelClass
*/
private function validatedScopedId(string $modelClass, mixed $id, ManagedEnvironment $tenant, bool $requiresEnvironment): ?int
{
if ($id === null || $id === '') {
return null;
}
if (! is_numeric($id)) {
throw new InvalidArgumentException(sprintf('%s reference must be numeric.', class_basename($modelClass)));
}
$query = $modelClass::query()
->whereKey((int) $id)
->where('workspace_id', (int) $tenant->workspace_id);
if ($requiresEnvironment) {
$query->where('managed_environment_id', (int) $tenant->getKey());
}
if (! $query->exists()) {
$this->throwNotFound($modelClass, (int) $id);
}
return (int) $id;
}
/**
* @param class-string<Model> $modelClass
*/
private function throwNotFound(string $modelClass, int $id): never
{
throw (new ModelNotFoundException)->setModel($modelClass, [$id]);
}
private function nullableString(mixed $value): ?string
{
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
private function audit(
AuditActionId $action,
User $actor,
ProviderResourceBinding $binding,
?ProviderResourceBinding $oldBinding,
string $operatorNote,
): void {
$metadata = [
'provider_resource_binding_id' => (int) $binding->getKey(),
'old_binding_id' => $oldBinding instanceof ProviderResourceBinding ? (int) $oldBinding->getKey() : null,
'new_binding_id' => (int) $binding->getKey(),
'provider_key' => (string) $binding->provider_key,
'canonical_subject_key' => (string) $binding->canonical_subject_key,
'subject_class' => (string) $binding->subject_class,
'subject_type_key' => (string) $binding->subject_type_key,
'resolution_mode' => $binding->resolution_mode instanceof ProviderResourceResolutionMode
? $binding->resolution_mode->value
: (string) $binding->resolution_mode,
'binding_status' => $binding->binding_status instanceof ProviderResourceBindingStatus
? $binding->binding_status->value
: (string) $binding->binding_status,
'provider_resource_type' => $binding->provider_resource_type,
'provider_resource_identity_kind' => $binding->provider_resource_identity_kind,
'provider_resource_fingerprint' => $binding->provider_resource_fingerprint,
'source_operation_run_id' => $binding->source_operation_run_id,
'source_baseline_snapshot_id' => $binding->source_baseline_snapshot_id,
'source_inventory_item_id' => $binding->source_inventory_item_id,
'source_policy_version_id' => $binding->source_policy_version_id,
'operator_note_sha256' => hash('sha256', $operatorNote),
'operator_note_length' => mb_strlen($operatorNote),
'resolution_reason_present' => filled($binding->resolution_reason),
];
$this->auditRecorder->record(
action: $action,
context: ['metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null)],
workspace: $binding->workspace,
tenant: $binding->tenant,
actor: AuditActorSnapshot::human($actor),
target: new AuditTargetSnapshot(
type: 'provider_resource_binding',
id: (string) $binding->getKey(),
label: 'Provider resource binding #'.$binding->getKey(),
),
outcome: AuditOutcome::Success,
);
}
}