116 lines
4.9 KiB
PHP
116 lines
4.9 KiB
PHP
|
|
<?php
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
use Illuminate\Database\QueryException;
|
|||
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|||
|
|
use Illuminate\Support\Facades\DB;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* CTO-13: RLS smoke-test.
|
|||
|
|
*
|
|||
|
|
* Проверяет ключевые сценарии Row Level Security из Прил. И «Часть Г.1»:
|
|||
|
|
* - Кейс 1: SET LOCAL app.current_tenant_id изолирует SELECT по tenant_id.
|
|||
|
|
* - Кейс 4: WITH CHECK блокирует INSERT в чужой тенант.
|
|||
|
|
*
|
|||
|
|
* Кейсы 2-3 (PgBouncer pooling, job retry) — отдельно в production-окружении
|
|||
|
|
* через Прил. И Г.1 (PgBouncer на native Windows-стеке нет).
|
|||
|
|
*
|
|||
|
|
* Кейс 5 (REVOKE на 6 saas-таблицах) — требует роли `crm_app_user` из
|
|||
|
|
* db/00_create_roles.sql + db/02_grants.sql, на dev superuser обходит REVOKE.
|
|||
|
|
*
|
|||
|
|
* Стратегия: для эмуляции crm_app_user на dev-машине создаётся NOLOGIN-роль
|
|||
|
|
* `testing_rls_user` без BYPASSRLS. SET LOCAL ROLE внутри транзакции —
|
|||
|
|
* сбрасывается ROLLBACK'ом DatabaseTransactions.
|
|||
|
|
*/
|
|||
|
|
uses(DatabaseTransactions::class);
|
|||
|
|
|
|||
|
|
beforeEach(function () {
|
|||
|
|
// Идемпотентное создание testing-роли (роль = глобальный объект, не Rolled Back).
|
|||
|
|
DB::unprepared(<<<'SQL'
|
|||
|
|
DO $$
|
|||
|
|
BEGIN
|
|||
|
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'testing_rls_user') THEN
|
|||
|
|
CREATE ROLE testing_rls_user NOLOGIN;
|
|||
|
|
GRANT USAGE ON SCHEMA public TO testing_rls_user;
|
|||
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO testing_rls_user;
|
|||
|
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO testing_rls_user;
|
|||
|
|
END IF;
|
|||
|
|
END
|
|||
|
|
$$;
|
|||
|
|
SQL);
|
|||
|
|
|
|||
|
|
// 2 тенанта для проверки изоляции
|
|||
|
|
$this->tenant1Id = DB::table('tenants')->insertGetId([
|
|||
|
|
'subdomain' => 'rls-tenant-a-'.uniqid(),
|
|||
|
|
'organization_name' => 'RLS Tenant A',
|
|||
|
|
'contact_email' => 'a@rls-test.local',
|
|||
|
|
'api_key_limit' => 5,
|
|||
|
|
]);
|
|||
|
|
$this->tenant2Id = DB::table('tenants')->insertGetId([
|
|||
|
|
'subdomain' => 'rls-tenant-b-'.uniqid(),
|
|||
|
|
'organization_name' => 'RLS Tenant B',
|
|||
|
|
'contact_email' => 'b@rls-test.local',
|
|||
|
|
'api_key_limit' => 5,
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$this->project1Id = DB::table('projects')->insertGetId([
|
|||
|
|
'tenant_id' => $this->tenant1Id,
|
|||
|
|
'name' => 'Project A1',
|
|||
|
|
]);
|
|||
|
|
$this->project2Id = DB::table('projects')->insertGetId([
|
|||
|
|
'tenant_id' => $this->tenant2Id,
|
|||
|
|
'name' => 'Project B1',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
DB::table('deals')->insert([
|
|||
|
|
['tenant_id' => $this->tenant1Id, 'project_id' => $this->project1Id, 'phone' => '+79000010001', 'received_at' => now()],
|
|||
|
|
['tenant_id' => $this->tenant1Id, 'project_id' => $this->project1Id, 'phone' => '+79000010002', 'received_at' => now()],
|
|||
|
|
['tenant_id' => $this->tenant2Id, 'project_id' => $this->project2Id, 'phone' => '+79000020001', 'received_at' => now()],
|
|||
|
|
['tenant_id' => $this->tenant2Id, 'project_id' => $this->project2Id, 'phone' => '+79000020002', 'received_at' => now()],
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('кейс 1: SET LOCAL app.current_tenant_id изолирует SELECT в deals', function () {
|
|||
|
|
DB::statement('SET LOCAL ROLE testing_rls_user');
|
|||
|
|
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant1Id}'");
|
|||
|
|
|
|||
|
|
$allDealsVisible = DB::table('deals')->count();
|
|||
|
|
$foreignDealsVisible = DB::table('deals')->where('tenant_id', $this->tenant2Id)->count();
|
|||
|
|
|
|||
|
|
expect($allDealsVisible)->toBe(2);
|
|||
|
|
expect($foreignDealsVisible)->toBe(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('кейс 1: переключение tenant_id показывает только свои deals', function () {
|
|||
|
|
DB::statement('SET LOCAL ROLE testing_rls_user');
|
|||
|
|
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant2Id}'");
|
|||
|
|
|
|||
|
|
$allDealsVisible = DB::table('deals')->count();
|
|||
|
|
$foreignDealsVisible = DB::table('deals')->where('tenant_id', $this->tenant1Id)->count();
|
|||
|
|
|
|||
|
|
expect($allDealsVisible)->toBe(2);
|
|||
|
|
expect($foreignDealsVisible)->toBe(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('кейс 1: RLS работает на projects и users (не только deals)', function () {
|
|||
|
|
DB::statement('SET LOCAL ROLE testing_rls_user');
|
|||
|
|
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant1Id}'");
|
|||
|
|
|
|||
|
|
$projectsVisible = DB::table('projects')->count();
|
|||
|
|
$foreignProjectsVisible = DB::table('projects')->where('tenant_id', $this->tenant2Id)->count();
|
|||
|
|
|
|||
|
|
expect($projectsVisible)->toBe(1);
|
|||
|
|
expect($foreignProjectsVisible)->toBe(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('кейс 4: WITH CHECK блокирует INSERT projects с чужим tenant_id', function () {
|
|||
|
|
DB::statement('SET LOCAL ROLE testing_rls_user');
|
|||
|
|
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant1Id}'");
|
|||
|
|
|
|||
|
|
expect(fn () => DB::table('projects')->insert([
|
|||
|
|
'tenant_id' => $this->tenant2Id,
|
|||
|
|
'name' => 'Hijack attempt',
|
|||
|
|
]))->toThrow(QueryException::class);
|
|||
|
|
});
|