Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m9s
Added ProviderResourceBinding model, migrations, policies, and supporting framework for canonical resource identity mapping as defined in Spec 381.
447 lines
18 KiB
PHP
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,
|
|
);
|
|
}
|
|
}
|