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); } }; }