File size: 19,680 Bytes
7cc81cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
"""Executable PostgreSQL RLS verification for the production tenant boundary.

Run only against an isolated disposable database:

  SOCIAL_TEST_ADMIN_DATABASE_URL=postgresql://...  # BYPASSRLS migration/seeding role
  SOCIAL_TEST_TENANT_DATABASE_URL=postgresql://... # non-owner, non-BYPASSRLS role
  pytest tests/test_postgres_rls.py

The test never falls back to SQLite because SQLite cannot validate PostgreSQL
policies. It intentionally does not run in normal CI without those dedicated
credentials.
"""

from __future__ import annotations

import os
from pathlib import Path

import pytest

admin_url = os.getenv("SOCIAL_TEST_ADMIN_DATABASE_URL", "").strip()
tenant_url = os.getenv("SOCIAL_TEST_TENANT_DATABASE_URL", "").strip()
pytestmark = pytest.mark.skipif(
    not (admin_url and tenant_url),
    reason="PostgreSQL RLS integration credentials are not configured.",
)


def _asyncpg_url(value: str) -> str:
    return value.replace("postgresql+asyncpg://", "postgresql://", 1)


async def _apply_migrations(connection: object, directory: Path) -> None:
    for migration in sorted(directory.glob("*.sql")):
        await connection.execute(migration.read_text(encoding="utf-8"))  # type: ignore[attr-defined]


async def _tenant_context(connection: object, workspace_id: str, user_id: str) -> None:
    await connection.execute("select set_config('app.workspace_id', $1, false)", workspace_id)  # type: ignore[attr-defined]
    await connection.execute("select set_config('app.user_id', $1, false)", user_id)  # type: ignore[attr-defined]


