Replaced legacy tenant and environment bindings in the BaselineDriftEngine with the new ProviderResourceIdentity framework as defined in Spec 382. This ensures cross-environment compatibility and deterministic baseline matching. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #453
458 lines
18 KiB
PHP
458 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');
|
|
$computedCanonicalSubjectKey = BaselineSubjectKey::forProviderResourceIdentity($subjectDomain, $subjectClass, $subjectTypeKey, $identity);
|
|
$providedCanonicalSubjectKey = $this->nullableString($attributes['canonical_subject_key'] ?? null);
|
|
|
|
if ($computedCanonicalSubjectKey === null) {
|
|
throw new InvalidArgumentException('Provider resource binding requires a canonical subject key.');
|
|
}
|
|
|
|
if (
|
|
$providedCanonicalSubjectKey !== null
|
|
&& (
|
|
! BaselineSubjectKey::isProviderResourceCanonicalKey($providedCanonicalSubjectKey)
|
|
|| $providedCanonicalSubjectKey !== $computedCanonicalSubjectKey
|
|
)
|
|
) {
|
|
throw new InvalidArgumentException('Provider resource binding canonical subject keys must be generated from the supplied provider resource identity.');
|
|
}
|
|
|
|
$canonicalSubjectKey = $providedCanonicalSubjectKey ?? $computedCanonicalSubjectKey;
|
|
|
|
$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,
|
|
'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,
|
|
);
|
|
}
|
|
}
|