Spaces:
Running
Running
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()
|