chore(rls): stage projects canary path
This commit is contained in:
@@ -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},
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
65
docs/runbooks/AWOOOP-RLS-CANARY-WAVE1-2.md
Normal file
65
docs/runbooks/AWOOOP-RLS-CANARY-WAVE1-2.md
Normal 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.
|
||||||
19
scripts/ops/awooop-rls-canary-wave1-2-projects-rollback.sql
Normal file
19
scripts/ops/awooop-rls-canary-wave1-2-projects-rollback.sql
Normal 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;
|
||||||
107
scripts/ops/awooop-rls-canary-wave1-2-projects.sql
Normal file
107
scripts/ops/awooop-rls-canary-wave1-2-projects.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user