feat(resources): implement provider resource identity binding (#452)
Added `ProviderResourceBinding` model, migrations, policies, and supporting framework for canonical resource identity mapping as defined in Spec 381. This provides the structural capability to resolve baseline and posture discrepancies by binding logical entities across source providers to canonical identities. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #452
This commit is contained in:
parent
d52b674f9a
commit
04d0d6184f
83
apps/platform/app/Models/ProviderResourceBinding.php
Normal file
83
apps/platform/app/Models/ProviderResourceBinding.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\Resources\ProviderResourceBindingStatus;
|
||||
use App\Support\Resources\ProviderResourceResolutionMode;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProviderResourceBinding extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'binding_status' => ProviderResourceBindingStatus::class,
|
||||
'resolution_mode' => ProviderResourceResolutionMode::class,
|
||||
'decided_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('binding_status', ProviderResourceBindingStatus::Active->value);
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ManagedEnvironment::class, 'managed_environment_id')->withTrashed();
|
||||
}
|
||||
|
||||
public function managedEnvironment(): BelongsTo
|
||||
{
|
||||
return $this->tenant();
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function providerConnection(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProviderConnection::class);
|
||||
}
|
||||
|
||||
public function sourceOperationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class, 'source_operation_run_id');
|
||||
}
|
||||
|
||||
public function sourceBaselineSnapshot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BaselineSnapshot::class, 'source_baseline_snapshot_id');
|
||||
}
|
||||
|
||||
public function sourceInventoryItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryItem::class, 'source_inventory_item_id');
|
||||
}
|
||||
|
||||
public function sourcePolicyVersion(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PolicyVersion::class, 'source_policy_version_id');
|
||||
}
|
||||
|
||||
public function decidedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'decided_by_user_id');
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->binding_status === ProviderResourceBindingStatus::Active;
|
||||
}
|
||||
}
|
||||
99
apps/platform/app/Policies/ProviderResourceBindingPolicy.php
Normal file
99
apps/platform/app/Policies/ProviderResourceBindingPolicy.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ProviderResourceBinding;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ProviderResourceBindingPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function view(User $user, ProviderResourceBinding $binding): Response
|
||||
{
|
||||
$tenant = $this->tenantForBinding($binding);
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $binding->workspace_id !== (int) $tenant->workspace_id) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeEnvironment($user, $tenant, Capabilities::WORKSPACE_BASELINES_VIEW);
|
||||
}
|
||||
|
||||
public function createForEnvironment(User $user, ManagedEnvironment $tenant): Response
|
||||
{
|
||||
return $this->authorizeEnvironment($user, $tenant, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
|
||||
public function update(User $user, ProviderResourceBinding $binding): Response
|
||||
{
|
||||
return $this->manage($user, $binding);
|
||||
}
|
||||
|
||||
public function revoke(User $user, ProviderResourceBinding $binding): Response
|
||||
{
|
||||
return $this->manage($user, $binding);
|
||||
}
|
||||
|
||||
public function delete(User $user, ProviderResourceBinding $binding): Response
|
||||
{
|
||||
return $this->manage($user, $binding);
|
||||
}
|
||||
|
||||
private function manage(User $user, ProviderResourceBinding $binding): Response
|
||||
{
|
||||
$tenant = $this->tenantForBinding($binding);
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $binding->workspace_id !== (int) $tenant->workspace_id) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeEnvironment($user, $tenant, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
|
||||
private function authorizeEnvironment(User $user, ManagedEnvironment $tenant, string $capability): Response
|
||||
{
|
||||
$decision = app(ManagedEnvironmentAccessScopeResolver::class)->decision($user, $tenant, $capability);
|
||||
|
||||
if (! $decision->workspaceMember || ! $decision->managedEnvironmentAllowed) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (! $decision->capabilityAllowed) {
|
||||
return Response::denyWithStatus(403, 'Missing required baseline capability.');
|
||||
}
|
||||
|
||||
return Response::allow();
|
||||
}
|
||||
|
||||
private function tenantForBinding(ProviderResourceBinding $binding): ?ManagedEnvironment
|
||||
{
|
||||
if ($binding->relationLoaded('tenant') && $binding->tenant instanceof ManagedEnvironment) {
|
||||
return $binding->tenant;
|
||||
}
|
||||
|
||||
if (! is_numeric($binding->managed_environment_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ManagedEnvironment::query()
|
||||
->withTrashed()
|
||||
->whereKey((int) $binding->managed_environment_id)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderResourceBinding;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentOnboardingSession;
|
||||
use App\Models\EnvironmentReview;
|
||||
@ -17,6 +18,7 @@
|
||||
use App\Policies\AlertDestinationPolicy;
|
||||
use App\Policies\AlertRulePolicy;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Policies\ProviderResourceBindingPolicy;
|
||||
use App\Policies\ManagedEnvironmentOnboardingSessionPolicy;
|
||||
use App\Policies\EnvironmentReviewPolicy;
|
||||
use App\Policies\WorkspaceSettingPolicy;
|
||||
@ -31,6 +33,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected $policies = [
|
||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||
ProviderResourceBinding::class => ProviderResourceBindingPolicy::class,
|
||||
ManagedEnvironmentOnboardingSession::class => ManagedEnvironmentOnboardingSessionPolicy::class,
|
||||
EnvironmentReview::class => EnvironmentReviewPolicy::class,
|
||||
WorkspaceSetting::class => WorkspaceSettingPolicy::class,
|
||||
|
||||
@ -0,0 +1,446 @@
|
||||
<?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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -85,6 +85,9 @@ enum AuditActionId: string
|
||||
case BaselineAssignmentCreated = 'baseline_assignment.created';
|
||||
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
|
||||
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
|
||||
case ProviderResourceBindingCreated = 'provider_resource_binding.created';
|
||||
case ProviderResourceBindingSuperseded = 'provider_resource_binding.superseded';
|
||||
case ProviderResourceBindingRevoked = 'provider_resource_binding.revoked';
|
||||
|
||||
case FindingTriaged = 'finding.triaged';
|
||||
case FindingInProgress = 'finding.in_progress';
|
||||
@ -261,6 +264,9 @@ private static function labels(): array
|
||||
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
|
||||
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
|
||||
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
|
||||
self::ProviderResourceBindingCreated->value => 'Provider resource binding created',
|
||||
self::ProviderResourceBindingSuperseded->value => 'Provider resource binding superseded',
|
||||
self::ProviderResourceBindingRevoked->value => 'Provider resource binding revoked',
|
||||
self::WorkspaceAutoSelected->value => 'Workspace auto-selected',
|
||||
self::WorkspaceSelected->value => 'Workspace selected',
|
||||
self::FindingTriaged->value => 'Finding triaged',
|
||||
@ -377,6 +383,9 @@ private static function summaries(): array
|
||||
self::CrossEnvironmentPromotionPreflightGenerated->value => 'Cross-environment promotion preflight generated',
|
||||
self::CrossEnvironmentPromotionExecutionQueued->value => 'Cross-environment promotion execution queued',
|
||||
self::CrossEnvironmentPromotionExecutionCompleted->value => 'Cross-environment promotion execution completed',
|
||||
self::ProviderResourceBindingCreated->value => 'Provider resource binding created',
|
||||
self::ProviderResourceBindingSuperseded->value => 'Provider resource binding superseded',
|
||||
self::ProviderResourceBindingRevoked->value => 'Provider resource binding revoked',
|
||||
self::AlertDestinationCreated->value => 'Alert destination created',
|
||||
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
||||
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Resources\ResourceIdentity;
|
||||
|
||||
final class BaselineSubjectKey
|
||||
{
|
||||
@ -70,4 +71,70 @@ public static function workspaceSafeSubjectExternalIdForPolicy(string $policyTyp
|
||||
|
||||
return self::workspaceSafeSubjectExternalId($policyType, $identityInput);
|
||||
}
|
||||
|
||||
public static function forProviderResourceIdentity(
|
||||
string $subjectDomain,
|
||||
SubjectClass|string $subjectClass,
|
||||
string $subjectTypeKey,
|
||||
ResourceIdentity $identity,
|
||||
): ?string {
|
||||
$domain = self::canonicalSegment($subjectDomain);
|
||||
$class = self::canonicalSegment($subjectClass instanceof SubjectClass ? $subjectClass->value : $subjectClass);
|
||||
$type = self::canonicalSegment($subjectTypeKey);
|
||||
$provider = self::canonicalSegment($identity->providerKey);
|
||||
$resourceType = self::canonicalSegment($identity->providerResourceType ?? 'none');
|
||||
$stableIdentity = $identity->stableIdentityValue();
|
||||
|
||||
if ($domain === null || $class === null || $type === null || $provider === null || $resourceType === null || $stableIdentity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(':', [
|
||||
'provider-resource',
|
||||
'v1',
|
||||
$domain,
|
||||
$class,
|
||||
$type,
|
||||
$provider,
|
||||
$resourceType,
|
||||
$identity->identityKind,
|
||||
hash('sha256', $stableIdentity),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function fromProviderResourceIdentity(
|
||||
string $subjectDomain,
|
||||
SubjectClass|string $subjectClass,
|
||||
string $subjectTypeKey,
|
||||
string $providerKey,
|
||||
?string $providerResourceType,
|
||||
string $stableIdentity,
|
||||
string $identityKind = 'provider_resource',
|
||||
): ?string {
|
||||
return self::forProviderResourceIdentity(
|
||||
$subjectDomain,
|
||||
$subjectClass,
|
||||
$subjectTypeKey,
|
||||
new ResourceIdentity(
|
||||
providerKey: $providerKey,
|
||||
identityKind: $identityKind,
|
||||
providerResourceType: $providerResourceType,
|
||||
providerResourceId: $identityKind === 'provider_resource' ? $stableIdentity : null,
|
||||
canonicalDiscriminator: $identityKind === 'provider_resource' ? null : $stableIdentity,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private static function canonicalSegment(?string $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim(mb_strtolower($value));
|
||||
$normalized = preg_replace('/[^a-z0-9._-]+/', '-', $normalized);
|
||||
$normalized = is_string($normalized) ? trim($normalized, '-') : null;
|
||||
|
||||
return $normalized !== null && $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Resources;
|
||||
|
||||
enum ProviderResourceBindingStatus: string
|
||||
{
|
||||
case Active = 'active';
|
||||
case Superseded = 'superseded';
|
||||
case Revoked = 'revoked';
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Resources;
|
||||
|
||||
use App\Support\Baselines\SubjectClass;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* @implements Arrayable<string, mixed>
|
||||
*/
|
||||
final readonly class ProviderResourceDescriptor implements Arrayable, JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param array<string, string|int|float|bool|null> $sourceReferences
|
||||
*/
|
||||
public function __construct(
|
||||
public ResourceIdentity $identity,
|
||||
public ?string $displayLabel,
|
||||
public string $subjectDomain,
|
||||
public SubjectClass|string $subjectClass,
|
||||
public string $subjectTypeKey,
|
||||
public array $sourceReferences = [],
|
||||
public ?string $fingerprint = null,
|
||||
public ?string $lastSeenAt = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int|float|bool|null> $sourceReferences
|
||||
*/
|
||||
public static function fromIdentity(
|
||||
ResourceIdentity $identity,
|
||||
string $subjectDomain,
|
||||
SubjectClass|string $subjectClass,
|
||||
string $subjectTypeKey,
|
||||
?string $displayLabel = null,
|
||||
array $sourceReferences = [],
|
||||
?string $fingerprint = null,
|
||||
?string $lastSeenAt = null,
|
||||
): self {
|
||||
return new self(
|
||||
identity: $identity,
|
||||
displayLabel: $displayLabel,
|
||||
subjectDomain: $subjectDomain,
|
||||
subjectClass: $subjectClass,
|
||||
subjectTypeKey: $subjectTypeKey,
|
||||
sourceReferences: self::safeSourceReferences($sourceReferences),
|
||||
fingerprint: $fingerprint,
|
||||
lastSeenAt: $lastSeenAt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromArray(array $payload): self
|
||||
{
|
||||
$identityPayload = $payload['identity'] ?? [];
|
||||
|
||||
return new self(
|
||||
identity: ResourceIdentity::fromArray(is_array($identityPayload) ? $identityPayload : []),
|
||||
displayLabel: self::nullableString($payload['display_label'] ?? null),
|
||||
subjectDomain: (string) ($payload['subject_domain'] ?? ''),
|
||||
subjectClass: (string) ($payload['subject_class'] ?? ''),
|
||||
subjectTypeKey: (string) ($payload['subject_type_key'] ?? ''),
|
||||
sourceReferences: self::safeSourceReferences(is_array($payload['source_references'] ?? null) ? $payload['source_references'] : []),
|
||||
fingerprint: self::nullableString($payload['fingerprint'] ?? null),
|
||||
lastSeenAt: self::nullableString($payload['last_seen_at'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* identity: array<string, mixed>,
|
||||
* display_label: ?string,
|
||||
* subject_domain: string,
|
||||
* subject_class: string,
|
||||
* subject_type_key: string,
|
||||
* source_references: array<string, string|int|float|bool|null>,
|
||||
* fingerprint: ?string,
|
||||
* last_seen_at: ?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'identity' => $this->identity->toArray(),
|
||||
'display_label' => $this->displayLabel,
|
||||
'subject_domain' => $this->subjectDomain,
|
||||
'subject_class' => $this->subjectClass instanceof SubjectClass ? $this->subjectClass->value : $this->subjectClass,
|
||||
'subject_type_key' => $this->subjectTypeKey,
|
||||
'source_references' => self::safeSourceReferences($this->sourceReferences),
|
||||
'fingerprint' => $this->fingerprint,
|
||||
'last_seen_at' => $this->lastSeenAt,
|
||||
];
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sourceReferences
|
||||
* @return array<string, string|int|float|bool|null>
|
||||
*/
|
||||
private static function safeSourceReferences(array $sourceReferences): array
|
||||
{
|
||||
$safe = [];
|
||||
|
||||
foreach ($sourceReferences as $key => $value) {
|
||||
if (! is_string($key) || trim($key) === '' || (! is_scalar($value) && $value !== null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$safe[$key] = $value;
|
||||
}
|
||||
|
||||
return $safe;
|
||||
}
|
||||
|
||||
private static function nullableString(mixed $value): ?string
|
||||
{
|
||||
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Resources;
|
||||
|
||||
enum ProviderResourceResolutionMode: string
|
||||
{
|
||||
case ExactProviderIdentity = 'exact_provider_identity';
|
||||
case CanonicalBuiltin = 'canonical_builtin';
|
||||
case CanonicalVirtualTarget = 'canonical_virtual_target';
|
||||
case ManualBinding = 'manual_binding';
|
||||
case ExcludedNonGoverned = 'excluded_non_governed';
|
||||
case AcceptedLimitation = 'accepted_limitation';
|
||||
case UnsupportedCoverage = 'unsupported_coverage';
|
||||
case MissingExpected = 'missing_expected';
|
||||
}
|
||||
214
apps/platform/app/Support/Resources/ResourceIdentity.php
Normal file
214
apps/platform/app/Support/Resources/ResourceIdentity.php
Normal file
@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Resources;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use InvalidArgumentException;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* @implements Arrayable<string, mixed>
|
||||
*/
|
||||
final readonly class ResourceIdentity implements Arrayable, JsonSerializable
|
||||
{
|
||||
public const string ProviderResource = 'provider_resource';
|
||||
|
||||
public const string CanonicalBuiltin = 'canonical_builtin';
|
||||
|
||||
public const string CanonicalDefault = 'canonical_default';
|
||||
|
||||
public const string CanonicalVirtualTarget = 'canonical_virtual_target';
|
||||
|
||||
public const string Unsupported = 'unsupported';
|
||||
|
||||
public const string Unknown = 'unknown';
|
||||
|
||||
public function __construct(
|
||||
public string $providerKey,
|
||||
public string $identityKind,
|
||||
public ?string $providerResourceType = null,
|
||||
public ?string $providerResourceId = null,
|
||||
public ?string $canonicalDiscriminator = null,
|
||||
) {
|
||||
$this->assertValid();
|
||||
}
|
||||
|
||||
public static function providerResource(string $providerKey, string $resourceType, string $resourceId): self
|
||||
{
|
||||
return new self(
|
||||
providerKey: $providerKey,
|
||||
identityKind: self::ProviderResource,
|
||||
providerResourceType: $resourceType,
|
||||
providerResourceId: $resourceId,
|
||||
);
|
||||
}
|
||||
|
||||
public static function canonicalBuiltin(string $providerKey, string $resourceType, string $discriminator): self
|
||||
{
|
||||
return new self(
|
||||
providerKey: $providerKey,
|
||||
identityKind: self::CanonicalBuiltin,
|
||||
providerResourceType: $resourceType,
|
||||
canonicalDiscriminator: $discriminator,
|
||||
);
|
||||
}
|
||||
|
||||
public static function canonicalDefault(string $providerKey, string $resourceType, string $discriminator): self
|
||||
{
|
||||
return new self(
|
||||
providerKey: $providerKey,
|
||||
identityKind: self::CanonicalDefault,
|
||||
providerResourceType: $resourceType,
|
||||
canonicalDiscriminator: $discriminator,
|
||||
);
|
||||
}
|
||||
|
||||
public static function virtualTarget(string $providerKey, string $resourceType, string $discriminator): self
|
||||
{
|
||||
return new self(
|
||||
providerKey: $providerKey,
|
||||
identityKind: self::CanonicalVirtualTarget,
|
||||
providerResourceType: $resourceType,
|
||||
canonicalDiscriminator: $discriminator,
|
||||
);
|
||||
}
|
||||
|
||||
public static function unsupported(string $providerKey, string $resourceType, string $discriminator): self
|
||||
{
|
||||
return new self(
|
||||
providerKey: $providerKey,
|
||||
identityKind: self::Unsupported,
|
||||
providerResourceType: $resourceType,
|
||||
canonicalDiscriminator: $discriminator,
|
||||
);
|
||||
}
|
||||
|
||||
public static function unknown(string $providerKey, string $resourceType, string $discriminator): self
|
||||
{
|
||||
return new self(
|
||||
providerKey: $providerKey,
|
||||
identityKind: self::Unknown,
|
||||
providerResourceType: $resourceType,
|
||||
canonicalDiscriminator: $discriminator,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromArray(array $payload): self
|
||||
{
|
||||
return new self(
|
||||
providerKey: (string) ($payload['provider_key'] ?? ''),
|
||||
identityKind: (string) ($payload['identity_kind'] ?? ''),
|
||||
providerResourceType: self::nullableString($payload['provider_resource_type'] ?? null),
|
||||
providerResourceId: self::nullableString($payload['provider_resource_id'] ?? null),
|
||||
canonicalDiscriminator: self::nullableString($payload['canonical_discriminator'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
public function stableIdentityValue(): ?string
|
||||
{
|
||||
return $this->identityKind === self::ProviderResource
|
||||
? $this->providerResourceId
|
||||
: $this->canonicalDiscriminator;
|
||||
}
|
||||
|
||||
public function fingerprint(): string
|
||||
{
|
||||
return hash('sha256', json_encode($this->payload(), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* provider_key: string,
|
||||
* identity_kind: string,
|
||||
* provider_resource_type: ?string,
|
||||
* provider_resource_id: ?string,
|
||||
* canonical_discriminator: ?string,
|
||||
* fingerprint: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$payload = $this->payload();
|
||||
|
||||
return $payload + [
|
||||
'fingerprint' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* provider_key: string,
|
||||
* identity_kind: string,
|
||||
* provider_resource_type: ?string,
|
||||
* provider_resource_id: ?string,
|
||||
* canonical_discriminator: ?string
|
||||
* }
|
||||
*/
|
||||
private function payload(): array
|
||||
{
|
||||
return [
|
||||
'provider_key' => $this->providerKey,
|
||||
'identity_kind' => $this->identityKind,
|
||||
'provider_resource_type' => $this->providerResourceType,
|
||||
'provider_resource_id' => $this->providerResourceId,
|
||||
'canonical_discriminator' => $this->canonicalDiscriminator,
|
||||
];
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
private function assertValid(): void
|
||||
{
|
||||
if (trim($this->providerKey) === '') {
|
||||
throw new InvalidArgumentException('Resource identities require a provider key.');
|
||||
}
|
||||
|
||||
if (! in_array($this->identityKind, self::validKinds(), true)) {
|
||||
throw new InvalidArgumentException(sprintf('Unsupported resource identity kind [%s].', $this->identityKind));
|
||||
}
|
||||
|
||||
if ($this->providerResourceType !== null && trim($this->providerResourceType) === '') {
|
||||
throw new InvalidArgumentException('Provider resource type must be non-empty when supplied.');
|
||||
}
|
||||
|
||||
if ($this->identityKind === self::ProviderResource) {
|
||||
if ($this->providerResourceType === null || trim($this->providerResourceType) === '' || $this->providerResourceId === null || trim($this->providerResourceId) === '') {
|
||||
throw new InvalidArgumentException('Provider resource identities require provider resource type and ID.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->canonicalDiscriminator === null || trim($this->canonicalDiscriminator) === '') {
|
||||
throw new InvalidArgumentException('Canonical resource identities require a discriminator.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function validKinds(): array
|
||||
{
|
||||
return [
|
||||
self::ProviderResource,
|
||||
self::CanonicalBuiltin,
|
||||
self::CanonicalDefault,
|
||||
self::CanonicalVirtualTarget,
|
||||
self::Unsupported,
|
||||
self::Unknown,
|
||||
];
|
||||
}
|
||||
|
||||
private static function nullableString(mixed $value): ?string
|
||||
{
|
||||
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ProviderResourceBinding;
|
||||
use App\Models\User;
|
||||
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\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProviderResourceBinding>
|
||||
*/
|
||||
class ProviderResourceBindingFactory extends Factory
|
||||
{
|
||||
protected $model = ProviderResourceBinding::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', fake()->uuid());
|
||||
$subjectDomain = 'baseline';
|
||||
$subjectClass = SubjectClass::PolicyBacked;
|
||||
$subjectTypeKey = 'deviceConfiguration';
|
||||
|
||||
return [
|
||||
'managed_environment_id' => ManagedEnvironment::factory(),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$tenantId = $attributes['managed_environment_id'] ?? null;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return (int) ManagedEnvironment::factory()->create()->workspace_id;
|
||||
}
|
||||
|
||||
$tenant = ManagedEnvironment::query()->whereKey((int) $tenantId)->firstOrFail();
|
||||
|
||||
return (int) $tenant->workspace_id;
|
||||
},
|
||||
'provider_key' => $identity->providerKey,
|
||||
'provider_connection_id' => null,
|
||||
'subject_domain' => $subjectDomain,
|
||||
'subject_class' => $subjectClass->value,
|
||||
'subject_type_key' => $subjectTypeKey,
|
||||
'legacy_subject_key' => null,
|
||||
'canonical_subject_key' => BaselineSubjectKey::forProviderResourceIdentity(
|
||||
$subjectDomain,
|
||||
$subjectClass,
|
||||
$subjectTypeKey,
|
||||
$identity,
|
||||
),
|
||||
'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' => fake()->words(3, true),
|
||||
'binding_status' => ProviderResourceBindingStatus::Active->value,
|
||||
'resolution_mode' => ProviderResourceResolutionMode::ExactProviderIdentity->value,
|
||||
'resolution_reason' => null,
|
||||
'operator_note' => 'Binding decision recorded for test coverage.',
|
||||
'source_operation_run_id' => null,
|
||||
'source_baseline_snapshot_id' => null,
|
||||
'source_inventory_item_id' => null,
|
||||
'source_policy_version_id' => null,
|
||||
'decided_by_user_id' => User::factory(),
|
||||
'decided_at' => now(),
|
||||
'ended_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function fakeProvider(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'provider_key' => 'fake-provider',
|
||||
]);
|
||||
}
|
||||
|
||||
public function providerResource(?ResourceIdentity $identity = null): static
|
||||
{
|
||||
$identity ??= ResourceIdentity::providerResource('fake-provider', 'policy', fake()->uuid());
|
||||
|
||||
return $this->withIdentity($identity, ProviderResourceResolutionMode::ExactProviderIdentity);
|
||||
}
|
||||
|
||||
public function canonicalBuiltin(?ResourceIdentity $identity = null): static
|
||||
{
|
||||
$identity ??= ResourceIdentity::canonicalBuiltin('fake-provider', 'target', 'all-principals');
|
||||
|
||||
return $this->withIdentity($identity, ProviderResourceResolutionMode::CanonicalBuiltin);
|
||||
}
|
||||
|
||||
public function revoked(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'binding_status' => ProviderResourceBindingStatus::Revoked->value,
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function superseded(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'binding_status' => ProviderResourceBindingStatus::Superseded->value,
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function withIdentity(ResourceIdentity $identity, ProviderResourceResolutionMode $resolutionMode): static
|
||||
{
|
||||
$subjectDomain = 'baseline';
|
||||
$subjectClass = SubjectClass::PolicyBacked;
|
||||
$subjectTypeKey = 'deviceConfiguration';
|
||||
|
||||
return $this->state(fn (): array => [
|
||||
'provider_key' => $identity->providerKey,
|
||||
'subject_domain' => $subjectDomain,
|
||||
'subject_class' => $subjectClass->value,
|
||||
'subject_type_key' => $subjectTypeKey,
|
||||
'canonical_subject_key' => BaselineSubjectKey::forProviderResourceIdentity(
|
||||
$subjectDomain,
|
||||
$subjectClass,
|
||||
$subjectTypeKey,
|
||||
$identity,
|
||||
),
|
||||
'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(),
|
||||
'resolution_mode' => $resolutionMode->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('provider_resource_bindings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||
$table->foreignId('managed_environment_id')->constrained('managed_environments')->cascadeOnDelete();
|
||||
$table->string('provider_key');
|
||||
$table->foreignId('provider_connection_id')->nullable()->constrained('provider_connections')->nullOnDelete();
|
||||
$table->string('subject_domain');
|
||||
$table->string('subject_class');
|
||||
$table->string('subject_type_key');
|
||||
$table->string('legacy_subject_key')->nullable();
|
||||
$table->string('canonical_subject_key');
|
||||
$table->string('provider_resource_type')->nullable();
|
||||
$table->string('provider_resource_id')->nullable();
|
||||
$table->string('provider_resource_discriminator')->nullable();
|
||||
$table->string('provider_resource_identity_kind');
|
||||
$table->string('provider_resource_fingerprint', 64)->nullable();
|
||||
$table->string('display_label')->nullable();
|
||||
$table->string('binding_status')->default('active');
|
||||
$table->string('resolution_mode');
|
||||
$table->text('resolution_reason')->nullable();
|
||||
$table->text('operator_note');
|
||||
$table->foreignId('source_operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||
$table->foreignId('source_baseline_snapshot_id')->nullable()->constrained('baseline_snapshots')->nullOnDelete();
|
||||
$table->foreignId('source_inventory_item_id')->nullable()->constrained('inventory_items')->nullOnDelete();
|
||||
$table->foreignId('source_policy_version_id')->nullable()->constrained('policy_versions')->nullOnDelete();
|
||||
$table->foreignId('decided_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestampTz('decided_at');
|
||||
$table->timestampTz('ended_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('workspace_id');
|
||||
$table->index('managed_environment_id');
|
||||
$table->index('provider_key');
|
||||
$table->index('provider_connection_id');
|
||||
$table->index('canonical_subject_key');
|
||||
$table->index('binding_status');
|
||||
$table->index('resolution_mode');
|
||||
$table->index('source_operation_run_id');
|
||||
$table->index('source_baseline_snapshot_id');
|
||||
$table->index('source_inventory_item_id');
|
||||
$table->index('source_policy_version_id');
|
||||
$table->index(
|
||||
['workspace_id', 'managed_environment_id', 'provider_key', 'canonical_subject_key', 'binding_status'],
|
||||
'provider_resource_bindings_lookup_idx',
|
||||
);
|
||||
|
||||
$table
|
||||
->foreign(['managed_environment_id', 'workspace_id'], 'provider_resource_bindings_environment_workspace_fk')
|
||||
->references(['id', 'workspace_id'])
|
||||
->on('managed_environments')
|
||||
->cascadeOnDelete();
|
||||
});
|
||||
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
DB::statement(
|
||||
"CREATE UNIQUE INDEX provider_resource_bindings_active_unique ON provider_resource_bindings (workspace_id, managed_environment_id, provider_key, canonical_subject_key) WHERE binding_status = 'active'",
|
||||
);
|
||||
|
||||
DB::statement(sprintf(
|
||||
"ALTER TABLE provider_resource_bindings ADD CONSTRAINT provider_resource_bindings_status_check CHECK (binding_status IN ('%s'))",
|
||||
implode("','", ['active', 'superseded', 'revoked']),
|
||||
));
|
||||
|
||||
DB::statement(sprintf(
|
||||
"ALTER TABLE provider_resource_bindings ADD CONSTRAINT provider_resource_bindings_resolution_mode_check CHECK (resolution_mode IN ('%s'))",
|
||||
implode("','", [
|
||||
'exact_provider_identity',
|
||||
'canonical_builtin',
|
||||
'canonical_virtual_target',
|
||||
'manual_binding',
|
||||
'excluded_non_governed',
|
||||
'accepted_limitation',
|
||||
'unsupported_coverage',
|
||||
'missing_expected',
|
||||
]),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('provider_resource_bindings');
|
||||
}
|
||||
};
|
||||
@ -7,12 +7,15 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\ProviderResourceBinding;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Resources\ProviderResourceResolutionMode;
|
||||
use App\Support\Resources\ResourceIdentity;
|
||||
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
|
||||
|
||||
it('classifies compare capture-path gaps as structural or missing-local-data without using policy_not_found', function (): void {
|
||||
@ -46,6 +49,16 @@
|
||||
expect($policySubjectKey)->not->toBeNull()
|
||||
->and($foundationSubjectKey)->not->toBeNull();
|
||||
|
||||
ProviderResourceBinding::factory()
|
||||
->providerResource(ResourceIdentity::providerResource('fake-provider', 'policy', 'compare-missing-policy'))
|
||||
->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'canonical_subject_key' => (string) $policySubjectKey,
|
||||
'display_label' => 'Fake Provider Binding For Missing Compare Policy',
|
||||
'resolution_mode' => ProviderResourceResolutionMode::ManualBinding->value,
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
@ -123,6 +136,7 @@
|
||||
$run->refresh();
|
||||
|
||||
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBeNull()
|
||||
->and(data_get($run->context, 'baseline_compare.provider_resource_bindings'))->toBeNull()
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1)
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
|
||||
it('does not automatically consume provider resource bindings during baseline compare v1', function (): void {
|
||||
$compareJobFile = (new ReflectionClass(CompareBaselineToTenantJob::class))->getFileName();
|
||||
$compareJobSource = is_string($compareJobFile) ? file_get_contents($compareJobFile) : false;
|
||||
|
||||
expect($compareJobFile)->toBeString()
|
||||
->and($compareJobSource)->toBeString()
|
||||
->and($compareJobSource)->not->toContain('ProviderResourceBinding')
|
||||
->and($compareJobSource)->not->toContain('ProviderResourceBindingService');
|
||||
});
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderResourceBinding;
|
||||
use App\Services\Evidence\Sources\BaselineDriftPostureSource;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -9,10 +10,16 @@
|
||||
it('keeps baseline drift posture missing when no drift findings or compare proof exist', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
ProviderResourceBinding::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
|
||||
|
||||
expect($payload['state'])->toBe(EvidenceCompletenessState::Missing->value)
|
||||
->and($payload['summary_payload']['drift_count'])->toBe(0)
|
||||
->and($payload['summary_payload'])->not->toHaveKey('provider_resource_bindings')
|
||||
->and($payload['summary_payload']['latest_compare_run_id'])->toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderResourceBinding;
|
||||
use App\Services\Resources\ProviderResourceBindingService;
|
||||
use App\Support\Baselines\SubjectClass;
|
||||
use App\Support\Resources\ResourceIdentity;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
function providerResourceBindingAuthorizationAttributes(array $overrides = []): array
|
||||
{
|
||||
return array_replace([
|
||||
'subject_domain' => 'baseline',
|
||||
'subject_class' => SubjectClass::PolicyBacked,
|
||||
'subject_type_key' => 'deviceConfiguration',
|
||||
'operator_note' => 'Authorization test decision note.',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('allows managers to create provider resource bindings', function (): void {
|
||||
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$binding = app(ProviderResourceBindingService::class)->createExactProviderIdentity(
|
||||
actor: $manager,
|
||||
environment: $tenant,
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'manager-allowed'),
|
||||
attributes: providerResourceBindingAuthorizationAttributes(),
|
||||
);
|
||||
|
||||
expect($binding->exists)->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns forbidden for entitled readonly users missing baseline manage capability', function (): void {
|
||||
[$readonly, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
try {
|
||||
app(ProviderResourceBindingService::class)->createManualBinding(
|
||||
actor: $readonly,
|
||||
environment: $tenant,
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'readonly-denied'),
|
||||
attributes: providerResourceBindingAuthorizationAttributes(),
|
||||
);
|
||||
|
||||
$this->fail('Expected authorization denial.');
|
||||
} catch (AuthorizationException $exception) {
|
||||
expect($exception->status())->toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns deny-as-not-found for users outside the binding workspace', function (): void {
|
||||
[$actor] = createUserWithTenant(role: 'owner');
|
||||
[, $foreignTenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
try {
|
||||
app(ProviderResourceBindingService::class)->createManualBinding(
|
||||
actor: $actor,
|
||||
environment: $foreignTenant,
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'foreign-denied'),
|
||||
attributes: providerResourceBindingAuthorizationAttributes(),
|
||||
);
|
||||
|
||||
$this->fail('Expected not-found authorization denial.');
|
||||
} catch (AuthorizationException $exception) {
|
||||
expect($exception->status())->toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects provider connections from another managed environment as not found', function (): void {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
[, $foreignTenant] = createUserWithTenant(role: 'owner');
|
||||
$foreignConnection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||
'managed_environment_id' => (int) $foreignTenant->getKey(),
|
||||
'provider' => 'fake-provider',
|
||||
]);
|
||||
|
||||
expect(fn () => app(ProviderResourceBindingService::class)->createManualBinding(
|
||||
actor: $actor,
|
||||
environment: $tenant,
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'foreign-connection'),
|
||||
attributes: providerResourceBindingAuthorizationAttributes([
|
||||
'provider_connection_id' => (int) $foreignConnection->getKey(),
|
||||
]),
|
||||
))->toThrow(ModelNotFoundException::class);
|
||||
});
|
||||
|
||||
it('rejects source references from another managed environment as not found', function (): void {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
[, $foreignTenant] = createUserWithTenant(role: 'owner');
|
||||
$foreignInventory = InventoryItem::factory()->create([
|
||||
'managed_environment_id' => (int) $foreignTenant->getKey(),
|
||||
]);
|
||||
|
||||
expect(fn () => app(ProviderResourceBindingService::class)->createManualBinding(
|
||||
actor: $actor,
|
||||
environment: $tenant,
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'foreign-source'),
|
||||
attributes: providerResourceBindingAuthorizationAttributes([
|
||||
'source_inventory_item_id' => (int) $foreignInventory->getKey(),
|
||||
]),
|
||||
))->toThrow(ModelNotFoundException::class);
|
||||
});
|
||||
|
||||
it('rejects baseline snapshot references not assigned to the binding managed environment as not found', function (): void {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$foreignTenant = ManagedEnvironment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$foreignProfile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
$foreignSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $foreignProfile->getKey(),
|
||||
]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $foreignTenant->getKey(),
|
||||
'baseline_profile_id' => (int) $foreignProfile->getKey(),
|
||||
]);
|
||||
|
||||
expect(fn () => app(ProviderResourceBindingService::class)->createManualBinding(
|
||||
actor: $actor,
|
||||
environment: $tenant,
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'foreign-baseline-snapshot'),
|
||||
attributes: providerResourceBindingAuthorizationAttributes([
|
||||
'source_baseline_snapshot_id' => (int) $foreignSnapshot->getKey(),
|
||||
]),
|
||||
))->toThrow(ModelNotFoundException::class);
|
||||
});
|
||||
|
||||
it('uses baseline capabilities in the registered policy', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$binding = ProviderResourceBinding::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
expect(Gate::forUser($owner)->inspect('revoke', $binding)->allowed())->toBeTrue()
|
||||
->and(Gate::forUser($readonly)->inspect('view', $binding)->allowed())->toBeTrue()
|
||||
->and(Gate::forUser($readonly)->inspect('revoke', $binding)->status())->toBe(403);
|
||||
});
|
||||
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ProviderResourceBinding;
|
||||
use App\Support\Resources\ProviderResourceBindingStatus;
|
||||
use App\Support\Resources\ProviderResourceResolutionMode;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
beforeEach(function (): void {
|
||||
if (DB::getDriverName() !== 'pgsql') {
|
||||
$this->markTestSkipped('PostgreSQL-only binding integrity test.');
|
||||
}
|
||||
});
|
||||
|
||||
it('enforces one active binding per managed environment provider and canonical subject key', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
$binding = ProviderResourceBinding::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'provider_key' => 'fake-provider',
|
||||
'canonical_subject_key' => 'provider-resource:v1:duplicate',
|
||||
'binding_status' => ProviderResourceBindingStatus::Active->value,
|
||||
]);
|
||||
|
||||
expectPgsqlBindingConstraintViolation(fn () => ProviderResourceBinding::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'provider_key' => 'fake-provider',
|
||||
'canonical_subject_key' => $binding->canonical_subject_key,
|
||||
'binding_status' => ProviderResourceBindingStatus::Active->value,
|
||||
]));
|
||||
|
||||
ProviderResourceBinding::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'provider_key' => 'fake-provider',
|
||||
'canonical_subject_key' => $binding->canonical_subject_key,
|
||||
'binding_status' => ProviderResourceBindingStatus::Superseded->value,
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
expect(ProviderResourceBinding::query()->where('canonical_subject_key', $binding->canonical_subject_key)->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('enforces managed environment and workspace pair integrity', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
$otherTenant = ManagedEnvironment::factory()->create();
|
||||
|
||||
$attributes = ProviderResourceBinding::factory()->make([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
])->getAttributes();
|
||||
$attributes['workspace_id'] = (int) $otherTenant->workspace_id;
|
||||
|
||||
expectPgsqlBindingConstraintViolation(fn () => DB::table('provider_resource_bindings')->insert($attributes));
|
||||
});
|
||||
|
||||
it('rejects invalid status and resolution mode values', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
|
||||
$attributes = ProviderResourceBinding::factory()->make([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
])->getAttributes();
|
||||
|
||||
$attributes['binding_status'] = 'pending';
|
||||
|
||||
expectPgsqlBindingConstraintViolation(fn () => DB::table('provider_resource_bindings')->insert($attributes));
|
||||
|
||||
$attributes = ProviderResourceBinding::factory()->make([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
])->getAttributes();
|
||||
$attributes['resolution_mode'] = 'display_name_match';
|
||||
|
||||
expectPgsqlBindingConstraintViolation(fn () => DB::table('provider_resource_bindings')->insert($attributes));
|
||||
});
|
||||
|
||||
it('creates the active lookup partial unique index', function (): void {
|
||||
$indexDefinition = DB::table('pg_indexes')
|
||||
->where('schemaname', 'public')
|
||||
->where('tablename', 'provider_resource_bindings')
|
||||
->where('indexname', 'provider_resource_bindings_active_unique')
|
||||
->value('indexdef');
|
||||
|
||||
expect((string) $indexDefinition)
|
||||
->toContain('provider_resource_bindings')
|
||||
->toContain("WHERE ((binding_status)::text = 'active'::text)");
|
||||
});
|
||||
|
||||
function expectPgsqlBindingConstraintViolation(callable $callback): void
|
||||
{
|
||||
$savepoint = 'provider_resource_binding_constraint_check';
|
||||
$exception = null;
|
||||
|
||||
DB::statement("SAVEPOINT {$savepoint}");
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} catch (QueryException $queryException) {
|
||||
$exception = $queryException;
|
||||
} finally {
|
||||
DB::statement("ROLLBACK TO SAVEPOINT {$savepoint}");
|
||||
DB::statement("RELEASE SAVEPOINT {$savepoint}");
|
||||
}
|
||||
|
||||
expect($exception)->toBeInstanceOf(QueryException::class);
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderResourceBinding;
|
||||
use App\Services\Resources\ProviderResourceBindingService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Baselines\SubjectClass;
|
||||
use App\Support\Resources\ProviderResourceBindingStatus;
|
||||
use App\Support\Resources\ProviderResourceResolutionMode;
|
||||
use App\Support\Resources\ResourceIdentity;
|
||||
|
||||
function providerResourceBindingAttributes(array $overrides = []): array
|
||||
{
|
||||
return array_replace([
|
||||
'subject_domain' => 'baseline',
|
||||
'subject_class' => SubjectClass::PolicyBacked,
|
||||
'subject_type_key' => 'deviceConfiguration',
|
||||
'operator_note' => 'Operator confirmed this provider resource binding after reviewing duplicate labels.',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('creates manual fake-provider bindings with scoped references and safe audit metadata', function (): void {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'fake-provider',
|
||||
]);
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create();
|
||||
$inventory = InventoryItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
$policyVersion = PolicyVersion::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
[, $snapshot] = seedActiveBaselineForTenant($tenant);
|
||||
|
||||
$binding = app(ProviderResourceBindingService::class)->createManualBinding(
|
||||
actor: $actor,
|
||||
environment: $tenant,
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'fake-policy-1'),
|
||||
attributes: providerResourceBindingAttributes([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'source_operation_run_id' => (int) $run->getKey(),
|
||||
'source_inventory_item_id' => (int) $inventory->getKey(),
|
||||
'source_policy_version_id' => (int) $policyVersion->getKey(),
|
||||
'source_baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'display_label' => 'Duplicate policy label',
|
||||
'resolution_reason' => 'duplicate-provider-resource',
|
||||
]),
|
||||
);
|
||||
|
||||
expect((int) $binding->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||
->and((int) $binding->managed_environment_id)->toBe((int) $tenant->getKey())
|
||||
->and($binding->provider_key)->toBe('fake-provider')
|
||||
->and($binding->resolution_mode)->toBe(ProviderResourceResolutionMode::ManualBinding)
|
||||
->and($binding->binding_status)->toBe(ProviderResourceBindingStatus::Active)
|
||||
->and((int) $binding->provider_connection_id)->toBe((int) $connection->getKey())
|
||||
->and((int) $binding->source_operation_run_id)->toBe((int) $run->getKey())
|
||||
->and((int) $binding->source_inventory_item_id)->toBe((int) $inventory->getKey())
|
||||
->and((int) $binding->source_policy_version_id)->toBe((int) $policyVersion->getKey())
|
||||
->and((int) $binding->source_baseline_snapshot_id)->toBe((int) $snapshot->getKey());
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::ProviderResourceBindingCreated->value)
|
||||
->where('resource_type', 'provider_resource_binding')
|
||||
->firstOrFail();
|
||||
|
||||
expect((int) $audit->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||
->and((int) $audit->managed_environment_id)->toBe((int) $tenant->getKey())
|
||||
->and(data_get($audit->metadata, 'provider_key'))->toBe('fake-provider')
|
||||
->and(data_get($audit->metadata, 'resolution_mode'))->toBe(ProviderResourceResolutionMode::ManualBinding->value)
|
||||
->and(data_get($audit->metadata, 'source_operation_run_id'))->toBe((int) $run->getKey())
|
||||
->and(data_get($audit->metadata, 'operator_note'))->toBeNull()
|
||||
->and(data_get($audit->metadata, 'operator_note_sha256'))->toBe(hash('sha256', providerResourceBindingAttributes()['operator_note']))
|
||||
->and(data_get($audit->metadata, 'operator_note_length'))->toBe(mb_strlen(providerResourceBindingAttributes()['operator_note']));
|
||||
});
|
||||
|
||||
it('supersedes an existing active binding and keeps exactly one active row', function (): void {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$service = app(ProviderResourceBindingService::class);
|
||||
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'same-subject');
|
||||
|
||||
$first = $service->createExactProviderIdentity($actor, $tenant, $identity, providerResourceBindingAttributes([
|
||||
'operator_note' => 'Initial identity binding.',
|
||||
]));
|
||||
|
||||
$second = $service->createManualBinding($actor, $tenant, $identity, providerResourceBindingAttributes([
|
||||
'operator_note' => 'Replacement identity binding after manual review.',
|
||||
]));
|
||||
|
||||
expect($first->refresh()->binding_status)->toBe(ProviderResourceBindingStatus::Superseded)
|
||||
->and($second->binding_status)->toBe(ProviderResourceBindingStatus::Active)
|
||||
->and(ProviderResourceBinding::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('provider_key', 'fake-provider')
|
||||
->where('canonical_subject_key', $second->canonical_subject_key)
|
||||
->active()
|
||||
->count())->toBe(1);
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::ProviderResourceBindingSuperseded->value)
|
||||
->firstOrFail();
|
||||
|
||||
expect(data_get($audit->metadata, 'old_binding_id'))->toBe((int) $first->getKey())
|
||||
->and(data_get($audit->metadata, 'new_binding_id'))->toBe((int) $second->getKey())
|
||||
->and(data_get($audit->metadata, 'resolution_mode'))->toBe(ProviderResourceResolutionMode::ManualBinding->value);
|
||||
});
|
||||
|
||||
it('revokes active bindings with audit metadata', function (): void {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$binding = app(ProviderResourceBindingService::class)->createAcceptedLimitation(
|
||||
actor: $actor,
|
||||
environment: $tenant,
|
||||
identity: ResourceIdentity::unsupported('fake-provider', 'foundation-resource', 'unsupported-subject'),
|
||||
attributes: providerResourceBindingAttributes([
|
||||
'operator_note' => 'Accepted limitation for unsupported coverage.',
|
||||
'subject_class' => SubjectClass::FoundationBacked,
|
||||
'subject_type_key' => 'foundationResource',
|
||||
]),
|
||||
);
|
||||
|
||||
$revoked = app(ProviderResourceBindingService::class)->revoke(
|
||||
actor: $actor,
|
||||
binding: $binding,
|
||||
operatorNote: 'Revoked because the limitation is no longer accepted.',
|
||||
);
|
||||
|
||||
expect($revoked->binding_status)->toBe(ProviderResourceBindingStatus::Revoked)
|
||||
->and($revoked->ended_at)->not->toBeNull();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::ProviderResourceBindingRevoked->value)
|
||||
->firstOrFail();
|
||||
|
||||
expect(data_get($audit->metadata, 'old_binding_id'))->toBe((int) $binding->getKey())
|
||||
->and(data_get($audit->metadata, 'binding_status'))->toBe(ProviderResourceBindingStatus::Revoked->value)
|
||||
->and(data_get($audit->metadata, 'operator_note'))->toBeNull();
|
||||
});
|
||||
|
||||
it('requires operator notes for binding decisions', function (): void {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
expect(fn () => app(ProviderResourceBindingService::class)->createManualBinding(
|
||||
actor: $actor,
|
||||
environment: $tenant,
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'missing-note'),
|
||||
attributes: providerResourceBindingAttributes(['operator_note' => '']),
|
||||
))->toThrow(InvalidArgumentException::class, 'operator note');
|
||||
});
|
||||
|
||||
it('persists every v1 resolution mode without Microsoft literals', function (string $method, ResourceIdentity $identity, ProviderResourceResolutionMode $mode): void {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$binding = app(ProviderResourceBindingService::class)->{$method}(
|
||||
actor: $actor,
|
||||
environment: $tenant,
|
||||
identity: $identity,
|
||||
attributes: providerResourceBindingAttributes([
|
||||
'subject_class' => SubjectClass::FoundationBacked,
|
||||
'subject_type_key' => 'fakeSubjectType',
|
||||
'operator_note' => 'Provider-neutral decision for '.$mode->value,
|
||||
]),
|
||||
);
|
||||
|
||||
expect($binding->provider_key)->toBe('fake-provider')
|
||||
->and($binding->resolution_mode)->toBe($mode)
|
||||
->and($binding->canonical_subject_key)->toContain('fake-provider')
|
||||
->and($binding->canonical_subject_key)->not->toContain('microsoft');
|
||||
})->with([
|
||||
'exact provider identity' => ['createExactProviderIdentity', ResourceIdentity::providerResource('fake-provider', 'policy', 'exact-1'), ProviderResourceResolutionMode::ExactProviderIdentity],
|
||||
'provider default as canonical builtin' => ['createCanonicalBuiltin', ResourceIdentity::canonicalDefault('fake-provider', 'default', 'provider-default'), ProviderResourceResolutionMode::CanonicalBuiltin],
|
||||
'canonical virtual target' => ['createCanonicalVirtualTarget', ResourceIdentity::virtualTarget('fake-provider', 'target', 'virtual-target'), ProviderResourceResolutionMode::CanonicalVirtualTarget],
|
||||
'manual binding' => ['createManualBinding', ResourceIdentity::providerResource('fake-provider', 'policy', 'manual-1'), ProviderResourceResolutionMode::ManualBinding],
|
||||
'excluded non governed' => ['createExclusion', ResourceIdentity::unsupported('fake-provider', 'object', 'excluded-1'), ProviderResourceResolutionMode::ExcludedNonGoverned],
|
||||
'accepted limitation' => ['createAcceptedLimitation', ResourceIdentity::unsupported('fake-provider', 'object', 'limited-1'), ProviderResourceResolutionMode::AcceptedLimitation],
|
||||
'unsupported coverage' => ['markUnsupported', ResourceIdentity::unsupported('fake-provider', 'object', 'unsupported-1'), ProviderResourceResolutionMode::UnsupportedCoverage],
|
||||
'missing expected' => ['markMissingExpected', ResourceIdentity::unknown('fake-provider', 'object', 'missing-1'), ProviderResourceResolutionMode::MissingExpected],
|
||||
]);
|
||||
@ -110,9 +110,17 @@
|
||||
expect($guidance['state'])->toBe('customer_safe_ready')
|
||||
->and($guidance['label'])->toBe('Customer-safe review pack ready')
|
||||
->and($guidance['primary_action']['label'])->toBe('Download customer-safe review pack')
|
||||
->and($guidance)->not->toHaveKey('provider_resource_bindings')
|
||||
->and($guidance['limitations'])->toBeEmpty();
|
||||
});
|
||||
|
||||
it('keeps review pack output guidance independent from provider resource bindings', function (): void {
|
||||
$guidanceFile = (new ReflectionClass(ReviewPackOutputResolutionGuidance::class))->getFileName();
|
||||
|
||||
expect($guidanceFile)->toBeString()
|
||||
->and(file_get_contents((string) $guidanceFile))->not->toContain('ProviderResourceBinding');
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, int> $requiredSectionStates
|
||||
* @param list<string> $publishBlockers
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\SubjectClass;
|
||||
use App\Support\Resources\ResourceIdentity;
|
||||
|
||||
it('does not collapse same-label provider resources with different stable IDs', function (): void {
|
||||
$left = BaselineSubjectKey::forProviderResourceIdentity(
|
||||
subjectDomain: 'baseline',
|
||||
subjectClass: SubjectClass::PolicyBacked,
|
||||
subjectTypeKey: 'deviceConfiguration',
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'provider-resource-1'),
|
||||
);
|
||||
|
||||
$right = BaselineSubjectKey::forProviderResourceIdentity(
|
||||
subjectDomain: 'baseline',
|
||||
subjectClass: SubjectClass::PolicyBacked,
|
||||
subjectTypeKey: 'deviceConfiguration',
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'provider-resource-2'),
|
||||
);
|
||||
|
||||
expect($left)->toStartWith('provider-resource:v1:baseline:policy_backed:deviceconfiguration:fake-provider:policy:provider_resource:')
|
||||
->and($right)->toStartWith('provider-resource:v1:baseline:policy_backed:deviceconfiguration:fake-provider:policy:provider_resource:')
|
||||
->and($left)->not->toBe($right);
|
||||
});
|
||||
|
||||
it('creates canonical keys for built-in and virtual targets without provider object IDs', function (): void {
|
||||
$builtin = BaselineSubjectKey::forProviderResourceIdentity(
|
||||
subjectDomain: 'baseline',
|
||||
subjectClass: SubjectClass::FoundationBacked,
|
||||
subjectTypeKey: 'assignmentTarget',
|
||||
identity: ResourceIdentity::canonicalBuiltin('fake-provider', 'target', 'all-principals'),
|
||||
);
|
||||
|
||||
$virtual = BaselineSubjectKey::forProviderResourceIdentity(
|
||||
subjectDomain: 'baseline',
|
||||
subjectClass: SubjectClass::FoundationBacked,
|
||||
subjectTypeKey: 'assignmentTarget',
|
||||
identity: ResourceIdentity::virtualTarget('fake-provider', 'target', 'dynamic-group-all-devices'),
|
||||
);
|
||||
|
||||
expect($builtin)->toStartWith('provider-resource:v1:baseline:foundation_backed:assignmenttarget:fake-provider:target:canonical_builtin:')
|
||||
->and($virtual)->toStartWith('provider-resource:v1:baseline:foundation_backed:assignmenttarget:fake-provider:target:canonical_virtual_target:')
|
||||
->and($builtin)->not->toBe($virtual);
|
||||
});
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Baselines\SubjectClass;
|
||||
use App\Support\Resources\ProviderResourceDescriptor;
|
||||
use App\Support\Resources\ResourceIdentity;
|
||||
|
||||
it('serializes descriptors with identity, source references, fingerprints, and last-seen metadata', function (): void {
|
||||
$descriptor = ProviderResourceDescriptor::fromIdentity(
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'policy-123'),
|
||||
subjectDomain: 'baseline',
|
||||
subjectClass: SubjectClass::PolicyBacked,
|
||||
subjectTypeKey: 'deviceConfiguration',
|
||||
displayLabel: 'Duplicate Policy Name',
|
||||
sourceReferences: [
|
||||
'inventory_item_id' => 12,
|
||||
'ignored_nested_payload' => ['unsafe' => true],
|
||||
],
|
||||
fingerprint: hash('sha256', 'descriptor'),
|
||||
lastSeenAt: '2026-06-15T12:00:00+00:00',
|
||||
);
|
||||
|
||||
$payload = $descriptor->toArray();
|
||||
|
||||
expect($payload['identity']['provider_key'])->toBe('fake-provider')
|
||||
->and($payload['display_label'])->toBe('Duplicate Policy Name')
|
||||
->and($payload['subject_class'])->toBe(SubjectClass::PolicyBacked->value)
|
||||
->and($payload['source_references'])->toBe(['inventory_item_id' => 12])
|
||||
->and($payload['fingerprint'])->toBe(hash('sha256', 'descriptor'))
|
||||
->and($payload['last_seen_at'])->toBe('2026-06-15T12:00:00+00:00');
|
||||
|
||||
expect(ProviderResourceDescriptor::fromArray($payload)->toArray())->toBe($payload);
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Resources\ResourceIdentity;
|
||||
|
||||
it('represents provider resource identity forms without display labels', function (): void {
|
||||
$identities = [
|
||||
ResourceIdentity::providerResource('fake-provider', 'policy', 'resource-1'),
|
||||
ResourceIdentity::canonicalBuiltin('fake-provider', 'assignment-target', 'all-principals'),
|
||||
ResourceIdentity::canonicalDefault('fake-provider', 'scope-tag', 'default-tag'),
|
||||
ResourceIdentity::virtualTarget('fake-provider', 'assignment-target', 'all-devices-dynamic'),
|
||||
ResourceIdentity::unsupported('fake-provider', 'foundation-resource', 'not-covered'),
|
||||
ResourceIdentity::unknown('fake-provider', 'policy', 'unknown-policy'),
|
||||
];
|
||||
|
||||
expect($identities)->toHaveCount(6);
|
||||
|
||||
foreach ($identities as $identity) {
|
||||
expect($identity->providerKey)->toBe('fake-provider')
|
||||
->and($identity->stableIdentityValue())->not->toBeNull()
|
||||
->and($identity->toArray())->not->toHaveKey('display_label')
|
||||
->and($identity->fingerprint())->toBeString()->toHaveLength(64);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores serialized provider-neutral identities', function (): void {
|
||||
$identity = ResourceIdentity::canonicalBuiltin('fake-provider', 'built-in-group', 'all-workers');
|
||||
|
||||
$restored = ResourceIdentity::fromArray($identity->toArray());
|
||||
|
||||
expect($restored->toArray())->toBe($identity->toArray());
|
||||
});
|
||||
@ -0,0 +1,56 @@
|
||||
# Requirements Checklist: Spec 381 - Provider Resource Identity and Binding Foundation v1
|
||||
|
||||
**Purpose**: Validate that the preparation artifacts define a bounded, implementable, constitution-aligned foundation for provider resource identity and binding decisions.
|
||||
**Created**: 2026-06-15
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
**Note**: This checklist covers preparation quality only. It does not mark implementation work complete.
|
||||
|
||||
## Applicability And Low-Impact Gate
|
||||
|
||||
- [x] CHK001 The spec explicitly states that no operator-facing surface or guardrail workflow surface is affected.
|
||||
- [x] CHK002 The spec, plan, and tasks consistently classify the feature as a backend/domain foundation with no UI surface impact.
|
||||
- [x] CHK029 The spec includes exactly one coherent UI Surface Impact decision: checked `No UI surface impact` with rationale.
|
||||
- [x] CHK032 Navigation entries, Filament panel/provider changes, Livewire components, Blade views, routes, and actions were explicitly considered and excluded from v1.
|
||||
- [x] CHK033 Browser screenshots and full page-report expectations are not required because v1 has no UI or customer-facing output change.
|
||||
|
||||
## Provider Boundary And Vocabulary
|
||||
|
||||
- [x] CHK010 The shared provider/platform seam is identified as mixed: provider identity data is stored in platform-core persistence, while provider-specific mapping semantics stay outside core.
|
||||
- [x] CHK011 Microsoft-specific display names, Graph endpoints, and provider-default object labels are explicitly prohibited from becoming platform-core identity truth.
|
||||
- [x] CHK034 A fake-provider proof is required so the identity and binding foundation does not silently become Microsoft-only.
|
||||
|
||||
## Persistence, State, And Proportionality
|
||||
|
||||
- [x] CHK035 The new `provider_resource_bindings` table is justified as independent, auditable product truth that outlives a run.
|
||||
- [x] CHK036 The spec avoids duplicate active truth by using `binding_status=active` rather than adding a separate `is_active` flag.
|
||||
- [x] CHK037 New status and resolution-mode families are justified by distinct lifecycle, audit, lookup, or future operator-action consequences.
|
||||
- [x] CHK038 The plan keeps v1 managed-environment-scoped and defers workspace-level, baseline-profile-specific, and subject-only binding scopes.
|
||||
- [x] CHK039 The plan reuses `BaselineSubjectKey` instead of introducing a parallel canonical-key taxonomy.
|
||||
|
||||
## RBAC, Audit, And Tenant Isolation
|
||||
|
||||
- [x] CHK040 Binding reads and mutations require workspace and managed-environment entitlement.
|
||||
- [x] CHK041 Non-member access is deny-as-not-found; entitled users missing capability receive forbidden.
|
||||
- [x] CHK042 V1 reuses existing baseline capabilities unless the spec is updated before dedicated binding capabilities are introduced.
|
||||
- [x] CHK043 Binding decisions and revocations require safe audit records with actor, workspace, managed environment, provider key, canonical subject key, mode, and source references.
|
||||
- [x] CHK044 Audit metadata explicitly excludes secrets, credentials, raw provider payloads, raw Graph responses, signed URLs, stack traces, and sensitive raw JSON.
|
||||
|
||||
## OperationRun And Runtime Truth
|
||||
|
||||
- [x] CHK019 The spec explicitly states that v1 creates no `OperationRun`, queued work, scheduler behavior, provider runtime calls, or Graph calls.
|
||||
- [x] CHK045 The spec preserves current baseline compare, evidence readiness, environment review readiness, review-pack publication, and customer-facing output semantics.
|
||||
- [x] CHK046 Follow-up matching, UI resolution, evidence/review consumption, and automatic built-in mapping work is listed as follow-up scope rather than hidden v1 behavior.
|
||||
|
||||
## Test Readiness
|
||||
|
||||
- [x] CHK047 The test-governance lane is explicit: Unit, Feature, PostgreSQL, and targeted no-op regression tests; no Browser family.
|
||||
- [x] CHK048 PostgreSQL validation is scoped to partial unique/index behavior that SQLite cannot prove.
|
||||
- [x] CHK049 Tasks require authorization, audit, fake-provider, persistence, identity, and no-op runtime regression coverage.
|
||||
- [x] CHK050 Final validation commands are concrete enough for a later implementation loop.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- [x] CHK016 Review outcome class: `acceptable-special-case`.
|
||||
- [x] CHK017 Workflow outcome: `keep`.
|
||||
- [x] CHK018 Final note location: implementation close-out for `specs/381-provider-resource-identity-binding/` must record validation commands, deployment impact, Livewire/Filament status, and any confirmed scope deviations.
|
||||
@ -0,0 +1,101 @@
|
||||
# Implementation Close-Out: Spec 381 - Provider Resource Identity and Binding Foundation v1
|
||||
|
||||
Date: 2026-06-15
|
||||
Branch: `381-provider-resource-identity-binding`
|
||||
Base HEAD observed during close-out: `d52b674f spec: record management report pdf staging validation gate (#451)`
|
||||
|
||||
## Scope
|
||||
|
||||
Implemented backend-only provider resource identity and managed-environment-scoped binding foundation.
|
||||
|
||||
No Filament Resource, page, route, Livewire component, Blade view, navigation item, Graph client, queued job, scheduler behavior, OperationRun type, or customer-facing output was added.
|
||||
|
||||
## Repo-Truth Notes
|
||||
|
||||
- `provider_resource_bindings` is tenant-owned operational truth and remains scoped by `workspace_id` and `managed_environment_id`.
|
||||
- Baseline snapshots are workspace-owned through baseline profiles, not directly tenant-owned. For `source_baseline_snapshot_id`, managed-environment validity is enforced through `baseline_tenant_assignments` for the snapshot's `baseline_profile_id`.
|
||||
- No workspace-level, baseline-profile-specific, or subject-only binding scope was introduced.
|
||||
- No duplicate active-state truth was introduced; `binding_status = active` remains the active-binding truth.
|
||||
|
||||
## Livewire / Filament Contract
|
||||
|
||||
- Livewire v4.0+ compliance: unchanged. No Livewire code changed.
|
||||
- Provider registration location: unchanged. Laravel panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- Global search: no Filament Resource was added; `ProviderResourceBinding` is not globally searchable.
|
||||
- Destructive/high-impact actions: no Filament action was added. Backend supersede/revoke decisions require policy authorization and audit logging. Future UI confirmation belongs to Spec 384.
|
||||
- Assets: no assets were registered. No Spec 381-specific `filament:assets` deployment concern beyond normal Filament deploy procedure.
|
||||
|
||||
## RBAC, Isolation, And Audit
|
||||
|
||||
- Reads and mutations use `ProviderResourceBindingPolicy` with existing baseline capabilities:
|
||||
- view: `workspace_baselines.view`
|
||||
- create/supersede/revoke: `workspace_baselines.manage`
|
||||
- Non-members are denied as not found through managed-environment entitlement checks.
|
||||
- Entitled members without manage capability receive forbidden for mutations.
|
||||
- Provider connections and source references are validated against the binding workspace and managed environment before persistence.
|
||||
- Binding create/supersede/revoke actions write `AuditLog` records with safe identifiers and hashed/length-only operator note metadata.
|
||||
|
||||
## OperationRun Semantics
|
||||
|
||||
No OperationRun is created, queued, updated, or completed by Spec 381. Binding decisions are DB-only, security-relevant mutations that are audited directly.
|
||||
|
||||
## Browser Smoke
|
||||
|
||||
Not applicable. Spec 381 has no UI, user-facing flow, route, navigation, Filament, Livewire, or asset surface impact.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
Executed during final review and finding fix loop:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Resources/ResourceIdentityTest.php tests/Unit/Support/Resources/ProviderResourceDescriptorTest.php tests/Unit/Support/Baselines/BaselineSubjectKeyCanonicalIdentityTest.php
|
||||
```
|
||||
|
||||
Result: passed, 5 tests / 45 assertions.
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php tests/Feature/ProviderResources/ProviderResourceBindingAuthorizationTest.php
|
||||
```
|
||||
|
||||
Result after finding fix: passed, 19 tests / 72 assertions.
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/ProviderResources/ProviderResourceBindingPostgresTest.php
|
||||
```
|
||||
|
||||
Result: passed, 4 tests / 7 assertions.
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareProviderResourceBindingNoOpTest.php tests/Feature/Baselines/BaselineCompareGapClassificationTest.php tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php
|
||||
```
|
||||
|
||||
Result: passed, 11 tests / 83 assertions.
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --test --format agent
|
||||
```
|
||||
|
||||
Result: passed.
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Result: passed.
|
||||
|
||||
## Deployment Impact
|
||||
|
||||
- Additive migration only: `provider_resource_bindings`.
|
||||
- Staging must run the migration and the PostgreSQL lane before Production promotion.
|
||||
- No environment variable, queue, scheduler, storage, reverse-proxy, or asset change is required.
|
||||
- Rollback before follow-up specs consume the table is dropping the new table. After follow-up specs consume bindings, rollback must be redesigned.
|
||||
|
||||
## Residual Risks / Follow-Up
|
||||
|
||||
No confirmed in-scope findings remain after the final fix loop.
|
||||
|
||||
Follow-up specs remain as planned:
|
||||
|
||||
- Spec 382: matching pipeline consumption.
|
||||
- Spec 384: operator resolution UI and destructive/high-impact UI confirmations.
|
||||
- Spec 385: evidence/review readiness consumption.
|
||||
347
specs/381-provider-resource-identity-binding/plan.md
Normal file
347
specs/381-provider-resource-identity-binding/plan.md
Normal file
@ -0,0 +1,347 @@
|
||||
# Implementation Plan: Spec 381 - Provider Resource Identity and Binding Foundation v1
|
||||
|
||||
**Branch**: `381-provider-resource-identity-binding` | **Date**: 2026-06-15 | **Spec**: `specs/381-provider-resource-identity-binding/spec.md`
|
||||
**Input**: Feature specification from `specs/381-provider-resource-identity-binding/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add the managed-environment-scoped provider resource identity and binding foundation needed before future baseline matching, resolution UI, and evidence/review integration. The plan introduces provider-neutral identity primitives, extends the existing `BaselineSubjectKey` path for canonical keys, persists auditable provider resource bindings, and proves isolation, uniqueness, RBAC, auditability, and no-op compare/evidence behavior. It does not add UI, Graph calls, OperationRun behavior, matching changes, or evidence/review readiness changes.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15, Laravel 12.52.0
|
||||
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, PostgreSQL, existing Laravel policies/Gates, existing `AuditLog`, existing baseline compare/resolution support
|
||||
**Storage**: PostgreSQL table `provider_resource_bindings` with managed-environment scope and partial unique active-binding index
|
||||
**Testing**: Pest 4 Unit, Feature, and PostgreSQL lane
|
||||
**Validation Lanes**: fast-feedback, confidence, PostgreSQL; no browser lane because no UI surface changes
|
||||
**Target Platform**: Laravel Sail locally; Dokploy-first Staging/Production containers
|
||||
**Project Type**: Laravel monolith with Filament admin/system panels
|
||||
**Performance Goals**: Active binding lookup must be indexed by workspace, managed environment, provider key, canonical subject key, and active status. No render-time provider calls are introduced.
|
||||
**Constraints**: no UI, no Graph calls, no OperationRun creation, no matching pipeline rewrite, no evidence/review behavior change, no workspace/baseline-profile binding scopes in v1
|
||||
**Scale/Scope**: One new scoped binding table, identity/descriptor support classes, one binding service, one policy/capability path, focused tests
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: no operator-facing surface change.
|
||||
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: none in v1; existing baseline compare/evidence/review surfaces are regression targets only.
|
||||
- **No-impact class, if applicable**: backend-only identity and persistence foundation.
|
||||
- **Native vs custom classification summary**: N/A.
|
||||
- **Shared-family relevance**: provider boundary vocabulary, baseline subject identity, RBAC, audit, and future evidence/review truth.
|
||||
- **State layers in scope**: backend persistence and service state only; no shell/page/detail/URL-query state.
|
||||
- **Audience modes in scope**: future operator/MSP and support/platform users are represented through audit and service semantics only. No customer-readable surface changes.
|
||||
- **Decision/diagnostic/raw hierarchy plan**: N/A for UI. Audit metadata must remain safe and redacted.
|
||||
- **Raw/support gating plan**: no raw provider payloads, secrets, tokens, signed URLs, or raw Graph data in binding records or audit metadata.
|
||||
- **One-primary-action / duplicate-truth control**: `binding_status` is the single active/superseded/revoked truth. Do not add an `is_active` mirror.
|
||||
- **Handling modes by drift class or surface**: hard-stop if implementation adds UI, matching behavior, workspace-level scope, Graph calls, or a new OperationRun type without updating the spec/plan first.
|
||||
- **Repository-signal treatment**: review-mandatory for any separate capability family; follow-up-spec for workspace/baseline-profile scopes or automatic canonicalization.
|
||||
- **Special surface test profiles**: N/A.
|
||||
- **Required tests or manual smoke**: Unit, Feature, PostgreSQL, and targeted no-op regression tests.
|
||||
- **Exception path and spread control**: none.
|
||||
- **Active feature PR close-out entry**: Identity Binding Foundation / Provider Boundary.
|
||||
- **UI/Productization coverage decision**: No UI surface impact.
|
||||
- **Coverage artifacts to update**: none.
|
||||
- **No-impact rationale**: The spec adds backend identity/persistence only and no route/page/action/navigation/presentation changes.
|
||||
- **Navigation / Filament provider-panel handling**: N/A.
|
||||
- **Screenshot or page-report need**: no.
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes.
|
||||
- **Systems touched**: baseline subject key support, provider resource descriptors, binding persistence, capability/policy enforcement, audit logging, PostgreSQL migration/index patterns.
|
||||
- **Shared abstractions reused**: `App\Support\Baselines\BaselineSubjectKey`, `App\Support\Baselines\SubjectClass`, `App\Support\Baselines\ResolutionOutcome`, `App\Models\ProviderConnection`, `App\Models\ManagedEnvironment`, `App\Models\AuditLog`, `App\Support\Auth\Capabilities`.
|
||||
- **New abstraction introduced? why?**: `ResourceIdentity` and `ProviderResourceDescriptor` are introduced because stable provider identity and descriptor serialization are not currently represented independently of compare runtime records. `ProviderResourceBindingService` is introduced because binding mutation requires scoped authorization, uniqueness, supersession, note validation, and audit in one transaction.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: Existing `BaselineSubjectKey` is sufficient to host canonical key generation. Existing compare/resolution classes are insufficient as durable binding truth because they are run/result semantics, not reusable operator decisions.
|
||||
- **Bounded deviation / spread control**: New binding foundation must remain passive until Spec 382 or later explicitly consumes it.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: no.
|
||||
- **Central contract reused**: N/A.
|
||||
- **Delegated UX behaviors**: N/A.
|
||||
- **Surface-owned behavior kept local**: N/A.
|
||||
- **Queued DB-notification policy**: N/A.
|
||||
- **Terminal notification path**: N/A.
|
||||
- **Exception path**: none.
|
||||
|
||||
Binding records may reference a source `OperationRun`, but creating or revoking a binding is a short DB-only security-relevant action. It writes `AuditLog` and does not create a new run.
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes.
|
||||
- **Provider-owned seams**: concrete provider resource IDs, external IDs, resource type names, discriminators, fingerprints, and future built-in canonicalization rules.
|
||||
- **Platform-core seams**: `ResourceIdentity`, descriptor shape, canonical subject key generation, binding persistence, active uniqueness, audit/action semantics.
|
||||
- **Neutral platform terms / contracts preserved**: provider, provider connection, managed environment, governed subject, canonical subject key, resource identity, descriptor, binding, resolution mode.
|
||||
- **Retained provider-specific semantics and why**: provider fields are retained as opaque descriptors because exact provider identity must be represented. The core must not interpret Microsoft display labels.
|
||||
- **Bounded extraction or follow-up path**: automatic Microsoft built-in mapping and matching behavior are follow-up specs.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
- **Inventory-first, Snapshots-second**: PASS. Inventory remains last-observed provider state. Bindings are operator/provider identity decision truth, not a replacement for inventory or snapshots.
|
||||
- **Read/Write Separation by Default**: PASS. No provider writes. Binding mutations affect TenantPilot only and require authorization plus audit.
|
||||
- **Single Contract Path to Graph**: PASS. No Graph calls are introduced.
|
||||
- **Deterministic Capabilities**: PASS with constraint. V1 reuses existing baseline capabilities unless spec/plan update authorizes dedicated capabilities.
|
||||
- **Workspace isolation**: PASS. All rows include `workspace_id`, and services/policies must enforce workspace entitlement.
|
||||
- **Tenant/Managed-environment isolation**: PASS. V1 rows include non-null `managed_environment_id`; no workspace-level binding scope is implemented.
|
||||
- **RBAC-UX**: PASS. Non-member access is deny-as-not-found; member without capability is forbidden; policies/Gates enforce server-side.
|
||||
- **Run observability**: PASS. No new long-running/remote/queued work. Security-relevant DB-only actions audit-log instead of creating an OperationRun.
|
||||
- **Proportionality / No premature abstraction**: PASS as narrowed. New persistence is justified because bindings outlive runs and affect future governance truth. No provider adapter framework or UI is included.
|
||||
- **Persisted truth**: PASS. Binding records have independent lifecycle and auditability.
|
||||
- **Behavioral state**: PASS. `binding_status` affects active lookup and supersession/revocation; `resolution_mode` affects audit and future operator/matching behavior.
|
||||
- **Provider boundary**: PASS with guardrails. Core stores provider identity but must not branch on Microsoft display names or Graph endpoint assumptions.
|
||||
- **UI/Productization coverage**: PASS. `No UI surface impact` is recorded.
|
||||
- **Test governance**: PASS. Unit/Feature/PostgreSQL lanes are named and narrow.
|
||||
- **Release safety**: PASS. Migration is additive and reversible where practical. PostgreSQL partial unique behavior is test-covered before production promotion.
|
||||
|
||||
## Existing Repository Surfaces
|
||||
|
||||
Read-only context and likely implementation targets:
|
||||
|
||||
- `apps/platform/app/Support/Baselines/BaselineSubjectKey.php`
|
||||
- `apps/platform/app/Support/Baselines/SubjectClass.php`
|
||||
- `apps/platform/app/Support/Baselines/ResolutionOutcome.php`
|
||||
- `apps/platform/app/Support/Baselines/Compare/CompareSubjectIdentity.php`
|
||||
- `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php`
|
||||
- `apps/platform/app/Services/Baselines/BaselineCompareService.php`
|
||||
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- `apps/platform/app/Services/Baselines/BaselineCaptureService.php`
|
||||
- `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
- `apps/platform/app/Models/ProviderConnection.php`
|
||||
- `apps/platform/app/Models/ManagedEnvironment.php`
|
||||
- `apps/platform/app/Models/InventoryItem.php`
|
||||
- `apps/platform/app/Models/PolicyVersion.php`
|
||||
- `apps/platform/app/Models/BaselineSnapshot.php`
|
||||
- `apps/platform/app/Models/OperationRun.php`
|
||||
- `apps/platform/app/Models/AuditLog.php`
|
||||
- `apps/platform/app/Support/Auth/Capabilities.php`
|
||||
- `apps/platform/app/Services/Auth/WorkspaceRoleCapabilityMap.php`
|
||||
- `apps/platform/app/Services/Auth/RoleCapabilityMap.php`
|
||||
- `apps/platform/database/migrations/2026_03_20_000000_create_environment_reviews_table.php`
|
||||
- `apps/platform/database/migrations/2026_02_19_100003_create_baseline_snapshot_items_table.php`
|
||||
- `apps/platform/database/migrations/2026_01_07_142720_create_inventory_items_table.php`
|
||||
|
||||
Expected write surfaces during later implementation:
|
||||
|
||||
- `apps/platform/database/migrations/<timestamp>_create_provider_resource_bindings_table.php`
|
||||
- `apps/platform/app/Models/ProviderResourceBinding.php`
|
||||
- `apps/platform/database/factories/ProviderResourceBindingFactory.php`
|
||||
- `apps/platform/app/Policies/ProviderResourceBindingPolicy.php`
|
||||
- `apps/platform/app/Support/Resources/ResourceIdentity.php`
|
||||
- `apps/platform/app/Support/Resources/ProviderResourceDescriptor.php`
|
||||
- `apps/platform/app/Support/Resources/ProviderResourceBindingStatus.php`
|
||||
- `apps/platform/app/Support/Resources/ProviderResourceResolutionMode.php`
|
||||
- `apps/platform/app/Services/Resources/ProviderResourceBindingService.php`
|
||||
- `apps/platform/app/Support/Baselines/BaselineSubjectKey.php`
|
||||
- `apps/platform/app/Support/Audit/AuditActionId.php`
|
||||
- `apps/platform/tests/Unit/Support/Resources/ResourceIdentityTest.php`
|
||||
- `apps/platform/tests/Unit/Support/Resources/ProviderResourceDescriptorTest.php`
|
||||
- `apps/platform/tests/Unit/Support/Baselines/BaselineSubjectKeyCanonicalIdentityTest.php`
|
||||
- `apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php`
|
||||
- `apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingAuthorizationTest.php`
|
||||
- `apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingPostgresTest.php`
|
||||
- Targeted existing baseline/evidence/review tests listed in the validation section
|
||||
|
||||
No Filament resource/page/action, route, Livewire component, Blade view, job, Graph client, provider adapter, or customer output surface is planned.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
1. Add deterministic provider-neutral identity and descriptor primitives.
|
||||
2. Extend `BaselineSubjectKey` with canonical key methods that accept provider key, subject class, subject type key, resource type, and stable identity or canonical discriminator.
|
||||
3. Add a managed-environment-scoped `provider_resource_bindings` table:
|
||||
- `workspace_id` non-null foreign key
|
||||
- `managed_environment_id` non-null foreign key
|
||||
- composite foreign key from `(managed_environment_id, workspace_id)` to `managed_environments(id, workspace_id)`
|
||||
- `provider_key`
|
||||
- nullable `provider_connection_id`
|
||||
- `subject_domain`, `subject_class`, `subject_type_key`
|
||||
- nullable `legacy_subject_key`
|
||||
- `canonical_subject_key`
|
||||
- nullable provider resource descriptor columns
|
||||
- `binding_status`
|
||||
- `resolution_mode`
|
||||
- nullable `resolution_reason`
|
||||
- required `operator_note` for manual decisions
|
||||
- nullable source references to `operation_runs`, `baseline_snapshots`, `inventory_items`, and `policy_versions`
|
||||
- `decided_by_user_id`, `decided_at`, nullable `ended_at`
|
||||
- timestamps
|
||||
4. Use PostgreSQL partial unique index for active rows:
|
||||
- `(workspace_id, managed_environment_id, provider_key, canonical_subject_key) WHERE binding_status = 'active'`
|
||||
5. Add a model, factory, policy, and service:
|
||||
- model uses `DerivesWorkspaceIdFromTenant` or an equivalent invariant to derive and protect `workspace_id`
|
||||
- service methods create manual binding, create exclusion, create accepted limitation, mark unsupported, mark missing expected, supersede active binding, and revoke binding
|
||||
- all mutations run in transactions
|
||||
- all mutations enforce authorization, workspace/managed-environment scope, provider connection scope, source-reference scope, note requirements, active uniqueness, and audit
|
||||
6. Add tests before or alongside implementation.
|
||||
7. Add targeted regression proof that current compare/evidence/review behavior does not consume bindings yet.
|
||||
|
||||
## Domain / Model Implications
|
||||
|
||||
- `ProviderResourceBinding` is tenant-owned operational truth because it is bound to a managed environment and affects future tenant governance semantics.
|
||||
- Binding scope is deliberately not a persisted axis in v1. All rows are managed-environment scoped.
|
||||
- Provider defaults use `canonical_builtin` resolution mode in v1; do not introduce a separate default-specific resolution mode unless spec/plan are updated first.
|
||||
- The model must reject or prevent workspace drift from the managed environment, matching the repo's tenant-owned model invariant.
|
||||
- `binding_status` is the lifecycle truth:
|
||||
- `active`
|
||||
- `superseded`
|
||||
- `revoked`
|
||||
- `resolution_mode` is the decision meaning:
|
||||
- `exact_provider_identity`
|
||||
- `canonical_builtin`
|
||||
- `canonical_virtual_target`
|
||||
- `manual_binding`
|
||||
- `excluded_non_governed`
|
||||
- `accepted_limitation`
|
||||
- `unsupported_coverage`
|
||||
- `missing_expected`
|
||||
- Do not add `is_active`, validity windows, workspace-level rows, baseline-profile rows, or subject-only rows in v1.
|
||||
|
||||
## UI / Filament Implications
|
||||
|
||||
- No Filament Resource, Page, RelationManager, action, table, form, navigation item, modal, drawer, wizard, or view is added or changed.
|
||||
- Livewire v4.0+ compliance remains unchanged; installed Livewire is 4.1.4.
|
||||
- Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- No globally searchable resource is added. `ProviderResourceBinding` must not be globally searchable because no Filament Resource/View/Edit page is introduced.
|
||||
- No destructive Filament action is added. Service-level revocation and supersession are high-impact backend mutations and require server-side authorization plus audit; future UI confirmation belongs to Spec 384.
|
||||
- No assets are registered. Normal deployment still runs `php artisan filament:assets` when the app deploys existing Filament assets, but Spec 381 adds none.
|
||||
|
||||
## OperationRun / Monitoring Implications
|
||||
|
||||
- No new OperationRun type or lifecycle transition.
|
||||
- Binding mutations are DB-only and expected to complete under 2 seconds.
|
||||
- Binding audit events must include safe source references where provided.
|
||||
- Future matching specs may link compare OperationRuns to binding decisions, but v1 only stores optional source references.
|
||||
|
||||
## RBAC / Policy Implications
|
||||
|
||||
- Add a `ProviderResourceBindingPolicy` or an equivalent policy path consistent with existing model policy conventions.
|
||||
- Reuse existing `workspace_baselines.view` for read authorization and `workspace_baselines.manage` for create/supersede/revoke decisions in v1.
|
||||
- Do not introduce raw capability strings in feature code.
|
||||
- Non-members or actors outside the workspace/managed environment receive 404 deny-as-not-found.
|
||||
- Entitled members lacking baseline management capability receive 403 for mutations.
|
||||
- Register the policy explicitly through the existing Laravel provider convention used by this app (`apps/platform/app/Providers/AuthServiceProvider.php` unless implementation finds a stronger adjacent precedent and updates this plan).
|
||||
- Include positive and negative authorization tests.
|
||||
|
||||
## Audit / Evidence Implications
|
||||
|
||||
- Manual binding, exclusion, accepted limitation, unsupported coverage, missing expected, supersession, and revocation emit audit events.
|
||||
- Audit metadata includes only safe identifiers:
|
||||
- provider key
|
||||
- canonical subject key
|
||||
- subject class/type
|
||||
- old/new binding IDs
|
||||
- resolution mode
|
||||
- source operation/snapshot/inventory/policy version IDs
|
||||
- redacted, summarized, length-limited, or reference-only operator note/reason metadata
|
||||
- Audit metadata must not include tokens, secrets, raw credential payloads, raw provider payloads, raw Graph response bodies, signed URLs, or customer-sensitive raw JSON.
|
||||
- Raw operator notes must not be copied unchecked into audit metadata. The binding row may retain the required note; audit assertions must prove the audit payload remains safe.
|
||||
|
||||
## Data / Migration Implications
|
||||
|
||||
- Add one reversible migration for `provider_resource_bindings`.
|
||||
- Add the repo-standard tenant-owned composite foreign key `(managed_environment_id, workspace_id)` -> `managed_environments(id, workspace_id)`.
|
||||
- Add scoped foreign keys or service-enforced validation for `provider_connection_id` and source reference IDs so referenced records cannot point outside the binding workspace/managed environment. `provider_connection_id`, when present, must match `provider_key`.
|
||||
- Use `jsonb` only if implementation needs a small descriptor payload; prefer explicit indexed columns listed above for lookup truth.
|
||||
- Add indexes:
|
||||
- `workspace_id`
|
||||
- `managed_environment_id`
|
||||
- `provider_key`
|
||||
- `provider_connection_id`
|
||||
- `canonical_subject_key`
|
||||
- `binding_status`
|
||||
- `resolution_mode`
|
||||
- `source_operation_run_id`
|
||||
- `source_baseline_snapshot_id`
|
||||
- `source_inventory_item_id`
|
||||
- `source_policy_version_id`
|
||||
- Add partial unique active index in a PostgreSQL migration statement and drop it in `down()` before dropping the table if required by migration ordering.
|
||||
- PostgreSQL lane is required because SQLite cannot prove partial unique index, composite foreign key, and tenant/workspace constraint behavior.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
Required validation during implementation:
|
||||
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Resources/ResourceIdentityTest.php tests/Unit/Support/Resources/ProviderResourceDescriptorTest.php tests/Unit/Support/Baselines/BaselineSubjectKeyCanonicalIdentityTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php tests/Feature/ProviderResources/ProviderResourceBindingAuthorizationTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/ProviderResources/ProviderResourceBindingPostgresTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
|
||||
If Sail is unavailable, use `./scripts/platform-sail ...` only if configured and record the blocker in the implementation close-out.
|
||||
|
||||
## Rollout Considerations
|
||||
|
||||
- Additive schema migration only.
|
||||
- Staging must run the migration and PostgreSQL lane before Production.
|
||||
- No env var, queue, scheduler, storage, or reverse-proxy changes are expected.
|
||||
- No queue worker restart is specifically required beyond the normal deploy/reload process.
|
||||
- Rollback is dropping the new table before any follow-up spec consumes it; after follow-up consumption exists, rollback must be redesigned.
|
||||
- Since the product is pre-production, no historical backfill is required.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Keep all binding records managed-environment scoped.
|
||||
- Enforce workspace/environment alignment in the database and the model, not only in the service.
|
||||
- Reject cross-scope provider connections and source references before persistence.
|
||||
- Use partial unique index and service transaction to prevent duplicate active bindings.
|
||||
- Require operator note for all governance-impacting decisions.
|
||||
- Audit every mutation with redacted metadata and safe note handling.
|
||||
- Add fake-provider tests to block Microsoft-only leakage through both identity primitives and service-level binding persistence.
|
||||
- Add no-op regression tests so compare/evidence/review behavior remains unchanged.
|
||||
- Stop and update spec/plan before adding UI, matching pipeline consumption, workspace-level scope, provider-specific built-in mappings, or evidence/review changes.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1 - Baseline And Guardrail Reconfirmation
|
||||
|
||||
Confirm repo state, completed-spec guardrails, current baseline/provider classes, capability patterns, audit patterns, and PostgreSQL migration conventions.
|
||||
|
||||
### Phase 2 - Identity And Canonical Key Foundation
|
||||
|
||||
Add identity/descriptor primitives and extend `BaselineSubjectKey` for canonical provider resource keys with unit coverage.
|
||||
|
||||
### Phase 3 - Binding Persistence And Integrity
|
||||
|
||||
Add migration, model, factory, enums, policy, service, PostgreSQL partial unique index, and tests.
|
||||
|
||||
### Phase 4 - Audit, RBAC, And Fake Provider Proof
|
||||
|
||||
Add service-level authorization, audit events, fake-provider fixtures/tests, and no-secret audit metadata assertions.
|
||||
|
||||
### Phase 5 - No-Op Runtime Regression
|
||||
|
||||
Run targeted compare, evidence, and review tests; ensure no current pipeline consumes bindings.
|
||||
|
||||
### Phase 6 - Final Validation And Hygiene
|
||||
|
||||
Run formatting, `git diff --check`, PostgreSQL lane, and final artifact close-out.
|
||||
|
||||
## Structure Decision
|
||||
|
||||
Use the existing Laravel monolith structure under `apps/platform`. Add small support classes under `app/Support/Resources`, extend the existing baseline support namespace only where current semantics already live, and keep all mutation behavior in a service under `app/Services/Resources`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|---|---|---|
|
||||
| New table/model `provider_resource_bindings` | Binding decisions must survive runs, be unique, be auditable, and affect future governance truth | Run-local JSON context or display-name normalization would not be durable, constrained, or independently auditable |
|
||||
| New identity/descriptor classes | Provider resource identity must represent built-ins, virtuals, unsupported resources, and fake providers without display names | Extending compare result objects would couple persistent identity to one runtime pipeline |
|
||||
| New status/mode enums | Active lookup, supersession, revocation, audit meaning, and future operator action differ by state/mode | String literals or presentation labels would spread state semantics through services/tests |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Future operators need stable, auditable provider resource identity decisions instead of repeated ambiguity around mutable labels.
|
||||
- **Existing structure is insufficient because**: Current subject/resolution support classifies run outcomes but does not persist binding decisions or provider resource identity truth.
|
||||
- **Narrowest correct implementation**: Managed-environment-only binding table, reuse existing baseline capability and subject-key seams, no UI, no matching rewrite, no provider canonicalizer.
|
||||
- **Ownership cost created**: Migration/index, model/policy/service, two support classes, two enum families, PostgreSQL tests, and provider-boundary review burden.
|
||||
- **Alternative intentionally rejected**: Display-name normalization, run-context notes, and workspace-level/baseline-profile scope in v1.
|
||||
- **Release truth**: Current-release foundation needed to unblock the immediately proposed follow-up specs without increasing current runtime behavior risk.
|
||||
|
||||
## Filament v5 Output Contract For Implementation Close-Out
|
||||
|
||||
- **Livewire v4.0+ compliance**: unchanged; Livewire 4.1.4 is installed and no Livewire component changes are planned.
|
||||
- **Provider registration location**: unchanged; Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- **Global search**: no Filament Resource is added; `ProviderResourceBinding` is not globally searchable.
|
||||
- **Destructive/high-impact actions**: no Filament action is added. Backend supersede/revoke decisions are high-impact and require policy authorization plus audit; future UI confirmation belongs to Spec 384.
|
||||
- **Asset strategy**: no new assets; no Spec 381-specific `filament:assets` concern beyond normal deployment.
|
||||
- **Testing plan**: Unit, Feature, PostgreSQL, and targeted no-op regression tests listed above.
|
||||
- **Deployment impact**: additive migration only; no env vars, queues, scheduler, storage, or assets expected.
|
||||
378
specs/381-provider-resource-identity-binding/spec.md
Normal file
378
specs/381-provider-resource-identity-binding/spec.md
Normal file
@ -0,0 +1,378 @@
|
||||
# Feature Specification: Spec 381 - Provider Resource Identity and Binding Foundation v1
|
||||
|
||||
**Feature Branch**: `381-provider-resource-identity-binding`
|
||||
**Created**: 2026-06-15
|
||||
**Status**: Draft / Ready for implementation preparation review
|
||||
**Input**: User-provided draft candidate "Spec 381 - Provider Resource Identity & Binding Foundation v1" from `/Users/ahmeddarrazi/.codex/attachments/6b3f1f8d-d672-4e3a-a862-51fc5707afb9/pasted-text.txt`.
|
||||
|
||||
## Repo-Truth Adjustment
|
||||
|
||||
The active automatic candidate queue in `docs/product/spec-candidates.md` currently says no safe automatic next-best-prep target remains. This package is therefore treated as an explicit user-provided manual candidate, not an auto-selected queue item.
|
||||
|
||||
The source draft proposes a broad provider-agnostic identity and binding foundation. This prepared Spec 381 intentionally narrows it to the smallest implementation-ready foundation that fits current repo truth and the constitution:
|
||||
|
||||
- V1 is managed-environment scoped only. Workspace-wide, baseline-profile-specific, and subject-only binding scopes are deferred until a concrete current workflow requires them.
|
||||
- V1 reuses and extends `App\Support\Baselines\BaselineSubjectKey` rather than creating a parallel `CanonicalSubjectKey` class by default.
|
||||
- V1 reuses existing baseline management capabilities for binding decisions unless implementation proves a separate capability is necessary and the spec/plan are updated first.
|
||||
- V1 does not add a Filament resource, resolution UI, matching pipeline rewrite, automatic Microsoft built-in mapping, evidence readiness change, or review-pack behavior change.
|
||||
- V1 removes duplicate `is_active` state from the source draft. `binding_status=active` is the single active-binding truth.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Baseline compare and future governance flows still risk treating mutable display names and overloaded `policy_type + subject_key` values as durable identity for governed subjects.
|
||||
- **Today's failure**: Provider built-ins, virtual assignment targets, tenant-owned duplicates, restored/test objects, and inventory-only foundation resources can collapse into ambiguous display-name-derived subjects, creating false red outcomes, false green outcomes, or non-auditable operator workarounds.
|
||||
- **User-visible improvement**: Future compare, review, and evidence surfaces can identify whether a governed subject is a concrete provider resource, provider built-in, virtual target, tenant-owned duplicate, accepted limitation, excluded object, unsupported coverage, or missing expected resource without relying on display names as identity.
|
||||
- **Smallest enterprise-capable version**: Add provider-agnostic identity primitives, extend the existing baseline subject-key foundation, add a provider resource descriptor shape, persist managed-environment-scoped provider resource bindings, enforce active uniqueness, authorize and audit manual decisions, and prove the foundation without changing compare/evidence/review runtime behavior.
|
||||
- **Explicit non-goals**: No full matching pipeline, no automatic Microsoft built-in canonicalization, no resolution UI, no evidence/review readiness changes, no customer-facing output changes, no generic workflow engine, no multi-provider framework, no Graph calls, no historical OperationRun backfill, and no workspace/baseline-profile binding scopes in v1.
|
||||
- **Permanent complexity imported**: One new managed-environment-scoped binding table/model/factory/policy/service, two small identity/descriptor support classes, two focused enum families for binding status and resolution mode, migration/index tests, service/audit tests, and terminology that future baseline identity specs must preserve.
|
||||
- **Why now**: The roadmap's provider-neutrality hardening theme names Provider Identity & Target Scope Neutrality as an anti-drift foundation, and the user supplied the numbered Spec 381 draft as the next manual candidate after Spec 380.
|
||||
- **Why not local**: A local compare patch would keep display-name identity and manual decisions as run-local interpretation. Binding decisions must survive across runs, be queryable by managed environment, and be auditable because they affect future governance truth.
|
||||
- **Approval class**: Core Enterprise.
|
||||
- **Red flags triggered**: New truth model, new persisted entity, new enum/status family, foundation language, and follow-up sequence. Defense: the spec is narrowed to current managed-environment scope, removes duplicate state, reuses existing baseline/capability seams, and prohibits matching/UI/evidence expansion.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve as a narrowed Core Enterprise foundation, with matching, UI, and evidence/review integration split into follow-up specs.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- **Selected candidate**: Spec 381 - Provider Resource Identity and Binding Foundation v1.
|
||||
- **Source**: Direct user-provided candidate attachment, with roadmap relationship to `docs/product/roadmap.md` R1.x Foundation Hardening - Governance Platform Anti-Drift, especially Provider Identity & Target Scope Neutrality.
|
||||
- **Why selected**: The active auto-prep queue is empty, but the user supplied a complete, numbered manual candidate that follows the completed Spec 380 sequence and unlocks the proposed Specs 382-385.
|
||||
- **Roadmap relationship**: Supports provider boundary hardening, governed-subject vocabulary enforcement, and baseline identity safety before additional matching, evidence, or review readiness semantics are added.
|
||||
- **Close alternatives deferred**:
|
||||
- `governance-artifact-lifecycle-retention-runtime`: broader artifact lifecycle P2 candidate, unrelated to baseline subject identity.
|
||||
- `provider-readiness-onboarding-productization`: optional onboarding/readiness UX, not the underlying compare identity foundation.
|
||||
- `cross-domain-indicator-runtime-follow-through`: UI/runtime indicator adoption, not provider resource identity.
|
||||
- `manual-system-panel-browser-fixture-or-audit-procedure`: validation procedure, not a domain foundation.
|
||||
- `first-governed-ai-runtime-consumer`: P3 runtime consumer and explicitly later.
|
||||
- **Completed-spec guardrail result**:
|
||||
- `specs/163-baseline-subject-resolution/` has completed task markers and is historical implementation context. It must not be rewritten or converted back into preparation state.
|
||||
- `specs/380-management-report-pdf-staging-runtime-validation/` is completed release-validation context for the dependency sequence. It is not modified by Spec 381.
|
||||
- No `specs/381-*` directory or `381-*` local/remote branch existed before this preparation run.
|
||||
- **Smallest viable implementation slice**: Managed-environment-scoped provider resource identity and binding persistence, with no-op compare/evidence integration proof.
|
||||
- **Gate result**: PASS. The candidate is user-provided, unprepared, not completed, roadmap-aligned, and narrowed to a bounded implementation slice.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
TenantPilot currently has structured baseline subject and resolution semantics, but the durable identity for a governed subject can still fall back to display-name-derived keys or overloaded policy-centric identifiers. Display names are labels, not identity. They are mutable, non-unique, provider-specific, and frequently reused by defaults, restored objects, copied policies, test resources, and built-in targets.
|
||||
|
||||
The missing product foundation is a durable way to say which provider resource, built-in target, virtual target, unsupported subject, exclusion, or accepted limitation a managed-environment baseline subject maps to. Without this, future matching and evidence specs would keep patching around display-name ambiguity instead of using auditable provider resource truth.
|
||||
|
||||
## Business / Product Value
|
||||
|
||||
- Reduces false ambiguity and false confidence in baseline compare results.
|
||||
- Lets future operators make binding, exclusion, unsupported-coverage, and accepted-limitation decisions once and have them persist safely.
|
||||
- Creates auditable groundwork for later resolution UI and evidence/readiness behavior.
|
||||
- Keeps platform-core identity terms provider-neutral while allowing provider-specific knowledge behind later adapters.
|
||||
- Protects customer trust by separating "not found", "not supported", "accepted limitation", and "known built-in or virtual target" before these states reach management-ready outputs.
|
||||
|
||||
## Primary Users / Operators
|
||||
|
||||
- MSP operator or workspace manager responsible for baseline governance accuracy.
|
||||
- Tenant operator reviewing compare results and deciding whether a mismatch is actionable.
|
||||
- Release owner validating that baseline identity semantics do not regress into provider-specific or display-name-only shortcuts.
|
||||
- Future support/platform operator diagnosing why a subject was resolved, excluded, or treated as a limitation.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant-owned managed-environment identity foundation within workspace boundary.
|
||||
- **Primary Routes**: No new or changed routes in v1. Existing baseline compare, evidence, review, and OperationRun surfaces are regression targets only.
|
||||
- **Data Ownership**: New `provider_resource_bindings` rows are tenant-owned operational truth and MUST include `workspace_id` and `managed_environment_id` as non-null scope columns. The table MUST enforce the repo-standard composite workspace/environment invariant against `managed_environments(id, workspace_id)`, and the model MUST use `DerivesWorkspaceIdFromTenant` or an equivalent invariant so `workspace_id` cannot drift from the bound managed environment. Provider identity support classes are derived in memory. Existing baseline snapshots, inventory items, policy versions, operation runs, evidence snapshots, stored reports, and review packs remain authoritative for their current domains.
|
||||
- **RBAC**: Existing workspace membership plus managed-environment entitlement is required. V1 reuses `workspace_baselines.view` for read semantics and `workspace_baselines.manage` for create/supersede/revoke decisions unless the implementation updates this spec before introducing dedicated capabilities.
|
||||
|
||||
For canonical-view specs:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Not applicable. Spec 381 adds no canonical-view route.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Binding reads and mutations must resolve the target managed environment through workspace and managed-environment entitlement. Non-members receive deny-as-not-found. Members missing capability receive forbidden after membership is established.
|
||||
|
||||
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||
|
||||
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||
|
||||
- [x] No UI surface impact
|
||||
- [ ] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [ ] Navigation changed
|
||||
- [ ] Filament panel/provider surface changed
|
||||
- [ ] New modal/drawer/wizard/action added
|
||||
- [ ] New table/form/state added
|
||||
- [ ] Customer-facing surface changed
|
||||
- [ ] Dangerous action changed
|
||||
- [ ] Status/evidence/review presentation changed
|
||||
- [ ] Workspace/environment context presentation changed
|
||||
|
||||
## UI/Productization Coverage
|
||||
|
||||
N/A - no reachable UI surface impact. Spec 381 creates backend identity and binding foundations only. Baseline compare, evidence, review, and OperationRun surfaces remain unchanged and are regression targets. The future resolution UI belongs to Spec 384.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse
|
||||
|
||||
- **Cross-cutting feature?**: yes, it touches provider/platform seams, baseline subject identity, RBAC, audit, and future evidence/review truth.
|
||||
- **Interaction class(es)**: no UI interaction class in v1. Backend shared families include provider boundary vocabulary, baseline subject keys, managed-environment isolation, audit events, and capability enforcement.
|
||||
- **Systems touched**: `BaselineSubjectKey`, baseline subject/resolution support classes, provider connection/resource identity metadata, `AuditLog`, capability/policy enforcement, PostgreSQL migration/index patterns.
|
||||
- **Existing pattern(s) to extend**: existing baseline subject and resolution support, existing `Capabilities` registry, existing policy/Gate semantics, existing `AuditLog` model/builder patterns, existing partial unique index migration patterns.
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: none for UI. Reuse `BaselineSubjectKey`, `SubjectClass`, `ResolutionOutcome`, `ProviderConnection`, `InventoryItem`, `PolicyVersion`, `BaselineSnapshot`, `OperationRun`, and `AuditLog` as current repo truth.
|
||||
- **Why the existing shared path is sufficient or insufficient**: Existing subject classes and resolution outcomes classify compare behavior, but they do not persist durable provider resource binding decisions. Existing `BaselineSubjectKey` is sufficient as the place to evolve canonical subject key creation without a parallel class in v1.
|
||||
- **Allowed deviation and why**: one new binding persistence/service path is allowed because manual identity decisions must outlive a run and be audited independently.
|
||||
- **Consistency impact**: New identity terms must not create a second subject taxonomy. Existing compare/evidence/review semantics must remain unchanged until follow-up specs deliberately consume bindings.
|
||||
- **Review focus**: no display-name-only identity as primary truth, no Microsoft built-in literals in core, no workspace/managed-environment leakage, no duplicate active binding, and no hidden compare behavior change.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: no.
|
||||
- **Shared OperationRun UX contract/layer reused**: N/A.
|
||||
- **Delegated start/completion UX behaviors**: N/A.
|
||||
- **Local surface-owned behavior that remains**: N/A.
|
||||
- **Queued DB-notification policy**: N/A.
|
||||
- **Terminal notification path**: N/A.
|
||||
- **Exception required?**: none.
|
||||
|
||||
Bindings may reference a source `OperationRun` for traceability, but creating, updating, superseding, or revoking a binding is expected to be a short DB-only, security-relevant action that writes `AuditLog` and does not create a new `OperationRun`.
|
||||
|
||||
## Provider Boundary / Platform Core Check
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes.
|
||||
- **Boundary classification**: mixed. `ResourceIdentity`, descriptors, bindings, and canonical subject keys are platform-core. Provider-specific built-in names, Graph endpoints, and Microsoft matching rules remain provider-owned and out of scope.
|
||||
- **Seams affected**: governed subject keys, provider resource identity descriptors, binding persistence, baseline compare future input seams, audit metadata, capability enforcement.
|
||||
- **Neutral platform terms preserved or introduced**: provider, provider connection, managed environment, governed subject, subject class, subject type key, canonical subject key, provider resource identity, binding, resolution mode.
|
||||
- **Provider-specific semantics retained and why**: `provider_key` and provider resource type/id/discriminator fields are retained as provider-owned descriptors, not platform-wide nouns. Microsoft examples may appear only in tests or docs as provider-specific examples, not as core logic.
|
||||
- **Why this does not deepen provider coupling accidentally**: Core classes must not branch on literal Microsoft display names such as `All users`, `All devices`, or `Default`. A fake-provider contract test must prove identity and binding can work without Microsoft values.
|
||||
- **Follow-up path**: Spec 382 may consume descriptors and bindings in the matching pipeline. Spec 384 may add operator UI. Spec 385 may integrate evidence/review readiness.
|
||||
|
||||
## UI / Surface Guardrail Impact
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / N/A Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Provider resource identity and binding foundation | no | N/A | baseline identity, audit, RBAC | backend domain truth only | no | No route, page, navigation, action, table, modal, or presentation change in v1 |
|
||||
|
||||
## Decision-First Surface Role
|
||||
|
||||
N/A - no operator-facing surface change.
|
||||
|
||||
## Audience-Aware Disclosure
|
||||
|
||||
N/A - no customer/operator-facing detail or status surface change. Future disclosure belongs to resolution UI and evidence/review integration follow-up specs.
|
||||
|
||||
## UI/UX Surface Classification
|
||||
|
||||
N/A - no operator-facing list, detail, queue, audit, config, or report surface is added or materially changed.
|
||||
|
||||
## Operator Surface Contract
|
||||
|
||||
N/A - no new operator-facing page or material page refactor.
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: yes. Active `provider_resource_bindings` rows become durable managed-environment-scoped identity decision truth for future baseline subject resolution.
|
||||
- **New persisted entity/table/artifact?**: yes. `provider_resource_bindings`.
|
||||
- **New abstraction?**: yes, narrowly: `ResourceIdentity`, `ProviderResourceDescriptor`, and a binding service. `BaselineSubjectKey` is extended rather than replaced.
|
||||
- **New enum/state/reason family?**: yes. Binding status and resolution mode are introduced because they change active lookup, audit consequences, revocation/supersession behavior, and later matching/readiness semantics.
|
||||
- **New cross-domain UI framework/taxonomy?**: no.
|
||||
- **Current operator problem**: Operators need future compare/review truth to distinguish provider resource identity, built-ins, virtual targets, unsupported coverage, exclusions, and accepted limitations without renaming provider objects or relying on one run's diagnostics.
|
||||
- **Existing structure is insufficient because**: Existing subject/resolution classes classify runtime gaps, but they do not persist auditable binding decisions or durable provider resource identity across compare runs.
|
||||
- **Narrowest correct implementation**: Managed-environment-scoped bindings only, existing capability reuse, no UI, no matching rewrite, no automatic canonicalizer, no workspace/baseline-profile scopes, and no duplicate active-state column.
|
||||
- **Ownership cost**: Migration/index maintenance, one model/policy/service, identity/descriptor unit tests, PostgreSQL partial unique coverage, and reviewer discipline to keep follow-up matching/UI/evidence work separate.
|
||||
- **Alternative intentionally rejected**: Run-local JSON context notes or display-name normalization only. Those are not durable, not uniquely constrained, not safely queryable, and not independently auditable.
|
||||
- **Release truth**: current-release foundation needed before the immediately proposed matching, compare semantics, UI, and evidence/review specs can be implemented safely.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment. Existing records remain readable, but no legacy alias, dual-write path, historical OperationRun rewrite, or backfill is required. Existing display-name-derived subject keys may remain as legacy/fallback inputs while new canonical subject keys are introduced for new binding records.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit for identity/value-object behavior, Feature for service/RBAC/audit/isolation, PostgreSQL for partial unique indexes and constraints, targeted regression for baseline compare/evidence/review no-op behavior.
|
||||
- **Validation lane(s)**: fast-feedback, confidence, and PostgreSQL. Browser is not required because no UI changes.
|
||||
- **Why this classification and these lanes are sufficient**: The change is backend identity and persistence truth. Unit tests prove deterministic identity; feature tests prove business behavior; PostgreSQL proves partial unique active binding safety that SQLite cannot prove.
|
||||
- **New or expanded test families**: a focused `ProviderResources` or equivalent feature/unit family for binding foundation; no heavy-governance or browser family.
|
||||
- **Fixture / helper cost impact**: new factory for `ProviderResourceBinding`; fake-provider fixtures must stay local to these tests and must not widen default workspace/provider setup.
|
||||
- **Heavy-family visibility / justification**: none.
|
||||
- **Special surface test profile**: N/A - no Filament or operator surface.
|
||||
- **Standard-native relief or required special coverage**: no UI coverage required.
|
||||
- **Reviewer handoff**: verify no display-name-only primary identity, no Microsoft literal core branches, no duplicate active truth, no managed-environment leakage, and no compare/evidence/review behavior change.
|
||||
- **Budget / baseline / trend impact**: minimal; PostgreSQL lane adds one focused migration/index test. Escalate if implementation broadens compare/evidence suites or creates heavy fixtures.
|
||||
- **Escalation needed**: document-in-feature if a contained provider-boundary exception remains; follow-up-spec if workspace-level binding scope, resolution UI, automatic canonicalization, or evidence readiness behavior becomes necessary.
|
||||
- **Active feature PR close-out entry**: Identity Binding Foundation / Provider Boundary.
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Resources/ResourceIdentityTest.php tests/Unit/Support/Resources/ProviderResourceDescriptorTest.php tests/Unit/Support/Baselines/BaselineSubjectKeyCanonicalIdentityTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/ProviderResources/ProviderResourceBindingPostgresTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Represent provider identity without display names (Priority: P1)
|
||||
|
||||
As a baseline governance operator, I need TenantPilot to represent governed subjects by stable provider identity or canonical platform identity instead of display names, so copied, restored, built-in, virtual, and duplicate resources do not collapse into the same subject.
|
||||
|
||||
**Why this priority**: This is the core trust gap. Later matching and resolution UI cannot be safe while identity is still display-name-first.
|
||||
|
||||
**Independent Test**: Unit tests create tenant-owned, built-in, virtual, unsupported, and unknown `ResourceIdentity` values and canonical subject keys, then prove display labels are optional and equality/key generation uses stable identity fields.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** two provider resources with the same display label but different provider resource IDs, **When** canonical subject keys are generated, **Then** the keys differ without requiring a renamed display label.
|
||||
2. **Given** a provider built-in or virtual assignment target with no provider object ID, **When** the identity is represented, **Then** the identity uses provider/canonical discriminator fields rather than a display-name fallback.
|
||||
3. **Given** a fake provider with non-Microsoft resource types, **When** descriptors are serialized and compared, **Then** the core identity model remains valid without Microsoft Intune literals.
|
||||
|
||||
### User Story 2 - Persist auditable binding decisions (Priority: P1)
|
||||
|
||||
As an authorized workspace baseline manager, I need manual binding, exclusion, accepted limitation, unsupported coverage, and revocation decisions to persist with audit evidence, so future compare runs can reuse trusted decisions instead of re-litigating ambiguity.
|
||||
|
||||
**Why this priority**: Durable and auditable decisions are the difference between a one-run diagnostic note and enterprise governance truth.
|
||||
|
||||
**Independent Test**: Feature and PostgreSQL tests create, supersede, and revoke managed-environment-scoped bindings; assert active uniqueness, note requirements, authorization, workspace/managed-environment isolation, and audit events.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authorized manager creates a manual binding for a managed environment, **When** the service stores it, **Then** the row includes workspace, managed environment, provider key, canonical subject key, resolution mode, actor, note, and audit metadata.
|
||||
2. **Given** an active binding already exists for the same managed-environment/provider/canonical subject key, **When** a new binding supersedes it, **Then** exactly one active binding remains and the old row is marked superseded.
|
||||
3. **Given** a user from another workspace or without baseline management capability, **When** they attempt to create or revoke a binding, **Then** cross-workspace access is denied as not found and missing capability is forbidden after entitlement is established.
|
||||
|
||||
### User Story 3 - Preserve existing compare and evidence behavior (Priority: P2)
|
||||
|
||||
As a release owner, I need this foundation to land without changing baseline compare, evidence readiness, review readiness, or customer-facing output, so the platform gains identity safety without hidden behavior changes.
|
||||
|
||||
**Why this priority**: Follow-up specs will deliberately consume bindings. Spec 381 must not silently change current operational truth.
|
||||
|
||||
**Independent Test**: Existing targeted compare/evidence/review tests still pass, and new no-op regression coverage proves the binding table is not automatically consumed by current compare or readiness paths.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** existing baseline compare fixtures, **When** Spec 381 foundation code exists, **Then** compare result semantics remain unchanged unless a follow-up spec explicitly changes them.
|
||||
2. **Given** evidence and review readiness tests, **When** bindings exist in the database, **Then** readiness output remains unchanged in v1.
|
||||
3. **Given** the implementation adds passive helper seams, **When** they are unused by the current pipeline, **Then** they do not perform Graph calls, queue jobs, or alter OperationRun lifecycle.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Two tenant-owned resources share the same display label but have different provider IDs.
|
||||
- A provider built-in or virtual target has no provider resource ID.
|
||||
- A provider connection is deleted or disabled after a binding is created.
|
||||
- A binding references source evidence from an old OperationRun, inventory item, policy version, or baseline snapshot.
|
||||
- A binding attempts to reference a provider connection, OperationRun, inventory item, policy version, or baseline snapshot outside the binding workspace, managed environment, or provider scope.
|
||||
- A manual decision lacks an operator note.
|
||||
- A revoked or superseded binding exists for the same canonical subject as a new active binding.
|
||||
- A fake provider descriptor uses a provider key, resource type, and discriminator that are not Microsoft-shaped.
|
||||
- Existing compare inputs continue to include legacy display-name-derived subject keys.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature adds no Graph calls, no queued work, no OperationRun lifecycle change, no UI surface, and no customer-facing output change. It introduces new persistence and semantic machinery, so the proportionality review is mandatory and included above. Mutating binding decisions are DB-only but security-relevant, so they require policy/Gate authorization and `AuditLog` entries.
|
||||
|
||||
**Constitution alignment (PROV-001):** Provider-specific knowledge remains outside platform core. Core identity classes may store provider-owned fields, but must not branch on Microsoft display names or Graph endpoint assumptions.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** Binding reads and mutations must enforce workspace and managed-environment entitlement. Non-member access is deny-as-not-found; entitled member missing capability is forbidden. UI visibility is not security, even though no UI is included in v1.
|
||||
|
||||
**Constitution alignment (PERSIST-001 / STATE-001):** Bindings are persisted because they are independent, auditable identity decisions that survive a run and change future resolution behavior. Binding statuses and resolution modes are allowed only because they determine active lookup, audit meaning, supersession/revocation behavior, and future operator next actions.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-381-001**: TenantPilot MUST represent provider resource identity without requiring display name as primary identity.
|
||||
- **FR-381-002**: TenantPilot MUST support identity shapes for tenant-owned provider resources, provider built-ins, provider defaults, virtual resources, unsupported resources, and unknown resources.
|
||||
- **FR-381-003**: TenantPilot MUST evolve the existing baseline subject key foundation so canonical keys can encode provider key, subject class, subject type, resource type, and stable identity or canonical discriminator.
|
||||
- **FR-381-004**: TenantPilot MUST provide a provider resource descriptor shape that wraps identity, display label, subject metadata, source references, fingerprint where available, and last-seen metadata.
|
||||
- **FR-381-005**: TenantPilot MUST persist managed-environment-scoped provider resource bindings in `provider_resource_bindings`.
|
||||
- **FR-381-006**: Each provider resource binding MUST be scoped by `workspace_id`, `managed_environment_id`, `provider_key`, and `canonical_subject_key`.
|
||||
- **FR-381-006A**: Each persisted binding MUST enforce the workspace/managed-environment pair with a composite foreign key to `managed_environments(id, workspace_id)` and a model-level workspace derivation guard.
|
||||
- **FR-381-007**: TenantPilot MUST prevent more than one active binding for the same managed-environment/provider/canonical subject key.
|
||||
- **FR-381-008**: Binding status MUST distinguish active, superseded, and revoked rows.
|
||||
- **FR-381-009**: Resolution mode MUST distinguish exact provider identity, canonical built-in, canonical virtual target, manual binding, excluded non-governed object, accepted limitation, unsupported coverage, and missing expected resource. Provider defaults are represented as canonical built-ins in v1 unless this spec is updated before implementation.
|
||||
- **FR-381-010**: Manual binding, exclusion, accepted limitation, unsupported coverage, missing expected, supersession, and revocation decisions MUST require an operator note or reason.
|
||||
- **FR-381-011**: Binding decisions and revocations MUST emit audit records with actor, workspace, managed environment, provider key, canonical subject key, old binding ID where applicable, new binding ID where applicable, resolution mode, and safe source references. Operator-provided note text MUST be redacted, summarized, length-limited, or referenced safely before it is written to audit metadata; raw notes MUST NOT be copied into audit metadata unchecked.
|
||||
- **FR-381-012**: Binding service methods MUST enforce workspace and managed-environment scope before reading, creating, superseding, or revoking records.
|
||||
- **FR-381-012A**: Binding service methods MUST validate any `provider_connection_id`, `source_operation_run_id`, `source_inventory_item_id`, `source_policy_version_id`, and `source_baseline_snapshot_id` against the binding workspace and managed environment before persistence. A referenced provider connection MUST belong to the same workspace and managed environment and match `provider_key` when present.
|
||||
- **FR-381-013**: V1 MUST reuse existing baseline capabilities unless the spec is updated before dedicated provider-resource-binding capabilities are introduced.
|
||||
- **FR-381-014**: Core identity and binding logic MUST NOT hardcode Microsoft Intune display names, Graph endpoint names, or provider-default object labels.
|
||||
- **FR-381-015**: Fake-provider contract tests MUST prove the identity and binding foundation is not Microsoft-only, including service-level persistence and mutation behavior, not only in-memory identity/descriptor serialization.
|
||||
- **FR-381-016**: V1 MUST NOT rewrite baseline compare matching, evidence completeness, review readiness, review-pack publication, or customer-facing output.
|
||||
- **FR-381-017**: Existing baseline snapshots, baseline snapshot items, inventory items, policy versions, operation runs, evidence snapshots, stored reports, and review packs MUST remain readable without backfill.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-381-001 - Workspace isolation**: No binding may leak across workspace boundaries; database constraints, model invariants, service resolution, and policy tests must all reinforce the same workspace boundary.
|
||||
- **NFR-381-002 - Managed-environment isolation**: No binding may affect another managed environment; source references and provider connections must be rejected if they do not belong to the binding's managed environment.
|
||||
- **NFR-381-003 - Auditability**: Every governance-impacting manual binding decision is traceable and redacted.
|
||||
- **NFR-381-004 - Provider neutrality**: Platform-core code stores provider identity fields but does not treat Microsoft as platform truth.
|
||||
- **NFR-381-005 - Backward compatibility**: Existing development data remains readable; no historical OperationRun or snapshot rewrite is required.
|
||||
- **NFR-381-006 - Minimal runtime risk**: No current compare, evidence, review, or report outcome changes in v1.
|
||||
- **NFR-381-007 - PostgreSQL integrity**: Active binding uniqueness, workspace/environment composite integrity, enum/check-constraint validity where practical, and service-level validation are covered in the PostgreSQL lane.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
N/A - no Filament Resource, RelationManager, Page, action, table, form, modal, drawer, wizard, navigation entry, or panel/provider surface is added or changed.
|
||||
|
||||
## Key Entities *(include if feature involves data)*
|
||||
|
||||
- **ResourceIdentity**: Provider-agnostic in-memory identity for a tenant-owned, built-in, default, virtual, unsupported, or unknown provider resource.
|
||||
- **ProviderResourceDescriptor**: Normalized in-memory descriptor that wraps `ResourceIdentity` and carries display label, subject class/type, source references, fingerprint, and last-seen metadata for future matching.
|
||||
- **Canonical Subject Key**: Stable governed-subject key created through the existing baseline key foundation, using provider/resource identity or canonical discriminator rather than display-name-only identity.
|
||||
- **ProviderResourceBinding**: Managed-environment-scoped persisted decision that maps a canonical subject key to a provider identity, built-in/virtual target, manual binding, exclusion, accepted limitation, unsupported coverage, or missing expected marker.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-381-001**: Unit tests prove at least six identity forms: tenant-owned, built-in, default, virtual, unsupported, and unknown.
|
||||
- **SC-381-002**: Unit tests prove two subjects with the same display label but different stable provider identity produce distinct canonical keys.
|
||||
- **SC-381-003**: PostgreSQL tests prove only one active binding can exist for a managed-environment/provider/canonical subject key.
|
||||
- **SC-381-004**: Feature tests prove unauthorized cross-workspace access is deny-as-not-found and missing capability is forbidden after entitlement is established.
|
||||
- **SC-381-005**: Feature tests prove manual decisions require notes and emit audit records.
|
||||
- **SC-381-006**: Fake-provider tests prove the core foundation and binding service work without Microsoft Intune literals.
|
||||
- **SC-381-007**: Targeted existing compare, evidence, and review tests pass without behavior changes.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Baseline matching pipeline rewrite.
|
||||
- Automatic canonicalization of Microsoft built-ins such as All users, All devices, or default role scope tags.
|
||||
- Baseline Subject Resolution UI.
|
||||
- Evidence Snapshot readiness changes.
|
||||
- Environment Review readiness changes.
|
||||
- Review Pack publication/output changes.
|
||||
- Customer-facing output changes.
|
||||
- Historical OperationRun or snapshot backfill.
|
||||
- Workspace-wide, baseline-profile-specific, or subject-only binding scopes.
|
||||
- Generic provider adapter framework or workflow engine.
|
||||
- Graph calls or provider runtime calls.
|
||||
- New Filament resource or global search surface.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Over-generalization**: The foundation could drift into a provider framework. Mitigation: no provider adapters, automatic canonicalizers, or matching pipeline changes in v1.
|
||||
- **Duplicate taxonomy**: New classes could duplicate `SubjectClass` and existing baseline semantics. Mitigation: reuse/extend existing subject key and subject class seams.
|
||||
- **Scope leakage**: Bindings could apply across workspaces or managed environments. Mitigation: non-null scope columns, policy checks, service checks, partial unique index, and isolation tests.
|
||||
- **False green later**: Accepted limitation could be interpreted as verified success. Mitigation: resolution mode is explicit and follow-up specs must map it as limitation, not no-drift.
|
||||
- **Provider-specific leakage**: Core logic could accidentally hardcode Microsoft defaults. Mitigation: fake-provider tests and provider-boundary review.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Spec 380 has been completed/committed separately and is not modified by this package.
|
||||
- Managed-environment scope is sufficient for the first valuable binding foundation.
|
||||
- Existing `workspace_baselines.view` and `workspace_baselines.manage` capabilities are acceptable for v1.
|
||||
- Provider connection ID is nullable for built-in, virtual, unsupported, missing expected, and fake-provider cases, but any supplied provider connection ID must be scoped to the same workspace and managed environment and must match `provider_key`.
|
||||
- Source references are traceability hints only; they never authorize cross-scope access and must be rejected when they point outside the binding workspace or managed environment.
|
||||
- Revocation marks the existing binding revoked and non-active through status; it does not create a new revocation row unless implementation discovers an audit/legal need and updates the spec first.
|
||||
- Automatic provider built-in bindings are not stored by v1; they are future matching/canonicalization behavior.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None blocking for implementation. The source draft questions are resolved by the narrowed assumptions above. Broader scope choices are deferred to follow-up specs.
|
||||
|
||||
## Follow-up Spec Candidates
|
||||
|
||||
- **Spec 382 - Baseline Matching Pipeline & Canonicalization v1**: consume `ResourceIdentity`, descriptors, canonical subject keys, and bindings before display-name fallback.
|
||||
- **Spec 383 - Baseline Compare Result Semantics & Gap Classification v1**: split overloaded compare result states using binding-aware semantics.
|
||||
- **Spec 384 - Baseline Subject Resolution UI & Operator Decisions v1**: add operator UI for bind, exclude, accept limitation, mark unsupported, and revoke.
|
||||
- **Spec 385 - Evidence & Review Readiness Integration v1**: map resolved, blocked, limited, and unsupported binding semantics into evidence and review readiness.
|
||||
- **Workspace or baseline-profile binding scope**: only if a concrete workspace-level or baseline-profile-level operator workflow proves environment scope insufficient.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **AC-381-001**: Provider-agnostic resource identity exists and does not require display name as primary identity.
|
||||
- **AC-381-002**: Canonical subject key creation can encode stable provider/resource identity or canonical discriminator through the existing baseline key foundation.
|
||||
- **AC-381-003**: Provider resource descriptor shape exists and is serializable for future matching use.
|
||||
- **AC-381-004**: `provider_resource_bindings` exists with workspace and managed-environment scope, composite workspace/environment integrity, active uniqueness, and scoped source references.
|
||||
- **AC-381-005**: Binding status and resolution mode are implemented and test-covered.
|
||||
- **AC-381-006**: Manual decision note requirements, authorization, isolation, supersession, revocation, audit behavior, and audit-note redaction/safe-reference behavior are test-covered.
|
||||
- **AC-381-007**: Fake-provider coverage proves provider neutrality through identity, descriptor, and binding-service persistence behavior.
|
||||
- **AC-381-008**: Existing compare, evidence, review, and review-pack behavior is unchanged in v1.
|
||||
122
specs/381-provider-resource-identity-binding/tasks.md
Normal file
122
specs/381-provider-resource-identity-binding/tasks.md
Normal file
@ -0,0 +1,122 @@
|
||||
# Tasks: Spec 381 - Provider Resource Identity and Binding Foundation v1
|
||||
|
||||
**Input**: `specs/381-provider-resource-identity-binding/spec.md`, `specs/381-provider-resource-identity-binding/plan.md`
|
||||
**Prerequisites**: Spec and plan are complete. Spec 163 and Spec 380 are historical/context only and must not be rewritten. This task list is for a later implementation loop, not for this preparation step.
|
||||
**Tests**: Unit, Feature, PostgreSQL, and targeted no-op baseline/evidence/review regression tests are required. Browser tests are not required because no UI surface changes.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||
- [X] New tests stay in the smallest honest family; no heavy-governance or browser family is introduced.
|
||||
- [X] Shared helpers, factories, seeds, fixtures, provider setup, workspace membership context, and fake-provider defaults stay cheap by default.
|
||||
- [X] PostgreSQL validation is used only for migration/partial unique/composite foreign key/index behavior that SQLite cannot prove.
|
||||
- [X] Planned validation commands cover identity, binding service behavior, authorization, audit, PostgreSQL uniqueness, workspace/environment integrity, source-reference scoping, and current-runtime no-op behavior.
|
||||
- [X] Any material budget, baseline, trend, or escalation note is recorded in the implementation close-out.
|
||||
|
||||
## Phase 1: Baseline And Guardrail Reconfirmation
|
||||
|
||||
**Purpose**: Confirm repo truth and protect completed-spec history before implementation.
|
||||
|
||||
- [X] T001 Record current branch, HEAD, dirty state, and intended touched-file set in the implementation close-out notes for `specs/381-provider-resource-identity-binding/`.
|
||||
- [X] T002 Re-read `specs/381-provider-resource-identity-binding/spec.md`, `specs/381-provider-resource-identity-binding/plan.md`, and `specs/381-provider-resource-identity-binding/tasks.md`.
|
||||
- [X] T003 Re-read `specs/163-baseline-subject-resolution/spec.md`, `specs/163-baseline-subject-resolution/plan.md`, and `specs/163-baseline-subject-resolution/tasks.md` as completed/historical context without editing Spec 163.
|
||||
- [X] T004 Re-read `apps/platform/app/Support/Baselines/BaselineSubjectKey.php`, `apps/platform/app/Support/Baselines/SubjectClass.php`, `apps/platform/app/Support/Baselines/ResolutionOutcome.php`, and `apps/platform/app/Support/Baselines/Compare/CompareSubjectIdentity.php`.
|
||||
- [X] T005 Re-read `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Models/ManagedEnvironment.php`, `apps/platform/app/Models/AuditLog.php`, `apps/platform/app/Support/Auth/Capabilities.php`, and existing provider/baseline policy tests.
|
||||
- [X] T006 Confirm no Filament resource/page/action, route, Livewire component, Blade view, Graph client, provider adapter, OperationRun type, or evidence/review behavior change is planned; if one appears necessary, stop and update spec/plan before continuing.
|
||||
|
||||
## Phase 2: Identity And Canonical Key Foundation
|
||||
|
||||
**Purpose**: Represent provider identity and canonical subject keys without display names as primary identity.
|
||||
|
||||
- [X] T007 [P] [US1] Add unit coverage in `apps/platform/tests/Unit/Support/Resources/ResourceIdentityTest.php` for tenant-owned, built-in, default, virtual, unsupported, and unknown identities.
|
||||
- [X] T008 [P] [US1] Add unit coverage in `apps/platform/tests/Unit/Support/Baselines/BaselineSubjectKeyCanonicalIdentityTest.php` proving canonical keys use provider/resource identity or canonical discriminator and do not collapse same-label distinct resources.
|
||||
- [X] T009 [P] [US1] Add unit coverage in `apps/platform/tests/Unit/Support/Resources/ProviderResourceDescriptorTest.php` for descriptor serialization, source references, last-seen metadata, fingerprints, and fake-provider data.
|
||||
- [X] T010 [US1] Implement `apps/platform/app/Support/Resources/ResourceIdentity.php` with named constructors for provider resource, canonical built-in/default, virtual target, unsupported, and unknown identities.
|
||||
- [X] T011 [US1] Extend `apps/platform/app/Support/Baselines/BaselineSubjectKey.php` with canonical provider-resource key helpers rather than creating a parallel `CanonicalSubjectKey` class.
|
||||
- [X] T012 [US1] Implement `apps/platform/app/Support/Resources/ProviderResourceDescriptor.php` as a small serializable descriptor over `ResourceIdentity`.
|
||||
- [X] T013 [US1] Run the focused identity unit tests for `ResourceIdentity`, `BaselineSubjectKeyCanonicalIdentity`, and `ProviderResourceDescriptor`.
|
||||
|
||||
## Phase 3: Binding Persistence And Integrity
|
||||
|
||||
**Purpose**: Persist managed-environment-scoped binding decisions with active uniqueness and no duplicate active truth.
|
||||
|
||||
- [X] T014 [P] [US2] Add PostgreSQL migration/index coverage in `apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingPostgresTest.php` for active partial unique index, composite `(managed_environment_id, workspace_id)` foreign key integrity, managed-environment non-null scope, enum/check-constraint validity where practical, and index-backed lookup assumptions.
|
||||
- [X] T015 [P] [US2] Add feature coverage in `apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php` for create, supersede, revoke, required note, single-active binding behavior, provider-default-as-canonical-built-in behavior, fake-provider service persistence, and every resolution mode listed in `spec.md`.
|
||||
- [X] T016 [US2] Create `apps/platform/database/migrations/<timestamp>_create_provider_resource_bindings_table.php` with non-null `workspace_id`, non-null `managed_environment_id`, composite `(managed_environment_id, workspace_id)` foreign key to `managed_environments(id, workspace_id)`, provider/subject/resource descriptor fields, binding status, resolution mode, source references, actor fields, timestamps, indexes, and a PostgreSQL partial unique index on active bindings.
|
||||
- [X] T017 [US2] Implement `apps/platform/app/Support/Resources/ProviderResourceBindingStatus.php` with `active`, `superseded`, and `revoked`.
|
||||
- [X] T018 [US2] Implement `apps/platform/app/Support/Resources/ProviderResourceResolutionMode.php` with the modes listed in `spec.md`.
|
||||
- [X] T019 [US2] Implement `apps/platform/app/Models/ProviderResourceBinding.php` with casts, relationships, active lookup scope, `DerivesWorkspaceIdFromTenant` or equivalent workspace-derivation invariant, and managed-environment/workspace helpers.
|
||||
- [X] T020 [US2] Implement `apps/platform/database/factories/ProviderResourceBindingFactory.php` with cheap defaults and explicit fake-provider/provider-resource states.
|
||||
- [X] T021 [US2] Run the PostgreSQL binding migration/index test through `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/ProviderResources/ProviderResourceBindingPostgresTest.php`.
|
||||
|
||||
## Phase 4: Binding Service, RBAC, And Audit
|
||||
|
||||
**Purpose**: Make binding decisions safe, authorized, note-backed, and auditable.
|
||||
|
||||
- [X] T022 [P] [US2] Add authorization coverage in `apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingAuthorizationTest.php` for allowed manager, read-only denial, missing capability 403, cross-workspace deny-as-not-found, and cross-managed-environment deny-as-not-found for records and source references.
|
||||
- [X] T023 [P] [US2] Add audit assertions to `apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php` for create, supersede, exclusion, accepted limitation, unsupported coverage, missing expected, revocation, old binding ID where applicable, new binding ID where applicable, resolution mode, safe source references, and redacted/safe operator note metadata.
|
||||
- [X] T024 [US2] Implement `apps/platform/app/Policies/ProviderResourceBindingPolicy.php` using existing `workspace_baselines.view` and `workspace_baselines.manage` capability semantics unless the spec is updated first.
|
||||
- [X] T025 [US2] Register the `ProviderResourceBindingPolicy` in `apps/platform/app/Providers/AuthServiceProvider.php` unless implementation updates `plan.md` with a stronger adjacent provider-registration precedent first.
|
||||
- [X] T026 [US2] Implement `apps/platform/app/Services/Resources/ProviderResourceBindingService.php` with transactional methods for manual binding, exclusion, accepted limitation, unsupported coverage, missing expected, supersession, and revocation, including provider-connection/provider-key validation and scoped source-reference validation before persistence.
|
||||
- [X] T027 [US2] Add stable audit action IDs for provider resource binding decisions in `apps/platform/app/Support/Audit/AuditActionId.php`.
|
||||
- [X] T028 [US2] Ensure service audit metadata excludes secrets, tokens, raw credentials, raw provider payloads, raw Graph response bodies, signed URLs, stack traces, raw sensitive JSON, and unchecked raw operator note text.
|
||||
- [X] T029 [US2] Run the binding service and authorization feature tests.
|
||||
|
||||
## Phase 5: No-Op Runtime Regression
|
||||
|
||||
**Purpose**: Prove Spec 381 does not silently change current compare, evidence, review, or report behavior.
|
||||
|
||||
- [X] T030 [P] [US3] Add no-op regression coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareProviderResourceBindingNoOpTest.php` proving existing compare behavior does not automatically consume bindings in v1.
|
||||
- [X] T031 [P] [US3] Add no-op regression coverage in `apps/platform/tests/Feature/Evidence/BaselineDriftPostureSourceTest.php` or a focused adjacent test proving existing evidence posture output is unchanged when bindings exist.
|
||||
- [X] T032 [P] [US3] Add no-op regression coverage in `apps/platform/tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php` or a focused adjacent test proving review guidance/readiness does not treat accepted limitation as no-drift in v1.
|
||||
- [X] T033 [US3] Run targeted existing baseline/evidence/review tests listed in `plan.md`.
|
||||
- [X] T034 [US3] Confirm the migration does not alter or backfill existing baseline snapshots, baseline snapshot items, inventory items, policy versions, operation runs, evidence snapshots, stored reports, or review packs.
|
||||
- [X] T035 [US3] Confirm no code path in current baseline compare, evidence readiness, review readiness, or review-pack publication automatically resolves subjects through `ProviderResourceBindingService`; if implementation needs that, stop and prepare Spec 382/385 instead.
|
||||
|
||||
## Phase 6: Final Validation And Artifact Hygiene
|
||||
|
||||
**Purpose**: Close implementation with bounded proof and no hidden scope.
|
||||
|
||||
- [X] T036 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Resources/ResourceIdentityTest.php tests/Unit/Support/Resources/ProviderResourceDescriptorTest.php tests/Unit/Support/Baselines/BaselineSubjectKeyCanonicalIdentityTest.php`.
|
||||
- [X] T037 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php tests/Feature/ProviderResources/ProviderResourceBindingAuthorizationTest.php`.
|
||||
- [X] T038 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/ProviderResources/ProviderResourceBindingPostgresTest.php`.
|
||||
- [X] T039 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`.
|
||||
- [X] T040 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- [X] T041 Run `git diff --check`.
|
||||
- [X] T042 Scan changed files for secrets, tokens, raw credentials, raw provider payloads, raw Graph payloads, signed URLs, stack traces, SQL errors, and unnecessary customer-sensitive data.
|
||||
- [X] T043 Complete implementation close-out with Livewire v4 compliance, provider registration location, global-search status, high-impact action handling, asset strategy, validation commands, and deployment impact.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- [X] NT001 Do not add a Baseline Subject Resolution UI, Filament resource, route, navigation item, Livewire component, or Blade view.
|
||||
- [X] NT002 Do not implement baseline matching pipeline consumption, automatic Microsoft built-in mapping, or provider adapter canonicalization.
|
||||
- [X] NT003 Do not change evidence snapshot readiness, environment review readiness, review-pack publication, or customer-facing output.
|
||||
- [X] NT004 Do not add Graph calls, provider runtime calls, queued jobs, OperationRun types, terminal notifications, or scheduler behavior.
|
||||
- [X] NT005 Do not add workspace-level, baseline-profile-specific, or subject-only binding scopes in v1.
|
||||
- [X] NT006 Do not add an `is_active` column or other duplicate active-binding truth.
|
||||
- [X] NT007 Do not rewrite completed Spec 163 or Spec 380 artifacts or remove their close-out/completed-task history.
|
||||
|
||||
## Dependencies And Ordering
|
||||
|
||||
- Phase 1 must complete before code changes.
|
||||
- Phase 2 identity primitives must exist before descriptors and binding service payloads depend on them.
|
||||
- Phase 3 migration/model/enums must complete before the service persists decisions.
|
||||
- Phase 4 policy/service/audit depends on Phase 3 persistence.
|
||||
- Phase 5 no-op regression runs after bindings can exist.
|
||||
- Phase 6 runs last.
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
- T007, T008, and T009 can run in parallel.
|
||||
- T014 and T015 can run in parallel.
|
||||
- T022 and T023 can run in parallel.
|
||||
- T030, T031, and T032 can run in parallel.
|
||||
- Final validation commands T036 through T039 may run in parallel if Sail resources allow.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
1. Prove identity primitives first.
|
||||
2. Add persistence and PostgreSQL uniqueness.
|
||||
3. Add authorized/audited service mutations.
|
||||
4. Prove current compare/evidence/review behavior is unchanged.
|
||||
5. Run focused validation and stop before follow-up matching/UI/evidence scopes.
|
||||
Loading…
Reference in New Issue
Block a user