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: try:
from sqlalchemy import text from sqlalchemy import text
from src.db.base import get_db_context 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( row = await db.execute(
text("SELECT budget_limit_usd FROM awooop_projects WHERE project_id = :pid"), text("SELECT budget_limit_usd FROM awooop_projects WHERE project_id = :pid"),
{"pid": project_id}, {"pid": project_id},

View File

@@ -15,7 +15,7 @@ from uuid import UUID
import structlog import structlog
from fastapi import HTTPException, status 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 sqlalchemy import or_ as sa_or
from src.db.awooop_models import ( from src.db.awooop_models import (
@@ -23,7 +23,6 @@ from src.db.awooop_models import (
AwoooPConversationEvent, AwoooPConversationEvent,
AwoooPMcpGatewayAudit, AwoooPMcpGatewayAudit,
AwoooPOutboundMessage, AwoooPOutboundMessage,
AwoooPProject,
AwoooPRunState, AwoooPRunState,
AwoooPRunStepJournal, AwoooPRunStepJournal,
) )
@@ -49,18 +48,27 @@ async def list_tenants() -> dict[str, Any]:
"""列出所有 AwoooP 租戶Operator Console不依 RLS 過濾)。""" """列出所有 AwoooP 租戶Operator Console不依 RLS 過濾)。"""
async with get_db_context("awoooi") as db: async with get_db_context("awoooi") as db:
result = await db.execute( 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 = [ tenants = [
{ {
"project_id": r.project_id, "project_id": r["project_id"],
"display_name": r.display_name, "display_name": r["display_name"],
"migration_mode": r.migration_mode, "migration_mode": r["migration_mode"],
"budget_limit_usd": r.budget_limit_usd, "budget_limit_usd": r["budget_limit_usd"],
"is_active": r.is_active, "is_active": r["is_active"],
"created_at": r.created_at, "created_at": r["created_at"],
} }
for r in rows 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;