chore(ops): 補強 RLS role bootstrap gate
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
This commit is contained in:
86
scripts/ops/awooop-rls-role-bootstrap.sql
Normal file
86
scripts/ops/awooop-rls-role-bootstrap.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- AwoooP RLS role bootstrap.
|
||||
--
|
||||
-- IMPORTANT:
|
||||
-- - Do not put this file under apps/api/migrations; Gitea auto-migration should
|
||||
-- not attempt CREATE ROLE / BYPASSRLS with the limited migrator account.
|
||||
-- - Run manually as postgres or a CREATEROLE-capable operator after review.
|
||||
-- - This script does not create passwords and does not change application
|
||||
-- DATABASE_URL. It creates NOLOGIN group roles and grants awooop_app to the
|
||||
-- current production app connection role (`awoooi`).
|
||||
--
|
||||
-- Suggested command on 188:
|
||||
-- sudo -u postgres psql -d awoooi_prod -v ON_ERROR_STOP=1 \
|
||||
-- -f /path/to/awooop-rls-role-bootstrap.sql
|
||||
--
|
||||
-- Post-check:
|
||||
-- bash scripts/ops/awooop-rls-preflight.sh --exact-counts
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awooop_app') THEN
|
||||
EXECUTE 'CREATE ROLE awooop_app NOLOGIN';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awooop_platform_admin') THEN
|
||||
EXECUTE 'CREATE ROLE awooop_platform_admin NOLOGIN';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awooop_migration') THEN
|
||||
EXECUTE 'CREATE ROLE awooop_migration NOLOGIN';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER ROLE awooop_platform_admin BYPASSRLS;
|
||||
ALTER ROLE awooop_migration BYPASSRLS;
|
||||
|
||||
-- Current production API connects as `awoooi`. Until DATABASE_URL is split to a
|
||||
-- dedicated LOGIN role, make that role a member of the RLS-constrained group so
|
||||
-- policies written as `FOR ALL TO awooop_app` apply without secret rotation.
|
||||
GRANT awooop_app TO awoooi;
|
||||
|
||||
-- Keep existing migration account usable without changing its password or
|
||||
-- DATABASE_URL. The group role is NOLOGIN; operators may SET ROLE during manual
|
||||
-- RLS migrations if needed.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awoooi_migrator') THEN
|
||||
EXECUTE 'GRANT awooop_migration TO awoooi_migrator';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Minimum grants for existing target tables that already have project_id. RLS
|
||||
-- policies remain a separate staged migration and are not enabled here.
|
||||
GRANT USAGE ON SCHEMA public TO awooop_app;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
table_name text;
|
||||
target_tables text[] := ARRAY[
|
||||
'incidents',
|
||||
'knowledge_entries',
|
||||
'playbooks',
|
||||
'audit_logs',
|
||||
'budget_ledger',
|
||||
'awooop_projects',
|
||||
'awooop_contract_revisions',
|
||||
'awooop_run_state',
|
||||
'awooop_mcp_tool_registry',
|
||||
'awooop_mcp_grants',
|
||||
'awooop_mcp_credential_refs',
|
||||
'awooop_mcp_gateway_audit',
|
||||
'awooop_conversation_event',
|
||||
'awooop_outbound_message'
|
||||
];
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY target_tables LOOP
|
||||
IF to_regclass('public.' || table_name) IS NOT NULL THEN
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO awooop_app', table_name);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO awooop_app;
|
||||
|
||||
COMMIT;
|
||||
@@ -85,6 +85,8 @@ async def collect(exact_counts: bool) -> tuple[list[Check], dict[str, Any]]:
|
||||
current_user AS current_user,
|
||||
session_user AS session_user,
|
||||
r.rolsuper AS current_user_superuser,
|
||||
r.rolcreaterole AS current_user_createrole,
|
||||
r.rolcreatedb AS current_user_createdb,
|
||||
r.rolbypassrls AS current_user_bypassrls
|
||||
FROM pg_roles r
|
||||
WHERE r.rolname = current_user
|
||||
@@ -125,8 +127,13 @@ async def collect(exact_counts: bool) -> tuple[list[Check], dict[str, Any]]:
|
||||
SELECT
|
||||
rr.rolname,
|
||||
r.rolsuper,
|
||||
r.rolcreaterole,
|
||||
r.rolbypassrls,
|
||||
r.oid IS NOT NULL AS exists
|
||||
r.oid IS NOT NULL AS exists,
|
||||
CASE
|
||||
WHEN r.oid IS NULL THEN FALSE
|
||||
ELSE pg_has_role(current_user, rr.rolname, 'member')
|
||||
END AS current_user_is_member
|
||||
FROM required_roles rr
|
||||
LEFT JOIN pg_roles r ON r.rolname = rr.rolname
|
||||
ORDER BY rr.rolname
|
||||
@@ -141,6 +148,29 @@ async def collect(exact_counts: bool) -> tuple[list[Check], dict[str, Any]]:
|
||||
else:
|
||||
add(checks, "required_roles", "PASS", "all required RLS roles exist")
|
||||
|
||||
if not role.get("current_user_superuser") and not role.get("current_user_createrole") and missing_roles:
|
||||
add(
|
||||
checks,
|
||||
"role_bootstrap_authority",
|
||||
"WARN",
|
||||
"current API DB user cannot create missing roles; bootstrap requires postgres/CREATEROLE",
|
||||
)
|
||||
elif missing_roles:
|
||||
add(checks, "role_bootstrap_authority", "PASS", "current DB user can create roles")
|
||||
|
||||
app_role = next((row for row in roles if row["rolname"] == "awooop_app" and row["exists"]), None)
|
||||
if app_role is None:
|
||||
add(checks, "app_role_membership", "WARN", "awooop_app role missing; membership not evaluated")
|
||||
elif app_role["current_user_is_member"]:
|
||||
add(checks, "app_role_membership", "PASS", "current API DB user is member of awooop_app")
|
||||
else:
|
||||
add(
|
||||
checks,
|
||||
"app_role_membership",
|
||||
"BLOCKED",
|
||||
"current API DB user is not a member of awooop_app; policies FOR awooop_app would not apply",
|
||||
)
|
||||
|
||||
table_rows = await rows(
|
||||
conn,
|
||||
"""
|
||||
@@ -153,6 +183,7 @@ async def collect(exact_counts: bool) -> tuple[list[Check], dict[str, Any]]:
|
||||
c.oid,
|
||||
c.relrowsecurity,
|
||||
c.relforcerowsecurity,
|
||||
pg_get_userbyid(c.relowner) AS table_owner,
|
||||
COALESCE(c.reltuples, 0)::bigint AS estimated_rows
|
||||
FROM target t
|
||||
LEFT JOIN pg_class c
|
||||
@@ -191,6 +222,7 @@ async def collect(exact_counts: bool) -> tuple[list[Check], dict[str, Any]]:
|
||||
COALESCE(ps.policy_count, 0) AS policy_count,
|
||||
COALESCE(ps.has_null_fail_open_policy, FALSE) AS has_null_fail_open_policy,
|
||||
COALESCE(ps.has_empty_string_fail_open_policy, FALSE) AS has_empty_string_fail_open_policy,
|
||||
r.table_owner,
|
||||
r.estimated_rows
|
||||
FROM rels r
|
||||
LEFT JOIN project_columns pc ON pc.table_name = r.relname
|
||||
@@ -273,6 +305,7 @@ def print_human(checks: list[Check], evidence: dict[str, Any]) -> None:
|
||||
f"current_user={role.get('current_user')} "
|
||||
f"session_user={role.get('session_user')} "
|
||||
f"superuser={role.get('current_user_superuser')} "
|
||||
f"createrole={role.get('current_user_createrole')} "
|
||||
f"bypassrls={role.get('current_user_bypassrls')}"
|
||||
)
|
||||
|
||||
@@ -287,6 +320,7 @@ def print_human(checks: list[Check], evidence: dict[str, Any]) -> None:
|
||||
f"policies={row['policy_count']} "
|
||||
f"fail_open_null={row['has_null_fail_open_policy']} "
|
||||
f"fail_open_empty={row['has_empty_string_fail_open_policy']} "
|
||||
f"owner={row['table_owner']} "
|
||||
f"estimated_rows={row['estimated_rows']}"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user