diff --git a/apps/api/src/services/budget_service.py b/apps/api/src/services/budget_service.py index 605ae6a9..bd7c8c11 100644 --- a/apps/api/src/services/budget_service.py +++ b/apps/api/src/services/budget_service.py @@ -336,7 +336,7 @@ async def _get_tenant_budget_limit(project_id: str) -> Decimal | None: try: from sqlalchemy import text from src.db.base import get_db_context - async with get_db_context() as db: + async with get_db_context(project_id) as db: row = await db.execute( text("SELECT budget_limit_usd FROM awooop_projects WHERE project_id = :pid"), {"pid": project_id}, diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 51b85a74..7d392183 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -15,7 +15,7 @@ from uuid import UUID import structlog from fastapi import HTTPException, status -from sqlalchemy import func, select, update +from sqlalchemy import func, select, text, update from sqlalchemy import or_ as sa_or from src.db.awooop_models import ( @@ -23,7 +23,6 @@ from src.db.awooop_models import ( AwoooPConversationEvent, AwoooPMcpGatewayAudit, AwoooPOutboundMessage, - AwoooPProject, AwoooPRunState, AwoooPRunStepJournal, ) @@ -49,18 +48,27 @@ async def list_tenants() -> dict[str, Any]: """列出所有 AwoooP 租戶(Operator Console,不依 RLS 過濾)。""" async with get_db_context("awoooi") as db: result = await db.execute( - select(AwoooPProject).order_by(AwoooPProject.created_at.asc()) + text(""" + SELECT + project_id, + display_name, + migration_mode, + budget_limit_usd, + is_active, + created_at + FROM awooop_operator_list_projects() + """) ) - rows = list(result.scalars().all()) + rows = list(result.mappings().all()) tenants = [ { - "project_id": r.project_id, - "display_name": r.display_name, - "migration_mode": r.migration_mode, - "budget_limit_usd": r.budget_limit_usd, - "is_active": r.is_active, - "created_at": r.created_at, + "project_id": r["project_id"], + "display_name": r["display_name"], + "migration_mode": r["migration_mode"], + "budget_limit_usd": r["budget_limit_usd"], + "is_active": r["is_active"], + "created_at": r["created_at"], } for r in rows ] diff --git a/docs/runbooks/AWOOOP-RLS-CANARY-WAVE1-2.md b/docs/runbooks/AWOOOP-RLS-CANARY-WAVE1-2.md new file mode 100644 index 00000000..78a7a694 --- /dev/null +++ b/docs/runbooks/AWOOOP-RLS-CANARY-WAVE1-2.md @@ -0,0 +1,65 @@ +# AwoooP RLS Canary Wave 1.2 + +This wave targets: + +- `awooop_projects` + +Status: staged, apply pending. + +## Safety Model + +`awooop_projects` is special. Runtime checks such as MCP Gate 1 and budget +lookup should be tenant-scoped, but Operator Console needs a cross-tenant +project list. + +Wave 1.2 keeps normal table access tenant-scoped and adds an explicit platform +read function: + +```sql +public.awooop_operator_list_projects() +``` + +The function is `SECURITY DEFINER`, has a fixed `search_path`, returns only the +Operator Console project-list columns, and grants execute only to `awooop_app`. + +## App Changes + +- `platform_operator_service.list_tenants()` reads from + `awooop_operator_list_projects()`. +- `budget_service._get_tenant_budget_limit(project_id)` now opens + `get_db_context(project_id)`, so tenant budget reads match RLS context. + +## Apply + +```bash +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \ + -f scripts/ops/awooop-rls-canary-wave1-2-projects.sql +``` + +The SQL aborts if: + +- table is missing, +- `project_id` is missing, +- any `project_id` is NULL, +- row count exceeds the reviewed canary cap of 20 rows. + +## Verification + +Expected after apply: + +- `app.project_id='awoooi'`: direct table read sees only `awoooi`. +- `app.project_id='ewoooc'`: direct table read sees only `ewoooc`. +- `awooop_operator_list_projects()`: returns both projects for Operator Console. +- tenant budget lookup can read the matching tenant row. +- global RLS preflight remains blocked only by later-wave tables. + +## Rollback + +```bash +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \ + -f scripts/ops/awooop-rls-canary-wave1-2-projects-rollback.sql +``` + +Rollback disables RLS and removes the Wave1.2 policies on `awooop_projects`. +It intentionally keeps `awooop_operator_list_projects()` for deployed API +compatibility. diff --git a/scripts/ops/awooop-rls-canary-wave1-2-projects-rollback.sql b/scripts/ops/awooop-rls-canary-wave1-2-projects-rollback.sql new file mode 100644 index 00000000..8cd6bfc0 --- /dev/null +++ b/scripts/ops/awooop-rls-canary-wave1-2-projects-rollback.sql @@ -0,0 +1,19 @@ +-- Rollback for AwoooP RLS Canary Wave 1.2. +-- This removes project table policies and disables RLS on awooop_projects. +-- It intentionally keeps awooop_operator_list_projects() so deployed API code +-- that uses the operator list path remains compatible. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +DROP POLICY IF EXISTS awooop_projects_select_tenant ON awooop_projects; +DROP POLICY IF EXISTS awooop_projects_insert_tenant ON awooop_projects; +DROP POLICY IF EXISTS awooop_projects_update_tenant ON awooop_projects; +DROP POLICY IF EXISTS awooop_projects_delete_tenant ON awooop_projects; + +ALTER TABLE awooop_projects NO FORCE ROW LEVEL SECURITY; +ALTER TABLE awooop_projects DISABLE ROW LEVEL SECURITY; + +COMMIT; diff --git a/scripts/ops/awooop-rls-canary-wave1-2-projects.sql b/scripts/ops/awooop-rls-canary-wave1-2-projects.sql new file mode 100644 index 00000000..02f2280e --- /dev/null +++ b/scripts/ops/awooop-rls-canary-wave1-2-projects.sql @@ -0,0 +1,107 @@ +-- AwoooP RLS Canary Wave 1.2: projects table with explicit operator list path +-- Date: 2026-05-12 +-- +-- Scope: +-- - awooop_projects +-- +-- Safety model: +-- - normal app access is tenant-scoped by app.project_id. +-- - Operator Console cross-tenant list uses a fixed SECURITY DEFINER +-- function owned by the migration/operator role. +-- - no NULL/empty-string/__platform__ policy bypass. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +DO $$ +DECLARE + total_rows bigint; + null_project_rows bigint; +BEGIN + IF to_regclass('public.awooop_projects') IS NULL THEN + RAISE EXCEPTION 'RLS canary target table does not exist: awooop_projects'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'awooop_projects' + AND column_name = 'project_id' + ) THEN + RAISE EXCEPTION 'RLS canary target missing project_id: awooop_projects'; + END IF; + + SELECT COUNT(*), COUNT(*) FILTER (WHERE project_id IS NULL) + INTO total_rows, null_project_rows + FROM awooop_projects; + + IF null_project_rows <> 0 THEN + RAISE EXCEPTION 'RLS canary target has NULL project_id rows: %, nulls=%', + 'awooop_projects', null_project_rows; + END IF; + + IF total_rows > 20 THEN + RAISE EXCEPTION 'RLS canary wave1.2 reviewed cap exceeded: %, rows=%', + 'awooop_projects', total_rows; + END IF; +END +$$; + +CREATE OR REPLACE FUNCTION public.awooop_operator_list_projects() +RETURNS TABLE ( + project_id varchar, + display_name varchar, + migration_mode varchar, + budget_limit_usd numeric, + is_active boolean, + created_at timestamp without time zone +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public, pg_catalog +AS $$ + SELECT + p.project_id, + p.display_name, + p.migration_mode, + p.budget_limit_usd, + p.is_active, + p.created_at + FROM public.awooop_projects AS p + ORDER BY p.created_at ASC; +$$; + +REVOKE ALL ON FUNCTION public.awooop_operator_list_projects() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.awooop_operator_list_projects() TO awooop_app; + +ALTER TABLE awooop_projects ENABLE ROW LEVEL SECURITY; +ALTER TABLE awooop_projects FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS awooop_projects_select_tenant ON awooop_projects; +DROP POLICY IF EXISTS awooop_projects_insert_tenant ON awooop_projects; +DROP POLICY IF EXISTS awooop_projects_update_tenant ON awooop_projects; +DROP POLICY IF EXISTS awooop_projects_delete_tenant ON awooop_projects; +DROP POLICY IF EXISTS projects_tenant_isolation ON awooop_projects; + +CREATE POLICY awooop_projects_select_tenant ON awooop_projects + FOR SELECT TO awooop_app + USING (project_id = current_setting('app.project_id', TRUE)); + +CREATE POLICY awooop_projects_insert_tenant ON awooop_projects + FOR INSERT TO awooop_app + WITH CHECK (project_id = current_setting('app.project_id', TRUE)); + +CREATE POLICY awooop_projects_update_tenant ON awooop_projects + FOR UPDATE TO awooop_app + USING (project_id = current_setting('app.project_id', TRUE)) + WITH CHECK (project_id = current_setting('app.project_id', TRUE)); + +CREATE POLICY awooop_projects_delete_tenant ON awooop_projects + FOR DELETE TO awooop_app + USING (project_id = current_setting('app.project_id', TRUE)); + +COMMIT;