feat: add generic content-backed coverage capture
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m37s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m37s
This commit is contained in:
parent
dfda397eb6
commit
736e61c73e
@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||||
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\Audit\AuditRecorder;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\TenantConfiguration\GenericContentEvidenceCaptureService;
|
||||||
|
use App\Support\Audit\AuditActorSnapshot;
|
||||||
|
use App\Support\Audit\AuditOutcome;
|
||||||
|
use App\Support\Audit\AuditTargetSnapshot;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class CaptureTenantConfigurationEvidenceJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public OperationRun $run,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new EnsureQueuedExecutionLegitimate,
|
||||||
|
new TrackOperationRun,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOperationRun(): ?OperationRun
|
||||||
|
{
|
||||||
|
return $this->operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOperationRunId(): int
|
||||||
|
{
|
||||||
|
return (int) $this->run->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
GenericContentEvidenceCaptureService $captureService,
|
||||||
|
OperationRunService $operationRuns,
|
||||||
|
AuditRecorder $auditRecorder,
|
||||||
|
): void {
|
||||||
|
$run = $this->run->fresh(['tenant.workspace', 'user']);
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
throw new RuntimeException('OperationRun context is required for tenant configuration capture.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->operationRun = $run;
|
||||||
|
|
||||||
|
if ($run->status === OperationRunStatus::Completed->value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $run->tenant;
|
||||||
|
$providerConnectionId = (int) data_get($run->context, 'target_scope.provider_connection_id', 0);
|
||||||
|
$providerConnection = ProviderConnection::query()
|
||||||
|
->whereKey($providerConnectionId)
|
||||||
|
->where('workspace_id', (int) $run->workspace_id)
|
||||||
|
->where('managed_environment_id', (int) $run->managed_environment_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $tenant || ! $providerConnection instanceof ProviderConnection) {
|
||||||
|
throw new RuntimeException('Tenant configuration capture run is missing its managed environment or same-scope provider connection.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $captureService->capture(
|
||||||
|
tenant: $tenant,
|
||||||
|
providerConnection: $providerConnection,
|
||||||
|
operationRun: $run,
|
||||||
|
canonicalTypes: $this->canonicalTypes($run),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->forceFill([
|
||||||
|
'context' => $this->contextWithCaptureResult($run, $result['outcomes']),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$completed = $operationRuns->updateRun(
|
||||||
|
run: $run,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: $result['run_outcome'],
|
||||||
|
summaryCounts: $result['summary_counts'],
|
||||||
|
failures: $result['failures'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->recordTerminalAudit($auditRecorder, $completed, $providerConnection, $result);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->recordFailureAudit($auditRecorder, $run, $providerConnection, $e);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>|null
|
||||||
|
*/
|
||||||
|
private function canonicalTypes(OperationRun $run): ?array
|
||||||
|
{
|
||||||
|
$types = data_get($run->context, 'resource_types');
|
||||||
|
|
||||||
|
if (! is_array($types)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($types)
|
||||||
|
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
||||||
|
->map(static fn (string $type): string => trim($type))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $outcomes
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function contextWithCaptureResult(OperationRun $run, array $outcomes): array
|
||||||
|
{
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$context['capture'] = [
|
||||||
|
'resource_type_outcomes' => $outcomes,
|
||||||
|
'completed_at' => now()->toJSON(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{outcomes: list<array<string, mixed>>, summary_counts: array<string, int>, run_outcome: string, failures: list<array<string, mixed>>} $result
|
||||||
|
*/
|
||||||
|
private function recordTerminalAudit(
|
||||||
|
AuditRecorder $auditRecorder,
|
||||||
|
OperationRun $run,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
array $result,
|
||||||
|
): void {
|
||||||
|
$tenant = $run->tenant;
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditRecorder->record(
|
||||||
|
action: $result['run_outcome'] === 'failed'
|
||||||
|
? 'tenant_configuration.capture.failed'
|
||||||
|
: 'tenant_configuration.capture.completed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'provider_connection_id' => (int) $providerConnection->getKey(),
|
||||||
|
'resource_type_outcomes' => $result['outcomes'],
|
||||||
|
'summary_counts' => $result['summary_counts'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $run->user ? AuditActorSnapshot::human($run->user) : null,
|
||||||
|
target: new AuditTargetSnapshot('operation_run', (int) $run->getKey()),
|
||||||
|
outcome: AuditOutcome::normalize($result['run_outcome']),
|
||||||
|
operationRunId: (int) $run->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordFailureAudit(
|
||||||
|
AuditRecorder $auditRecorder,
|
||||||
|
OperationRun $run,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
Throwable $e,
|
||||||
|
): void {
|
||||||
|
$tenant = $run->tenant;
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditRecorder->record(
|
||||||
|
action: 'tenant_configuration.capture.failed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'provider_connection_id' => (int) $providerConnection->getKey(),
|
||||||
|
'reason_code' => 'capture_exception',
|
||||||
|
'message' => RunFailureSanitizer::sanitizeMessage($e->getMessage()),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $run->user ? AuditActorSnapshot::human($run->user) : null,
|
||||||
|
target: new AuditTargetSnapshot('operation_run', (int) $run->getKey()),
|
||||||
|
outcome: AuditOutcome::Failed,
|
||||||
|
operationRunId: (int) $run->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/platform/app/Models/TenantConfigurationResource.php
Normal file
71
apps/platform/app/Models/TenantConfigurationResource.php
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use App\Support\TenantConfiguration\IdentityState;
|
||||||
|
use App\Support\TenantConfiguration\SourceClass;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class TenantConfigurationResource extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'source_class' => SourceClass::class,
|
||||||
|
'source_metadata' => 'array',
|
||||||
|
'latest_evidence_state' => EvidenceState::class,
|
||||||
|
'latest_identity_state' => IdentityState::class,
|
||||||
|
'latest_claim_state' => ClaimState::class,
|
||||||
|
'latest_captured_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ManagedEnvironment::class, 'managed_environment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function managedEnvironment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->tenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providerConnection(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProviderConnection::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resourceType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TenantConfigurationResourceType::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latestEvidence(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TenantConfigurationResourceEvidence::class, 'latest_evidence_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function evidence(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TenantConfigurationResourceEvidence::class, 'resource_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class TenantConfigurationResourceEvidence extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'tenant_configuration_resource_evidence';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'source_metadata' => 'array',
|
||||||
|
'raw_payload' => 'array',
|
||||||
|
'normalized_payload' => 'array',
|
||||||
|
'permission_context' => 'array',
|
||||||
|
'evidence_state' => EvidenceState::class,
|
||||||
|
'coverage_level' => CoverageLevel::class,
|
||||||
|
'capture_outcome' => CaptureOutcome::class,
|
||||||
|
'captured_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resource(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TenantConfigurationResource::class, 'resource_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ManagedEnvironment::class, 'managed_environment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function managedEnvironment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->tenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providerConnection(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProviderConnection::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resourceType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TenantConfigurationResourceType::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function operationRun(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OperationRun::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -193,6 +193,7 @@ private function evaluateExecutionPrerequisites(QueuedExecutionContext $context,
|
|||||||
if ($context->providerConnectionId !== null) {
|
if ($context->providerConnectionId !== null) {
|
||||||
$validProviderConnection = ProviderConnection::query()
|
$validProviderConnection = ProviderConnection::query()
|
||||||
->whereKey($context->providerConnectionId)
|
->whereKey($context->providerConnectionId)
|
||||||
|
->where('workspace_id', $context->workspaceId)
|
||||||
->when(
|
->when(
|
||||||
$context->tenant instanceof ManagedEnvironment,
|
$context->tenant instanceof ManagedEnvironment,
|
||||||
fn ($query) => $query->where('managed_environment_id', (int) $context->tenant->getKey()),
|
fn ($query) => $query->where('managed_environment_id', (int) $context->tenant->getKey()),
|
||||||
|
|||||||
@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
|
||||||
|
final class CoverageCaptureOutcomeSummarizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array{canonical_type: string, outcome: string, item_count?: int, reason_code?: string|null}> $outcomes
|
||||||
|
* @return array{summary_counts: array<string, int>, run_outcome: string, failures: list<array{code: string, message: string, resource_type?: string}>}
|
||||||
|
*/
|
||||||
|
public function summarize(array $outcomes): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'total' => count($outcomes),
|
||||||
|
'processed' => count($outcomes),
|
||||||
|
'succeeded' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'errors_recorded' => 0,
|
||||||
|
];
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
foreach ($outcomes as $outcomeRow) {
|
||||||
|
$outcome = CaptureOutcome::tryFrom((string) $outcomeRow['outcome']);
|
||||||
|
|
||||||
|
if ($outcome === CaptureOutcome::Captured) {
|
||||||
|
$summary['succeeded']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($outcome?->isFailure()) {
|
||||||
|
$summary['failed']++;
|
||||||
|
$summary['errors_recorded']++;
|
||||||
|
$failures[] = [
|
||||||
|
'code' => (string) ($outcomeRow['reason_code'] ?? CaptureOutcome::Failed->value),
|
||||||
|
'message' => sprintf('Capture failed for %s.', (string) $outcomeRow['canonical_type']),
|
||||||
|
'resource_type' => (string) $outcomeRow['canonical_type'],
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary['skipped']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary_counts' => $summary,
|
||||||
|
'run_outcome' => $this->runOutcome($summary),
|
||||||
|
'failures' => $failures,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $summary
|
||||||
|
*/
|
||||||
|
private function runOutcome(array $summary): string
|
||||||
|
{
|
||||||
|
if (($summary['failed'] ?? 0) > 0 && ($summary['succeeded'] ?? 0) > 0) {
|
||||||
|
return OperationRunOutcome::PartiallySucceeded->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($summary['failed'] ?? 0) > 0) {
|
||||||
|
return OperationRunOutcome::Failed->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($summary['succeeded'] ?? 0) > 0) {
|
||||||
|
return OperationRunOutcome::Succeeded->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRunOutcome::Blocked->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CoverageEvidenceWriter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $rawPayload
|
||||||
|
* @param array<string, mixed> $normalizedPayload
|
||||||
|
* @param array<string, mixed> $permissionContext
|
||||||
|
*/
|
||||||
|
public function append(
|
||||||
|
TenantConfigurationResource $resource,
|
||||||
|
TenantConfigurationResourceType $resourceType,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
OperationRun $operationRun,
|
||||||
|
CoverageSourceContractDecision $decision,
|
||||||
|
array $rawPayload,
|
||||||
|
array $normalizedPayload,
|
||||||
|
string $payloadHash,
|
||||||
|
array $permissionContext = [],
|
||||||
|
): TenantConfigurationResourceEvidence {
|
||||||
|
if (! $decision->capturable()) {
|
||||||
|
throw new InvalidArgumentException('Cannot append captured evidence for a non-capturable source contract decision.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertScoped($resource, $resourceType, $providerConnection, $operationRun);
|
||||||
|
|
||||||
|
/** @var TenantConfigurationResourceEvidence $evidence */
|
||||||
|
$evidence = DB::transaction(function () use (
|
||||||
|
$resource,
|
||||||
|
$resourceType,
|
||||||
|
$providerConnection,
|
||||||
|
$operationRun,
|
||||||
|
$decision,
|
||||||
|
$rawPayload,
|
||||||
|
$normalizedPayload,
|
||||||
|
$payloadHash,
|
||||||
|
$permissionContext,
|
||||||
|
): TenantConfigurationResourceEvidence {
|
||||||
|
$capturedAt = now();
|
||||||
|
|
||||||
|
$evidence = TenantConfigurationResourceEvidence::query()->create([
|
||||||
|
'resource_id' => (int) $resource->getKey(),
|
||||||
|
'workspace_id' => (int) $resource->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $resource->managed_environment_id,
|
||||||
|
'provider_connection_id' => (int) $providerConnection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'operation_run_id' => (int) $operationRun->getKey(),
|
||||||
|
'source_contract_key' => (string) $decision->contractKey,
|
||||||
|
'source_endpoint' => (string) $decision->sourceEndpoint,
|
||||||
|
'source_version' => $decision->sourceVersion,
|
||||||
|
'source_schema_hash' => $decision->sourceSchemaHash,
|
||||||
|
'source_metadata' => $decision->sourceMetadata,
|
||||||
|
'raw_payload' => $rawPayload,
|
||||||
|
'normalized_payload' => $normalizedPayload,
|
||||||
|
'payload_hash' => $payloadHash,
|
||||||
|
'permission_context' => $permissionContext,
|
||||||
|
'evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
|
'coverage_level' => CoverageLevel::ContentBacked->value,
|
||||||
|
'capture_outcome' => CaptureOutcome::Captured->value,
|
||||||
|
'captured_at' => $capturedAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resource->forceFill([
|
||||||
|
'latest_evidence_id' => (int) $evidence->getKey(),
|
||||||
|
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
|
'latest_identity_state' => $resourceType->default_identity_state,
|
||||||
|
'latest_claim_state' => $resourceType->default_claim_state,
|
||||||
|
'latest_payload_hash' => $payloadHash,
|
||||||
|
'latest_captured_at' => $capturedAt,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $evidence;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $evidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertScoped(
|
||||||
|
TenantConfigurationResource $resource,
|
||||||
|
TenantConfigurationResourceType $resourceType,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
OperationRun $operationRun,
|
||||||
|
): void {
|
||||||
|
if ((int) $resource->resource_type_id !== (int) $resourceType->getKey()) {
|
||||||
|
throw new InvalidArgumentException('Resource type mismatch while appending tenant configuration evidence.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $resource->provider_connection_id !== (int) $providerConnection->getKey()) {
|
||||||
|
throw new InvalidArgumentException('Provider connection mismatch while appending tenant configuration evidence.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $operationRun->workspace_id !== (int) $resource->workspace_id
|
||||||
|
|| (int) $operationRun->managed_environment_id !== (int) $resource->managed_environment_id
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('Operation run scope mismatch while appending tenant configuration evidence.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
final class CoveragePayloadRedactor
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const SENSITIVE_KEY_PARTS = [
|
||||||
|
'access_token',
|
||||||
|
'authorization',
|
||||||
|
'assertion',
|
||||||
|
'bearer',
|
||||||
|
'certificate',
|
||||||
|
'client_secret',
|
||||||
|
'cookie',
|
||||||
|
'credential',
|
||||||
|
'id_token',
|
||||||
|
'password',
|
||||||
|
'private_key',
|
||||||
|
'refresh_token',
|
||||||
|
'secret',
|
||||||
|
'set-cookie',
|
||||||
|
'token',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function redact(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_is_list($value)) {
|
||||||
|
return array_map(fn (mixed $item): mixed => $this->redact($item), $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$redacted = [];
|
||||||
|
|
||||||
|
foreach ($value as $key => $nestedValue) {
|
||||||
|
$key = (string) $key;
|
||||||
|
$redacted[$key] = $this->isSensitiveKey($key) ? '[redacted]' : $this->redact($nestedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSensitiveKey(string $key): bool
|
||||||
|
{
|
||||||
|
$normalized = strtolower($key);
|
||||||
|
|
||||||
|
foreach (self::SENSITIVE_KEY_PARTS as $part) {
|
||||||
|
if (str_contains($normalized, $part)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use App\Support\TenantConfiguration\IdentityState;
|
||||||
|
use App\Support\TenantConfiguration\SourceClass;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CoverageResourceUpserter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, mixed> $sourceMetadata
|
||||||
|
*/
|
||||||
|
public function upsert(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
TenantConfigurationResourceType $resourceType,
|
||||||
|
array $payload,
|
||||||
|
array $sourceMetadata = [],
|
||||||
|
): TenantConfigurationResource {
|
||||||
|
$this->assertScoped($tenant, $providerConnection);
|
||||||
|
|
||||||
|
$sourceResourceId = $this->extractSourceResourceId($payload);
|
||||||
|
$canonicalType = (string) $resourceType->canonical_type;
|
||||||
|
$canonicalResourceKey = sprintf('%s:%s', $canonicalType, $sourceResourceId);
|
||||||
|
$sourceClass = $resourceType->source_class instanceof SourceClass
|
||||||
|
? $resourceType->source_class->value
|
||||||
|
: (string) $resourceType->source_class;
|
||||||
|
|
||||||
|
$resource = TenantConfigurationResource::query()->firstOrNew([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $providerConnection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'canonical_resource_key' => $canonicalResourceKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resource->fill([
|
||||||
|
'source_class' => $sourceClass,
|
||||||
|
'canonical_type' => $canonicalType,
|
||||||
|
'source_resource_id' => $sourceResourceId,
|
||||||
|
'source_display_name' => $this->extractDisplayName($payload),
|
||||||
|
'source_metadata' => $sourceMetadata,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $resource->exists) {
|
||||||
|
$resource->forceFill([
|
||||||
|
'latest_evidence_state' => EvidenceState::NotCaptured->value,
|
||||||
|
'latest_identity_state' => IdentityState::Stable->value,
|
||||||
|
'latest_claim_state' => ClaimState::InternalOnly->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resource->save();
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertScoped(ManagedEnvironment $tenant, ProviderConnection $providerConnection): void
|
||||||
|
{
|
||||||
|
if ((int) $providerConnection->managed_environment_id !== (int) $tenant->getKey()) {
|
||||||
|
throw new InvalidArgumentException('Provider connection does not belong to the managed environment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $providerConnection->workspace_id !== (int) $tenant->workspace_id) {
|
||||||
|
throw new InvalidArgumentException('Provider connection does not belong to the managed environment workspace.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function extractSourceResourceId(array $payload): string
|
||||||
|
{
|
||||||
|
$id = $payload['id'] ?? $payload['sourceId'] ?? null;
|
||||||
|
|
||||||
|
if (! is_scalar($id)) {
|
||||||
|
throw new InvalidArgumentException('Captured resource payload must include a stable source id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = trim((string) $id);
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
throw new InvalidArgumentException('Captured resource payload must include a non-empty source id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function extractDisplayName(array $payload): ?string
|
||||||
|
{
|
||||||
|
$displayName = $payload['displayName'] ?? $payload['name'] ?? null;
|
||||||
|
|
||||||
|
if (! is_scalar($displayName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayName = trim((string) $displayName);
|
||||||
|
|
||||||
|
return $displayName !== '' ? $displayName : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
|
||||||
|
final readonly class CoverageSourceContractDecision
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $contract
|
||||||
|
* @param array<string, mixed> $sourceMetadata
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $canonicalType,
|
||||||
|
public CaptureOutcome $outcome,
|
||||||
|
public ?string $contractKey = null,
|
||||||
|
public ?string $sourceEndpoint = null,
|
||||||
|
public string $sourceVersion = 'v1.0',
|
||||||
|
public ?string $sourceSchemaHash = null,
|
||||||
|
public ?string $reasonCode = null,
|
||||||
|
public array $contract = [],
|
||||||
|
public array $sourceMetadata = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function capturable(): bool
|
||||||
|
{
|
||||||
|
return $this->outcome === CaptureOutcome::Captured
|
||||||
|
&& is_string($this->contractKey)
|
||||||
|
&& $this->contractKey !== ''
|
||||||
|
&& is_string($this->sourceEndpoint)
|
||||||
|
&& $this->sourceEndpoint !== '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\SourceClass;
|
||||||
|
use App\Support\TenantConfiguration\SupportState;
|
||||||
|
|
||||||
|
final class CoverageSourceContractResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Explicit source contract mappings only. Missing entries remain blocked.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const CONTRACT_KEYS = [
|
||||||
|
'deviceAndAppManagementAssignmentFilter' => 'assignmentFilter',
|
||||||
|
'notificationMessageTemplate' => 'notificationMessageTemplate',
|
||||||
|
'roleScopeTag' => 'roleScopeTag',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly GraphContractRegistry $contracts,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function resolve(TenantConfigurationResourceType $resourceType, bool $allowBetaCapture = false): CoverageSourceContractDecision
|
||||||
|
{
|
||||||
|
$canonicalType = (string) $resourceType->canonical_type;
|
||||||
|
$sourceClass = $resourceType->source_class instanceof SourceClass
|
||||||
|
? $resourceType->source_class
|
||||||
|
: SourceClass::tryFrom((string) $resourceType->source_class);
|
||||||
|
$supportState = $resourceType->support_state instanceof SupportState
|
||||||
|
? $resourceType->support_state
|
||||||
|
: SupportState::tryFrom((string) $resourceType->support_state);
|
||||||
|
|
||||||
|
if (in_array($supportState, [SupportState::Unsupported, SupportState::OutOfScope], true)) {
|
||||||
|
return $this->blocked($canonicalType, CaptureOutcome::BlockedUnsupported, 'resource_type_unsupported');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceClass === SourceClass::GraphBetaExperimental && ! $allowBetaCapture) {
|
||||||
|
return $this->blocked($canonicalType, CaptureOutcome::BlockedBeta, 'beta_capture_disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contractKey = self::CONTRACT_KEYS[$canonicalType] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($contractKey) || $contractKey === '') {
|
||||||
|
return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = $this->contracts->get($contractKey);
|
||||||
|
$resource = is_string($contract['resource'] ?? null) ? trim((string) $contract['resource']) : '';
|
||||||
|
|
||||||
|
if ($contract === [] || $resource === '') {
|
||||||
|
return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_graph_contract_resource');
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = [
|
||||||
|
'source_contract_key' => $contractKey,
|
||||||
|
'source_endpoint' => '/'.ltrim($resource, '/'),
|
||||||
|
'source_class' => $sourceClass?->value,
|
||||||
|
'support_state' => $supportState?->value,
|
||||||
|
];
|
||||||
|
|
||||||
|
return new CoverageSourceContractDecision(
|
||||||
|
canonicalType: $canonicalType,
|
||||||
|
outcome: CaptureOutcome::Captured,
|
||||||
|
contractKey: $contractKey,
|
||||||
|
sourceEndpoint: '/'.ltrim($resource, '/'),
|
||||||
|
sourceVersion: $this->sourceVersion($contract),
|
||||||
|
sourceSchemaHash: $this->sourceSchemaHash($contract),
|
||||||
|
reasonCode: null,
|
||||||
|
contract: $contract,
|
||||||
|
sourceMetadata: array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function blocked(string $canonicalType, CaptureOutcome $outcome, string $reasonCode): CoverageSourceContractDecision
|
||||||
|
{
|
||||||
|
return new CoverageSourceContractDecision(
|
||||||
|
canonicalType: $canonicalType,
|
||||||
|
outcome: $outcome,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
sourceMetadata: ['reason_code' => $reasonCode],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $contract
|
||||||
|
*/
|
||||||
|
private function sourceVersion(array $contract): string
|
||||||
|
{
|
||||||
|
$version = $contract['version'] ?? $contract['graph_version'] ?? null;
|
||||||
|
|
||||||
|
return is_string($version) && trim($version) !== ''
|
||||||
|
? trim($version)
|
||||||
|
: 'v1.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $contract
|
||||||
|
*/
|
||||||
|
private function sourceSchemaHash(array $contract): ?string
|
||||||
|
{
|
||||||
|
if ($contract === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema = [
|
||||||
|
'resource' => $contract['resource'] ?? null,
|
||||||
|
'allowed_select' => $contract['allowed_select'] ?? [],
|
||||||
|
'type_family' => $contract['type_family'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
return hash('sha256', $this->canonicalJson($schema));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function canonicalJson(array $payload): string
|
||||||
|
{
|
||||||
|
ksort($payload);
|
||||||
|
|
||||||
|
return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,322 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Providers\ProviderGateway;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class GenericContentEvidenceCaptureService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResourceTypeRegistry $resourceTypes,
|
||||||
|
private readonly CoverageSourceContractResolver $contractResolver,
|
||||||
|
private readonly ProviderGateway $providerGateway,
|
||||||
|
private readonly GenericPayloadNormalizer $normalizer,
|
||||||
|
private readonly CoveragePayloadRedactor $redactor,
|
||||||
|
private readonly CoverageResourceUpserter $resourceUpserter,
|
||||||
|
private readonly CoverageEvidenceWriter $evidenceWriter,
|
||||||
|
private readonly CoverageCaptureOutcomeSummarizer $summarizer,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string>|null $canonicalTypes
|
||||||
|
* @return array{
|
||||||
|
* outcomes: list<array{canonical_type: string, outcome: string, item_count?: int, reason_code?: string|null, source_contract_key?: string|null}>,
|
||||||
|
* summary_counts: array<string, int>,
|
||||||
|
* run_outcome: string,
|
||||||
|
* failures: list<array{code: string, message: string, resource_type?: string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function capture(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
OperationRun $operationRun,
|
||||||
|
?array $canonicalTypes = null,
|
||||||
|
bool $allowBetaCapture = false,
|
||||||
|
): array {
|
||||||
|
$this->assertScopedExecution($tenant, $providerConnection, $operationRun);
|
||||||
|
|
||||||
|
$outcomes = [];
|
||||||
|
|
||||||
|
foreach ($this->selectedResourceTypes($canonicalTypes) as $resourceType) {
|
||||||
|
$decision = $this->contractResolver->resolve($resourceType, allowBetaCapture: $allowBetaCapture);
|
||||||
|
|
||||||
|
if (! $decision->capturable()) {
|
||||||
|
$outcomes[] = $this->outcomeRow($resourceType, $decision->outcome, $decision->reasonCode, 0, $decision->contractKey);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->providerGateway->listPolicies(
|
||||||
|
$providerConnection,
|
||||||
|
(string) $decision->contractKey,
|
||||||
|
$this->graphListOptions($operationRun),
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$outcomes[] = $this->outcomeRow($resourceType, CaptureOutcome::Failed, $this->exceptionReasonCode($e), 0, $decision->contractKey);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
$outcomes[] = $this->failedResponseOutcome($resourceType, $response, $decision);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$capturedItems = $this->captureResponseItems(
|
||||||
|
tenant: $tenant,
|
||||||
|
providerConnection: $providerConnection,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
resourceType: $resourceType,
|
||||||
|
decision: $decision,
|
||||||
|
response: $response,
|
||||||
|
);
|
||||||
|
|
||||||
|
$outcomes[] = $this->outcomeRow($resourceType, CaptureOutcome::Captured, null, $capturedItems, $decision->contractKey);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$outcomes[] = $this->outcomeRow($resourceType, CaptureOutcome::Failed, $this->exceptionReasonCode($e), 0, $decision->contractKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'outcomes' => $outcomes,
|
||||||
|
...$this->summarizer->summarize($outcomes),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string>|null $canonicalTypes
|
||||||
|
* @return Collection<int, TenantConfigurationResourceType>
|
||||||
|
*/
|
||||||
|
private function selectedResourceTypes(?array $canonicalTypes): Collection
|
||||||
|
{
|
||||||
|
$selected = collect($canonicalTypes ?? ResourceTypeRegistry::defaultCanonicalTypes())
|
||||||
|
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
||||||
|
->map(static fn (string $type): string => trim($type))
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $this->resourceTypes->active()
|
||||||
|
->filter(static fn (TenantConfigurationResourceType $resourceType): bool => in_array((string) $resourceType->canonical_type, $selected, true))
|
||||||
|
->sortBy(static fn (TenantConfigurationResourceType $resourceType): string => (string) $resourceType->canonical_type)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function graphListOptions(OperationRun $operationRun): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'client_request_id' => sprintf('tenant-config-capture-%d', (int) $operationRun->getKey()),
|
||||||
|
'top' => 999,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertScopedExecution(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
OperationRun $operationRun,
|
||||||
|
): void {
|
||||||
|
if ((int) $providerConnection->workspace_id !== (int) $tenant->workspace_id
|
||||||
|
|| (int) $providerConnection->managed_environment_id !== (int) $tenant->getKey()
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('Provider connection does not belong to the managed environment scope.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $operationRun->workspace_id !== (int) $tenant->workspace_id
|
||||||
|
|| (int) $operationRun->managed_environment_id !== (int) $tenant->getKey()
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('Operation run does not belong to the managed environment scope.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $operationRun->type !== OperationRunType::TenantConfigurationCapture->value) {
|
||||||
|
throw new InvalidArgumentException('Operation run type is not valid for tenant configuration capture.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) data_get($operationRun->context, 'target_scope.workspace_id') !== (int) $tenant->workspace_id
|
||||||
|
|| (int) data_get($operationRun->context, 'target_scope.managed_environment_id') !== (int) $tenant->getKey()
|
||||||
|
|| (int) data_get($operationRun->context, 'target_scope.provider_connection_id') !== (int) $providerConnection->getKey()
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('Operation run target scope does not match the capture provider scope.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failedResponseOutcome(
|
||||||
|
TenantConfigurationResourceType $resourceType,
|
||||||
|
GraphResponse $response,
|
||||||
|
CoverageSourceContractDecision $decision,
|
||||||
|
): array {
|
||||||
|
$status = (int) ($response->status ?? 0);
|
||||||
|
|
||||||
|
if (in_array($status, [401, 403], true)) {
|
||||||
|
return $this->outcomeRow(
|
||||||
|
resourceType: $resourceType,
|
||||||
|
outcome: CaptureOutcome::BlockedPermission,
|
||||||
|
reasonCode: 'graph_permission_blocked',
|
||||||
|
itemCount: 0,
|
||||||
|
sourceContractKey: $decision->contractKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->outcomeRow(
|
||||||
|
resourceType: $resourceType,
|
||||||
|
outcome: CaptureOutcome::Failed,
|
||||||
|
reasonCode: 'graph_response_failed_'.$status,
|
||||||
|
itemCount: 0,
|
||||||
|
sourceContractKey: $decision->contractKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function captureResponseItems(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
OperationRun $operationRun,
|
||||||
|
TenantConfigurationResourceType $resourceType,
|
||||||
|
CoverageSourceContractDecision $decision,
|
||||||
|
GraphResponse $response,
|
||||||
|
): int {
|
||||||
|
$captured = 0;
|
||||||
|
$volatileFields = $this->volatileFields($decision);
|
||||||
|
$permissionContext = $this->permissionContext($providerConnection);
|
||||||
|
|
||||||
|
foreach ($this->responseItems($response) as $item) {
|
||||||
|
$normalizedPayload = $this->normalizer->normalize($item, $volatileFields);
|
||||||
|
$payloadHash = $this->normalizer->payloadHash($normalizedPayload);
|
||||||
|
|
||||||
|
$resource = $this->resourceUpserter->upsert(
|
||||||
|
tenant: $tenant,
|
||||||
|
providerConnection: $providerConnection,
|
||||||
|
resourceType: $resourceType,
|
||||||
|
payload: $item,
|
||||||
|
sourceMetadata: $decision->sourceMetadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->evidenceWriter->append(
|
||||||
|
resource: $resource,
|
||||||
|
resourceType: $resourceType,
|
||||||
|
providerConnection: $providerConnection,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
decision: $decision,
|
||||||
|
rawPayload: $item,
|
||||||
|
normalizedPayload: $normalizedPayload,
|
||||||
|
payloadHash: $payloadHash,
|
||||||
|
permissionContext: $permissionContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
$captured++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function responseItems(GraphResponse $response): array
|
||||||
|
{
|
||||||
|
$data = $response->data;
|
||||||
|
|
||||||
|
if (array_is_list($data)) {
|
||||||
|
return array_values(array_filter($data, static fn (mixed $item): bool => is_array($item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['value']) && is_array($data['value'])) {
|
||||||
|
return array_values(array_filter($data['value'], static fn (mixed $item): bool => is_array($item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['id'])) {
|
||||||
|
return [$data];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function volatileFields(CoverageSourceContractDecision $decision): array
|
||||||
|
{
|
||||||
|
$volatileFields = $decision->contract['volatile_fields'] ?? [];
|
||||||
|
|
||||||
|
if (! is_array($volatileFields)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($volatileFields)
|
||||||
|
->filter(static fn (mixed $field): bool => is_string($field) && trim($field) !== '')
|
||||||
|
->map(static fn (string $field): string => trim($field))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function permissionContext(ProviderConnection $providerConnection): array
|
||||||
|
{
|
||||||
|
return $this->redactor->redact([
|
||||||
|
'provider_connection_id' => (int) $providerConnection->getKey(),
|
||||||
|
'provider' => (string) $providerConnection->provider,
|
||||||
|
'connection_type' => $this->stringValue($providerConnection->connection_type),
|
||||||
|
'consent_status' => $this->stringValue($providerConnection->consent_status),
|
||||||
|
'verification_status' => $this->stringValue($providerConnection->verification_status),
|
||||||
|
'scopes_granted' => is_array($providerConnection->scopes_granted) ? $providerConnection->scopes_granted : [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stringValue(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if ($value instanceof \BackedEnum) {
|
||||||
|
return (string) $value->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_scalar($value)) {
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
return $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{canonical_type: string, outcome: string, item_count: int, reason_code?: string|null, source_contract_key?: string|null}
|
||||||
|
*/
|
||||||
|
private function outcomeRow(
|
||||||
|
TenantConfigurationResourceType $resourceType,
|
||||||
|
CaptureOutcome $outcome,
|
||||||
|
?string $reasonCode = null,
|
||||||
|
int $itemCount = 0,
|
||||||
|
?string $sourceContractKey = null,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'canonical_type' => (string) $resourceType->canonical_type,
|
||||||
|
'outcome' => $outcome->value,
|
||||||
|
'item_count' => max(0, $itemCount),
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'source_contract_key' => $sourceContractKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function exceptionReasonCode(Throwable $e): string
|
||||||
|
{
|
||||||
|
return RunFailureSanitizer::normalizeReasonCode($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
final class GenericPayloadNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param list<string> $volatileFields
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function normalize(array $payload, array $volatileFields = []): array
|
||||||
|
{
|
||||||
|
$volatileLookup = array_fill_keys(array_map('strval', $volatileFields), true);
|
||||||
|
|
||||||
|
return $this->normalizeValue($payload, $volatileLookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $normalizedPayload
|
||||||
|
*/
|
||||||
|
public function payloadHash(array $normalizedPayload): string
|
||||||
|
{
|
||||||
|
return hash('sha256', $this->canonicalJson($normalizedPayload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function canonicalJson(array $payload): string
|
||||||
|
{
|
||||||
|
return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, bool> $volatileLookup
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
private function normalizeValue(mixed $value, array $volatileLookup): mixed
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isList($value)) {
|
||||||
|
return array_map(fn (mixed $item): mixed => $this->normalizeValue($item, $volatileLookup), $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($value as $key => $nestedValue) {
|
||||||
|
$key = (string) $key;
|
||||||
|
|
||||||
|
if (isset($volatileLookup[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[$key] = $this->normalizeValue($nestedValue, $volatileLookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($normalized);
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isList(array $value): bool
|
||||||
|
{
|
||||||
|
return array_is_list($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Jobs\TenantConfiguration\CaptureTenantConfigurationEvidenceJob;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\AuditRecorder;
|
||||||
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Audit\AuditActorSnapshot;
|
||||||
|
use App\Support\Audit\AuditOutcome;
|
||||||
|
use App\Support\Audit\AuditTargetSnapshot;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Operations\ExecutionAuthorityMode;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class StartTenantConfigurationCapture
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ManagedEnvironmentAccessScopeResolver $accessScopeResolver,
|
||||||
|
private readonly OperationRunService $operationRuns,
|
||||||
|
private readonly AuditRecorder $auditRecorder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string>|null $canonicalTypes
|
||||||
|
*/
|
||||||
|
public function start(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
User $actor,
|
||||||
|
?array $canonicalTypes = null,
|
||||||
|
): OperationRun {
|
||||||
|
$this->authorize($tenant, $actor);
|
||||||
|
$this->assertProviderConnectionInScope($tenant, $providerConnection);
|
||||||
|
|
||||||
|
$resourceTypes = $this->normalizeResourceTypes($canonicalTypes);
|
||||||
|
$context = $this->runContext($tenant, $providerConnection, $resourceTypes);
|
||||||
|
|
||||||
|
$run = $this->operationRuns->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
identityInputs: [
|
||||||
|
'provider_connection_id' => (int) $providerConnection->getKey(),
|
||||||
|
'resource_types' => $resourceTypes,
|
||||||
|
],
|
||||||
|
context: $context,
|
||||||
|
initiator: $actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $run->wasRecentlyCreated) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recordAudit(
|
||||||
|
action: 'tenant_configuration.capture.started',
|
||||||
|
run: $run,
|
||||||
|
tenant: $tenant,
|
||||||
|
providerConnection: $providerConnection,
|
||||||
|
actor: $actor,
|
||||||
|
outcome: AuditOutcome::Info,
|
||||||
|
metadata: ['resource_types' => $resourceTypes],
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->operationRuns->dispatchOrFail(
|
||||||
|
$run,
|
||||||
|
fn () => CaptureTenantConfigurationEvidenceJob::dispatch($run),
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->recordAudit(
|
||||||
|
action: 'tenant_configuration.capture.failed',
|
||||||
|
run: $run->fresh() ?? $run,
|
||||||
|
tenant: $tenant,
|
||||||
|
providerConnection: $providerConnection,
|
||||||
|
actor: $actor,
|
||||||
|
outcome: AuditOutcome::Failed,
|
||||||
|
metadata: [
|
||||||
|
'reason_code' => 'dispatch_failed',
|
||||||
|
'message' => RunFailureSanitizer::sanitizeMessage($e->getMessage()),
|
||||||
|
'resource_types' => $resourceTypes,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorize(ManagedEnvironment $tenant, User $actor): void
|
||||||
|
{
|
||||||
|
$decision = $this->accessScopeResolver->decision($actor, $tenant, Capabilities::EVIDENCE_MANAGE);
|
||||||
|
|
||||||
|
if ($decision->allowed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($decision->shouldDenyAsNotFound()) {
|
||||||
|
throw new NotFoundHttpException('Managed environment not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw (new AuthorizationException('This action is unauthorized.'))->withStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertProviderConnectionInScope(ManagedEnvironment $tenant, ProviderConnection $providerConnection): void
|
||||||
|
{
|
||||||
|
if ((int) $providerConnection->managed_environment_id !== (int) $tenant->getKey()
|
||||||
|
|| (int) $providerConnection->workspace_id !== (int) $tenant->workspace_id
|
||||||
|
) {
|
||||||
|
throw new NotFoundHttpException('Provider connection not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string>|null $canonicalTypes
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function normalizeResourceTypes(?array $canonicalTypes): array
|
||||||
|
{
|
||||||
|
$types = $canonicalTypes ?? ResourceTypeRegistry::defaultCanonicalTypes();
|
||||||
|
|
||||||
|
return collect($types)
|
||||||
|
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
||||||
|
->map(static fn (string $type): string => trim($type))
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $resourceTypes
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function runContext(ManagedEnvironment $tenant, ProviderConnection $providerConnection, array $resourceTypes): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'operation' => [
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
],
|
||||||
|
'target_scope' => [
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $providerConnection->getKey(),
|
||||||
|
],
|
||||||
|
'resource_types' => $resourceTypes,
|
||||||
|
'required_capability' => Capabilities::EVIDENCE_MANAGE,
|
||||||
|
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
private function recordAudit(
|
||||||
|
string $action,
|
||||||
|
OperationRun $run,
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $providerConnection,
|
||||||
|
User $actor,
|
||||||
|
AuditOutcome $outcome,
|
||||||
|
array $metadata = [],
|
||||||
|
): void {
|
||||||
|
$this->auditRecorder->record(
|
||||||
|
action: $action,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'provider_connection_id' => (int) $providerConnection->getKey(),
|
||||||
|
...$metadata,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: AuditActorSnapshot::human($actor),
|
||||||
|
target: new AuditTargetSnapshot('operation_run', (int) $run->getKey()),
|
||||||
|
outcome: $outcome,
|
||||||
|
operationRunId: (int) $run->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -347,6 +347,9 @@ private static function labels(): array
|
|||||||
'baseline.compare.completed' => 'Baseline compare completed',
|
'baseline.compare.completed' => 'Baseline compare completed',
|
||||||
'baseline.compare.failed' => 'Baseline compare failed',
|
'baseline.compare.failed' => 'Baseline compare failed',
|
||||||
'baseline.evidence.resume.started' => 'Baseline evidence capture resumed',
|
'baseline.evidence.resume.started' => 'Baseline evidence capture resumed',
|
||||||
|
'tenant_configuration.capture.started' => 'Tenant configuration capture started',
|
||||||
|
'tenant_configuration.capture.completed' => 'Tenant configuration capture completed',
|
||||||
|
'tenant_configuration.capture.failed' => 'Tenant configuration capture failed',
|
||||||
'backup.created' => 'Backup set created',
|
'backup.created' => 'Backup set created',
|
||||||
'backup.updated' => 'Backup set updated',
|
'backup.updated' => 'Backup set updated',
|
||||||
'backup.archived' => 'Backup set archived',
|
'backup.archived' => 'Backup set archived',
|
||||||
|
|||||||
@ -280,6 +280,7 @@ private static function canonicalDefinitions(): array
|
|||||||
'environment.review.compose' => new CanonicalOperationType('environment.review.compose', 'platform_foundation', 'environment_review', 'Review composition', true, 60),
|
'environment.review.compose' => new CanonicalOperationType('environment.review.compose', 'platform_foundation', 'environment_review', 'Review composition', true, 60),
|
||||||
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
|
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
|
||||||
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
|
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
|
||||||
|
'tenant_configuration.capture' => new CanonicalOperationType('tenant_configuration.capture', 'platform_foundation', null, 'Tenant configuration capture', false, 120),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -344,6 +345,7 @@ private static function operationAliases(): array
|
|||||||
new OperationTypeAlias('environment.review.compose', 'environment.review.compose', 'canonical', true),
|
new OperationTypeAlias('environment.review.compose', 'environment.review.compose', 'canonical', true),
|
||||||
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
|
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
|
||||||
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
|
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
|
||||||
|
new OperationTypeAlias('tenant_configuration.capture', 'tenant_configuration.capture', 'canonical', true),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ enum OperationRunType: string
|
|||||||
case EnvironmentReviewCompose = 'environment.review.compose';
|
case EnvironmentReviewCompose = 'environment.review.compose';
|
||||||
case EvidenceSnapshotGenerate = 'tenant.evidence.snapshot.generate';
|
case EvidenceSnapshotGenerate = 'tenant.evidence.snapshot.generate';
|
||||||
case RbacHealthCheck = 'rbac.health_check';
|
case RbacHealthCheck = 'rbac.health_check';
|
||||||
|
case TenantConfigurationCapture = 'tenant_configuration.capture';
|
||||||
|
|
||||||
public static function values(): array
|
public static function values(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\TenantConfiguration;
|
||||||
|
|
||||||
|
enum CaptureOutcome: string
|
||||||
|
{
|
||||||
|
case Captured = 'captured';
|
||||||
|
case BlockedMissingContract = 'capture_blocked_missing_contract';
|
||||||
|
case BlockedPermission = 'capture_blocked_permission';
|
||||||
|
case BlockedBeta = 'capture_blocked_beta';
|
||||||
|
case BlockedUnsupported = 'capture_blocked_unsupported';
|
||||||
|
case Failed = 'capture_failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCaptured(): bool
|
||||||
|
{
|
||||||
|
return $this === self::Captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFailure(): bool
|
||||||
|
{
|
||||||
|
return $this === self::Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isBlocked(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [
|
||||||
|
self::BlockedMissingContract,
|
||||||
|
self::BlockedPermission,
|
||||||
|
self::BlockedBeta,
|
||||||
|
self::BlockedUnsupported,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<TenantConfigurationResourceEvidence>
|
||||||
|
*/
|
||||||
|
class TenantConfigurationResourceEvidenceFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = TenantConfigurationResourceEvidence::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'resource_id' => TenantConfigurationResource::factory(),
|
||||||
|
'workspace_id' => fn (array $attributes): int => $this->resource($attributes)->workspace_id,
|
||||||
|
'managed_environment_id' => fn (array $attributes): int => $this->resource($attributes)->managed_environment_id,
|
||||||
|
'provider_connection_id' => fn (array $attributes): int => $this->resource($attributes)->provider_connection_id,
|
||||||
|
'resource_type_id' => fn (array $attributes): int => $this->resource($attributes)->resource_type_id,
|
||||||
|
'operation_run_id' => fn (array $attributes): int => (int) OperationRun::factory()->create([
|
||||||
|
'workspace_id' => $this->resource($attributes)->workspace_id,
|
||||||
|
'managed_environment_id' => $this->resource($attributes)->managed_environment_id,
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
])->getKey(),
|
||||||
|
'source_contract_key' => 'assignmentFilter',
|
||||||
|
'source_endpoint' => '/deviceManagement/assignmentFilters',
|
||||||
|
'source_version' => 'v1.0',
|
||||||
|
'source_schema_hash' => null,
|
||||||
|
'source_metadata' => ['factory' => 'tenant_configuration_resource_evidence'],
|
||||||
|
'raw_payload' => ['id' => fake()->uuid()],
|
||||||
|
'normalized_payload' => ['id' => fake()->uuid()],
|
||||||
|
'payload_hash' => hash('sha256', fake()->uuid()),
|
||||||
|
'permission_context' => ['scopes_granted' => []],
|
||||||
|
'evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
|
'coverage_level' => CoverageLevel::ContentBacked->value,
|
||||||
|
'capture_outcome' => CaptureOutcome::Captured->value,
|
||||||
|
'captured_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(array $attributes): TenantConfigurationResource
|
||||||
|
{
|
||||||
|
return TenantConfigurationResource::query()->findOrFail((int) $attributes['resource_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use App\Support\TenantConfiguration\IdentityState;
|
||||||
|
use App\Support\TenantConfiguration\SourceClass;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<TenantConfigurationResource>
|
||||||
|
*/
|
||||||
|
class TenantConfigurationResourceFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = TenantConfigurationResource::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'managed_environment_id' => ManagedEnvironment::factory()->for(Workspace::factory()),
|
||||||
|
'workspace_id' => function (array $attributes): int {
|
||||||
|
return $this->workspaceIdForTenant((int) ($attributes['managed_environment_id'] ?? 0));
|
||||||
|
},
|
||||||
|
'provider_connection_id' => function (array $attributes): int {
|
||||||
|
$tenantId = (int) ($attributes['managed_environment_id'] ?? 0);
|
||||||
|
$workspaceId = $this->workspaceIdForTenant($tenantId);
|
||||||
|
|
||||||
|
return (int) ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'managed_environment_id' => $tenantId,
|
||||||
|
])->getKey();
|
||||||
|
},
|
||||||
|
'resource_type_id' => TenantConfigurationResourceType::factory(),
|
||||||
|
'latest_evidence_id' => null,
|
||||||
|
'source_class' => SourceClass::Tcm->value,
|
||||||
|
'canonical_type' => function (array $attributes): string {
|
||||||
|
$resourceType = TenantConfigurationResourceType::query()->find((int) ($attributes['resource_type_id'] ?? 0));
|
||||||
|
|
||||||
|
return $resourceType instanceof TenantConfigurationResourceType
|
||||||
|
? (string) $resourceType->canonical_type
|
||||||
|
: fake()->slug();
|
||||||
|
},
|
||||||
|
'canonical_resource_key' => fn (array $attributes): string => sprintf(
|
||||||
|
'%s:%s',
|
||||||
|
(string) ($attributes['canonical_type'] ?? 'resource'),
|
||||||
|
fake()->uuid(),
|
||||||
|
),
|
||||||
|
'source_resource_id' => fake()->uuid(),
|
||||||
|
'source_display_name' => fake()->words(3, true),
|
||||||
|
'source_metadata' => ['factory' => 'tenant_configuration_resource'],
|
||||||
|
'latest_evidence_state' => EvidenceState::NotCaptured->value,
|
||||||
|
'latest_identity_state' => IdentityState::Stable->value,
|
||||||
|
'latest_claim_state' => ClaimState::InternalOnly->value,
|
||||||
|
'latest_payload_hash' => null,
|
||||||
|
'latest_captured_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspaceIdForTenant(int $tenantId): int
|
||||||
|
{
|
||||||
|
$tenant = ManagedEnvironment::query()->find($tenantId);
|
||||||
|
|
||||||
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
|
return (int) Workspace::factory()->create()->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant->workspace_id === null) {
|
||||||
|
$workspaceId = (int) Workspace::factory()->create()->getKey();
|
||||||
|
$tenant->forceFill(['workspace_id' => $workspaceId])->save();
|
||||||
|
|
||||||
|
return $workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $tenant->workspace_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
private const SOURCE_CLASSES = ['tcm', 'graph_v1_fallback', 'graph_beta_experimental'];
|
||||||
|
|
||||||
|
private const COVERAGE_LEVELS = ['detected', 'content_backed', 'comparable', 'renderable', 'restorable', 'certified'];
|
||||||
|
|
||||||
|
private const EVIDENCE_STATES = ['not_captured', 'captured', 'content_backed', 'permission_blocked', 'source_unavailable', 'schema_unknown', 'capture_failed'];
|
||||||
|
|
||||||
|
private const IDENTITY_STATES = ['stable', 'derived', 'identity_conflict', 'missing_external_id', 'unsupported_identity'];
|
||||||
|
|
||||||
|
private const CLAIM_STATES = ['claim_allowed', 'claim_limited', 'claim_blocked', 'internal_only'];
|
||||||
|
|
||||||
|
private const CAPTURE_OUTCOMES = [
|
||||||
|
'captured',
|
||||||
|
'capture_blocked_missing_contract',
|
||||||
|
'capture_blocked_permission',
|
||||||
|
'capture_blocked_beta',
|
||||||
|
'capture_blocked_unsupported',
|
||||||
|
'capture_failed',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('tenant_configuration_resources', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('managed_environment_id')->constrained('managed_environments')->cascadeOnDelete();
|
||||||
|
$table->foreignId('provider_connection_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('resource_type_id')->constrained('tenant_configuration_resource_types')->restrictOnDelete();
|
||||||
|
$table->unsignedBigInteger('latest_evidence_id')->nullable();
|
||||||
|
$table->string('source_class');
|
||||||
|
$table->string('canonical_type');
|
||||||
|
$table->string('canonical_resource_key');
|
||||||
|
$table->string('source_resource_id');
|
||||||
|
$table->string('source_display_name')->nullable();
|
||||||
|
$table->jsonb('source_metadata')->default('{}');
|
||||||
|
$table->string('latest_evidence_state')->default('not_captured');
|
||||||
|
$table->string('latest_identity_state')->default('stable');
|
||||||
|
$table->string('latest_claim_state')->default('internal_only');
|
||||||
|
$table->string('latest_payload_hash', 64)->nullable();
|
||||||
|
$table->timestampTz('latest_captured_at')->nullable();
|
||||||
|
$table->timestampsTz();
|
||||||
|
|
||||||
|
$table->unique(
|
||||||
|
['workspace_id', 'managed_environment_id', 'provider_connection_id', 'resource_type_id', 'canonical_resource_key'],
|
||||||
|
'tenant_config_resources_scope_identity_unique',
|
||||||
|
);
|
||||||
|
$table->index(['workspace_id', 'managed_environment_id', 'provider_connection_id'], 'tenant_config_resources_scope_idx');
|
||||||
|
$table->index(['resource_type_id', 'source_class'], 'tenant_config_resources_type_source_idx');
|
||||||
|
$table->index(['latest_evidence_id'], 'tenant_config_resources_latest_evidence_idx');
|
||||||
|
$table->index(['latest_evidence_state', 'latest_captured_at'], 'tenant_config_resources_latest_state_idx');
|
||||||
|
$table->index(['latest_payload_hash'], 'tenant_config_resources_payload_hash_idx');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('tenant_configuration_resource_evidence', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('resource_id')->constrained('tenant_configuration_resources')->cascadeOnDelete();
|
||||||
|
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('managed_environment_id')->constrained('managed_environments')->cascadeOnDelete();
|
||||||
|
$table->foreignId('provider_connection_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('resource_type_id')->constrained('tenant_configuration_resource_types')->restrictOnDelete();
|
||||||
|
$table->foreignId('operation_run_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->string('source_contract_key');
|
||||||
|
$table->string('source_endpoint');
|
||||||
|
$table->string('source_version')->default('v1.0');
|
||||||
|
$table->string('source_schema_hash', 64)->nullable();
|
||||||
|
$table->jsonb('source_metadata')->default('{}');
|
||||||
|
$table->jsonb('raw_payload');
|
||||||
|
$table->jsonb('normalized_payload');
|
||||||
|
$table->string('payload_hash', 64);
|
||||||
|
$table->jsonb('permission_context')->default('{}');
|
||||||
|
$table->string('evidence_state')->default('content_backed');
|
||||||
|
$table->string('coverage_level')->default('content_backed');
|
||||||
|
$table->string('capture_outcome');
|
||||||
|
$table->timestampTz('captured_at');
|
||||||
|
$table->timestampsTz();
|
||||||
|
|
||||||
|
$table->index(['workspace_id', 'managed_environment_id', 'captured_at'], 'tenant_config_evidence_scope_captured_idx');
|
||||||
|
$table->index(['resource_id', 'captured_at'], 'tenant_config_evidence_resource_captured_idx');
|
||||||
|
$table->index(['operation_run_id'], 'tenant_config_evidence_operation_run_idx');
|
||||||
|
$table->index(['provider_connection_id', 'resource_type_id'], 'tenant_config_evidence_provider_type_idx');
|
||||||
|
$table->index(['payload_hash'], 'tenant_config_evidence_payload_hash_idx');
|
||||||
|
$table->index(['capture_outcome', 'captured_at'], 'tenant_config_evidence_outcome_idx');
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->addPostgresConstraints();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (DB::getDriverName() === 'pgsql') {
|
||||||
|
DB::statement('ALTER TABLE IF EXISTS tenant_configuration_resources DROP CONSTRAINT IF EXISTS tenant_config_resources_latest_evidence_scope_fk');
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::dropIfExists('tenant_configuration_resource_evidence');
|
||||||
|
Schema::dropIfExists('tenant_configuration_resources');
|
||||||
|
|
||||||
|
if (DB::getDriverName() === 'pgsql') {
|
||||||
|
DB::statement('ALTER TABLE provider_connections DROP CONSTRAINT IF EXISTS provider_connections_environment_scope_fk');
|
||||||
|
DB::statement('DROP INDEX IF EXISTS tenant_config_provider_connections_scope_unique');
|
||||||
|
DB::statement('DROP INDEX IF EXISTS tenant_config_operation_runs_scope_unique');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addPostgresConstraints(): void
|
||||||
|
{
|
||||||
|
if (DB::getDriverName() !== 'pgsql') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::statement($this->checkIn('tenant_configuration_resources', 'source_class', self::SOURCE_CLASSES, 'tenant_config_resources_source_class_check'));
|
||||||
|
DB::statement($this->checkIn('tenant_configuration_resources', 'latest_evidence_state', self::EVIDENCE_STATES, 'tenant_config_resources_latest_evidence_state_check'));
|
||||||
|
DB::statement($this->checkIn('tenant_configuration_resources', 'latest_identity_state', self::IDENTITY_STATES, 'tenant_config_resources_latest_identity_state_check'));
|
||||||
|
DB::statement($this->checkIn('tenant_configuration_resources', 'latest_claim_state', self::CLAIM_STATES, 'tenant_config_resources_latest_claim_state_check'));
|
||||||
|
DB::statement("ALTER TABLE tenant_configuration_resources ADD CONSTRAINT tenant_config_resources_source_metadata_object_check CHECK (jsonb_typeof(source_metadata) = 'object')");
|
||||||
|
DB::statement('ALTER TABLE provider_connections ADD CONSTRAINT provider_connections_environment_scope_fk FOREIGN KEY (managed_environment_id, workspace_id) REFERENCES managed_environments (id, workspace_id) ON DELETE CASCADE');
|
||||||
|
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS tenant_config_provider_connections_scope_unique ON provider_connections (id, workspace_id, managed_environment_id)');
|
||||||
|
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS tenant_config_resources_scope_reference_unique ON tenant_configuration_resources (id, workspace_id, managed_environment_id, provider_connection_id, resource_type_id)');
|
||||||
|
DB::statement('ALTER TABLE tenant_configuration_resources ADD CONSTRAINT tenant_config_resources_environment_scope_fk FOREIGN KEY (managed_environment_id, workspace_id) REFERENCES managed_environments (id, workspace_id) ON DELETE CASCADE');
|
||||||
|
DB::statement('ALTER TABLE tenant_configuration_resources ADD CONSTRAINT tenant_config_resources_provider_scope_fk FOREIGN KEY (provider_connection_id, workspace_id, managed_environment_id) REFERENCES provider_connections (id, workspace_id, managed_environment_id) ON DELETE RESTRICT');
|
||||||
|
|
||||||
|
DB::statement($this->checkIn('tenant_configuration_resource_evidence', 'evidence_state', self::EVIDENCE_STATES, 'tenant_config_evidence_state_check'));
|
||||||
|
DB::statement($this->checkIn('tenant_configuration_resource_evidence', 'coverage_level', self::COVERAGE_LEVELS, 'tenant_config_evidence_coverage_level_check'));
|
||||||
|
DB::statement($this->checkIn('tenant_configuration_resource_evidence', 'capture_outcome', self::CAPTURE_OUTCOMES, 'tenant_config_evidence_capture_outcome_check'));
|
||||||
|
DB::statement("ALTER TABLE tenant_configuration_resource_evidence ADD CONSTRAINT tenant_config_evidence_source_metadata_object_check CHECK (jsonb_typeof(source_metadata) = 'object')");
|
||||||
|
DB::statement("ALTER TABLE tenant_configuration_resource_evidence ADD CONSTRAINT tenant_config_evidence_raw_payload_object_check CHECK (jsonb_typeof(raw_payload) = 'object')");
|
||||||
|
DB::statement("ALTER TABLE tenant_configuration_resource_evidence ADD CONSTRAINT tenant_config_evidence_normalized_payload_object_check CHECK (jsonb_typeof(normalized_payload) = 'object')");
|
||||||
|
DB::statement("ALTER TABLE tenant_configuration_resource_evidence ADD CONSTRAINT tenant_config_evidence_permission_context_object_check CHECK (jsonb_typeof(permission_context) = 'object')");
|
||||||
|
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS tenant_config_operation_runs_scope_unique ON operation_runs (id, workspace_id, managed_environment_id)');
|
||||||
|
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS tenant_config_evidence_latest_reference_unique ON tenant_configuration_resource_evidence (id, resource_id, workspace_id, managed_environment_id, provider_connection_id, resource_type_id)');
|
||||||
|
DB::statement('ALTER TABLE tenant_configuration_resource_evidence ADD CONSTRAINT tenant_config_evidence_environment_scope_fk FOREIGN KEY (managed_environment_id, workspace_id) REFERENCES managed_environments (id, workspace_id) ON DELETE CASCADE');
|
||||||
|
DB::statement('ALTER TABLE tenant_configuration_resource_evidence ADD CONSTRAINT tenant_config_evidence_provider_scope_fk FOREIGN KEY (provider_connection_id, workspace_id, managed_environment_id) REFERENCES provider_connections (id, workspace_id, managed_environment_id) ON DELETE RESTRICT');
|
||||||
|
DB::statement('ALTER TABLE tenant_configuration_resource_evidence ADD CONSTRAINT tenant_config_evidence_resource_scope_fk FOREIGN KEY (resource_id, workspace_id, managed_environment_id, provider_connection_id, resource_type_id) REFERENCES tenant_configuration_resources (id, workspace_id, managed_environment_id, provider_connection_id, resource_type_id) ON DELETE CASCADE');
|
||||||
|
DB::statement('ALTER TABLE tenant_configuration_resource_evidence ADD CONSTRAINT tenant_config_evidence_operation_scope_fk FOREIGN KEY (operation_run_id, workspace_id, managed_environment_id) REFERENCES operation_runs (id, workspace_id, managed_environment_id) ON DELETE RESTRICT');
|
||||||
|
DB::statement('ALTER TABLE tenant_configuration_resources ADD CONSTRAINT tenant_config_resources_latest_evidence_scope_fk FOREIGN KEY (latest_evidence_id, id, workspace_id, managed_environment_id, provider_connection_id, resource_type_id) REFERENCES tenant_configuration_resource_evidence (id, resource_id, workspace_id, managed_environment_id, provider_connection_id, resource_type_id) ON DELETE SET NULL (latest_evidence_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $values
|
||||||
|
*/
|
||||||
|
private function checkIn(string $table, string $column, array $values, string $constraintName): string
|
||||||
|
{
|
||||||
|
$quotedValues = implode(', ', array_map(
|
||||||
|
static fn (string $value): string => "'".str_replace("'", "''", $value)."'",
|
||||||
|
$values,
|
||||||
|
));
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s IN (%s))',
|
||||||
|
$table,
|
||||||
|
$constraintName,
|
||||||
|
$column,
|
||||||
|
$quotedValues,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\TenantConfiguration\CaptureTenantConfigurationEvidenceJob;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\TenantConfiguration\StartTenantConfigurationCapture;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
it('starts an authorized capture run and queues remote execution', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createStandardUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = app(StartTenantConfigurationCapture::class)->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalTypes: ['deviceAndAppManagementAssignmentFilter'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($run->type)->toBe('tenant_configuration.capture')
|
||||||
|
->and($run->status)->toBe(OperationRunStatus::Queued->value)
|
||||||
|
->and(data_get($run->context, 'required_capability'))->toBe('evidence.manage')
|
||||||
|
->and(data_get($run->context, 'target_scope.provider_connection_id'))->toBe((int) $connection->getKey());
|
||||||
|
|
||||||
|
Queue::assertPushed(CaptureTenantConfigurationEvidenceJob::class);
|
||||||
|
|
||||||
|
expect(AuditLog::query()->where('action', 'tenant_configuration.capture.started')->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns forbidden when the user lacks the evidence manage capability', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createStandardUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => app(StartTenantConfigurationCapture::class)->start($tenant, $connection, $user))
|
||||||
|
->toThrow(AuthorizationException::class);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides managed environments outside the user workspace scope', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
[, $tenant] = createStandardUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => app(StartTenantConfigurationCapture::class)->start($tenant, $connection, $user))
|
||||||
|
->toThrow(NotFoundHttpException::class);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides managed environments excluded by explicit environment entitlement scope', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$allowedTenant = ManagedEnvironment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('managed_environment_memberships')
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
DB::table('managed_environment_memberships')->insert([
|
||||||
|
'id' => (string) Str::uuid(),
|
||||||
|
'managed_environment_id' => (int) $allowedTenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => app(StartTenantConfigurationCapture::class)->start($tenant, $connection, $user))
|
||||||
|
->toThrow(NotFoundHttpException::class);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\TenantConfiguration\CaptureTenantConfigurationEvidenceJob;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\TenantConfiguration\StartTenantConfigurationCapture;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
it('reuses active capture runs and keeps queued context sanitized', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(StartTenantConfigurationCapture::class);
|
||||||
|
|
||||||
|
$first = $service->start($tenant, $connection, $user, ['deviceAndAppManagementAssignmentFilter']);
|
||||||
|
$second = $service->start($tenant, $connection, $user, ['deviceAndAppManagementAssignmentFilter']);
|
||||||
|
|
||||||
|
expect($second->getKey())->toBe($first->getKey())
|
||||||
|
->and($first->status)->toBe(OperationRunStatus::Queued->value)
|
||||||
|
->and(json_encode($first->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret')
|
||||||
|
->and(json_encode($first->context, JSON_THROW_ON_ERROR))->not->toContain('access_token');
|
||||||
|
|
||||||
|
Queue::assertPushed(CaptureTenantConfigurationEvidenceJob::class, 1);
|
||||||
|
});
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
|
use App\Services\TenantConfiguration\CoverageEvidenceWriter;
|
||||||
|
use App\Services\TenantConfiguration\CoverageResourceUpserter;
|
||||||
|
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
|
||||||
|
use App\Services\TenantConfiguration\GenericPayloadNormalizer;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
|
||||||
|
it('upserts concrete resources and appends immutable evidence snapshots', function (): void {
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
config()->set('graph_contracts.types.assignmentFilter.volatile_fields', ['@odata.etag']);
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
$run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Running->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
]);
|
||||||
|
$resourceType = TenantConfigurationResourceType::query()
|
||||||
|
->where('canonical_type', 'deviceAndAppManagementAssignmentFilter')
|
||||||
|
->firstOrFail();
|
||||||
|
$decision = app(CoverageSourceContractResolver::class)->resolve($resourceType);
|
||||||
|
$normalizer = new GenericPayloadNormalizer;
|
||||||
|
|
||||||
|
$firstPayload = ['id' => 'resource-1', 'displayName' => 'Resource', '@odata.etag' => 'one'];
|
||||||
|
$resource = app(CoverageResourceUpserter::class)->upsert($tenant, $connection, $resourceType, $firstPayload, $decision->sourceMetadata);
|
||||||
|
$firstNormalized = $normalizer->normalize($firstPayload, ['@odata.etag']);
|
||||||
|
|
||||||
|
app(CoverageEvidenceWriter::class)->append(
|
||||||
|
resource: $resource,
|
||||||
|
resourceType: $resourceType,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: $run,
|
||||||
|
decision: $decision,
|
||||||
|
rawPayload: $firstPayload,
|
||||||
|
normalizedPayload: $firstNormalized,
|
||||||
|
payloadHash: $normalizer->payloadHash($firstNormalized),
|
||||||
|
permissionContext: ['scopes_granted' => []],
|
||||||
|
);
|
||||||
|
|
||||||
|
$secondPayload = ['id' => 'resource-1', 'displayName' => 'Resource updated', '@odata.etag' => 'two'];
|
||||||
|
$sameResource = app(CoverageResourceUpserter::class)->upsert($tenant, $connection, $resourceType, $secondPayload, $decision->sourceMetadata);
|
||||||
|
$secondNormalized = $normalizer->normalize($secondPayload, ['@odata.etag']);
|
||||||
|
$secondEvidence = app(CoverageEvidenceWriter::class)->append(
|
||||||
|
resource: $sameResource,
|
||||||
|
resourceType: $resourceType,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: $run,
|
||||||
|
decision: $decision,
|
||||||
|
rawPayload: $secondPayload,
|
||||||
|
normalizedPayload: $secondNormalized,
|
||||||
|
payloadHash: $normalizer->payloadHash($secondNormalized),
|
||||||
|
permissionContext: ['scopes_granted' => []],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($sameResource->getKey())->toBe($resource->getKey())
|
||||||
|
->and(TenantConfigurationResourceEvidence::query()->where('resource_id', $resource->getKey())->count())->toBe(2)
|
||||||
|
->and($sameResource->fresh()->latest_evidence_id)->toBe((int) $secondEvidence->getKey())
|
||||||
|
->and($secondEvidence->operation_run_id)->toBe((int) $run->getKey())
|
||||||
|
->and($secondEvidence->source_metadata['source_contract_key'])->toBe('assignmentFilter')
|
||||||
|
->and($secondEvidence->normalized_payload)->not->toHaveKey('@odata.etag');
|
||||||
|
});
|
||||||
@ -0,0 +1,323 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\TenantConfiguration\CaptureTenantConfigurationEvidenceJob;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
it('captures content-backed evidence and updates the operation run without raw payload leakage', function (): void {
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
config()->set('graph_contracts.types.assignmentFilter.volatile_fields', ['@odata.etag']);
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'scopes_granted' => ['DeviceManagementConfiguration.Read.All'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$graph = captureGraphClient([
|
||||||
|
'assignmentFilter' => [
|
||||||
|
[
|
||||||
|
'id' => 'assignment-filter-1',
|
||||||
|
'displayName' => 'Corporate devices',
|
||||||
|
'@odata.etag' => 'volatile',
|
||||||
|
'platform' => 'windows10AndLater',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, $graph);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'context' => [
|
||||||
|
'target_scope' => [
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
'resource_types' => ['deviceAndAppManagementAssignmentFilter'],
|
||||||
|
'required_capability' => 'evidence.manage',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(CaptureTenantConfigurationEvidenceJob::class, ['run' => $run])->handle(
|
||||||
|
app(\App\Services\TenantConfiguration\GenericContentEvidenceCaptureService::class),
|
||||||
|
app(\App\Services\OperationRunService::class),
|
||||||
|
app(\App\Services\Audit\AuditRecorder::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
expect($graph->calls)->toHaveCount(1)
|
||||||
|
->and($graph->calls[0]['policy_type'])->toBe('assignmentFilter')
|
||||||
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
||||||
|
->and($run->summary_counts)->toMatchArray([
|
||||||
|
'total' => 1,
|
||||||
|
'processed' => 1,
|
||||||
|
'succeeded' => 1,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'errors_recorded' => 0,
|
||||||
|
])
|
||||||
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret')
|
||||||
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('volatile');
|
||||||
|
|
||||||
|
$resource = TenantConfigurationResource::query()->sole();
|
||||||
|
$evidence = TenantConfigurationResourceEvidence::query()->sole();
|
||||||
|
|
||||||
|
expect($resource->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($resource->managed_environment_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($resource->provider_connection_id)->toBe((int) $connection->getKey())
|
||||||
|
->and($resource->latest_evidence_id)->toBe((int) $evidence->getKey())
|
||||||
|
->and($resource->latest_evidence_state)->toBe(EvidenceState::ContentBacked)
|
||||||
|
->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured)
|
||||||
|
->and($evidence->raw_payload['id'])->toBe('assignment-filter-1')
|
||||||
|
->and($evidence->normalized_payload)->not->toHaveKey('@odata.etag')
|
||||||
|
->and($evidence->permission_context['scopes_granted'])->toBe(['DeviceManagementConfiguration.Read.All']);
|
||||||
|
|
||||||
|
expect(Schema::hasColumn('tenant_configuration_resources', 'tenant_id'))->toBeFalse()
|
||||||
|
->and(Schema::hasColumn('tenant_configuration_resource_evidence', 'tenant_id'))->toBeFalse()
|
||||||
|
->and(AuditLog::query()->where('action', 'tenant_configuration.capture.completed')->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores only bounded failure reasons when graph capture throws sensitive exceptions', function (): void {
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, captureThrowingGraphClient(
|
||||||
|
'invalid_client Authorization: Bearer super-secret-token access_token=abc client_secret=ghi cookie=session',
|
||||||
|
));
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'context' => [
|
||||||
|
'target_scope' => [
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
'resource_types' => ['deviceAndAppManagementAssignmentFilter'],
|
||||||
|
'required_capability' => 'evidence.manage',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(CaptureTenantConfigurationEvidenceJob::class, ['run' => $run])->handle(
|
||||||
|
app(\App\Services\TenantConfiguration\GenericContentEvidenceCaptureService::class),
|
||||||
|
app(\App\Services\OperationRunService::class),
|
||||||
|
app(\App\Services\Audit\AuditRecorder::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
$auditLog = AuditLog::query()
|
||||||
|
->where('action', 'tenant_configuration.capture.failed')
|
||||||
|
->latest('id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
expect($run->outcome)->toBe(OperationRunOutcome::Failed->value)
|
||||||
|
->and(data_get($run->context, 'capture.resource_type_outcomes.0.reason_code'))->toBe('provider_auth_failed')
|
||||||
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('super-secret-token')
|
||||||
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('access_token')
|
||||||
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret')
|
||||||
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('cookie=session')
|
||||||
|
->and(json_encode($auditLog->metadata, JSON_THROW_ON_ERROR))->not->toContain('super-secret-token')
|
||||||
|
->and(json_encode($auditLog->metadata, JSON_THROW_ON_ERROR))->not->toContain('access_token')
|
||||||
|
->and(json_encode($auditLog->metadata, JSON_THROW_ON_ERROR))->not->toContain('client_secret')
|
||||||
|
->and(json_encode($auditLog->metadata, JSON_THROW_ON_ERROR))->not->toContain('cookie=session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects cross-scope provider connections before the capture service calls graph', function (): void {
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
[, $otherTenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$foreignConnection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $otherTenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $otherTenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$graph = captureGraphClient([
|
||||||
|
'assignmentFilter' => [
|
||||||
|
['id' => 'should-not-be-read'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, $graph);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'context' => [
|
||||||
|
'target_scope' => [
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $foreignConnection->getKey(),
|
||||||
|
],
|
||||||
|
'resource_types' => ['deviceAndAppManagementAssignmentFilter'],
|
||||||
|
'required_capability' => 'evidence.manage',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => app(\App\Services\TenantConfiguration\GenericContentEvidenceCaptureService::class)->capture(
|
||||||
|
tenant: $tenant,
|
||||||
|
providerConnection: $foreignConnection,
|
||||||
|
operationRun: $run,
|
||||||
|
canonicalTypes: ['deviceAndAppManagementAssignmentFilter'],
|
||||||
|
))->toThrow(InvalidArgumentException::class, 'Provider connection does not belong to the managed environment scope.');
|
||||||
|
|
||||||
|
expect($graph->calls)->toHaveCount(0)
|
||||||
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
||||||
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes provider lookup in capture jobs before calling graph', function (): void {
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
[, $otherTenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$foreignConnection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $otherTenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $otherTenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$graph = captureGraphClient([
|
||||||
|
'assignmentFilter' => [
|
||||||
|
['id' => 'should-not-be-read'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, $graph);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'context' => [
|
||||||
|
'target_scope' => [
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $foreignConnection->getKey(),
|
||||||
|
],
|
||||||
|
'resource_types' => ['deviceAndAppManagementAssignmentFilter'],
|
||||||
|
'required_capability' => 'evidence.manage',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => app(CaptureTenantConfigurationEvidenceJob::class, ['run' => $run])->handle(
|
||||||
|
app(\App\Services\TenantConfiguration\GenericContentEvidenceCaptureService::class),
|
||||||
|
app(\App\Services\OperationRunService::class),
|
||||||
|
app(\App\Services\Audit\AuditRecorder::class),
|
||||||
|
))->toThrow(RuntimeException::class, 'same-scope provider connection');
|
||||||
|
|
||||||
|
expect($graph->calls)->toHaveCount(0)
|
||||||
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
||||||
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function captureGraphClient(array $responses): GraphClientInterface
|
||||||
|
{
|
||||||
|
return new class($responses) implements GraphClientInterface
|
||||||
|
{
|
||||||
|
public array $calls = [];
|
||||||
|
|
||||||
|
public function __construct(private readonly array $responses) {}
|
||||||
|
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
$this->calls[] = [
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'options' => $options,
|
||||||
|
];
|
||||||
|
|
||||||
|
return new GraphResponse(true, $this->responses[$policyType] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureThrowingGraphClient(string $message): GraphClientInterface
|
||||||
|
{
|
||||||
|
return new class($message) implements GraphClientInterface
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $message) {}
|
||||||
|
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
throw new \RuntimeException($this->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(false, [], 501);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
it('keeps Spec 415 inactive for UI and excludes legacy ownership and outcome vocabulary', function (): void {
|
||||||
|
expect(Schema::hasColumn('tenant_configuration_resources', 'tenant_id'))->toBeFalse()
|
||||||
|
->and(Schema::hasColumn('tenant_configuration_resource_evidence', 'tenant_id'))->toBeFalse();
|
||||||
|
|
||||||
|
$changedUiFiles = collect(glob(base_path('app/Filament/**/*'), GLOB_BRACE))
|
||||||
|
->merge(glob(base_path('resources/views/**/*'), GLOB_BRACE))
|
||||||
|
->filter(static fn (string $path): bool => is_file($path) && str_contains(file_get_contents($path) ?: '', 'tenant_configuration.capture'));
|
||||||
|
|
||||||
|
expect($changedUiFiles->all())->toBe([]);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'policy_record_missing',
|
||||||
|
'foundation_not_policy_backed',
|
||||||
|
'meta_fallback',
|
||||||
|
'ambiguous_match',
|
||||||
|
'raw_gap_count',
|
||||||
|
'primary_gap_count',
|
||||||
|
] as $legacyOutcome) {
|
||||||
|
expect(CaptureOutcome::values())->not->toContain($legacyOutcome);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,291 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\TenantConfiguration\CaptureTenantConfigurationEvidenceJob;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\Services\TenantConfiguration\StartTenantConfigurationCapture;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use App\Support\TenantConfiguration\IdentityState;
|
||||||
|
use App\Support\TenantConfiguration\SourceClass;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
it('allows provider connections in the same managed environment and workspace', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = app(StartTenantConfigurationCapture::class)->start($tenant, $connection, $user, [
|
||||||
|
'deviceAndAppManagementAssignmentFilter',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect((int) data_get($run->context, 'target_scope.provider_connection_id'))->toBe((int) $connection->getKey());
|
||||||
|
|
||||||
|
Queue::assertPushed(CaptureTenantConfigurationEvidenceJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects provider connections from another managed environment or workspace', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
[, $otherTenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$otherConnection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $otherTenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $otherTenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => app(StartTenantConfigurationCapture::class)->start($tenant, $otherConnection, $user))
|
||||||
|
->toThrow(NotFoundHttpException::class);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces provider connection scope at the PostgreSQL constraint layer', function (): void {
|
||||||
|
if (DB::getDriverName() !== 'pgsql') {
|
||||||
|
test()->markTestSkipped('PostgreSQL composite foreign key constraint coverage.');
|
||||||
|
}
|
||||||
|
|
||||||
|
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
[, $otherTenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$resourceType = spec415CaptureResourceType();
|
||||||
|
|
||||||
|
$foreignConnection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $otherTenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $otherTenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => TenantConfigurationResource::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $foreignConnection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'source_class' => SourceClass::Tcm->value,
|
||||||
|
'canonical_type' => (string) $resourceType->canonical_type,
|
||||||
|
'canonical_resource_key' => 'deviceAndAppManagementAssignmentFilter:foreign-provider',
|
||||||
|
'source_resource_id' => 'foreign-provider',
|
||||||
|
'source_metadata' => ['fixture' => 'provider-scope'],
|
||||||
|
'latest_evidence_state' => EvidenceState::NotCaptured->value,
|
||||||
|
'latest_identity_state' => IdentityState::Stable->value,
|
||||||
|
'latest_claim_state' => ClaimState::InternalOnly->value,
|
||||||
|
]))->toThrow(QueryException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces provider connection workspace/environment binding at the PostgreSQL constraint layer', function (): void {
|
||||||
|
if (DB::getDriverName() !== 'pgsql') {
|
||||||
|
test()->markTestSkipped('PostgreSQL composite foreign key constraint coverage.');
|
||||||
|
}
|
||||||
|
|
||||||
|
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$foreignWorkspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
expect(fn () => ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $foreignWorkspace->getKey(),
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]))->toThrow(QueryException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces evidence resource/provider scope at the PostgreSQL constraint layer', function (): void {
|
||||||
|
if (DB::getDriverName() !== 'pgsql') {
|
||||||
|
test()->markTestSkipped('PostgreSQL composite foreign key constraint coverage.');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$resourceType = spec415CaptureResourceType();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
$otherScopedConnection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
$resource = spec415CaptureResource($tenant, $connection, $resourceType, 'provider-mismatch');
|
||||||
|
$run = spec415CaptureRun($tenant, $user);
|
||||||
|
|
||||||
|
expect(fn () => DB::table('tenant_configuration_resource_evidence')->insert(
|
||||||
|
spec415EvidenceRow($tenant, $resource, $otherScopedConnection, $resourceType, $run),
|
||||||
|
))->toThrow(QueryException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces latest evidence pointer scope at the PostgreSQL constraint layer', function (): void {
|
||||||
|
if (DB::getDriverName() !== 'pgsql') {
|
||||||
|
test()->markTestSkipped('PostgreSQL composite foreign key constraint coverage.');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$resourceType = spec415CaptureResourceType();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
$sourceResource = spec415CaptureResource($tenant, $connection, $resourceType, 'latest-source');
|
||||||
|
$targetResource = spec415CaptureResource($tenant, $connection, $resourceType, 'latest-target');
|
||||||
|
$run = spec415CaptureRun($tenant, $user);
|
||||||
|
|
||||||
|
$sourceEvidence = TenantConfigurationResourceEvidence::factory()->create([
|
||||||
|
'resource_id' => (int) $sourceResource->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => $targetResource->forceFill([
|
||||||
|
'latest_evidence_id' => (int) $sourceEvidence->getKey(),
|
||||||
|
])->save())->toThrow(QueryException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows resource deletion after latest evidence is linked at the PostgreSQL constraint layer', function (): void {
|
||||||
|
if (DB::getDriverName() !== 'pgsql') {
|
||||||
|
test()->markTestSkipped('PostgreSQL composite foreign key lifecycle coverage.');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$resourceType = spec415CaptureResourceType();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
$resource = spec415CaptureResource($tenant, $connection, $resourceType, 'latest-delete');
|
||||||
|
$run = spec415CaptureRun($tenant, $user);
|
||||||
|
|
||||||
|
$evidence = TenantConfigurationResourceEvidence::factory()->create([
|
||||||
|
'resource_id' => (int) $resource->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resource->forceFill([
|
||||||
|
'latest_evidence_id' => (int) $evidence->getKey(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$resource->delete();
|
||||||
|
|
||||||
|
expect(TenantConfigurationResource::query()->whereKey($resource->getKey())->exists())->toBeFalse()
|
||||||
|
->and(TenantConfigurationResourceEvidence::query()->whereKey($evidence->getKey())->exists())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces evidence operation run scope at the PostgreSQL constraint layer', function (): void {
|
||||||
|
if (DB::getDriverName() !== 'pgsql') {
|
||||||
|
test()->markTestSkipped('PostgreSQL composite foreign key constraint coverage.');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
[, $otherTenant] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$resourceType = spec415CaptureResourceType();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
$resource = spec415CaptureResource($tenant, $connection, $resourceType, 'operation-mismatch');
|
||||||
|
$foreignRun = OperationRun::factory()->forTenant($otherTenant)->create([
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => DB::table('tenant_configuration_resource_evidence')->insert(
|
||||||
|
spec415EvidenceRow($tenant, $resource, $connection, $resourceType, $foreignRun),
|
||||||
|
))->toThrow(QueryException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
function spec415CaptureResourceType(): TenantConfigurationResourceType
|
||||||
|
{
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
|
||||||
|
return TenantConfigurationResourceType::query()
|
||||||
|
->where('canonical_type', 'deviceAndAppManagementAssignmentFilter')
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec415CaptureResource(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
TenantConfigurationResourceType $resourceType,
|
||||||
|
string $suffix,
|
||||||
|
): TenantConfigurationResource {
|
||||||
|
return TenantConfigurationResource::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'source_class' => SourceClass::Tcm->value,
|
||||||
|
'canonical_type' => (string) $resourceType->canonical_type,
|
||||||
|
'canonical_resource_key' => 'deviceAndAppManagementAssignmentFilter:'.$suffix,
|
||||||
|
'source_resource_id' => $suffix,
|
||||||
|
'source_metadata' => ['fixture' => 'resource-scope'],
|
||||||
|
'latest_evidence_state' => EvidenceState::NotCaptured->value,
|
||||||
|
'latest_identity_state' => IdentityState::Stable->value,
|
||||||
|
'latest_claim_state' => ClaimState::InternalOnly->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec415CaptureRun(ManagedEnvironment $tenant, User $user): OperationRun
|
||||||
|
{
|
||||||
|
return OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
|
||||||
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec415EvidenceRow(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
TenantConfigurationResource $resource,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
TenantConfigurationResourceType $resourceType,
|
||||||
|
OperationRun $run,
|
||||||
|
): array {
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'resource_id' => (int) $resource->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'resource_type_id' => (int) $resourceType->getKey(),
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'source_contract_key' => 'assignmentFilter',
|
||||||
|
'source_endpoint' => '/deviceManagement/assignmentFilters',
|
||||||
|
'source_version' => 'v1.0',
|
||||||
|
'source_metadata' => json_encode(['fixture' => 'evidence-scope'], JSON_THROW_ON_ERROR),
|
||||||
|
'raw_payload' => json_encode(['id' => 'assignment-filter-'.$resource->getKey()], JSON_THROW_ON_ERROR),
|
||||||
|
'normalized_payload' => json_encode(['id' => 'assignment-filter-'.$resource->getKey()], JSON_THROW_ON_ERROR),
|
||||||
|
'payload_hash' => hash('sha256', 'assignment-filter-'.$resource->getKey()),
|
||||||
|
'permission_context' => json_encode(['scopes_granted' => []], JSON_THROW_ON_ERROR),
|
||||||
|
'evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
|
'coverage_level' => 'content_backed',
|
||||||
|
'capture_outcome' => 'captured',
|
||||||
|
'captured_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Services\Operations\QueuedExecutionLegitimacyGate;
|
use App\Services\Operations\QueuedExecutionLegitimacyGate;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -99,6 +100,43 @@
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('denies provider prerequisites when the provider connection workspace does not match the run workspace', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$foreignWorkspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $foreignWorkspace->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->managed_environment_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'context' => [
|
||||||
|
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
|
||||||
|
|
||||||
|
expect($decision->allowed)->toBeFalse()
|
||||||
|
->and($decision->reasonCode)->toBe(ExecutionDenialReasonCode::ProviderConnectionInvalid)
|
||||||
|
->and($decision->checks)->toMatchArray([
|
||||||
|
'workspace_scope' => 'passed',
|
||||||
|
'tenant_scope' => 'passed',
|
||||||
|
'capability' => 'passed',
|
||||||
|
'tenant_operability' => 'passed',
|
||||||
|
'execution_prerequisites' => 'failed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('allows workspace-scoped onboarding bootstrap capabilities during queued reauthorization', function (): void {
|
it('allows workspace-scoped onboarding bootstrap capabilities during queued reauthorization', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\CoverageCaptureOutcomeSummarizer;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
|
||||||
|
it('summarizes capture outcomes with existing canonical operation summary keys', function (): void {
|
||||||
|
$result = (new CoverageCaptureOutcomeSummarizer)->summarize([
|
||||||
|
['canonical_type' => 'deviceAndAppManagementAssignmentFilter', 'outcome' => 'captured', 'item_count' => 1],
|
||||||
|
['canonical_type' => 'roleScopeTag', 'outcome' => 'capture_blocked_beta', 'item_count' => 0],
|
||||||
|
['canonical_type' => 'notificationMessageTemplate', 'outcome' => 'capture_failed', 'reason_code' => 'graph_response_failed_503'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result['summary_counts'])->toBe([
|
||||||
|
'total' => 3,
|
||||||
|
'processed' => 3,
|
||||||
|
'succeeded' => 1,
|
||||||
|
'skipped' => 1,
|
||||||
|
'failed' => 1,
|
||||||
|
'errors_recorded' => 1,
|
||||||
|
])
|
||||||
|
->and($result['run_outcome'])->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||||
|
|
||||||
|
foreach (array_keys($result['summary_counts']) as $key) {
|
||||||
|
expect(OperationCatalog::allowedSummaryKeys())->toContain($key);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
|
||||||
|
it('allows only the Spec 415 capture outcomes and excludes legacy gap vocabulary', function (): void {
|
||||||
|
$allowed = CaptureOutcome::values();
|
||||||
|
|
||||||
|
expect($allowed)->toBe([
|
||||||
|
'captured',
|
||||||
|
'capture_blocked_missing_contract',
|
||||||
|
'capture_blocked_permission',
|
||||||
|
'capture_blocked_beta',
|
||||||
|
'capture_blocked_unsupported',
|
||||||
|
'capture_failed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'policy_record_missing',
|
||||||
|
'foundation_not_policy_backed',
|
||||||
|
'meta_fallback',
|
||||||
|
'ambiguous_match',
|
||||||
|
'raw_gap_count',
|
||||||
|
'primary_gap_count',
|
||||||
|
] as $legacyOutcome) {
|
||||||
|
expect($allowed)->not->toContain($legacyOutcome);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\CoveragePayloadRedactor;
|
||||||
|
|
||||||
|
it('redacts sensitive metadata keys recursively', function (): void {
|
||||||
|
$redacted = (new CoveragePayloadRedactor)->redact([
|
||||||
|
'Authorization' => 'Bearer top-secret',
|
||||||
|
'bearer' => 'top-secret',
|
||||||
|
'client_secret' => 'super-secret',
|
||||||
|
'cookie' => 'session=secret',
|
||||||
|
'set-cookie' => 'session=secret',
|
||||||
|
'nested' => [
|
||||||
|
'access_token' => 'token',
|
||||||
|
'id_token' => 'jwt',
|
||||||
|
'refresh_token' => 'refresh',
|
||||||
|
'safe' => 'value',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($redacted)->toBe([
|
||||||
|
'Authorization' => '[redacted]',
|
||||||
|
'bearer' => '[redacted]',
|
||||||
|
'client_secret' => '[redacted]',
|
||||||
|
'cookie' => '[redacted]',
|
||||||
|
'set-cookie' => '[redacted]',
|
||||||
|
'nested' => [
|
||||||
|
'access_token' => '[redacted]',
|
||||||
|
'id_token' => '[redacted]',
|
||||||
|
'refresh_token' => '[redacted]',
|
||||||
|
'safe' => 'value',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
|
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use App\Support\TenantConfiguration\IdentityState;
|
||||||
|
use App\Support\TenantConfiguration\ResourceClass;
|
||||||
|
use App\Support\TenantConfiguration\RestoreTier;
|
||||||
|
use App\Support\TenantConfiguration\SourceClass;
|
||||||
|
use App\Support\TenantConfiguration\SupportState;
|
||||||
|
use App\Support\TenantConfiguration\Workload;
|
||||||
|
|
||||||
|
it('resolves only explicitly mapped graph contracts for capture', function (): void {
|
||||||
|
$resolver = new CoverageSourceContractResolver(new GraphContractRegistry);
|
||||||
|
|
||||||
|
$decision = $resolver->resolve(spec415ResourceType('deviceAndAppManagementAssignmentFilter'));
|
||||||
|
|
||||||
|
expect($decision->outcome)->toBe(CaptureOutcome::Captured)
|
||||||
|
->and($decision->contractKey)->toBe('assignmentFilter')
|
||||||
|
->and($decision->sourceEndpoint)->toBe('/deviceManagement/assignmentFilters')
|
||||||
|
->and($decision->sourceSchemaHash)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks beta-backed resource types unless explicitly enabled', function (): void {
|
||||||
|
$resolver = new CoverageSourceContractResolver(new GraphContractRegistry);
|
||||||
|
|
||||||
|
$decision = $resolver->resolve(spec415ResourceType(
|
||||||
|
canonicalType: 'roleScopeTag',
|
||||||
|
sourceClass: SourceClass::GraphBetaExperimental,
|
||||||
|
supportState: SupportState::Experimental,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect($decision->outcome)->toBe(CaptureOutcome::BlockedBeta)
|
||||||
|
->and($decision->reasonCode)->toBe('beta_capture_disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks resource types without explicit graph contract mappings', function (): void {
|
||||||
|
$resolver = new CoverageSourceContractResolver(new GraphContractRegistry);
|
||||||
|
|
||||||
|
$decision = $resolver->resolve(spec415ResourceType('appProtectionPolicyAndroid'));
|
||||||
|
|
||||||
|
expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract)
|
||||||
|
->and($decision->reasonCode)->toBe('missing_source_contract_mapping');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks unsupported and out-of-scope resource types', function (): void {
|
||||||
|
$resolver = new CoverageSourceContractResolver(new GraphContractRegistry);
|
||||||
|
|
||||||
|
$unsupported = $resolver->resolve(spec415ResourceType(
|
||||||
|
canonicalType: 'unsupportedResource',
|
||||||
|
supportState: SupportState::Unsupported,
|
||||||
|
));
|
||||||
|
$outOfScope = $resolver->resolve(spec415ResourceType(
|
||||||
|
canonicalType: 'outOfScopeResource',
|
||||||
|
supportState: SupportState::OutOfScope,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect($unsupported->outcome)->toBe(CaptureOutcome::BlockedUnsupported)
|
||||||
|
->and($outOfScope->outcome)->toBe(CaptureOutcome::BlockedUnsupported);
|
||||||
|
});
|
||||||
|
|
||||||
|
function spec415ResourceType(
|
||||||
|
string $canonicalType,
|
||||||
|
SourceClass $sourceClass = SourceClass::Tcm,
|
||||||
|
SupportState $supportState = SupportState::Supported,
|
||||||
|
): TenantConfigurationResourceType {
|
||||||
|
return new TenantConfigurationResourceType([
|
||||||
|
'canonical_type' => $canonicalType,
|
||||||
|
'display_name' => $canonicalType,
|
||||||
|
'source_class' => $sourceClass->value,
|
||||||
|
'workload' => Workload::Intune->value,
|
||||||
|
'resource_class' => ResourceClass::Configuration->value,
|
||||||
|
'support_state' => $supportState->value,
|
||||||
|
'default_coverage_level' => CoverageLevel::ContentBacked->value,
|
||||||
|
'default_evidence_state' => EvidenceState::ContentBacked->value,
|
||||||
|
'default_identity_state' => IdentityState::Stable->value,
|
||||||
|
'default_claim_state' => ClaimState::ClaimAllowed->value,
|
||||||
|
'restore_tier' => RestoreTier::PreviewOnly->value,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\GenericPayloadNormalizer;
|
||||||
|
|
||||||
|
it('normalizes payloads with stable object ordering and explicit volatile field removal', function (): void {
|
||||||
|
$normalizer = new GenericPayloadNormalizer;
|
||||||
|
|
||||||
|
$first = $normalizer->normalize([
|
||||||
|
'displayName' => 'Assignment filter',
|
||||||
|
'@odata.etag' => 'first',
|
||||||
|
'settings' => [
|
||||||
|
'zeta' => true,
|
||||||
|
'alpha' => 'value',
|
||||||
|
],
|
||||||
|
], ['@odata.etag']);
|
||||||
|
$second = $normalizer->normalize([
|
||||||
|
'settings' => [
|
||||||
|
'alpha' => 'value',
|
||||||
|
'zeta' => true,
|
||||||
|
],
|
||||||
|
'@odata.etag' => 'second',
|
||||||
|
'displayName' => 'Assignment filter',
|
||||||
|
], ['@odata.etag']);
|
||||||
|
|
||||||
|
expect($first)->toBe($second)
|
||||||
|
->and($normalizer->payloadHash($first))->toBe($normalizer->payloadHash($second))
|
||||||
|
->and($first)->not->toHaveKey('@odata.etag');
|
||||||
|
});
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
# Requirements Checklist: Spec 415 - Generic Content-Backed Capture
|
||||||
|
|
||||||
|
## Preparation Completeness
|
||||||
|
|
||||||
|
- [x] CHK001 `spec.md` exists and uses the active repository template sections.
|
||||||
|
- [x] CHK002 `plan.md` exists and identifies likely affected repo surfaces.
|
||||||
|
- [x] CHK003 `tasks.md` exists and is ordered, small, and verifiable.
|
||||||
|
- [x] CHK004 Spec 414 is treated as completed dependency context only.
|
||||||
|
- [x] CHK005 No application code was modified during preparation.
|
||||||
|
|
||||||
|
## Candidate Selection Gate
|
||||||
|
|
||||||
|
- [x] CHK010 The selected candidate was directly provided by the user.
|
||||||
|
- [x] CHK011 No existing `415-*` spec or branch was found before Spec Kit creation.
|
||||||
|
- [x] CHK012 Related Spec 414 is completed/validated and was excluded from modification.
|
||||||
|
- [x] CHK013 The active auto queue in `docs/product/spec-candidates.md` is empty, so the direct user-provided candidate is the safe source.
|
||||||
|
- [x] CHK014 Manual backlog alternatives were deferred because they require explicit product promotion.
|
||||||
|
- [x] CHK015 The candidate is scoped as a bounded internal runtime/evidence slice, not a broad activation/cutover.
|
||||||
|
- [x] CHK016 Candidate Selection Gate result: PASS.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- [x] CHK020 Scope is limited to generic content-backed Coverage v2 capture for the initial Spec 414 resource types.
|
||||||
|
- [x] CHK021 Coverage v2 remains inactive as customer/operator proof.
|
||||||
|
- [x] CHK022 Evidence Overview conversion is out of scope.
|
||||||
|
- [x] CHK023 Customer Review Workspace conversion is out of scope.
|
||||||
|
- [x] CHK024 Review Pack, Report, Restore Readiness, Baseline Compare, and operator surface conversion are out of scope.
|
||||||
|
- [x] CHK025 Full TCM catalog import, semantic compare, render, restore/apply, certification, and legacy removal are out of scope.
|
||||||
|
- [x] CHK026 Spec 416 Canonical Identity Engine and later activation/cutover specs are deferred.
|
||||||
|
|
||||||
|
## Ownership And Data Truth
|
||||||
|
|
||||||
|
- [x] CHK030 `workspace_id` and `managed_environment_id` are required for environment-owned resource/evidence records.
|
||||||
|
- [x] CHK031 `provider_connection_id` is required for provider-sourced records and must be same workspace/environment.
|
||||||
|
- [x] CHK032 `tenant_id` is forbidden as Coverage v2 ownership truth.
|
||||||
|
- [x] CHK033 Provider-native Microsoft tenant/directory/subscription/account IDs are metadata only.
|
||||||
|
- [x] CHK034 Concrete resources and append-only evidence are distinguished from OperationRun execution truth.
|
||||||
|
- [x] CHK035 Raw payload and normalized payload are evidence truth, not OperationRun context truth.
|
||||||
|
|
||||||
|
## Source Contract Safety
|
||||||
|
|
||||||
|
- [x] CHK040 Graph calls must use `GraphClientInterface`.
|
||||||
|
- [x] CHK041 Source contracts must come from the repo registry/config path.
|
||||||
|
- [x] CHK042 Missing contracts fail safe as `capture_blocked_missing_contract`.
|
||||||
|
- [x] CHK043 Beta experimental capture is blocked by default.
|
||||||
|
- [x] CHK044 Unsupported/out-of-scope types skip safely.
|
||||||
|
- [x] CHK045 Endpoint guessing and hardcoded quick endpoints are forbidden.
|
||||||
|
- [x] CHK046 Capture eligibility matrix is required in implementation report.
|
||||||
|
|
||||||
|
## Evidence And Redaction
|
||||||
|
|
||||||
|
- [x] CHK050 Raw payload is JSONB evidence storage only.
|
||||||
|
- [x] CHK051 Normalized payload is JSONB and hash input is deterministic.
|
||||||
|
- [x] CHK052 Evidence rows are append-only.
|
||||||
|
- [x] CHK053 Permission/source context is redacted.
|
||||||
|
- [x] CHK054 OperationRun context/messages, audit metadata, logs, and notifications must not contain raw payloads or secrets.
|
||||||
|
- [x] CHK055 Required redaction keys are listed.
|
||||||
|
|
||||||
|
## OperationRun
|
||||||
|
|
||||||
|
- [x] CHK060 Capture is OperationRun-backed.
|
||||||
|
- [x] CHK061 Remote/provider capture is queued/asynchronous.
|
||||||
|
- [x] CHK062 OperationRun status/outcome transitions are service-owned through `OperationRunService`.
|
||||||
|
- [x] CHK063 Summary counts use canonical numeric keys from `OperationSummaryKeys::all()`.
|
||||||
|
- [x] CHK064 Default summary keys avoid inventing `captured`/`blocked` counters.
|
||||||
|
- [x] CHK065 No local queued DB notification or terminal notification bypass is allowed.
|
||||||
|
|
||||||
|
## RBAC And Audit
|
||||||
|
|
||||||
|
- [x] CHK070 Non-member workspace access returns 404.
|
||||||
|
- [x] CHK071 Workspace member without managed-environment entitlement returns 404.
|
||||||
|
- [x] CHK072 Member without capture capability returns 403.
|
||||||
|
- [x] CHK073 Readonly cannot start capture.
|
||||||
|
- [x] CHK074 Default capability posture uses `Capabilities::EVIDENCE_MANAGE` unless implementation documents and tests a narrower existing capability.
|
||||||
|
- [x] CHK075 Start/completion/failure audit metadata is required and must be sanitized.
|
||||||
|
|
||||||
|
## No Legacy / No Dual Truth
|
||||||
|
|
||||||
|
- [x] CHK080 No v1-to-v2 adapter.
|
||||||
|
- [x] CHK081 No v1/v2 dual write.
|
||||||
|
- [x] CHK082 No fallback reader from old snapshots.
|
||||||
|
- [x] CHK083 No old snapshot promotion into v2 proof.
|
||||||
|
- [x] CHK084 No old gap taxonomy in v2 outcomes.
|
||||||
|
- [x] CHK085 No customer-facing dual truth.
|
||||||
|
- [x] CHK086 No completed historical spec rewrite.
|
||||||
|
|
||||||
|
## Product Surface
|
||||||
|
|
||||||
|
- [x] CHK090 UI Surface Impact is `No UI surface impact`.
|
||||||
|
- [x] CHK091 Product Surface Impact is `N/A - no rendered product surface changed`.
|
||||||
|
- [x] CHK092 Browser proof is `N/A - no rendered UI surface changed`.
|
||||||
|
- [x] CHK093 Human Product Sanity is N/A.
|
||||||
|
- [x] CHK094 Product Surface exceptions are none.
|
||||||
|
- [x] CHK095 Stop-and-amend rule exists for any UI file, route, navigation, download, report, or rendered surface change.
|
||||||
|
- [x] CHK096 Existing generic OperationRun/notification surfaces may show run records only through the shared lifecycle contract; no feature-local rendered UI or notification semantics are added.
|
||||||
|
|
||||||
|
## Tests And Validation
|
||||||
|
|
||||||
|
- [x] CHK100 Unit tests are required for resolver, normalizer, hash, redaction, outcomes, and summary key posture.
|
||||||
|
- [x] CHK101 Feature tests are required for persistence, OperationRun, RBAC, provider scope, fake Graph capture, and no-legacy/no-UI guards.
|
||||||
|
- [x] CHK102 PostgreSQL lane is required when JSONB/check constraints/composite FKs/partial indexes are added.
|
||||||
|
- [x] CHK103 Browser and heavy-governance lanes are not required unless scope changes.
|
||||||
|
- [x] CHK104 No real Graph/TCM calls are allowed in tests.
|
||||||
|
- [x] CHK105 Minimal validation commands are listed in `plan.md` and `tasks.md`.
|
||||||
|
|
||||||
|
## Spec Readiness Gate
|
||||||
|
|
||||||
|
- [x] CHK110 Problem statement, value, users, requirements, non-goals, acceptance criteria, assumptions, and risks are present.
|
||||||
|
- [x] CHK111 Plan identifies likely affected repo surfaces and does not contradict current architecture.
|
||||||
|
- [x] CHK112 Tasks are ordered, small, verifiable, and include tests/validation.
|
||||||
|
- [x] CHK113 RBAC, workspace/managed-environment isolation, auditability, OperationRun semantics, evidence/result truth, and UX/no-UI requirements are addressed.
|
||||||
|
- [x] CHK114 No open question blocks safe implementation.
|
||||||
|
- [x] CHK115 Required Product Surface and proportionality sections are complete.
|
||||||
|
- [x] CHK116 Spec Readiness Gate result: PASS.
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
# Implementation Report: Spec 415 - Generic Content-Backed Capture
|
||||||
|
|
||||||
|
## Preflight
|
||||||
|
|
||||||
|
- Branch: `415-generic-content-backed-capture`
|
||||||
|
- Baseline HEAD: `dfda397e feat: migrate tcm first coverage core cutover (#481)`
|
||||||
|
- Initial dirty state: `specs/415-generic-content-backed-capture/` untracked as the active spec artifact set.
|
||||||
|
- Spec 414 dependency: treated as completed/validated context only; no files under `specs/414-tcm-first-coverage-core-cutover/` were modified.
|
||||||
|
- Existing equivalent check: no existing `tenant_configuration_resources` or `tenant_configuration_resource_evidence` tables/models were present before implementation.
|
||||||
|
|
||||||
|
## Implementation Scope
|
||||||
|
|
||||||
|
- Added `tenant_configuration_resources` and `tenant_configuration_resource_evidence` persistence with `workspace_id`, `managed_environment_id`, `provider_connection_id`, and `resource_type_id`; no `tenant_id` fields.
|
||||||
|
- Added PostgreSQL composite scope constraints for managed environment/workspace, provider connection/workspace/environment, evidence/resource/provider/type, evidence/latest-resource/provider/type, and evidence/operation-run/workspace/environment integrity while keeping SQLite-compatible migrations for tests.
|
||||||
|
- Added a database-level `provider_connections(managed_environment_id, workspace_id)` binding so provider connections cannot be attached to a managed environment from another workspace.
|
||||||
|
- Added a scoped `latest_evidence_id` foreign key so a resource can only point at evidence for the same resource, workspace, managed environment, provider connection, and resource type.
|
||||||
|
- Added models and factories for concrete resources and append-only evidence.
|
||||||
|
- Added bounded capture outcomes only: `captured`, `capture_blocked_missing_contract`, `capture_blocked_permission`, `capture_blocked_beta`, `capture_blocked_unsupported`, `capture_failed`.
|
||||||
|
- Added contract resolution through `GraphContractRegistry` with explicit mappings only:
|
||||||
|
- `deviceAndAppManagementAssignmentFilter` -> `assignmentFilter` -> captured when Graph succeeds.
|
||||||
|
- `notificationMessageTemplate` -> `notificationMessageTemplate` -> captured when Graph succeeds.
|
||||||
|
- `roleScopeTag` -> beta-backed and blocked by default.
|
||||||
|
- Other initial TCM types remain `capture_blocked_missing_contract` until explicit contracts are introduced.
|
||||||
|
- Added deterministic normalization, configured volatile-field stripping, SHA-256 payload hashes, and recursive secret/token/header/cookie redaction for metadata/context.
|
||||||
|
- Added `tenant_configuration.capture` operation type/catalog label and a queued capture job.
|
||||||
|
- Added defense-in-depth scope validation before remote provider work: the job resolves provider connections only inside the OperationRun workspace/environment, and the capture service validates tenant/provider/run/context scope before calling the provider gateway.
|
||||||
|
|
||||||
|
## Capture Eligibility Matrix
|
||||||
|
|
||||||
|
| Canonical type | Source class | Source contract key | Default outcome |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `deviceAndAppManagementAssignmentFilter` | `tcm` | `assignmentFilter` | `captured` when Graph succeeds |
|
||||||
|
| `deviceEnrollmentLimitRestriction` | `tcm` | none | `capture_blocked_missing_contract` |
|
||||||
|
| `deviceEnrollmentPlatformRestriction` | `tcm` | none | `capture_blocked_missing_contract` |
|
||||||
|
| `deviceEnrollmentStatusPageWindows10` | `tcm` | none | `capture_blocked_missing_contract` |
|
||||||
|
| `appProtectionPolicyAndroid` | `tcm` | none | `capture_blocked_missing_contract` |
|
||||||
|
| `appProtectionPolicyiOS` | `tcm` | none | `capture_blocked_missing_contract` |
|
||||||
|
| `notificationMessageTemplate` | `graph_v1_fallback` | `notificationMessageTemplate` | `captured` when Graph succeeds |
|
||||||
|
| `roleScopeTag` | `graph_beta_experimental` | `roleScopeTag` | `capture_blocked_beta` unless beta capture is explicitly enabled |
|
||||||
|
|
||||||
|
## Operation And RBAC
|
||||||
|
|
||||||
|
- Start capability: existing `Capabilities::EVIDENCE_MANAGE`.
|
||||||
|
- Authorization behavior:
|
||||||
|
- non-member and excluded explicit environment entitlement -> 404.
|
||||||
|
- workspace member without capability / readonly -> 403.
|
||||||
|
- same workspace/environment/provider connection -> allowed.
|
||||||
|
- OperationRun behavior:
|
||||||
|
- creates/reuses `tenant_configuration.capture` queued runs from idempotent provider/resource-type inputs.
|
||||||
|
- remote/provider work is queued through the central OperationRun lifecycle.
|
||||||
|
- the central queued execution gate validates `provider_connection_id` against both the OperationRun workspace and managed environment before provider work is allowed.
|
||||||
|
- the capture job and capture service also fail closed before Graph access when the provider connection, managed environment, OperationRun columns, or OperationRun target scope do not match.
|
||||||
|
- summary keys are existing canonical keys only: `total`, `processed`, `succeeded`, `skipped`, `failed`, `errors_recorded`.
|
||||||
|
- raw payloads and Graph credential options are not written into OperationRun context or audit metadata.
|
||||||
|
- exception messages are reduced to bounded `RunFailureSanitizer` reason codes for resource outcomes; dispatch/job failure audit messages are sanitized before persistence.
|
||||||
|
- Audit action IDs used:
|
||||||
|
- `tenant_configuration.capture.started`
|
||||||
|
- `tenant_configuration.capture.completed`
|
||||||
|
- `tenant_configuration.capture.failed`
|
||||||
|
|
||||||
|
## Product Surface
|
||||||
|
|
||||||
|
- No rendered UI surface changed.
|
||||||
|
- No Filament resources, pages, widgets, relation managers, routes, panel providers, navigation entries, views, assets, review/report/evidence pages, or restore readiness surfaces were added or modified.
|
||||||
|
- Browser proof: `N/A - no rendered UI surface changed`.
|
||||||
|
- Human Product Sanity: `N/A - no product surface changed`.
|
||||||
|
- Visible complexity outcome: unchanged.
|
||||||
|
- Product Surface exceptions: none.
|
||||||
|
- No completed historical spec was rewritten or stripped of validation/task/review history.
|
||||||
|
|
||||||
|
## Filament V5 Contract
|
||||||
|
|
||||||
|
- Livewire v4.0+ compliance: no Livewire/Filament runtime UI code changed; application package baseline remains Livewire v4.
|
||||||
|
- Provider registration location: unchanged; Laravel 11+/12 panel provider registration remains `bootstrap/providers.php`.
|
||||||
|
- Global search: no globally searchable resource was added; `N/A`.
|
||||||
|
- Destructive/high-impact actions: no UI action added; queued capture start is backend service only and RBAC-gated.
|
||||||
|
- Asset strategy: no assets registered; no `filament:assets` requirement from this spec.
|
||||||
|
- Testing plan: Unit/Feature/PostgreSQL lanes only; no Livewire page/widget/relation-manager/action tests required.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- Persistence:
|
||||||
|
- `apps/platform/database/migrations/2026_06_25_000415_create_tenant_configuration_capture_tables.php`
|
||||||
|
- `apps/platform/app/Models/TenantConfigurationResource.php`
|
||||||
|
- `apps/platform/app/Models/TenantConfigurationResourceEvidence.php`
|
||||||
|
- `apps/platform/database/factories/TenantConfigurationResourceFactory.php`
|
||||||
|
- `apps/platform/database/factories/TenantConfigurationResourceEvidenceFactory.php`
|
||||||
|
- Capture support/services:
|
||||||
|
- `apps/platform/app/Support/TenantConfiguration/CaptureOutcome.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractDecision.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageCaptureOutcomeSummarizer.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/StartTenantConfigurationCapture.php`
|
||||||
|
- `apps/platform/app/Jobs/TenantConfiguration/CaptureTenantConfigurationEvidenceJob.php`
|
||||||
|
- Operation/audit registry:
|
||||||
|
- `apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php`
|
||||||
|
- `apps/platform/app/Support/OperationRunType.php`
|
||||||
|
- `apps/platform/app/Support/OperationCatalog.php`
|
||||||
|
- `apps/platform/app/Support/Audit/AuditActionId.php`
|
||||||
|
- Tests:
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageSourceContractResolverTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec415GenericPayloadNormalizerTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageRedactionTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOutcomeTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOperationRunSummaryTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageEvidencePersistenceTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec415ProviderConnectionScopeTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureAuthorizationTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureOperationRunTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec415GenericContentBackedCaptureTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec415NoLegacyNoUiActivationTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` -> pass.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration` -> 22 passed, 69 assertions.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php` -> 12 passed, 106 assertions.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec415GenericContentBackedCaptureTest.php` -> 4 passed, 50 assertions.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration` -> 24 passed, 7 skipped, 129 assertions.
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/TenantConfiguration` -> 31 passed, 141 assertions.
|
||||||
|
- PostgreSQL migration smoke on `tenantatlas_testing`: `migrate:fresh --force` -> pass; targeted rollback of `2026_06_25_000415_create_tenant_configuration_capture_tables.php` -> pass; final `migrate:fresh --force` -> pass.
|
||||||
|
- PostgreSQL latest-evidence lifecycle coverage: scoped pointer mismatch is rejected, same-scope pointer is accepted, and resource deletion cascades linked evidence without constraint failure.
|
||||||
|
- `git diff --check` -> pass.
|
||||||
|
- Additional untracked-file whitespace scan -> pass.
|
||||||
|
|
||||||
|
## Deployment Impact
|
||||||
|
|
||||||
|
- Migrations: yes, adds two new tenant configuration capture tables with PostgreSQL JSONB/check constraints and composite scope foreign keys; also adds a provider-connection environment/workspace binding constraint on the existing `provider_connections` table.
|
||||||
|
- Queue worker: yes, new queued capture job.
|
||||||
|
- Environment variables: no new variables.
|
||||||
|
- Scheduler: no change.
|
||||||
|
- Storage/volumes: no change.
|
||||||
|
- Assets: no change; `filament:assets` not required by this spec.
|
||||||
|
- Staging/production: run migrations before enabling any future caller; ensure queue workers are running; validate staging data has no existing provider-connection workspace/environment mismatches before production promotion.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
- Add explicit source contracts for the remaining TCM-backed resource types before claiming captured evidence for them.
|
||||||
|
- Add any UI start surface only through a future UI-affecting spec with Product Surface review.
|
||||||
281
specs/415-generic-content-backed-capture/plan.md
Normal file
281
specs/415-generic-content-backed-capture/plan.md
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
# Implementation Plan: Spec 415 - Generic Content-Backed Capture
|
||||||
|
|
||||||
|
**Branch**: `415-generic-content-backed-capture` | **Date**: 2026-06-25 | **Spec**: `specs/415-generic-content-backed-capture/spec.md`
|
||||||
|
**Input**: Feature specification from `/specs/415-generic-content-backed-capture/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Prepare Coverage v2 to store real content-backed evidence without activating it as customer/operator truth. The implementation should add concrete Coverage v2 resource and evidence persistence, resolve source contracts through the existing registry/Graph contract path, capture eligible payloads through `GraphClientInterface`, normalize/hash/redact payloads, and run remote capture through an authorized, queued, OperationRun-backed service.
|
||||||
|
|
||||||
|
The slice is intentionally backend/internal. No Filament page, route, navigation entry, customer output, review/report surface, restore readiness surface, or browser-visible v2 coverage claim is added.
|
||||||
|
|
||||||
|
## 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, Sail 1.52.0
|
||||||
|
**Storage**: PostgreSQL; JSONB for raw payload, normalized payload, permission/source metadata
|
||||||
|
**Testing**: Pest 4; unit, feature, and PostgreSQL lanes where database constraints/indexes require PostgreSQL proof
|
||||||
|
**Validation Lanes**: fast-feedback, confidence, pgsql; browser N/A unless UI scope is amended
|
||||||
|
**Target Platform**: Laravel monolith in `apps/platform`, Sail local, Dokploy container staging/production
|
||||||
|
**Project Type**: web application backend/runtime slice
|
||||||
|
**Performance Goals**: render-time remains DB-only/no Graph; remote capture queued; indexes limited to known query paths
|
||||||
|
**Constraints**: no UI activation, no direct Graph calls, no endpoint guessing, no `tenant_id`, no compatibility shim, no raw payload leakage, OperationRun lifecycle service-owned
|
||||||
|
**Scale/Scope**: initial Spec 414 resource types only; no full TCM catalog, compare/render/restore, or legacy removal
|
||||||
|
|
||||||
|
## Existing Repo Truth
|
||||||
|
|
||||||
|
- Spec 414 completed the inactive Coverage v2 kernel and contains implementation close-out evidence.
|
||||||
|
- Existing Coverage v2 kernel files include:
|
||||||
|
- `apps/platform/app/Models/TenantConfigurationResourceType.php`
|
||||||
|
- `apps/platform/app/Models/TenantConfigurationSupportedScope.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/SupportedScopeResolver.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php`
|
||||||
|
- `apps/platform/app/Support/TenantConfiguration/*`
|
||||||
|
- `apps/platform/database/migrations/2026_06_25_000414_create_tenant_configuration_kernel_tables.php`
|
||||||
|
- Spec 414 deferred `tenant_configuration_resources` and `tenant_configuration_resource_evidence`.
|
||||||
|
- `OperationRunType` does not yet contain `tenant_configuration.capture`.
|
||||||
|
- `OperationSummaryKeys::all()` does not currently contain `captured` or `blocked`; Spec 415 should use existing numeric keys unless it explicitly extends the canonical list with tests.
|
||||||
|
- `config/graph_contracts.php` contains contract entries relevant to `notificationMessageTemplate`, `roleScopeTag`, and `assignmentFilter`. TCM-aligned source eligibility must still be explicit; missing contracts must block capture.
|
||||||
|
- `Capabilities::EVIDENCE_MANAGE` exists and is granted to Manager/Owner but not Operator/Readonly. It is the default planned capability unless implementation finds a more specific existing capture capability.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: no operator-facing surface change.
|
||||||
|
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: N/A.
|
||||||
|
- **No-impact class, if applicable**: backend-only internal evidence capture. Existing generic Monitoring -> Operations and central DB-notification surfaces may show OperationRun records through the shared lifecycle contract; no feature-local UI, notification copy, links, or rendered controls are added.
|
||||||
|
- **Native vs custom classification summary**: N/A.
|
||||||
|
- **Shared-family relevance**: OperationRun lifecycle and Graph service boundary only.
|
||||||
|
- **State layers in scope**: none.
|
||||||
|
- **Audience modes in scope**: N/A.
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: raw payloads remain evidence-storage only; no default UI exposure.
|
||||||
|
- **Raw/support gating plan**: no rendered access path in this spec.
|
||||||
|
- **One-primary-action / duplicate-truth control**: no UI action introduced; no v2 customer/operator truth.
|
||||||
|
- **Handling modes by drift class or surface**: hard-stop if UI files/routes/navigation/customer output are touched without spec/plan/tasks amendment.
|
||||||
|
- **Repository-signal treatment**: review-mandatory for any UI file change, route addition, new Filament resource, or customer/report/review/evidence activation.
|
||||||
|
- **Special surface test profiles**: N/A.
|
||||||
|
- **Required tests or manual smoke**: functional-core, persistence, RBAC, OperationRun, no-UI static guards.
|
||||||
|
- **Exception path and spread control**: none.
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / N/A no rendered UI surface changed.
|
||||||
|
- **UI/Productization coverage decision**: No UI surface impact.
|
||||||
|
- **Coverage artifacts to update**: none.
|
||||||
|
- **No-impact rationale**: backend-only internal capture path; no reachable UI surface changed.
|
||||||
|
- **Navigation / Filament provider-panel handling**: no panel/provider change.
|
||||||
|
- **Screenshot or page-report need**: no.
|
||||||
|
|
||||||
|
## Product Surface Contract Plan
|
||||||
|
|
||||||
|
- **Product Surface Contract reference**: `docs/product/standards/product-surface-contract.md`.
|
||||||
|
- **No-legacy posture**: canonical v2 evidence path; no compatibility exception.
|
||||||
|
- **Page archetype and surface budget plan**: N/A - no rendered product surface changed.
|
||||||
|
- **Technical Annex and deep-link demotion plan**: no default product view exposes OperationRun, raw evidence IDs, source keys, payloads, fingerprints, or logs.
|
||||||
|
- **Canonical status vocabulary plan**: N/A - no product-facing status labels.
|
||||||
|
- **Product Surface exceptions**: none.
|
||||||
|
- **Browser verification plan**: `N/A - no rendered UI surface changed`; existing generic OperationRun surfaces are not customized by this spec.
|
||||||
|
- **Human Product Sanity plan**: N/A - no rendered product surface changed.
|
||||||
|
- **Visible complexity outcome target**: neutral for rendered UI.
|
||||||
|
- **Implementation report target**: `specs/415-generic-content-backed-capture/implementation-report.md`.
|
||||||
|
|
||||||
|
## Filament / Livewire / Deployment Posture
|
||||||
|
|
||||||
|
- **Livewire v4 compliance**: Livewire v4.1.4 confirmed; no Livewire UI code planned.
|
||||||
|
- **Panel provider registration location**: no panel change; Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||||
|
- **Global search posture**: no Filament Resource is added. If implementation accidentally adds a resource, stop and amend artifacts before continuing; resource must disable global search or provide safe View/Edit page and `$recordTitleAttribute`.
|
||||||
|
- **Destructive/high-impact action posture**: no rendered action. Capture start is high-impact remote/provider work and must authorize server-side, create/reuse OperationRun, audit safely, queue work, and avoid raw payloads.
|
||||||
|
- **Asset strategy**: no assets, no `filament:assets` deployment requirement from this spec.
|
||||||
|
- **Testing plan**: unit tests for resolver/normalizer/hash/redaction/outcomes; feature/pgsql tests for persistence, RBAC, provider scope, fake Graph, OperationRun, and no-legacy guards.
|
||||||
|
- **Deployment impact**: database migrations and queue workers expected; no env vars, scheduler, storage volume, routes, assets, or reverse proxy changes expected unless implementation discovers a repo-real need and amends artifacts.
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes.
|
||||||
|
- **Systems touched**: Coverage v2 kernel, OperationRun, Graph contracts/client, capability registry, audit recorder, queue/job infrastructure.
|
||||||
|
- **Shared abstractions reused**: `ResourceTypeRegistry`, `SupportedScopeResolver`, `ClaimGuard`, `GraphClientInterface`, `GraphContractRegistry`, `OperationRunService`, `OperationSummaryKeys`, `Capabilities`, `RoleCapabilityMap`.
|
||||||
|
- **New abstraction introduced? why?**: bounded capture-specific services are introduced because registry-only Coverage v2 cannot persist content evidence, normalize/hash payloads, or produce safe per-type outcomes.
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: existing kernel services classify resource types and claims but do not fetch, normalize, or persist payload evidence. Existing OperationRun and Graph seams are sufficient and must be reused.
|
||||||
|
- **Bounded deviation / spread control**: no new provider framework, no UI framework, no generic identity engine, no compare/render/restore pipeline.
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: backend lifecycle yes; rendered UX no.
|
||||||
|
- **Central contract reused**: OperationRun lifecycle via `OperationRunService`; if a start surface appears, central OperationRun Start UX Contract is mandatory and artifacts must be amended first.
|
||||||
|
- **Delegated UX behaviors**: no local toast/link/event planned; terminal notifications remain lifecycle-owned through the existing generic OperationRun path.
|
||||||
|
- **Surface-owned behavior kept local**: initiation inputs only in an internal service/action.
|
||||||
|
- **Queued DB-notification policy**: no queued DB notifications.
|
||||||
|
- **Terminal notification path**: central lifecycle mechanism.
|
||||||
|
- **Exception path**: none.
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes.
|
||||||
|
- **Provider-owned seams**: TCM source class, Graph v1 fallback source class, Graph beta experimental source class, provider-specific Graph contract metadata, permission/source context.
|
||||||
|
- **Platform-core seams**: concrete Coverage v2 resource/evidence ownership, capture outcome vocabulary, OperationRun execution truth, evidence payload truth.
|
||||||
|
- **Neutral platform terms / contracts preserved**: provider, source contract, operation, evidence, resource, managed environment, capture outcome.
|
||||||
|
- **Retained provider-specific semantics and why**: Spec 414 source classes remain because this is a TCM-first Microsoft coverage path.
|
||||||
|
- **Bounded extraction or follow-up path**: Spec 416 Canonical Identity Engine after payload-backed evidence exists.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
- Inventory-first / snapshots-second: PASS. This spec creates explicit evidence capture, not UI claim truth or snapshot replacement.
|
||||||
|
- Read/write separation: PASS with controls. Capture writes internal evidence and must be authorized, audited, queued, and OperationRun-backed.
|
||||||
|
- Single Graph contract path: PASS. Graph calls must go through `GraphClientInterface` and repo contract registry.
|
||||||
|
- Deterministic capabilities: PASS. Authorization uses canonical capability constants; default planned capability is `EVIDENCE_MANAGE`.
|
||||||
|
- RBAC-UX: PASS. Non-member/not entitled is 404; member missing capability is 403; server-side Gate/Policy required.
|
||||||
|
- Workspace isolation: PASS. `workspace_id` + `managed_environment_id` are required for environment-owned records.
|
||||||
|
- Tenant isolation: PASS in current terminology. No `tenant_id` ownership column is introduced.
|
||||||
|
- Provider boundary: PASS. Provider-native IDs stay metadata; provider connection must be same workspace/environment.
|
||||||
|
- OperationRun observability: PASS. Remote/provider capture uses OperationRun and queue.
|
||||||
|
- OperationRun lifecycle: PASS. Transitions must use `OperationRunService`.
|
||||||
|
- Summary counts: PASS with constraint. Use `OperationSummaryKeys::all()`; default existing keys avoid new summary-key family.
|
||||||
|
- Test governance: PASS. Unit/feature/pgsql lanes are named; browser/heavy-governance are N/A.
|
||||||
|
- Proportionality: PASS with justified complexity. New persistence and services are needed for audit/evidence/source-of-truth correctness.
|
||||||
|
- No premature abstraction: PASS with bounded exception. Capture services are specific to current Coverage v2 evidence needs and initial resource types.
|
||||||
|
- Persisted truth: PASS. Evidence rows are durable append-only proof with independent lifecycle.
|
||||||
|
- Behavioral state: PASS. Capture outcomes affect persistence, run summaries, retry/failure handling, and reviewer gates.
|
||||||
|
- UI semantics: PASS. No UI framework or rendered status taxonomy.
|
||||||
|
- Product Surface Contract: PASS. No rendered UI surface changed.
|
||||||
|
- LEAN-001: PASS. No aliases, dual writes, fallback readers, compatibility shims, or legacy fixtures.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Unit for pure capture helpers; Feature/PostgreSQL for persistence, RBAC, OperationRun, Graph fake, provider-scope constraints, no-legacy guards.
|
||||||
|
- **Affected validation lanes**: fast-feedback, confidence, pgsql.
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: no rendered UI exists; backend behavior and persistence are the risk.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/TenantConfiguration` when PostgreSQL-only constraints/indexes are added
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: managed-environment/provider-connection/Graph fake setup must remain explicit and local.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; any helper must be opt-in.
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none.
|
||||||
|
- **Surface-class relief / special coverage rule**: N/A no rendered UI.
|
||||||
|
- **Closing validation and reviewer handoff**: verify no UI activation, no real Graph calls, no `tenant_id`, no old v1 vocabulary, same-scope provider connection, sanitized contexts, OperationRunService lifecycle.
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected.
|
||||||
|
- **Review-stop questions**: lane fit, hidden Graph call risk, fixture breadth, OperationRun summary keys, provider-scope constraints.
|
||||||
|
- **Escalation path**: document-in-feature for local helper cost; follow-up-spec only for identity engine or UI activation.
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / no rendered UI.
|
||||||
|
- **Why no dedicated follow-up spec is needed**: the runtime capture foundation is the current feature; identity/UI/cutover are already deferred follow-ups.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/415-generic-content-backed-capture/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── tasks.md
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── implementation-report.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (likely affected in later implementation)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/app/Models/
|
||||||
|
├── TenantConfigurationResource.php
|
||||||
|
└── TenantConfigurationResourceEvidence.php
|
||||||
|
|
||||||
|
apps/platform/app/Services/TenantConfiguration/
|
||||||
|
├── CoverageSourceContractResolver.php
|
||||||
|
├── GenericPayloadNormalizer.php
|
||||||
|
├── CoverageResourceUpserter.php
|
||||||
|
├── CoverageEvidenceWriter.php
|
||||||
|
├── GenericContentEvidenceCaptureService.php
|
||||||
|
├── StartTenantConfigurationCapture.php
|
||||||
|
└── CoverageCaptureOutcomeSummarizer.php
|
||||||
|
|
||||||
|
apps/platform/app/Support/TenantConfiguration/
|
||||||
|
└── CaptureOutcome.php
|
||||||
|
|
||||||
|
apps/platform/app/Jobs/TenantConfiguration/
|
||||||
|
└── CaptureTenantConfigurationEvidenceJob.php
|
||||||
|
|
||||||
|
apps/platform/database/migrations/
|
||||||
|
└── *_create_tenant_configuration_capture_tables.php
|
||||||
|
|
||||||
|
apps/platform/database/factories/
|
||||||
|
├── TenantConfigurationResourceFactory.php
|
||||||
|
└── TenantConfigurationResourceEvidenceFactory.php
|
||||||
|
|
||||||
|
apps/platform/tests/Unit/Support/TenantConfiguration/
|
||||||
|
└── Spec415*Test.php
|
||||||
|
|
||||||
|
apps/platform/tests/Feature/TenantConfiguration/
|
||||||
|
└── Spec415*Test.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Use the existing `apps/platform` Laravel monolith. Keep capture domain code under the existing `Services/TenantConfiguration` and `Support/TenantConfiguration` namespaces. Add jobs under a tenant-configuration job namespace only if the repo does not already have a closer convention.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|---|---|---|
|
||||||
|
| New resource/evidence tables | Durable append-only v2 evidence needs independent lifecycle and auditability | v1 snapshots or metadata-only rows would create hidden dual truth |
|
||||||
|
| New capture services | Fetch/normalize/hash/redact/persist responsibilities must stay out of Filament, models, and jobs | Putting workflow in a job or model would make authorization/audit/Graph seams harder to test |
|
||||||
|
| New capture outcome family | Implementation must distinguish missing contract, permission, beta, unsupported, captured, and failed because each has different persistence/run behavior | Reusing old gap taxonomy is explicitly forbidden and misleading |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: future Coverage v2 operator/customer claims require concrete evidence proof; otherwise the product can overclaim coverage based on registry truth only.
|
||||||
|
- **Existing structure is insufficient because**: Spec 414 has registry/scope/claim guard only, while v1 runtime evidence cannot safely stand in for v2 proof.
|
||||||
|
- **Narrowest correct implementation**: initial 414 resource types, contract-driven eligibility, append-only evidence, generic normalization/hash, redaction, OperationRun-backed async execution, no UI.
|
||||||
|
- **Ownership cost created**: migrations/models/services/job/tests and ongoing care around redaction, queue behavior, and provider contract mapping.
|
||||||
|
- **Alternative intentionally rejected**: v1 snapshot promotion, metadata-only capture, UI activation, broad TCM catalog import, semantic compare/render/restore.
|
||||||
|
- **Release truth**: current-release foundation after completed Spec 414.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 0 - Preflight
|
||||||
|
|
||||||
|
- Confirm branch, HEAD, clean/dirty state.
|
||||||
|
- Confirm Spec 414 implementation report and completed tasks remain read-only context.
|
||||||
|
- Confirm no existing `tenant_configuration_resources` / `tenant_configuration_resource_evidence` equivalent exists.
|
||||||
|
- Confirm graph contract entries and 414 registry metadata for initial resource types.
|
||||||
|
|
||||||
|
### Phase 1 - Tests First
|
||||||
|
|
||||||
|
- Add unit tests for resolver, normalizer, hash, redaction, and capture outcome behavior.
|
||||||
|
- Add feature/PostgreSQL tests for persistence, JSONB, same-scope provider connection, RBAC, OperationRun, fake Graph, and no-legacy/no-UI guards.
|
||||||
|
|
||||||
|
### Phase 2 - Persistence
|
||||||
|
|
||||||
|
- Add concrete resource and evidence tables/models/factories if missing.
|
||||||
|
- Enforce `workspace_id`, `managed_environment_id`, same-scope `provider_connection_id`, no `tenant_id`, append-only evidence, and targeted indexes.
|
||||||
|
|
||||||
|
### Phase 3 - Source Resolution And Normalization
|
||||||
|
|
||||||
|
- Resolve source contracts from Coverage v2 registry and repo Graph contract registry.
|
||||||
|
- Block missing/beta/unsupported sources safely.
|
||||||
|
- Normalize payloads minimally, hash deterministically, and redact permission/source context.
|
||||||
|
|
||||||
|
### Phase 4 - OperationRun Start And Queue
|
||||||
|
|
||||||
|
- Add `tenant_configuration.capture` OperationRun type/catalog entry if required by repo conventions.
|
||||||
|
- Implement authorized internal start service/action.
|
||||||
|
- Queue remote capture job and keep lifecycle transitions in `OperationRunService`.
|
||||||
|
- Use existing `OperationSummaryKeys` unless a tested canonical extension is required.
|
||||||
|
|
||||||
|
### Phase 5 - Capture And Evidence Write
|
||||||
|
|
||||||
|
- Fetch via `GraphClientInterface` fakeable calls only where explicit contracts exist.
|
||||||
|
- Upsert concrete resource rows by deterministic identity.
|
||||||
|
- Write append-only evidence rows and per-type outcomes.
|
||||||
|
- Audit start/completion/failure safely through the existing `AuditRecorder` / `AuditEventBuilder` path with stable `tenant_configuration.capture.started`, `tenant_configuration.capture.completed`, and `tenant_configuration.capture.failed` action IDs.
|
||||||
|
|
||||||
|
### Phase 6 - Report And Validation
|
||||||
|
|
||||||
|
- Complete implementation report with eligibility matrix and safety proof.
|
||||||
|
- Run focused Pint/tests/pgsql lane as needed and `git diff --check`.
|
||||||
|
- Record no-browser, no-assets, no-global-search, no-Filament-provider-change, and deployment impact.
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
- Spec 414 kernel is missing or not accepted.
|
||||||
|
- Implementation needs customer/operator UI activation.
|
||||||
|
- Implementation needs v1 adapter, dual-write, fallback reader, old snapshot promotion, or old gap taxonomy.
|
||||||
|
- Implementation needs hardcoded Graph endpoints or direct HTTP/SDK calls outside `GraphClientInterface`.
|
||||||
|
- Implementation needs beta capture opt-in.
|
||||||
|
- Implementation needs broad identity engine, semantic compare, rendering, restore/apply, or full catalog import.
|
||||||
|
- Implementation changes rendered UI files, routes, navigation, reports, downloads, or evidence/review surfaces without amending spec/plan/tasks first.
|
||||||
369
specs/415-generic-content-backed-capture/spec.md
Normal file
369
specs/415-generic-content-backed-capture/spec.md
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
# Feature Specification: Spec 415 - Generic Content-Backed Capture
|
||||||
|
|
||||||
|
**Feature Branch**: `415-generic-content-backed-capture`
|
||||||
|
**Created**: 2026-06-25
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User-provided candidate "415 - Generic Content-Backed Capture" as the next bounded follow-up after Spec 414.
|
||||||
|
|
||||||
|
## Candidate Selection Summary
|
||||||
|
|
||||||
|
- **Selected candidate**: Spec 415 - Generic Content-Backed Capture.
|
||||||
|
- **Source**: Direct user-provided candidate in `/Users/ahmeddarrazi/.codex/attachments/b061549c-2e15-4bb3-b5ab-a2c3e23d0d55/pasted-text.txt`.
|
||||||
|
- **Why selected**: Spec 414 is implemented as an inactive Coverage v2 kernel and explicitly defers OperationRun-backed content-backed capture. This spec is the next narrow foundation slice: store real v2 payload evidence without activating v2 as customer-facing truth.
|
||||||
|
- **Roadmap relationship**: Supports evidence/coverage hardening, provider-boundary discipline, OperationRun observability, and future Coverage v2 activation. It does not create a new product surface.
|
||||||
|
- **Close alternatives deferred**: `docs/product/spec-candidates.md` currently has no safe automatic next-best-prep target. Manual backlog items require explicit product promotion. Spec 416 Canonical Identity Engine is deferred until v2 can persist payload-backed evidence.
|
||||||
|
- **Related completed-spec guardrail**: `specs/414-tcm-first-coverage-core-cutover/` is completed and validated. It is dependency context only and must not be rewritten. No existing `415-*` spec or branch was found before creation.
|
||||||
|
- **Smallest viable implementation slice**: Add internal, OperationRun-backed, contract-driven capture for the Spec 414 initial resource types, with concrete resource/evidence persistence, deterministic normalization/hashing, redaction, RBAC/scope guards, and no rendered UI activation.
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Coverage v2 can classify resource types after Spec 414, but it still cannot persist concrete, content-backed evidence for resources in a managed environment.
|
||||||
|
- **Today's failure**: Later activation would risk registry-only claims, metadata-only proof, hidden reliance on v1 snapshots, or future compare/render work without durable v2 payload truth.
|
||||||
|
- **User-visible improvement**: No immediate UI change. The trust improvement is that future customer/operator coverage claims can be grounded in append-only v2 evidence instead of registry assertions.
|
||||||
|
- **Smallest enterprise-capable version**: Persist concrete v2 resources and append-only evidence for the initial 414 resource types, capture only where explicit repo contracts exist, block unsafe/missing/beta contracts, and summarize execution through OperationRun.
|
||||||
|
- **Explicit non-goals**: No Coverage v2 UI activation, no Evidence Overview/Review Workspace/Review Pack/Report/Restore/Compare conversion, no v1-to-v2 adapter, no full TCM catalog import, no semantic diff, no rendering, no restore/apply, no browser-visible UI.
|
||||||
|
- **Permanent complexity imported**: New environment-owned resource/evidence tables and models, a bounded capture outcome family, capture/source/normalization/redaction services, an OperationRun type, job/start service, authorization tests, and focused guard tests.
|
||||||
|
- **Why now**: Spec 414 completed the inactive kernel and named generic content-backed capture as the next dependency. Payload evidence is a prerequisite for identity hardening, operator surface activation, compare/render packs, certification, and legacy removal.
|
||||||
|
- **Why not local**: Reusing v1 snapshots or adding a local metadata helper would keep dual truth and would not prove provider-connection ownership, payload redaction, Graph contract discipline, or OperationRun execution truth.
|
||||||
|
- **Approval class**: Core Enterprise.
|
||||||
|
- **Red flags triggered**: New persisted truth, new service abstractions, new outcome/status-like family, foundation terminology. Defense: the slice is bounded to real current dependency value, no UI activation, no compatibility layer, initial 414 resource types only, and security/audit/queue correctness justify the added structure.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve as a bounded preparation package.
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant / managed-environment operational evidence under workspace ownership.
|
||||||
|
- **Primary Routes**: N/A - no route or rendered UI surface is added or changed.
|
||||||
|
- **Data Ownership**: Environment-owned capture records use `workspace_id` + `managed_environment_id`; provider-sourced records include `provider_connection_id`. Provider-native tenant/directory/account IDs remain metadata only.
|
||||||
|
- **RBAC**: Workspace membership and managed-environment entitlement are required. Missing membership/entitlement returns 404. Established members without the capture capability return 403. Server-side Gate/Policy enforcement is required on the start service.
|
||||||
|
|
||||||
|
## No Legacy / No Backward Compatibility Constraint *(mandatory)*
|
||||||
|
|
||||||
|
TenantPilot is pre-production unless this spec explicitly records a compatibility exception.
|
||||||
|
|
||||||
|
- **Compatibility posture**: canonical v2 evidence path; no compatibility exception.
|
||||||
|
- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no.
|
||||||
|
- **Why clean replacement is safe now**: Coverage v2 is inactive and not customer-facing. No production data migration or external contract compatibility is required. Spec 415 must not read v1 snapshots as v2 proof, dual-write v1/v2 evidence, or translate old gap taxonomy.
|
||||||
|
|
||||||
|
## 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 *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
|
||||||
|
|
||||||
|
N/A - no reachable UI surface impact. This spec adds internal persistence, services, a queued job/start service, OperationRun tracking, authorization, and tests. It must not add Filament resources/pages, Blade views, routes, navigation, customer output, report output, or review/evidence UI activation.
|
||||||
|
|
||||||
|
Existing generic Monitoring -> Operations and central database-notification surfaces may show `OperationRun` records produced by this backend operation through the existing shared lifecycle contract. That is allowed only as existing technical/operational truth. This spec must not add feature-local notification copy, custom operation UI, custom run links, or new rendered capture controls without first amending `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
|
||||||
|
## Product Surface Impact *(mandatory for UI-affecting specs; otherwise write `N/A - no rendered product surface changed` plus rationale)*
|
||||||
|
|
||||||
|
Reference: `docs/product/standards/product-surface-contract.md`.
|
||||||
|
|
||||||
|
- **Product Surface Contract applies?**: no - no rendered product surface changes.
|
||||||
|
- **Page archetype**: N/A.
|
||||||
|
- **Primary user question**: N/A.
|
||||||
|
- **Primary action**: N/A.
|
||||||
|
- **Surface budget result**: N/A.
|
||||||
|
- **Technical Annex / deep-link demotion**: N/A - no default product view exposes OperationRun, evidence IDs, source keys, payloads, fingerprints, or logs.
|
||||||
|
- **Canonical status vocabulary**: N/A - no product-facing labels added.
|
||||||
|
- **Visible complexity impact**: neutral; no rendered UI surface changes.
|
||||||
|
- **Product Surface exceptions**: none.
|
||||||
|
|
||||||
|
## Browser Verification Plan *(mandatory)*
|
||||||
|
|
||||||
|
- **Browser proof required?**: no.
|
||||||
|
- **No-browser rationale**: `N/A - no rendered UI surface changed`.
|
||||||
|
- **Focused path when required**: N/A.
|
||||||
|
- **Primary interaction to execute**: N/A.
|
||||||
|
- **Console, Livewire, Filament, network, and 500-error checks**: N/A.
|
||||||
|
- **Full-suite failure triage**: N/A; no browser lane is expected unless implementation changes rendered UI, routes, downloads, reports, or reachable actions.
|
||||||
|
|
||||||
|
## Human Product Sanity Check *(mandatory)*
|
||||||
|
|
||||||
|
- **Required?**: no.
|
||||||
|
- **No-human-sanity rationale**: N/A - no product surface changed.
|
||||||
|
- **Reviewer questions**: N/A for rendered UI; reviewers should instead verify that v2 evidence remains inactive and hidden from customer/operator proof surfaces.
|
||||||
|
- **Planned result location**: implementation-report.
|
||||||
|
|
||||||
|
## Product Surface Merge Gate Checklist *(mandatory)*
|
||||||
|
|
||||||
|
- [x] No-legacy posture or approved exception recorded.
|
||||||
|
- [x] Product Surface Impact is completed or `N/A` is justified.
|
||||||
|
- [x] Browser proof is completed or `N/A - no rendered UI surface changed` is justified.
|
||||||
|
- [x] Human Product Sanity is completed or not applicable with rationale.
|
||||||
|
- [x] Product Surface exceptions are documented or `none`.
|
||||||
|
- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, visible complexity outcome, and no completed-spec rewrite assertion.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes.
|
||||||
|
- **Interaction class(es)**: OperationRun execution lifecycle, terminal notification lifecycle, audit logging, provider Graph contract boundary.
|
||||||
|
- **Systems touched**: `OperationRun`, `OperationRunService`, `OperationRunType`, `OperationCatalog` if required by current repo conventions, `OperationSummaryKeys`, `GraphClientInterface`, `GraphContractRegistry`, `AuditLog`/audit recorder path, RBAC capability checks.
|
||||||
|
- **Existing pattern(s) to extend**: OperationRun lifecycle service, Graph contract registry/client seam, canonical capability registry, existing role capability map, existing audit recorder pattern.
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: OperationRun start/lifecycle contract where any start feedback/link is introduced. No rendered start UX is planned, so no local toast/link composition is allowed.
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: Existing OperationRun and Graph seams are sufficient for execution truth and provider calls. New local UI or notification semantics are unnecessary.
|
||||||
|
- **Allowed deviation and why**: none.
|
||||||
|
- **Consistency impact**: capture must not invent local operation notifications, local run-link wording, or local summary counter keys.
|
||||||
|
- **Review focus**: verify OperationRun transitions use `OperationRunService`, terminal notification remains lifecycle-owned, Graph calls go through `GraphClientInterface`, and summary counts use allowed keys.
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes for backend execution lifecycle; no rendered start surface.
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: central OperationRun lifecycle and start contract. If any UI/start surface is unexpectedly added, implementation must stop and patch spec/plan/tasks before continuing.
|
||||||
|
- **Delegated start/completion UX behaviors**: no local UI behavior planned; terminal notification path remains central lifecycle mechanism through the existing generic OperationRun notification path only.
|
||||||
|
- **Local surface-owned behavior that remains**: initiation inputs only through an internal service/action; no Filament page/action, no toast, no local run link.
|
||||||
|
- **Queued DB-notification policy**: no queued DB notifications. Terminal notification behavior remains whatever `OperationRunService`/lifecycle emits for initiator-owned runs.
|
||||||
|
- **Terminal notification path**: central lifecycle mechanism / `OperationRunCompleted`.
|
||||||
|
- **Exception required?**: none.
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes.
|
||||||
|
- **Boundary classification**: mixed. Coverage resource/evidence ownership and outcome semantics are platform-core. Microsoft TCM/Graph source details are provider-owned source metadata.
|
||||||
|
- **Seams affected**: Coverage v2 resource type registry, Graph contract resolution, `GraphClientInterface`, provider connection provenance, source metadata, permission context redaction.
|
||||||
|
- **Neutral platform terms preserved or introduced**: provider, source contract, managed environment, evidence, resource, capture outcome, operation.
|
||||||
|
- **Provider-specific semantics retained and why**: `tcm`, `graph_v1_fallback`, and `graph_beta_experimental` remain from Spec 414 because this is a TCM-first Microsoft coverage path. They must not become generic platform ownership keys.
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: source-specific values stay in registry/source metadata and resolver decisions. Concrete resource/evidence ownership remains `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id`; provider-native tenant IDs remain metadata.
|
||||||
|
- **Follow-up path**: Spec 416 Canonical Identity Engine is deferred; no multi-provider framework is introduced here.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Coverage v2 internal capture persistence and service path | no | N/A | OperationRun and Graph service seams only | none | no | `N/A - backend/internal evidence capture only` |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
N/A - no operator-facing surface change.
|
||||||
|
|
||||||
|
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
N/A - no operator-facing surface change. Raw and normalized payloads are persisted only in internal evidence storage and must not be exposed through customer/operator default views in this spec.
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
N/A - no operator-facing surface change.
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
N/A - no operator-facing surface change.
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: yes. Append-only Coverage v2 evidence becomes durable internal evidence truth for captured resource payloads.
|
||||||
|
- **New persisted entity/table/artifact?**: yes. `tenant_configuration_resources` and `tenant_configuration_resource_evidence` or repo-equivalent names.
|
||||||
|
- **New abstraction?**: yes. Bounded source contract resolver, capture service, resource upserter, evidence writer, payload normalizer, and outcome summarizer.
|
||||||
|
- **New enum/state/reason family?**: yes. Capture outcomes: `captured`, `capture_blocked_missing_contract`, `capture_blocked_permission`, `capture_blocked_beta`, `capture_blocked_unsupported`, `capture_failed`.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no.
|
||||||
|
- **Current operator problem**: future operators must not receive false Coverage v2 claims or evidence-backed reports when no concrete payload evidence exists.
|
||||||
|
- **Existing structure is insufficient because**: Spec 414 registry/scope/claim guard has no concrete resource rows and no content payload persistence. v1 inventory/snapshot truth would create hidden dual truth and cannot enforce Coverage v2 source class, provider provenance, or claim safety.
|
||||||
|
- **Narrowest correct implementation**: concrete resources, append-only evidence, minimal generic normalization/hash, source-contract resolver, OperationRun-backed async capture, RBAC/scope guards, and tests for the initial 414 resource types only.
|
||||||
|
- **Ownership cost**: new migrations/models/services/jobs/tests and ongoing care for payload redaction, schema constraints, OperationRun keys, and provider contract mapping.
|
||||||
|
- **Alternative intentionally rejected**: using v1 snapshots as v2 evidence, storing metadata-only capture rows, or adding UI activation before evidence is durable. These alternatives would hide dual truth or overclaim coverage.
|
||||||
|
- **Release truth**: current-release foundation required by the completed 414 kernel and immediately following 416/417/418 lanes.
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, historical fixtures, v1 fallback readers, v1-to-v2 adapters, and compatibility-specific tests are out of scope.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Unit for source contract resolution, normalization, hashing, redaction, and outcomes. Feature/PostgreSQL for persistence, JSONB, provider-connection same-scope constraints, OperationRun, RBAC, fake GraphClientInterface capture, and no-legacy guards.
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence, and PostgreSQL lane where JSONB/check constraints/composite FKs/partial indexes are introduced. Browser lane is N/A unless UI changes are introduced by amendment.
|
||||||
|
- **Why this classification and these lanes are sufficient**: The change is backend/runtime and persistence-heavy. Unit tests prove deterministic pure behavior; feature/PostgreSQL tests prove ownership, authorization, persistence, and OperationRun behavior. Browser tests would not add proof without a rendered UI surface.
|
||||||
|
- **New or expanded test families**: `tests/Unit/Support/TenantConfiguration` and `tests/Feature/TenantConfiguration` may expand with Spec 415-specific files.
|
||||||
|
- **Fixture / helper cost impact**: managed-environment, workspace, provider-connection, user-role, and fake Graph setup must remain opt-in and local to Spec 415 tests.
|
||||||
|
- **Heavy-family visibility / justification**: none planned. Do not add heavy-governance or browser families for this slice.
|
||||||
|
- **Special surface test profile**: N/A - no rendered UI surface changed.
|
||||||
|
- **Standard-native relief or required special coverage**: N/A.
|
||||||
|
- **Reviewer handoff**: verify no real Graph/TCM calls, no UI activation, no v1 adapter, no `tenant_id`, same-scope provider connection, allowed summary keys, redaction, and OperationRunService lifecycle.
|
||||||
|
- **Budget / baseline / trend impact**: none expected; if new tests materially slow fast-feedback, document in implementation report.
|
||||||
|
- **Escalation needed**: document-in-feature if focused tests require a new helper; follow-up-spec only if identity matching or surface activation becomes necessary.
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / N/A no rendered UI surface changed.
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/TenantConfiguration` when migrations add JSONB/check constraints/composite FKs/partial unique indexes
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Persist Content-Backed Coverage v2 Evidence (Priority: P1)
|
||||||
|
|
||||||
|
As a platform engineer preparing Coverage v2 activation, I need concrete managed-environment resources and append-only evidence rows so future claims can be backed by durable payload truth.
|
||||||
|
|
||||||
|
**Independent Test**: Run the capture service against a fake GraphClientInterface response for an eligible resource type and assert concrete resource upsert plus append-only evidence persistence with raw payload, normalized payload, hash, and OperationRun link.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an eligible registered resource type with an explicit source contract, **When** capture runs for a managed environment and same-scope provider connection, **Then** a concrete Coverage v2 resource exists with deterministic identity fields.
|
||||||
|
2. **Given** a successful payload fetch, **When** evidence is written, **Then** a new append-only evidence row stores raw payload, normalized payload, payload hash, source metadata, permission context, captured timestamp, and OperationRun link.
|
||||||
|
3. **Given** the same normalized payload captured twice, **When** hashes are compared, **Then** the payload hash is deterministic and identical.
|
||||||
|
4. **Given** a changed normalized payload captured later, **When** evidence is written, **Then** a new evidence row is appended instead of mutating prior evidence.
|
||||||
|
|
||||||
|
### User Story 2 - Fail Safe On Missing, Beta, Unsupported, Or Unauthorized Capture (Priority: P1)
|
||||||
|
|
||||||
|
As a release reviewer, I need capture eligibility to fail safe so v2 does not guess endpoints, certify beta data, or emit old v1 gap reasons.
|
||||||
|
|
||||||
|
**Independent Test**: Resolve capture eligibility for one TCM-aligned type, one Graph v1 fallback type, and the beta experimental type; assert missing contracts, beta default, unsupported states, and permission denial produce structured v2 outcomes only.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a registered type without an explicit source contract, **When** capture eligibility is resolved, **Then** the outcome is `capture_blocked_missing_contract`.
|
||||||
|
2. **Given** a beta experimental resource type without explicit beta capture opt-in, **When** capture is requested, **Then** the outcome is `capture_blocked_beta` and no customer/certified claim is possible.
|
||||||
|
3. **Given** an unsupported or out-of-scope type, **When** capture is requested, **Then** it is skipped safely without endpoint guessing.
|
||||||
|
4. **Given** a missing provider permission, **When** capture fails due to authorization from the provider, **Then** the outcome is `capture_blocked_permission` with sanitized context only.
|
||||||
|
5. **Given** any blocked/failed capture, **When** outcomes are inspected, **Then** no old v1 gap vocabulary is emitted.
|
||||||
|
|
||||||
|
### User Story 3 - Run Capture As An Observable Authorized Operation (Priority: P1)
|
||||||
|
|
||||||
|
As an operator-authorized backend workflow, I need generic capture to be authorized, queued when remote work is involved, and observable through OperationRun without rendering a new UI.
|
||||||
|
|
||||||
|
**Independent Test**: Start capture through the internal service for authorized and unauthorized users; assert 404/403 semantics, OperationRun creation/dedupe, queued job dispatch, OperationRunService transitions, numeric-only summary counts, and audit entry.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a non-member user, **When** capture start is attempted, **Then** access is denied as not found (404).
|
||||||
|
2. **Given** a workspace member without managed-environment entitlement, **When** capture start is attempted, **Then** access is denied as not found (404).
|
||||||
|
3. **Given** a managed-environment member without the capture capability, **When** capture start is attempted, **Then** access is denied as forbidden (403).
|
||||||
|
4. **Given** an authorized actor, **When** capture starts, **Then** a `tenant_configuration.capture` OperationRun is created or reused and remote work is queued.
|
||||||
|
5. **Given** the capture job runs, **When** it updates execution state, **Then** status/outcome transitions go through `OperationRunService`.
|
||||||
|
6. **Given** capture outcomes are summarized, **When** `summary_counts` is saved, **Then** only canonical numeric keys are used. Default keys are `total`, `processed`, `succeeded`, `skipped`, `failed`, and `errors_recorded` unless the implementation explicitly extends `OperationSummaryKeys::all()` with tests and spec-aligned rationale.
|
||||||
|
|
||||||
|
### User Story 4 - Keep Coverage v2 Inactive And Non-Legacy (Priority: P1)
|
||||||
|
|
||||||
|
As a product/release reviewer, I need proof that content-backed v2 evidence does not become an active customer/operator truth or compatibility bridge in this slice.
|
||||||
|
|
||||||
|
**Independent Test**: Static/feature guards assert no Filament resources/pages/routes/views/navigation are added, no v1-to-v2 adapter or dual-write path appears, no v1 snapshots are read as v2 evidence, and no Coverage v2 table introduces `tenant_id`.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** v2 evidence rows exist, **When** current customer/operator surfaces render, **Then** they do not display v2 coverage claims from this spec.
|
||||||
|
2. **Given** existing v1 runtime remains, **When** capture implementation is inspected, **Then** there is no v1-to-v2 adapter, fallback reader, or dual-write path.
|
||||||
|
3. **Given** new migrations/models, **When** schema and code are inspected, **Then** no Coverage v2 ownership field uses `tenant_id`.
|
||||||
|
4. **Given** raw provider payloads exist in evidence storage, **When** logs, OperationRun context/messages, notifications, and audit metadata are inspected, **Then** raw payloads, tokens, secrets, and PII are absent.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-415-001**: The system MUST keep Coverage v2 inactive for customer-facing and operator-facing proof surfaces in this spec.
|
||||||
|
- **FR-415-002**: The system MUST create or reuse concrete Coverage v2 resource persistence for managed-environment resources, using `workspace_id`, `managed_environment_id`, and `provider_connection_id` for provider-sourced records.
|
||||||
|
- **FR-415-003**: The system MUST create append-only Coverage v2 evidence persistence linked to concrete resource, workspace, managed environment, provider connection, OperationRun, source metadata, raw payload, normalized payload, payload hash, permission context, and capture timestamp.
|
||||||
|
- **FR-415-004**: The system MUST use JSONB for raw payload, normalized payload, and permission/source metadata where persisted.
|
||||||
|
- **FR-415-005**: The system MUST compute deterministic payload hashes from normalized payloads.
|
||||||
|
- **FR-415-006**: The system MUST normalize payloads minimally: stable JSON key ordering, configured volatile-field handling only where explicit, source metadata separation, and no semantic compare/render/restore mapping.
|
||||||
|
- **FR-415-007**: The system MUST resolve capture source contracts from the Coverage v2 registry and the repo's Graph contract path; it MUST NOT guess endpoints.
|
||||||
|
- **FR-415-008**: All Microsoft Graph calls MUST go through `GraphClientInterface`; direct HTTP/SDK/quick endpoint calls outside the contract path are forbidden.
|
||||||
|
- **FR-415-009**: The initial resource scope is limited to the Spec 414 resource types: `deviceAndAppManagementAssignmentFilter`, `deviceEnrollmentLimitRestriction`, `deviceEnrollmentPlatformRestriction`, `deviceEnrollmentStatusPageWindows10`, `appProtectionPolicyAndroid`, `appProtectionPolicyiOS`, `notificationMessageTemplate`, and `roleScopeTag`.
|
||||||
|
- **FR-415-010**: The implementation MUST classify each initial resource type as `captured`, `capture_blocked_missing_contract`, `capture_blocked_permission`, `capture_blocked_beta`, `capture_blocked_unsupported`, or `capture_failed`.
|
||||||
|
- **FR-415-011**: The implementation MUST prove every initial resource type has an explicit capture decision: each TCM-aligned type is either captured through an explicit contract or blocked with a clear missing-contract reason, the Graph v1 fallback type is captured or blocked with a clear contract reason, and the beta experimental type is blocked by default.
|
||||||
|
- **FR-415-012**: Beta experimental capture MUST be blocked by default unless this spec is amended with explicit beta capture opt-in and safety tests.
|
||||||
|
- **FR-415-013**: Missing contracts MUST produce `capture_blocked_missing_contract` without failure escalation or endpoint guessing.
|
||||||
|
- **FR-415-014**: Unsupported/out-of-scope types MUST be skipped safely.
|
||||||
|
- **FR-415-015**: Provider connection provenance MUST be same-scope: any stored `provider_connection_id` must belong to the same `workspace_id` and `managed_environment_id` as the resource/evidence row.
|
||||||
|
- **FR-415-016**: External Microsoft tenant/directory/subscription/account IDs MUST be metadata only and MUST NOT become platform ownership keys.
|
||||||
|
- **FR-415-017**: Capture start MUST enforce workspace membership, managed-environment entitlement, and capture capability server-side through Gate/Policy or existing capability resolver.
|
||||||
|
- **FR-415-018**: The default capture capability is `Capabilities::EVIDENCE_MANAGE` unless implementation discovers a more specific existing repo-approved capture capability. Adding a new capability is allowed only with registry, role-map, and tests in this spec.
|
||||||
|
- **FR-415-019**: Capture MUST create or reuse a canonical `tenant_configuration.capture` OperationRun for remote/provider-backed work.
|
||||||
|
- **FR-415-020**: Remote/provider capture MUST execute asynchronously through a queued job.
|
||||||
|
- **FR-415-021**: OperationRun status and outcome transitions MUST go through `OperationRunService`.
|
||||||
|
- **FR-415-022**: OperationRun `summary_counts` MUST use flat numeric values and canonical keys from `OperationSummaryKeys::all()`. Default planned keys are `total`, `processed`, `succeeded`, `skipped`, `failed`, and `errors_recorded`.
|
||||||
|
- **FR-415-023**: OperationRun context/messages, notifications, audit metadata, and logs MUST NOT include raw payloads, tokens, secrets, or PII.
|
||||||
|
- **FR-415-024**: Permission context MUST be redacted before persistence.
|
||||||
|
- **FR-415-025**: Capture MUST emit safe audit evidence for start/completion/failure attempts through the repo's existing `AuditRecorder` / `AuditEventBuilder` path, using stable action IDs `tenant_configuration.capture.started`, `tenant_configuration.capture.completed`, and `tenant_configuration.capture.failed`, including actor, workspace, managed environment, provider connection, OperationRun, resource type counts, and no raw payloads.
|
||||||
|
- **FR-415-026**: No v1-to-v2 adapter, dual write, fallback reader, old snapshot promotion, legacy route, or old gap taxonomy may be introduced.
|
||||||
|
- **FR-415-027**: No Filament Resource/Page, Blade view, route, navigation entry, customer report/review/evidence surface, or browser-visible Coverage v2 activation may be added in this spec.
|
||||||
|
- **FR-415-028**: An implementation report MUST include the capture eligibility matrix for all initial resource types.
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
- **NFR-415-001**: Capture must fail safe under missing contracts, missing permissions, beta resources, provider errors, and cross-scope provider connections.
|
||||||
|
- **NFR-415-002**: Tests must fake all Graph/TCM/provider calls. No test may call real Microsoft Graph or TCM.
|
||||||
|
- **NFR-415-003**: Raw provider payloads may live only in evidence storage and must not leak into logs, audit metadata, OperationRun context/messages, or notifications.
|
||||||
|
- **NFR-415-004**: New indexes must follow proven query paths only. JSONB GIN indexes are allowed only when the implementation adds a real query path.
|
||||||
|
- **NFR-415-005**: The implementation must remain bounded to the initial Spec 414 resource set.
|
||||||
|
- **NFR-415-006**: The queue and deployment impact must be documented: migrations and queue workers are expected; no asset or browser deployment change is expected.
|
||||||
|
|
||||||
|
### Required Redaction Keys
|
||||||
|
|
||||||
|
At minimum, redaction must handle case-insensitive keys containing:
|
||||||
|
|
||||||
|
- `access_token`
|
||||||
|
- `refresh_token`
|
||||||
|
- `id_token`
|
||||||
|
- `client_secret`
|
||||||
|
- `secret`
|
||||||
|
- `password`
|
||||||
|
- `private_key`
|
||||||
|
- `certificate`
|
||||||
|
- `authorization`
|
||||||
|
- `cookie`
|
||||||
|
- `set-cookie`
|
||||||
|
- `bearer`
|
||||||
|
|
||||||
|
## Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Tenant Configuration Resource**: Concrete Coverage v2 resource observed in one workspace/managed environment/provider connection. It carries resource type, source class, canonical identity key, latest evidence state, and timestamps.
|
||||||
|
- **Tenant Configuration Resource Evidence**: Append-only payload evidence for a concrete Coverage v2 resource, linked to OperationRun and source metadata. It stores raw and normalized JSONB payloads, payload hash, redacted permission context, capture level/state, and captured timestamp.
|
||||||
|
- **Capture Outcome**: Non-UI service result classifying each resource type attempt as captured, blocked, skipped, or failed with a v2 reason.
|
||||||
|
- **OperationRun**: Execution truth for capture lifecycle, queue status, sanitized context, and numeric summary counts. It is not payload truth.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
- **SC-415-001**: Focused tests prove concrete v2 resources and append-only evidence can be persisted for an eligible fake source response.
|
||||||
|
- **SC-415-002**: Focused tests prove missing contract, beta default, unsupported, permission blocked, and failed capture outcomes are safe and use v2 vocabulary only.
|
||||||
|
- **SC-415-003**: Focused tests prove Graph calls use `GraphClientInterface` and no direct endpoint bypass exists in the capture path.
|
||||||
|
- **SC-415-004**: Focused tests prove same-scope `provider_connection_id` enforcement for resource/evidence writes.
|
||||||
|
- **SC-415-005**: Focused tests prove no `tenant_id` ownership field is introduced in new Coverage v2 tables/models/migrations.
|
||||||
|
- **SC-415-006**: Focused tests prove OperationRun creation/reuse, queued execution, service-owned transitions, numeric-only summary counts, and sanitized context.
|
||||||
|
- **SC-415-007**: Focused tests prove non-member 404, missing environment entitlement 404, missing capability 403, readonly denial, and authorized capture start.
|
||||||
|
- **SC-415-008**: Static/feature guards prove no v1 adapter, dual-write path, fallback reader, old snapshot promotion, old gap outcome vocabulary, or customer-facing v2 activation is introduced.
|
||||||
|
- **SC-415-009**: Implementation report includes the resource-type capture eligibility matrix and no-browser decision.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Spec 414 is implemented and accepted as the inactive Coverage v2 kernel. Repo artifacts show completed tasks, implementation report, tests, and kernel models/services/migration.
|
||||||
|
- `tenant_configuration_resources` and `tenant_configuration_resource_evidence` were deferred in Spec 414 and are expected to be added in Spec 415 unless implementation discovers equivalent repo-real tables.
|
||||||
|
- The local Boost database schema output may not reflect all migrations; implementation should use repo migrations/code as source of truth unless the Sail/PostgreSQL lane is explicitly run.
|
||||||
|
- Existing Graph contracts include some related Graph fallback/foundation types such as `notificationMessageTemplate`, `roleScopeTag`, and `assignmentFilter`, but exact source eligibility must be resolved by repo-real contract mapping rather than guessed endpoints.
|
||||||
|
- No rendered UI surface is required for capture start in this spec. Tests may start capture through an internal service/action.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
|---|---:|---|
|
||||||
|
| Capture becomes an activation/cutover spec | High | No UI, no customer proof, no v1 removal, no dual truth, stop-and-amend rule |
|
||||||
|
| Graph endpoints get guessed or hardcoded | High | Graph contract resolver + GraphClientInterface tests |
|
||||||
|
| Raw payloads/secrets leak | High | Redaction tests, OperationRun/audit/log context tests |
|
||||||
|
| Provider connection leaks across environments | High | Same-scope validation and tests |
|
||||||
|
| OperationRun lifecycle bypass | High | Use OperationRunService and existing guards |
|
||||||
|
| Summary counts drift into free-form keys | Medium | Use OperationSummaryKeys only; default existing keys |
|
||||||
|
| Capture tries to support too many types | Medium | Initial 414 resource types only; eligibility matrix |
|
||||||
|
| New tests become expensive | Medium | Unit/feature/pgsql only; fake Graph; no browser |
|
||||||
|
|
||||||
|
## Follow-up Spec Candidates
|
||||||
|
|
||||||
|
- Spec 416 - Canonical Identity Engine.
|
||||||
|
- Spec 417 - Coverage v2 Operator Surface.
|
||||||
|
- Spec 418 - Legacy Coverage Cutover and Removal.
|
||||||
|
- Spec 419 - Intune Core Comparable/Renderable Pack.
|
||||||
|
- Spec 420 - Certified Intune Core Coverage Pack.
|
||||||
|
- Spec 421 - Pilot Readiness Gate.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None blocking. Implementation must document any repo-real source contract mismatch in the eligibility matrix rather than guessing endpoints or broadening scope.
|
||||||
127
specs/415-generic-content-backed-capture/tasks.md
Normal file
127
specs/415-generic-content-backed-capture/tasks.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# Tasks: Spec 415 - Generic Content-Backed Capture
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/415-generic-content-backed-capture/`
|
||||||
|
**Prerequisites**: `spec.md`, `plan.md`, `checklists/requirements.md`, completed Spec 414 context
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
- [x] New or changed tests stay in Unit/Feature/PostgreSQL lanes; any heavy-governance or browser addition is explicit and requires spec amendment.
|
||||||
|
- [x] Shared helpers, factories, seeds, fixtures, provider setup, workspace context, membership context, and Graph fakes stay explicit and opt-in.
|
||||||
|
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||||
|
- [x] Browser proof is explicitly `N/A - no rendered UI surface changed`.
|
||||||
|
- [x] Human Product Sanity is `N/A - no product surface changed`.
|
||||||
|
- [x] Any material budget, baseline, trend, or escalation note is recorded in the implementation report.
|
||||||
|
|
||||||
|
## Phase 1: Preflight And Repo Verification
|
||||||
|
|
||||||
|
**Purpose**: Confirm the active repo truth before runtime implementation starts.
|
||||||
|
|
||||||
|
- [x] T001 Capture branch, HEAD, `git status --short`, and Spec 414 dependency status in `specs/415-generic-content-backed-capture/implementation-report.md`.
|
||||||
|
- [x] T002 Confirm `specs/414-tcm-first-coverage-core-cutover/` is completed/validated context only and do not modify any Spec 414 artifact.
|
||||||
|
- [x] T003 Inspect `apps/platform/app/Models/TenantConfigurationResourceType.php`, `apps/platform/app/Models/TenantConfigurationSupportedScope.php`, `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php`, `SupportedScopeResolver.php`, and `ClaimGuard.php` to map the existing kernel dependency surface.
|
||||||
|
- [x] T004 Inspect `apps/platform/database/migrations/2026_06_25_000414_create_tenant_configuration_kernel_tables.php` for the initial resource types, source classes, supported scopes, and no-`tenant_id` kernel posture.
|
||||||
|
- [x] T005 Confirm whether `tenant_configuration_resources` / `tenant_configuration_resource_evidence` or repo-equivalent models/tables already exist; if they do, document the exact equivalent and adjust implementation without duplicate tables.
|
||||||
|
- [x] T006 Inspect `apps/platform/config/graph_contracts.php` and `apps/platform/app/Services/Graph/GraphContractRegistry.php` for explicit contracts related to the initial 414 resource types.
|
||||||
|
- [x] T007 Confirm no rendered UI surface, route, navigation entry, Filament provider/panel, review/report/evidence output, or customer-visible v2 activation is required. If it is required, stop and patch `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
|
||||||
|
## Phase 2: Tests First - Pure Behavior
|
||||||
|
|
||||||
|
**Purpose**: Prove source resolution, normalization, hashing, redaction, and outcomes before implementation.
|
||||||
|
|
||||||
|
- [x] T008 [P] Add resolver unit tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageSourceContractResolverTest.php` covering explicit contract, missing contract, beta blocked by default, unsupported/out-of-scope skip, and no endpoint guessing.
|
||||||
|
- [x] T009 [P] Add normalizer/hash unit tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec415GenericPayloadNormalizerTest.php` covering stable key ordering, configured volatile-field handling, metadata separation, and deterministic hash.
|
||||||
|
- [x] T010 [P] Add redaction unit tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageRedactionTest.php` covering token/secret-like keys and sanitized permission/source context.
|
||||||
|
- [x] T011 [P] Add outcome unit tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOutcomeTest.php` covering allowed v2 outcomes and absence of old v1 gap vocabulary.
|
||||||
|
- [x] T012 [P] Add OperationRun summary unit/guard coverage ensuring Spec 415 uses existing `OperationSummaryKeys` keys unless a tested canonical key-list extension is explicitly implemented.
|
||||||
|
|
||||||
|
## Phase 3: Tests First - Runtime And Persistence
|
||||||
|
|
||||||
|
**Purpose**: Prove the end-to-end safety contract with fake provider calls.
|
||||||
|
|
||||||
|
- [x] T013 [P] Add persistence feature tests in `apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageEvidencePersistenceTest.php` for concrete resource upsert, append-only evidence writes, JSONB raw/normalized payloads, payload hash, source metadata, and OperationRun link.
|
||||||
|
- [x] T014 [P] Add provider connection scope tests in `apps/platform/tests/Feature/TenantConfiguration/Spec415ProviderConnectionScopeTest.php` proving same workspace/environment allowed and cross-workspace or cross-environment provider connections rejected.
|
||||||
|
- [x] T015 [P] Add authorization tests in `apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureAuthorizationTest.php` proving non-member 404, missing environment entitlement 404, missing capability 403, readonly denial, and authorized start.
|
||||||
|
- [x] T016 [P] Add OperationRun tests in `apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureOperationRunTest.php` proving `tenant_configuration.capture` run creation/reuse, queued job dispatch, service-owned transitions, sanitized context, and numeric summary counts.
|
||||||
|
- [x] T017 [P] Add fake Graph capture tests in `apps/platform/tests/Feature/TenantConfiguration/Spec415GenericContentBackedCaptureTest.php` proving `GraphClientInterface` is used and real Graph/TCM is never called.
|
||||||
|
- [x] T018 [P] Add no-legacy/no-UI guard tests in `apps/platform/tests/Feature/TenantConfiguration/Spec415NoLegacyNoUiActivationTest.php` proving no `tenant_id` ownership field, no v1 adapter/dual-write/fallback reader/old snapshot promotion, no old gap outcomes, and no Filament resource/page/route/navigation activation.
|
||||||
|
|
||||||
|
## Phase 4: Persistence Implementation
|
||||||
|
|
||||||
|
**Purpose**: Add durable v2 resource/evidence truth only if missing.
|
||||||
|
|
||||||
|
- [x] T019 Add migration under `apps/platform/database/migrations/` for `tenant_configuration_resources` if no equivalent exists, with `workspace_id`, `managed_environment_id`, `provider_connection_id`, `resource_type_id`, source class, canonical identity fields, latest coverage/evidence/identity/claim state, timestamps, and no `tenant_id`.
|
||||||
|
- [x] T020 Add migration under `apps/platform/database/migrations/` for `tenant_configuration_resource_evidence` if no equivalent exists, with resource/workspace/environment/provider/run links, source endpoint/version/schema metadata, JSONB raw payload, JSONB normalized payload, payload hash, redacted permission context, coverage/evidence state, captured timestamp, and no `tenant_id`.
|
||||||
|
- [x] T021 Add same-scope provider-connection enforcement through database constraints where practical and service validation where cross-table constraints cannot safely express the rule.
|
||||||
|
- [x] T022 Add targeted indexes only for known query paths: ownership lookup, resource latest evidence lookup, captured timestamp lookup, and payload hash lookup. Do not add broad JSONB GIN indexes unless a real query path exists.
|
||||||
|
- [x] T023 Add models `apps/platform/app/Models/TenantConfigurationResource.php` and `apps/platform/app/Models/TenantConfigurationResourceEvidence.php` with casts, relationships, guarded/fillable convention matching sibling models, and no Filament Resource.
|
||||||
|
- [x] T024 Add factories under `apps/platform/database/factories/` for new models with explicit workspace/managed-environment/provider-connection setup only.
|
||||||
|
|
||||||
|
## Phase 5: Source Contracts, Normalization, Redaction, Outcomes
|
||||||
|
|
||||||
|
**Purpose**: Implement bounded capture mechanics without provider endpoint guessing.
|
||||||
|
|
||||||
|
- [x] T025 Add `apps/platform/app/Support/TenantConfiguration/CaptureOutcome.php` or repo-equivalent bounded result type with only `captured`, `capture_blocked_missing_contract`, `capture_blocked_permission`, `capture_blocked_beta`, `capture_blocked_unsupported`, and `capture_failed`.
|
||||||
|
- [x] T026 Add `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` to resolve capture contracts from Coverage v2 resource types and `GraphContractRegistry`/`config/graph_contracts.php`, return an explicit contract-or-block decision for each of the 8 initial Spec 414 resource types, block beta by default, block missing contracts, and expose source metadata without hardcoding endpoints.
|
||||||
|
- [x] T027 Add `apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php` for deterministic generic normalization and hash input creation without semantic compare/render/restore mapping.
|
||||||
|
- [x] T028 Add redaction handling in a focused TenantConfiguration helper or reuse an existing repo sanitizer if present; cover required secret/token keys and sanitized exception context.
|
||||||
|
- [x] T029 Add `apps/platform/app/Services/TenantConfiguration/CoverageCaptureOutcomeSummarizer.php` to map outcomes to canonical OperationRun summary keys (`total`, `processed`, `succeeded`, `skipped`, `failed`, `errors_recorded`) unless a tested `OperationSummaryKeys` extension is explicitly justified.
|
||||||
|
|
||||||
|
## Phase 6: Start Service, Authorization, Queue, OperationRun
|
||||||
|
|
||||||
|
**Purpose**: Make capture observable and authorized without rendered UI.
|
||||||
|
|
||||||
|
- [x] T030 Add `tenant_configuration.capture` to `apps/platform/app/Support/OperationRunType.php` and any current repo operation catalog/config path required for operation labels/capabilities.
|
||||||
|
- [x] T031 Implement `apps/platform/app/Services/TenantConfiguration/StartTenantConfigurationCapture.php` or repo-equivalent action service that authorizes actor/scope, validates provider connection scope, creates/reuses OperationRun, dispatches the capture job, and writes safe audit metadata.
|
||||||
|
- [x] T032 Use `Capabilities::EVIDENCE_MANAGE` as the default start capability; if implementation adds a more specific capability, update `apps/platform/app/Support/Auth/Capabilities.php`, `apps/platform/app/Services/Auth/RoleCapabilityMap.php`, and related capability tests in this spec.
|
||||||
|
- [x] T033 Add `apps/platform/app/Jobs/TenantConfiguration/CaptureTenantConfigurationEvidenceJob.php` or repo-equivalent queued job that loads the OperationRun, marks it running and then terminal `completed` with the correct `OperationRunOutcome` / failure summary through `OperationRunService`, and never persists raw payloads in job-visible context.
|
||||||
|
- [x] T034 Ensure queued remote/provider work uses idempotent inputs, sanitized context, and no queued DB notification outside the central OperationRun lifecycle.
|
||||||
|
- [x] T035 Add or extend audit recording through the existing `AuditRecorder` / `AuditEventBuilder` path for capture start/completion/failure attempts, using stable action IDs `tenant_configuration.capture.started`, `tenant_configuration.capture.completed`, and `tenant_configuration.capture.failed`, with actor, workspace, managed environment, provider connection, OperationRun, resource type counts, and no raw payloads/secrets.
|
||||||
|
|
||||||
|
## Phase 7: Capture Implementation
|
||||||
|
|
||||||
|
**Purpose**: Fetch eligible payloads and write v2 evidence.
|
||||||
|
|
||||||
|
- [x] T036 Add `apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php` to orchestrate per-type resolution, fakeable GraphClientInterface fetch, normalization, upsert, evidence write, and outcome collection.
|
||||||
|
- [x] T037 Add `apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php` to upsert concrete resource rows by workspace/environment/provider/resource type/canonical key and reject display-name-only identity.
|
||||||
|
- [x] T038 Add `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php` to append evidence rows, link OperationRun, persist raw/normalized payload, hash, source metadata, redacted permission context, and coverage/evidence state.
|
||||||
|
- [x] T039 Implement source behavior for all 8 Spec 414 initial resource types only: each TCM-aligned type captured or blocked with missing-contract reason, the Graph v1 fallback type captured or blocked with contract reason, and `roleScopeTag` beta blocked by default.
|
||||||
|
- [x] T040 Ensure no old v1 gap reason (`policy_record_missing`, `foundation_not_policy_backed`, `meta_fallback`, `ambiguous_match`, `raw_gap_count`, `primary_gap_count`) appears in v2 capture outcomes.
|
||||||
|
|
||||||
|
## Phase 8: Product Surface, No-Legacy, And Report
|
||||||
|
|
||||||
|
**Purpose**: Prove the slice stayed bounded and inactive.
|
||||||
|
|
||||||
|
- [x] T041 Confirm no files under `apps/platform/app/Filament`, `apps/platform/resources/views`, route files, panel providers, navigation definitions, customer outputs, review/report/evidence pages, or restore readiness surfaces changed. If any changed, stop and amend spec/plan/tasks before continuing.
|
||||||
|
- [x] T042 Confirm any visible OperationRun completion notification or Monitoring -> Operations row uses the existing generic lifecycle path only, with no feature-local notification copy, custom run link, rendered capture control, or custom operation UI.
|
||||||
|
- [x] T043 Complete `specs/415-generic-content-backed-capture/implementation-report.md` with candidate gate, dirty state, files changed, tables/models added, source contracts used/blocked, capture eligibility matrix, OperationRun behavior, RBAC proof, redaction/log proof, no-`tenant_id`, no-legacy/no-dual-truth, tests run, browser/no-browser, deployment impact, and deferred work.
|
||||||
|
- [x] T044 Confirm no completed historical spec was rewritten or stripped of close-out/validation/task history.
|
||||||
|
- [x] T045 Confirm deployment impact: migrations yes, queue worker yes, env vars no unless discovered, scheduler no unless discovered, storage no unless discovered, assets no, `filament:assets` not required.
|
||||||
|
|
||||||
|
## Phase 9: Validation
|
||||||
|
|
||||||
|
**Purpose**: Run the narrowest proof set.
|
||||||
|
|
||||||
|
- [x] T046 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
|
- [x] T047 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration`.
|
||||||
|
- [x] T048 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration`.
|
||||||
|
- [x] T049 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/TenantConfiguration` if migrations add JSONB fields, PostgreSQL checks, composite FKs, partial indexes, or same-scope provider constraints.
|
||||||
|
- [x] T050 Run `git diff --check`.
|
||||||
|
- [x] T051 Record validation results, unrelated failures if any, and final dirty state in `specs/415-generic-content-backed-capture/implementation-report.md`.
|
||||||
|
|
||||||
|
## Dependency And Ordering Notes
|
||||||
|
|
||||||
|
- T001-T007 must finish before implementation.
|
||||||
|
- T008-T018 should be written before or alongside the implementation they prove.
|
||||||
|
- T019-T024 block persistence-dependent service tests.
|
||||||
|
- T025-T029 block capture service implementation.
|
||||||
|
- T030-T035 block queue/OperationRun tests.
|
||||||
|
- T041 is a hard stop check before final validation.
|
||||||
|
|
||||||
|
## Non-Goals For Implementers
|
||||||
|
|
||||||
|
- Do not activate Coverage v2 in any customer/operator UI.
|
||||||
|
- Do not create Filament resources/pages/actions or routes.
|
||||||
|
- Do not implement compare, render, restore/apply, identity engine, full TCM catalog, legacy removal, or browser-visible proof.
|
||||||
|
- Do not read v1 snapshots as v2 evidence.
|
||||||
|
- Do not add compatibility aliases, fallback readers, dual writes, or old gap vocabulary.
|
||||||
Loading…
Reference in New Issue
Block a user