From ca0f54614d53b98d12713f18ffbc8b07995a5cb4 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 25 Jun 2026 19:55:52 +0000 Subject: [PATCH] feat: add generic content-backed coverage capture (#482) Automated PR provided by Codex via Gitea API. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/482 --- .../CaptureTenantConfigurationEvidenceJob.php | 225 +++++++++++ .../Models/TenantConfigurationResource.php | 71 ++++ .../TenantConfigurationResourceEvidence.php | 73 ++++ .../QueuedExecutionLegitimacyGate.php | 1 + .../CoverageCaptureOutcomeSummarizer.php | 78 ++++ .../CoverageEvidenceWriter.php | 113 ++++++ .../CoveragePayloadRedactor.php | 62 +++ .../CoverageResourceUpserter.php | 114 ++++++ .../CoverageSourceContractDecision.php | 35 ++ .../CoverageSourceContractResolver.php | 130 ++++++ .../GenericContentEvidenceCaptureService.php | 322 +++++++++++++++ .../GenericPayloadNormalizer.php | 72 ++++ .../StartTenantConfigurationCapture.php | 192 +++++++++ .../app/Support/Audit/AuditActionId.php | 3 + .../platform/app/Support/OperationCatalog.php | 2 + .../platform/app/Support/OperationRunType.php | 1 + .../TenantConfiguration/CaptureOutcome.php | 43 ++ ...ntConfigurationResourceEvidenceFactory.php | 60 +++ .../TenantConfigurationResourceFactory.php | 84 ++++ ...te_tenant_configuration_capture_tables.php | 165 ++++++++ ...pec415CoverageCaptureAuthorizationTest.php | 106 +++++ ...Spec415CoverageCaptureOperationRunTest.php | 31 ++ ...Spec415CoverageEvidencePersistenceTest.php | 76 ++++ ...Spec415GenericContentBackedCaptureTest.php | 323 +++++++++++++++ .../Spec415NoLegacyNoUiActivationTest.php | 28 ++ .../Spec415ProviderConnectionScopeTest.php | 291 ++++++++++++++ .../QueuedExecutionLegitimacyGateTest.php | 38 ++ ...CoverageCaptureOperationRunSummaryTest.php | 29 ++ .../Spec415CoverageCaptureOutcomeTest.php | 29 ++ .../Spec415CoverageRedactionTest.php | 35 ++ ...c415CoverageSourceContractResolverTest.php | 87 +++++ .../Spec415GenericPayloadNormalizerTest.php | 30 ++ .../checklists/requirements.md | 114 ++++++ .../implementation-report.md | 145 +++++++ .../plan.md | 281 +++++++++++++ .../spec.md | 369 ++++++++++++++++++ .../tasks.md | 127 ++++++ 37 files changed, 3985 insertions(+) create mode 100644 apps/platform/app/Jobs/TenantConfiguration/CaptureTenantConfigurationEvidenceJob.php create mode 100644 apps/platform/app/Models/TenantConfigurationResource.php create mode 100644 apps/platform/app/Models/TenantConfigurationResourceEvidence.php create mode 100644 apps/platform/app/Services/TenantConfiguration/CoverageCaptureOutcomeSummarizer.php create mode 100644 apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php create mode 100644 apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php create mode 100644 apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php create mode 100644 apps/platform/app/Services/TenantConfiguration/CoverageSourceContractDecision.php create mode 100644 apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php create mode 100644 apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php create mode 100644 apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php create mode 100644 apps/platform/app/Services/TenantConfiguration/StartTenantConfigurationCapture.php create mode 100644 apps/platform/app/Support/TenantConfiguration/CaptureOutcome.php create mode 100644 apps/platform/database/factories/TenantConfigurationResourceEvidenceFactory.php create mode 100644 apps/platform/database/factories/TenantConfigurationResourceFactory.php create mode 100644 apps/platform/database/migrations/2026_06_25_000415_create_tenant_configuration_capture_tables.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureOperationRunTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageEvidencePersistenceTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec415GenericContentBackedCaptureTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec415NoLegacyNoUiActivationTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec415ProviderConnectionScopeTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOperationRunSummaryTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOutcomeTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageRedactionTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageSourceContractResolverTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec415GenericPayloadNormalizerTest.php create mode 100644 specs/415-generic-content-backed-capture/checklists/requirements.md create mode 100644 specs/415-generic-content-backed-capture/implementation-report.md create mode 100644 specs/415-generic-content-backed-capture/plan.md create mode 100644 specs/415-generic-content-backed-capture/spec.md create mode 100644 specs/415-generic-content-backed-capture/tasks.md diff --git a/apps/platform/app/Jobs/TenantConfiguration/CaptureTenantConfigurationEvidenceJob.php b/apps/platform/app/Jobs/TenantConfiguration/CaptureTenantConfigurationEvidenceJob.php new file mode 100644 index 00000000..ad0c1a92 --- /dev/null +++ b/apps/platform/app/Jobs/TenantConfiguration/CaptureTenantConfigurationEvidenceJob.php @@ -0,0 +1,225 @@ +operationRun = $run; + } + + /** + * @return array + */ + 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|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> $outcomes + * @return array + */ + 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>, summary_counts: array, run_outcome: string, failures: list>} $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(), + ); + } +} diff --git a/apps/platform/app/Models/TenantConfigurationResource.php b/apps/platform/app/Models/TenantConfigurationResource.php new file mode 100644 index 00000000..0e7fc053 --- /dev/null +++ b/apps/platform/app/Models/TenantConfigurationResource.php @@ -0,0 +1,71 @@ + + */ + 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'); + } +} diff --git a/apps/platform/app/Models/TenantConfigurationResourceEvidence.php b/apps/platform/app/Models/TenantConfigurationResourceEvidence.php new file mode 100644 index 00000000..a45ee101 --- /dev/null +++ b/apps/platform/app/Models/TenantConfigurationResourceEvidence.php @@ -0,0 +1,73 @@ + + */ + 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); + } +} diff --git a/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php b/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php index 0037639d..ff62cf2d 100644 --- a/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php +++ b/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php @@ -193,6 +193,7 @@ private function evaluateExecutionPrerequisites(QueuedExecutionContext $context, if ($context->providerConnectionId !== null) { $validProviderConnection = ProviderConnection::query() ->whereKey($context->providerConnectionId) + ->where('workspace_id', $context->workspaceId) ->when( $context->tenant instanceof ManagedEnvironment, fn ($query) => $query->where('managed_environment_id', (int) $context->tenant->getKey()), diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageCaptureOutcomeSummarizer.php b/apps/platform/app/Services/TenantConfiguration/CoverageCaptureOutcomeSummarizer.php new file mode 100644 index 00000000..f7db4072 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/CoverageCaptureOutcomeSummarizer.php @@ -0,0 +1,78 @@ + $outcomes + * @return array{summary_counts: array, run_outcome: string, failures: list} + */ + 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 $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; + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php b/apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php new file mode 100644 index 00000000..2a679df0 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php @@ -0,0 +1,113 @@ + $rawPayload + * @param array $normalizedPayload + * @param array $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.'); + } + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php b/apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php new file mode 100644 index 00000000..683190c6 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php @@ -0,0 +1,62 @@ + + */ + 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; + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php b/apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php new file mode 100644 index 00000000..b23840a5 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php @@ -0,0 +1,114 @@ + $payload + * @param array $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 $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 $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; + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractDecision.php b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractDecision.php new file mode 100644 index 00000000..102bed1c --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractDecision.php @@ -0,0 +1,35 @@ + $contract + * @param array $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 !== ''; + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php new file mode 100644 index 00000000..94481566 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php @@ -0,0 +1,130 @@ + + */ + 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 $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 $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 $payload + */ + private function canonicalJson(array $payload): string + { + ksort($payload); + + return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php b/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php new file mode 100644 index 00000000..27d94f12 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php @@ -0,0 +1,322 @@ +|null $canonicalTypes + * @return array{ + * outcomes: list, + * summary_counts: array, + * run_outcome: string, + * failures: list + * } + */ + 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|null $canonicalTypes + * @return Collection + */ + 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 + */ + 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> + */ + 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 + */ + 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 + */ + 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()); + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php b/apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php new file mode 100644 index 00000000..77d9ff8d --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php @@ -0,0 +1,72 @@ + $payload + * @param list $volatileFields + * @return array + */ + public function normalize(array $payload, array $volatileFields = []): array + { + $volatileLookup = array_fill_keys(array_map('strval', $volatileFields), true); + + return $this->normalizeValue($payload, $volatileLookup); + } + + /** + * @param array $normalizedPayload + */ + public function payloadHash(array $normalizedPayload): string + { + return hash('sha256', $this->canonicalJson($normalizedPayload)); + } + + /** + * @param array $payload + */ + public function canonicalJson(array $payload): string + { + return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + + /** + * @param array $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); + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/StartTenantConfigurationCapture.php b/apps/platform/app/Services/TenantConfiguration/StartTenantConfigurationCapture.php new file mode 100644 index 00000000..71aa0116 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/StartTenantConfigurationCapture.php @@ -0,0 +1,192 @@ +|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|null $canonicalTypes + * @return list + */ + 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 $resourceTypes + * @return array + */ + 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 $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(), + ); + } +} diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index f184b48c..9f034973 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -347,6 +347,9 @@ private static function labels(): array 'baseline.compare.completed' => 'Baseline compare completed', 'baseline.compare.failed' => 'Baseline compare failed', '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.updated' => 'Backup set updated', 'backup.archived' => 'Backup set archived', diff --git a/apps/platform/app/Support/OperationCatalog.php b/apps/platform/app/Support/OperationCatalog.php index d050e163..dabb6dac 100644 --- a/apps/platform/app/Support/OperationCatalog.php +++ b/apps/platform/app/Support/OperationCatalog.php @@ -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), '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), + '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('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true), new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true), + new OperationTypeAlias('tenant_configuration.capture', 'tenant_configuration.capture', 'canonical', true), ]; } } diff --git a/apps/platform/app/Support/OperationRunType.php b/apps/platform/app/Support/OperationRunType.php index 06eeb7ec..3c1d0e7a 100644 --- a/apps/platform/app/Support/OperationRunType.php +++ b/apps/platform/app/Support/OperationRunType.php @@ -22,6 +22,7 @@ enum OperationRunType: string case EnvironmentReviewCompose = 'environment.review.compose'; case EvidenceSnapshotGenerate = 'tenant.evidence.snapshot.generate'; case RbacHealthCheck = 'rbac.health_check'; + case TenantConfigurationCapture = 'tenant_configuration.capture'; public static function values(): array { diff --git a/apps/platform/app/Support/TenantConfiguration/CaptureOutcome.php b/apps/platform/app/Support/TenantConfiguration/CaptureOutcome.php new file mode 100644 index 00000000..5d3c83ec --- /dev/null +++ b/apps/platform/app/Support/TenantConfiguration/CaptureOutcome.php @@ -0,0 +1,43 @@ + + */ + 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); + } +} diff --git a/apps/platform/database/factories/TenantConfigurationResourceEvidenceFactory.php b/apps/platform/database/factories/TenantConfigurationResourceEvidenceFactory.php new file mode 100644 index 00000000..e8144cc9 --- /dev/null +++ b/apps/platform/database/factories/TenantConfigurationResourceEvidenceFactory.php @@ -0,0 +1,60 @@ + + */ +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']); + } +} diff --git a/apps/platform/database/factories/TenantConfigurationResourceFactory.php b/apps/platform/database/factories/TenantConfigurationResourceFactory.php new file mode 100644 index 00000000..a7a92cbe --- /dev/null +++ b/apps/platform/database/factories/TenantConfigurationResourceFactory.php @@ -0,0 +1,84 @@ + + */ +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; + } +} diff --git a/apps/platform/database/migrations/2026_06_25_000415_create_tenant_configuration_capture_tables.php b/apps/platform/database/migrations/2026_06_25_000415_create_tenant_configuration_capture_tables.php new file mode 100644 index 00000000..e0c6aa62 --- /dev/null +++ b/apps/platform/database/migrations/2026_06_25_000415_create_tenant_configuration_capture_tables.php @@ -0,0 +1,165 @@ +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 $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, + ); + } +}; diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureAuthorizationTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureAuthorizationTest.php new file mode 100644 index 00000000..5fb6947f --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureAuthorizationTest.php @@ -0,0 +1,106 @@ +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(); +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureOperationRunTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureOperationRunTest.php new file mode 100644 index 00000000..0bfe290a --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageCaptureOperationRunTest.php @@ -0,0 +1,31 @@ +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); +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageEvidencePersistenceTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageEvidencePersistenceTest.php new file mode 100644 index 00000000..f1b4b6a3 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageEvidencePersistenceTest.php @@ -0,0 +1,76 @@ +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'); +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec415GenericContentBackedCaptureTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec415GenericContentBackedCaptureTest.php new file mode 100644 index 00000000..4f5cc121 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec415GenericContentBackedCaptureTest.php @@ -0,0 +1,323 @@ +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); + } + }; +} diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec415NoLegacyNoUiActivationTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec415NoLegacyNoUiActivationTest.php new file mode 100644 index 00000000..324e6feb --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec415NoLegacyNoUiActivationTest.php @@ -0,0 +1,28 @@ +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); + } +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec415ProviderConnectionScopeTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec415ProviderConnectionScopeTest.php new file mode 100644 index 00000000..93366184 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec415ProviderConnectionScopeTest.php @@ -0,0 +1,291 @@ +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, + ]; +} diff --git a/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php b/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php index 9f0a08bb..c57cf585 100644 --- a/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php +++ b/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php @@ -4,6 +4,7 @@ use App\Models\OperationRun; use App\Models\ProviderConnection; +use App\Models\Workspace; use App\Support\Auth\Capabilities; use App\Services\Operations\QueuedExecutionLegitimacyGate; 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 { [$user, $tenant] = createUserWithTenant(role: 'owner'); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOperationRunSummaryTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOperationRunSummaryTest.php new file mode 100644 index 00000000..6e34a31e --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOperationRunSummaryTest.php @@ -0,0 +1,29 @@ +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); + } +}); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOutcomeTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOutcomeTest.php new file mode 100644 index 00000000..2f72564c --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageCaptureOutcomeTest.php @@ -0,0 +1,29 @@ +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); + } +}); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageRedactionTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageRedactionTest.php new file mode 100644 index 00000000..fcffdf62 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageRedactionTest.php @@ -0,0 +1,35 @@ +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', + ], + ]); +}); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageSourceContractResolverTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageSourceContractResolverTest.php new file mode 100644 index 00000000..5443fc02 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415CoverageSourceContractResolverTest.php @@ -0,0 +1,87 @@ +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, + ]); +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415GenericPayloadNormalizerTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415GenericPayloadNormalizerTest.php new file mode 100644 index 00000000..e07feaff --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec415GenericPayloadNormalizerTest.php @@ -0,0 +1,30 @@ +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'); +}); diff --git a/specs/415-generic-content-backed-capture/checklists/requirements.md b/specs/415-generic-content-backed-capture/checklists/requirements.md new file mode 100644 index 00000000..9e58fe57 --- /dev/null +++ b/specs/415-generic-content-backed-capture/checklists/requirements.md @@ -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. diff --git a/specs/415-generic-content-backed-capture/implementation-report.md b/specs/415-generic-content-backed-capture/implementation-report.md new file mode 100644 index 00000000..c793aaea --- /dev/null +++ b/specs/415-generic-content-backed-capture/implementation-report.md @@ -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. diff --git a/specs/415-generic-content-backed-capture/plan.md b/specs/415-generic-content-backed-capture/plan.md new file mode 100644 index 00000000..451a2e3d --- /dev/null +++ b/specs/415-generic-content-backed-capture/plan.md @@ -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. diff --git a/specs/415-generic-content-backed-capture/spec.md b/specs/415-generic-content-backed-capture/spec.md new file mode 100644 index 00000000..fa8e621e --- /dev/null +++ b/specs/415-generic-content-backed-capture/spec.md @@ -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. diff --git a/specs/415-generic-content-backed-capture/tasks.md b/specs/415-generic-content-backed-capture/tasks.md new file mode 100644 index 00000000..d327dd5e --- /dev/null +++ b/specs/415-generic-content-backed-capture/tasks.md @@ -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.