From 73b3693eebfaf42ab9cd14dafee9445be3a63268 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Mon, 21 Sep 2026 18:16:44 +0200 Subject: [PATCH] docs: record agent_runs RLS fix and Traefik IP candidate Co-authored-by: Cursor --- SECURITY.md | 3 + docs/OPERATOR.md | 3 +- scripts/dokploy_verify_agent_rls.py | 137 ++++++++++++++++++++++++++++ sql/agent_runs_rls.sql | 38 ++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 scripts/dokploy_verify_agent_rls.py create mode 100644 sql/agent_runs_rls.sql diff --git a/SECURITY.md b/SECURITY.md index 14bbb7d..8532bcd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,6 +20,9 @@ El PDF “Agencia 2030” y el antiguo `.env.example` del repo contenían **JWTs | `supabase` Studio | Solo red interna / SSO admin | | `app` | Público OK; APIs tras sesión (middleware) | +Patch Traefik listo en `.ops/HARDEN_SURFACES.md` (**no aplicado**). +Candidata egress oficina (2026-09-21): `90.161.248.78/32` — confirmar si es fija antes de allowlist. + ## Controles en código (este repo) - Middleware exige sesión en `/dashboard/*` y `/api/*` (excepto `/api/health`). diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index 108f4a6..2dc76ec 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -5,7 +5,8 @@ - Usuario **creado** en Supabase Auth (`role=admin`, `tenant_id` = 4Ventures Central). - Password local (gitignored): `.ops/marketing-credentials.txt` - Login verificado en https://app.4ventures.es/login → `/dashboard/*` y `/dashboard/agencia` -- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`) +- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21) +- Re-aplicar RLS `agent_runs` si hace falta: `sql/agent_runs_rls.sql` (idempotente) ## Login diff --git a/scripts/dokploy_verify_agent_rls.py b/scripts/dokploy_verify_agent_rls.py new file mode 100644 index 0000000..62f2fd0 --- /dev/null +++ b/scripts/dokploy_verify_agent_rls.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Verify agent_runs RLS; apply sql/agent_runs_rls.sql only if still disabled.""" +from __future__ import annotations + +import base64 +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SHOT = ROOT / "scripts/dokploy-shots" +OUT = ROOT / ".ops" / "rls_inventory.txt" +BASE = "https://dokploy.4ventures.es" +SQL_FILE = ROOT / "sql/agent_runs_rls.sql" + +INVENTORY = ( + "SELECT c.relname AS table_name, c.relrowsecurity AS rls_enabled " + "FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE n.nspname = 'public' AND c.relkind = 'r' ORDER BY 1;" +) + + +def env(key: str) -> str: + import os + + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text(encoding="utf-8").splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def term_run(page, cmd: str, wait_ms: int = 4000) -> None: + helper = page.locator("textarea.xterm-helper-textarea") + if helper.count(): + helper.first.click() + page.keyboard.type(cmd, delay=2) + page.keyboard.press("Enter") + page.wait_for_timeout(wait_ms) + + +def term_text(page) -> str: + return page.evaluate( + """() => { + const rows = document.querySelector('.xterm-rows'); + return rows ? rows.innerText : ''; + }""" + ) + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + if not password: + print("NO_DOKPLOY_PASSWORD", flush=True) + return 2 + SHOT.mkdir(parents=True, exist_ok=True) + OUT.parent.mkdir(parents=True, exist_ok=True) + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1600, "height": 1200}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(700) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + + page.goto(f"{BASE}/dashboard/docker", wait_until="networkidle", timeout=120000) + page.wait_for_timeout(1000) + page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill( + "supabase-db" + ) + page.wait_for_timeout(1200) + page.keyboard.press("Escape") + page.wait_for_timeout(300) + page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click() + page.wait_for_timeout(600) + page.get_by_text(re.compile(r"^Terminal$", re.I)).first.click() + page.wait_for_timeout(3500) + + term_run(page, f'psql -U postgres -d postgres -c "{INVENTORY}" ; echo RLS_INV', 7000) + text = term_text(page) + OUT.write_text(text[-4000:], encoding="utf-8") + print("inventory_written", flush=True) + + agent_on = bool(re.search(r"agent_runs\s+\|\s+t\b", text)) + agent_off = bool(re.search(r"agent_runs\s+\|\s+f\b", text)) + print("agent_runs_rls", "on" if agent_on else ("off" if agent_off else "unknown"), flush=True) + + if agent_on: + print("OK_ALREADY", flush=True) + browser.close() + return 0 + if not agent_off: + print("PARSE_FAIL — see .ops/rls_inventory.txt", flush=True) + page.screenshot(path=str(SHOT / "rls_parse_fail.png"), full_page=True) + browser.close() + return 4 + + # Apply SQL via base64 chunks + b64 = base64.b64encode(SQL_FILE.read_text(encoding="utf-8").encode()).decode() + chunks = [b64[i : i + 200] for i in range(0, len(b64), 200)] + term_run(page, "rm -f /tmp/agent_rls.b64 /tmp/agent_rls.sql; echo READY", 2000) + for i, ch in enumerate(chunks): + op = ">" if i == 0 else ">>" + term_run(page, f"printf '%s' '{ch}' {op} /tmp/agent_rls.b64", 800) + term_run( + page, + "base64 -d /tmp/agent_rls.b64 > /tmp/agent_rls.sql && " + "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/agent_rls.sql; " + "echo EXIT:$?", + 8000, + ) + term_run(page, f'psql -U postgres -d postgres -c "{INVENTORY}" ; echo RLS_INV2', 7000) + text2 = term_text(page) + OUT.write_text(text2[-4000:], encoding="utf-8") + page.screenshot(path=str(SHOT / "rls_after.png"), full_page=True) + ok = bool(re.search(r"agent_runs\s+\|\s+t\b", text2)) + print("agent_runs_after", "on" if ok else "still_off", flush=True) + browser.close() + return 0 if ok else 6 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/agent_runs_rls.sql b/sql/agent_runs_rls.sql new file mode 100644 index 0000000..299432e --- /dev/null +++ b/sql/agent_runs_rls.sql @@ -0,0 +1,38 @@ +-- Idempotent: enable RLS + tenant policies on agent_runs +-- Safe to re-run. + +alter table public.agent_runs enable row level security; + +drop policy if exists agent_runs_tenant_select on public.agent_runs; +drop policy if exists agent_runs_tenant_insert on public.agent_runs; +drop policy if exists agent_runs_tenant_update on public.agent_runs; + +create policy agent_runs_tenant_select on public.agent_runs + for select using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy agent_runs_tenant_insert on public.agent_runs + for insert with check ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy agent_runs_tenant_update on public.agent_runs + for update using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +-- Verify +select c.relname, c.relrowsecurity +from pg_class c +join pg_namespace n on n.oid = c.relnamespace +where n.nspname = 'public' and c.relname = 'agent_runs';