chore(rls): stage projects canary path
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m8s
CD Pipeline / build-and-deploy (push) Successful in 3m49s
CD Pipeline / post-deploy-checks (push) Successful in 1m25s

This commit is contained in:
Your Name
2026-05-12 21:25:24 +08:00
parent b7af597459
commit 7d92f0acd7
5 changed files with 210 additions and 11 deletions

View File

@@ -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},

View File

@@ -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
]

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;