324 lines
14 KiB
PHP
324 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\TenantConfiguration\CaptureTenantConfigurationEvidenceJob;
|
|
use App\Models\AuditLog;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\TenantConfigurationResource;
|
|
use App\Models\TenantConfigurationResourceEvidence;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\Graph\GraphResponse;
|
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
|
use App\Support\TenantConfiguration\EvidenceState;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
it('captures content-backed evidence and updates the operation run without raw payload leakage', function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
config()->set('graph_contracts.types.assignmentFilter.volatile_fields', ['@odata.etag']);
|
|
|
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'scopes_granted' => ['DeviceManagementConfiguration.Read.All'],
|
|
]);
|
|
|
|
$graph = captureGraphClient([
|
|
'assignmentFilter' => [
|
|
[
|
|
'id' => 'assignment-filter-1',
|
|
'displayName' => 'Corporate devices',
|
|
'@odata.etag' => 'volatile',
|
|
'platform' => 'windows10AndLater',
|
|
],
|
|
],
|
|
]);
|
|
|
|
app()->instance(GraphClientInterface::class, $graph);
|
|
|
|
$run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'context' => [
|
|
'target_scope' => [
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
'resource_types' => ['deviceAndAppManagementAssignmentFilter'],
|
|
'required_capability' => 'evidence.manage',
|
|
],
|
|
]);
|
|
|
|
app(CaptureTenantConfigurationEvidenceJob::class, ['run' => $run])->handle(
|
|
app(\App\Services\TenantConfiguration\GenericContentEvidenceCaptureService::class),
|
|
app(\App\Services\OperationRunService::class),
|
|
app(\App\Services\Audit\AuditRecorder::class),
|
|
);
|
|
|
|
$run->refresh();
|
|
|
|
expect($graph->calls)->toHaveCount(1)
|
|
->and($graph->calls[0]['policy_type'])->toBe('assignmentFilter')
|
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
|
->and($run->summary_counts)->toMatchArray([
|
|
'total' => 1,
|
|
'processed' => 1,
|
|
'succeeded' => 1,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'errors_recorded' => 0,
|
|
])
|
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret')
|
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('volatile');
|
|
|
|
$resource = TenantConfigurationResource::query()->sole();
|
|
$evidence = TenantConfigurationResourceEvidence::query()->sole();
|
|
|
|
expect($resource->workspace_id)->toBe((int) $tenant->workspace_id)
|
|
->and($resource->managed_environment_id)->toBe((int) $tenant->getKey())
|
|
->and($resource->provider_connection_id)->toBe((int) $connection->getKey())
|
|
->and($resource->latest_evidence_id)->toBe((int) $evidence->getKey())
|
|
->and($resource->latest_evidence_state)->toBe(EvidenceState::ContentBacked)
|
|
->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured)
|
|
->and($evidence->raw_payload['id'])->toBe('assignment-filter-1')
|
|
->and($evidence->normalized_payload)->not->toHaveKey('@odata.etag')
|
|
->and($evidence->permission_context['scopes_granted'])->toBe(['DeviceManagementConfiguration.Read.All']);
|
|
|
|
expect(Schema::hasColumn('tenant_configuration_resources', 'tenant_id'))->toBeFalse()
|
|
->and(Schema::hasColumn('tenant_configuration_resource_evidence', 'tenant_id'))->toBeFalse()
|
|
->and(AuditLog::query()->where('action', 'tenant_configuration.capture.completed')->exists())->toBeTrue();
|
|
});
|
|
|
|
it('stores only bounded failure reasons when graph capture throws sensitive exceptions', function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
|
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
]);
|
|
|
|
app()->instance(GraphClientInterface::class, captureThrowingGraphClient(
|
|
'invalid_client Authorization: Bearer super-secret-token access_token=abc client_secret=ghi cookie=session',
|
|
));
|
|
|
|
$run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'context' => [
|
|
'target_scope' => [
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
'resource_types' => ['deviceAndAppManagementAssignmentFilter'],
|
|
'required_capability' => 'evidence.manage',
|
|
],
|
|
]);
|
|
|
|
app(CaptureTenantConfigurationEvidenceJob::class, ['run' => $run])->handle(
|
|
app(\App\Services\TenantConfiguration\GenericContentEvidenceCaptureService::class),
|
|
app(\App\Services\OperationRunService::class),
|
|
app(\App\Services\Audit\AuditRecorder::class),
|
|
);
|
|
|
|
$run->refresh();
|
|
$auditLog = AuditLog::query()
|
|
->where('action', 'tenant_configuration.capture.failed')
|
|
->latest('id')
|
|
->firstOrFail();
|
|
|
|
expect($run->outcome)->toBe(OperationRunOutcome::Failed->value)
|
|
->and(data_get($run->context, 'capture.resource_type_outcomes.0.reason_code'))->toBe('provider_auth_failed')
|
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('super-secret-token')
|
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('access_token')
|
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret')
|
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('cookie=session')
|
|
->and(json_encode($auditLog->metadata, JSON_THROW_ON_ERROR))->not->toContain('super-secret-token')
|
|
->and(json_encode($auditLog->metadata, JSON_THROW_ON_ERROR))->not->toContain('access_token')
|
|
->and(json_encode($auditLog->metadata, JSON_THROW_ON_ERROR))->not->toContain('client_secret')
|
|
->and(json_encode($auditLog->metadata, JSON_THROW_ON_ERROR))->not->toContain('cookie=session');
|
|
});
|
|
|
|
it('rejects cross-scope provider connections before the capture service calls graph', function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
|
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
|
[, $otherTenant] = createMinimalUserWithTenant(role: 'owner');
|
|
$foreignConnection = ProviderConnection::factory()->withCredential()->create([
|
|
'workspace_id' => (int) $otherTenant->workspace_id,
|
|
'managed_environment_id' => (int) $otherTenant->getKey(),
|
|
]);
|
|
|
|
$graph = captureGraphClient([
|
|
'assignmentFilter' => [
|
|
['id' => 'should-not-be-read'],
|
|
],
|
|
]);
|
|
|
|
app()->instance(GraphClientInterface::class, $graph);
|
|
|
|
$run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'context' => [
|
|
'target_scope' => [
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'provider_connection_id' => (int) $foreignConnection->getKey(),
|
|
],
|
|
'resource_types' => ['deviceAndAppManagementAssignmentFilter'],
|
|
'required_capability' => 'evidence.manage',
|
|
],
|
|
]);
|
|
|
|
expect(fn () => app(\App\Services\TenantConfiguration\GenericContentEvidenceCaptureService::class)->capture(
|
|
tenant: $tenant,
|
|
providerConnection: $foreignConnection,
|
|
operationRun: $run,
|
|
canonicalTypes: ['deviceAndAppManagementAssignmentFilter'],
|
|
))->toThrow(InvalidArgumentException::class, 'Provider connection does not belong to the managed environment scope.');
|
|
|
|
expect($graph->calls)->toHaveCount(0)
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('scopes provider lookup in capture jobs before calling graph', function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
|
|
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
|
[, $otherTenant] = createMinimalUserWithTenant(role: 'owner');
|
|
$foreignConnection = ProviderConnection::factory()->withCredential()->create([
|
|
'workspace_id' => (int) $otherTenant->workspace_id,
|
|
'managed_environment_id' => (int) $otherTenant->getKey(),
|
|
]);
|
|
|
|
$graph = captureGraphClient([
|
|
'assignmentFilter' => [
|
|
['id' => 'should-not-be-read'],
|
|
],
|
|
]);
|
|
|
|
app()->instance(GraphClientInterface::class, $graph);
|
|
|
|
$run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'context' => [
|
|
'target_scope' => [
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'provider_connection_id' => (int) $foreignConnection->getKey(),
|
|
],
|
|
'resource_types' => ['deviceAndAppManagementAssignmentFilter'],
|
|
'required_capability' => 'evidence.manage',
|
|
],
|
|
]);
|
|
|
|
expect(fn () => app(CaptureTenantConfigurationEvidenceJob::class, ['run' => $run])->handle(
|
|
app(\App\Services\TenantConfiguration\GenericContentEvidenceCaptureService::class),
|
|
app(\App\Services\OperationRunService::class),
|
|
app(\App\Services\Audit\AuditRecorder::class),
|
|
))->toThrow(RuntimeException::class, 'same-scope provider connection');
|
|
|
|
expect($graph->calls)->toHaveCount(0)
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
});
|
|
|
|
function captureGraphClient(array $responses): GraphClientInterface
|
|
{
|
|
return new class($responses) implements GraphClientInterface
|
|
{
|
|
public array $calls = [];
|
|
|
|
public function __construct(private readonly array $responses) {}
|
|
|
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
{
|
|
$this->calls[] = [
|
|
'policy_type' => $policyType,
|
|
'options' => $options,
|
|
];
|
|
|
|
return new GraphResponse(true, $this->responses[$policyType] ?? []);
|
|
}
|
|
|
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function getOrganization(array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
};
|
|
}
|
|
|
|
function captureThrowingGraphClient(string $message): GraphClientInterface
|
|
{
|
|
return new class($message) implements GraphClientInterface
|
|
{
|
|
public function __construct(private readonly string $message) {}
|
|
|
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
{
|
|
throw new \RuntimeException($this->message);
|
|
}
|
|
|
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function getOrganization(array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
|
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(false, [], 501);
|
|
}
|
|
};
|
|
}
|