@pytest.mark.asyncio
async def test_postgres_rls_rejects_cross_workspace_reads_and_writes() -> None:
    asyncpg = pytest.importorskip("asyncpg")
    root = Path(__file__).resolve().parents[1]
    admin = await asyncpg.connect(_asyncpg_url(admin_url))
    tenant = await asyncpg.connect(_asyncpg_url(tenant_url))
    try:
        # The configured database must be disposable and owned by the
        # migration role. Never point these variables at a customer database.
        await _apply_migrations(admin, root / "app" / "security" / "migrations")
        await _apply_migrations(admin, root / "app" / "projects" / "migrations")
        await _apply_migrations(admin, root / "app" / "social" / "migrations")

        role = await tenant.fetchrow(
            "select r.rolsuper, r.rolbypassrls from pg_roles r where r.rolname = current_user"
        )
        assert role is not None
        assert not role["rolsuper"] and not role["rolbypassrls"]

        # Seed two fully independent tenants as the dedicated privileged role.
        await admin.execute(
            """
            insert into api_keys (id,name,key_prefix,key_hash,environment,status,scopes,created_at,requests_per_minute,concurrent_jobs,uploads_per_hour,processing_bytes_per_day)
            values ('key-a','A','mp_test_aaaaaaaa','aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','test','active','[]'::jsonb,now(),1,1,1,1048576),
                   ('key-b','B','mp_test_bbbbbbbb','bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb','test','active','[]'::jsonb,now(),1,1,1,1048576);
            insert into users (id,subject,display_name) values ('user-a','test:user-a','A'),('user-b','test:user-b','B');
            insert into workspaces (id,slug,name) values ('workspace-a','test-a','A'),('workspace-b','test-b','B');
            insert into workspace_memberships (id,workspace_id,user_id,role) values ('membership-a','workspace-a','user-a','owner'),('membership-b','workspace-b','user-b','owner');
            insert into api_key_principals (id,api_key_id,workspace_id,user_id,membership_id) values ('principal-a','key-a','workspace-a','user-a','membership-a'),('principal-b','key-b','workspace-b','user-b','membership-b');
            insert into media_assets (id,workspace_id,request_id,filename,mime_type,file_size,sha256) values
              ('asset-a','workspace-a','00000000-0000-0000-0000-000000000001','a.mp4','video/mp4',1,repeat('a',64)),
              ('asset-b','workspace-b','00000000-0000-0000-0000-000000000002','b.mp4','video/mp4',1,repeat('b',64));
            insert into media_asset_variants (id,workspace_id,asset_id,request_id,filename,mime_type,file_size,sha256) values
              ('asset-variant-a','workspace-a','asset-a','00000000-0000-0000-0000-000000000003','a-variant.mp4','video/mp4',1,repeat('c',64)),
              ('asset-variant-b','workspace-b','asset-b','00000000-0000-0000-0000-000000000004','b-variant.mp4','video/mp4',1,repeat('d',64));
            insert into generation_requests (id,workspace_id,created_by_user_id,provider,model_id,modality,spec,request_fingerprint,idempotency_key) values
              ('generation-request-a','workspace-a','user-a','test-provider','test-image','image','{"prompt":"A"}'::jsonb,repeat('1',64),'generation-key-a'),
              ('generation-request-b','workspace-b','user-b','test-provider','test-image','image','{"prompt":"B"}'::jsonb,repeat('2',64),'generation-key-b');
            insert into generation_jobs (id,generation_request_id,workspace_id,provider,status,output_asset_id) values
              ('generation-job-a','generation-request-a','workspace-a','test-provider','queued','asset-a'),
              ('generation-job-b','generation-request-b','workspace-b','test-provider','queued','asset-b');
            insert into generation_job_attempts (id,generation_job_id,attempt_number,status) values
              ('generation-attempt-a','generation-job-a',1,'started'),('generation-attempt-b','generation-job-b',1,'started');
            insert into projects (id,workspace_id,created_by,name) values
              ('project-a','workspace-a','user-a','A'),('project-b','workspace-b','user-b','B');
            update media_assets set project_id = 'project-a' where id = 'asset-a';
            update media_assets set project_id = 'project-b' where id = 'asset-b';
            insert into project_generation_jobs (id,workspace_id,project_id,generation_job_id,attached_by) values
              ('project-generation-a','workspace-a','project-a','generation-job-a','user-a'),
              ('project-generation-b','workspace-b','project-b','generation-job-b','user-b');
            insert into project_editor_states (id,workspace_id,project_id,revision,schema_version,state,updated_by) values
              ('editor-a','workspace-a','project-a',1,1,'{"schemaVersion":1,"projectId":"project-a","timeline":{"timeUnit":"milliseconds","tracks":[],"transitions":[],"markers":[]},"renderSettings":{"format":"mp4","width":1920,"height":1080,"frameRate":30}}'::jsonb,'user-a'),
              ('editor-b','workspace-b','project-b',1,1,'{"schemaVersion":1,"projectId":"project-b","timeline":{"timeUnit":"milliseconds","tracks":[],"transitions":[],"markers":[]},"renderSettings":{"format":"mp4","width":1920,"height":1080,"frameRate":30}}'::jsonb,'user-b');
            insert into project_render_jobs (id,workspace_id,project_id,editor_revision,editor_schema_version,editor_state,render_settings,request_fingerprint,idempotency_key,requested_by) values
              ('render-a','workspace-a','project-a',1,1,'{"schemaVersion":1,"projectId":"project-a"}'::jsonb,'{"format":"mp4"}'::jsonb,repeat('a',64),'render-key-a','user-a'),
              ('render-b','workspace-b','project-b',1,1,'{"schemaVersion":1,"projectId":"project-b"}'::jsonb,'{"format":"mp4"}'::jsonb,repeat('b',64),'render-key-b','user-b');
            insert into audit_events (id,workspace_id,actor_user_id,event_type,entity_type,entity_id) values
              ('project-audit-a','workspace-a','user-a','project.created','project','project-a'),
              ('project-audit-b','workspace-b','user-b','project.created','project','project-b');

            insert into social_accounts (id,workspace_id,provider,account_type,external_account_id,status) values
              ('account-a','workspace-a','youtube','channel','a','connected'),('account-b','workspace-b','youtube','channel','b','connected');
            insert into social_account_tokens (id,social_account_id,encrypted_payload) values ('token-a','account-a','opaque'),('token-b','account-b','opaque');
            insert into social_account_capabilities (id,social_account_id,capability,enabled) values ('cap-a','account-a','publish',true),('cap-b','account-b','publish',true);
            insert into media_variants (id,workspace_id,source_asset_id) values ('variant-a','workspace-a','asset-a'),('variant-b','workspace-b','asset-b');
            insert into social_media_assets (id,workspace_id,canonical_asset_id,request_id,filename,mime_type,file_size) values
              ('social-asset-a','workspace-a','asset-a','00000000-0000-0000-0000-000000000001','a.mp4','video/mp4',1),
              ('social-asset-b','workspace-b','asset-b','00000000-0000-0000-0000-000000000002','b.mp4','video/mp4',1);
            insert into social_campaigns (id,workspace_id,name) values ('campaign-a','workspace-a','A'),('campaign-b','workspace-b','B');
            insert into social_webhook_events (id,provider,event_type,external_event_id,workspace_id) values ('webhook-a','youtube','TEST','webhook-a','workspace-a'),('webhook-b','youtube','TEST','webhook-b','workspace-b');
            insert into social_posts (id,workspace_id,campaign_id,media_asset_id,source_variant_id,status,publish_mode) values
              ('post-a','workspace-a','campaign-a','social-asset-a','variant-a','draft','draft'),
              ('post-b','workspace-b','campaign-b','social-asset-b','variant-b','draft','draft');
            insert into social_post_targets (id,social_post_id,social_account_id,provider) values ('target-a','post-a','account-a','youtube'),('target-b','post-b','account-b','youtube');
            insert into social_post_media (id,social_post_id,media_variant_id,media_asset_id) values ('post-media-a','post-a','variant-a','social-asset-a'),('post-media-b','post-b','variant-b','social-asset-b');
            insert into social_schedules (id,social_post_id,scheduled_at,timezone) values ('schedule-a','post-a',now(),'UTC'),('schedule-b','post-b',now(),'UTC');
            insert into social_jobs (id,workspace_id,social_post_id,social_post_target_id,provider,status) values ('job-a','workspace-a','post-a','target-a','youtube','queued'),('job-b','workspace-b','post-b','target-b','youtube','queued');
            insert into social_job_attempts (id,social_job_id,attempt_number,status) values ('attempt-a','job-a',1,'started'),('attempt-b','job-b',1,'started');
            insert into social_post_metrics (id,social_post_id,social_post_target_id,provider) values ('metric-a','post-a','target-a','youtube'),('metric-b','post-b','target-b','youtube');
            insert into social_audit_events (id,workspace_id,event_type) values ('audit-a','workspace-a','TEST'),('audit-b','workspace-b','TEST');
            """
        )

        await _tenant_context(tenant, "workspace-a", "user-a")
        for table in (
            "users",
            "workspaces",
            "workspace_memberships",
            "api_key_principals",
            "media_assets",
            "media_asset_variants",
            "generation_requests",
            "generation_jobs",
            "generation_job_attempts",
            "social_accounts",
            "projects",
            "project_generation_jobs",
            "project_editor_states",
            "project_render_jobs",
            "audit_events",
            "social_account_tokens",
            "social_account_capabilities",
            "media_variants",
            "social_media_assets",
            "social_posts",
            "social_post_targets",
            "social_post_media",
            "social_schedules",
            "social_jobs",
            "social_job_attempts",
            "social_post_metrics",
            "social_audit_events",
            "social_campaigns",
            "social_webhook_events",
        ):
            assert await tenant.fetchval(f"select count(*) from {table}") == 1, table

        # RLS WITH CHECK rejects direct reassignment to tenant B. The relation
        # integrity triggers independently reject cross-tenant child links.
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "insert into social_accounts (id,workspace_id,provider,account_type,external_account_id,status) values ('blocked-account','workspace-b','youtube','channel','blocked','connected')"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "insert into workspace_memberships (id,workspace_id,user_id,role) values ('blocked-membership','workspace-b','user-a','member')"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "insert into media_assets (id,workspace_id,request_id,filename,mime_type,file_size,sha256) values ('blocked-asset','workspace-b','00000000-0000-0000-0000-000000000006','blocked.mp4','video/mp4',1,repeat('f',64))"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "insert into media_asset_variants (id,workspace_id,asset_id,request_id,filename,mime_type,file_size,sha256) values ('blocked-variant','workspace-a','asset-b','00000000-0000-0000-0000-000000000005','blocked.mp4','video/mp4',1,repeat('e',64))"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "insert into generation_requests (id,workspace_id,created_by_user_id,provider,model_id,modality,spec,request_fingerprint,idempotency_key) values ('blocked-generation-request','workspace-b','user-a','test-provider','test-image','image','{}'::jsonb,repeat('3',64),'blocked-generation-key')"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "insert into projects (id,workspace_id,created_by,name) values ('blocked-project','workspace-b','user-a','Blocked')"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "update projects set thumbnail_asset_id = 'asset-b' where id = 'project-a'"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "update media_assets set project_id = 'project-b' where id = 'asset-a'"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "insert into project_generation_jobs (id,workspace_id,project_id,generation_job_id,attached_by) values ('blocked-project-job','workspace-a','project-a','generation-job-b','user-a')"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "insert into project_editor_states (id,workspace_id,project_id,revision,schema_version,state,updated_by) values ('blocked-editor','workspace-b','project-b',1,1,'{}'::jsonb,'user-a')"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "insert into project_render_jobs (id,workspace_id,project_id,editor_revision,editor_schema_version,editor_state,render_settings,request_fingerprint,idempotency_key,requested_by) values ('blocked-render','workspace-a','project-b',1,1,'{}'::jsonb,'{}'::jsonb,repeat('c',64),'blocked-render','user-a')"
            )
        with pytest.raises(asyncpg.PostgresError):
            await admin.execute(
                "insert into project_generation_jobs (id,workspace_id,project_id,generation_job_id,attached_by) values ('blocked-project-job-admin','workspace-a','project-a','generation-job-b','user-a')"
            )
        # The provider-runtime migration prevents a trusted worker recovery
        # process from binding one opaque worker job to two tenant jobs.
        await admin.execute(
            "update generation_jobs set external_job_id = 'worker-job-a' where id = 'generation-job-a'"
        )
        with pytest.raises(asyncpg.PostgresError):
            await admin.execute(
                "update generation_jobs set external_job_id = 'worker-job-a' where id = 'generation-job-b'"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "update generation_jobs set output_asset_id = 'asset-b' where id = 'generation-job-a'"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "insert into social_post_targets (id,social_post_id,social_account_id,provider) values ('blocked-target','post-a','account-b','youtube')"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "update social_posts set workspace_id = 'workspace-b' where id = 'post-a'"
            )
        # RLS USING makes an attempted write to tenant B affect zero rows.
        assert (
            await tenant.execute("update social_jobs set status = 'failed' where id = 'job-b'")
            == "UPDATE 0"
        )
        assert (
            await tenant.execute("update projects set name = 'Blocked' where id = 'project-b'")
            == "UPDATE 0"
        )
        assert await tenant.execute("delete from projects where id = 'project-b'") == "DELETE 0"
        assert (
            await tenant.execute(
                "delete from project_generation_jobs where id = 'project-generation-b'"
            )
            == "DELETE 0"
        )
        assert (
            await tenant.fetchval(
                "select count(*) from project_editor_states where project_id = 'project-b'"
            )
            == 0
        )
        assert (
            await tenant.execute(
                "update project_editor_states set revision = 2 where id = 'editor-b'"
            )
            == "UPDATE 0"
        )
        assert (
            await tenant.execute(
                "update project_render_jobs set status = 'cancelled' where id = 'render-b'"
            )
            == "UPDATE 0"
        )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "update project_editor_states set revision = 3 where id = 'editor-a'"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "update project_render_jobs set render_settings = '{\"format\":\"webm\"}'::jsonb where id = 'render-a'"
            )
        with pytest.raises(asyncpg.PostgresError):
            await tenant.execute(
                "update project_render_jobs set status = 'completed', completed_at = now() where id = 'render-a'"
            )
        await admin.execute(
            "insert into users (id,subject,display_name) values ('viewer-a','test:viewer-a','Viewer'); "
            "insert into workspace_memberships (id,workspace_id,user_id,role) values ('viewer-membership-a','workspace-a','viewer-a','viewer')"
        )
        await _tenant_context(tenant, "workspace-a", "viewer-a")
        assert await tenant.fetchval("select count(*) from project_editor_states") == 1
        assert await tenant.fetchval("select count(*) from project_render_jobs") == 1
        assert (
            await tenant.execute(
                "update project_editor_states set revision = 2 where id = 'editor-a'"
            )
            == "UPDATE 0"
        )
        assert (
            await tenant.execute(
                "update project_render_jobs set status = 'cancelled' where id = 'render-a'"
            )
            == "UPDATE 0"
        )
    finally:
        await tenant.close()
        await admin.close()