diff --git a/deploy/cli-minimal/README.md b/deploy/cli-minimal/README.md index 660cc92cb..c60f95d77 100644 --- a/deploy/cli-minimal/README.md +++ b/deploy/cli-minimal/README.md @@ -25,10 +25,8 @@ Full platform deployment belongs to operator runbooks, not this profile. Default startup is local-only: ```bash -SUPABASE_JWT_SECRET=... \ -SUPABASE_ANON_KEY=... \ -LEON_SUPABASE_SERVICE_ROLE_KEY=... \ -docker compose -f deploy/cli-minimal/compose.yml up +python3 deploy/cli-minimal/generate-env.py > deploy/cli-minimal/.env +docker compose --env-file deploy/cli-minimal/.env -f deploy/cli-minimal/compose.yml up ``` That publishes `mycel-backend` on `127.0.0.1:8042`. This is the safe default @@ -38,15 +36,16 @@ To let `cel` on another machine connect to this CLI-minimal backend, publish an explicit reachable URL: ```bash -MYCEL_BIND_HOST=0.0.0.0 \ -MYCEL_PORT=8042 \ -MYCEL_PUBLIC_URL=http://:8042 \ -SUPABASE_JWT_SECRET=... \ -SUPABASE_ANON_KEY=... \ -LEON_SUPABASE_SERVICE_ROLE_KEY=... \ -docker compose -f deploy/cli-minimal/compose.yml up +python3 deploy/cli-minimal/generate-env.py \ + --bind-host 0.0.0.0 \ + --public-url http://:8042 \ + > deploy/cli-minimal/.env +docker compose --env-file deploy/cli-minimal/.env -f deploy/cli-minimal/compose.yml up ``` Other machines should configure `cel` with `MYCEL_PUBLIC_URL`. Future CLI onboarding should accept that URL, probe backend version/capabilities, and fail loudly if the target is not a compatible Mycel communication backend. + +`SUPABASE_ANON_KEY` and `LEON_SUPABASE_SERVICE_ROLE_KEY` are PostgREST JWTs +signed by `SUPABASE_JWT_SECRET`; do not replace them with opaque random strings. diff --git a/deploy/cli-minimal/generate-env.py b/deploy/cli-minimal/generate-env.py new file mode 100644 index 000000000..4fe973698 --- /dev/null +++ b/deploy/cli-minimal/generate-env.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import secrets +import time +from urllib.parse import urlparse + +import jwt + + +def _port_from_url(url: str) -> str: + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + raise SystemExit(f"--public-url must be an absolute URL: {url}") + if parsed.port is not None: + return str(parsed.port) + if parsed.scheme == "https": + return "443" + if parsed.scheme == "http": + return "80" + raise SystemExit(f"--public-url must use http or https: {url}") + + +def _supabase_role_token(role: str, secret: str) -> str: + now = int(time.time()) + return jwt.encode({"role": role, "iss": "mycel-cli-minimal", "iat": now}, secret, algorithm="HS256") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate a local Mycel CLI-minimal compose env.") + parser.add_argument("--jwt-secret", default=None, help="Supabase/PostgREST JWT secret. Random by default.") + parser.add_argument("--public-url", default="http://127.0.0.1:8042", help="URL that cel clients should connect to.") + parser.add_argument("--bind-host", default="127.0.0.1", help="Host address published by docker compose.") + args = parser.parse_args() + + jwt_secret = args.jwt_secret or secrets.token_urlsafe(48) + port = _port_from_url(args.public_url) + + print("# Generated by deploy/cli-minimal/generate-env.py") + print("# Keep this file local; it contains service credentials.") + print(f"SUPABASE_JWT_SECRET={jwt_secret}") + print(f"SUPABASE_ANON_KEY={_supabase_role_token('anon', jwt_secret)}") + print(f"LEON_SUPABASE_SERVICE_ROLE_KEY={_supabase_role_token('service_role', jwt_secret)}") + print(f"MYCEL_BIND_HOST={args.bind_host}") + print(f"MYCEL_PORT={port}") + print(f"MYCEL_PUBLIC_URL={args.public_url}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/apply_app_schema.py b/scripts/apply_app_schema.py index d58080073..eab8bec57 100644 --- a/scripts/apply_app_schema.py +++ b/scripts/apply_app_schema.py @@ -29,6 +29,16 @@ """.strip() +def local_supabase_role_grants_sql(schemas: list[str]) -> str: + schema_list = ", ".join(schemas) + return f""" +grant usage on schema {schema_list} to service_role; +grant all privileges on all tables in schema {schema_list} to service_role; +grant all privileges on all sequences in schema {schema_list} to service_role; +grant all privileges on all functions in schema {schema_list} to service_role; +""".strip() + + def load_manifest(path: Path = MANIFEST_PATH) -> dict[str, Any]: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): @@ -95,6 +105,10 @@ def apply_schema(database_url: str, *, prepare_supabase_roles: bool = False, sch with psycopg.connect(database_url, autocommit=True) as conn: with conn.cursor() as cur: cur.execute(path.read_text(encoding="utf-8")) + if prepare_supabase_roles: + with psycopg.connect(database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute(local_supabase_role_grants_sql(schemas)) return files diff --git a/scripts/check_app_schema.py b/scripts/check_app_schema.py index 4332d4635..2075a5715 100644 --- a/scripts/check_app_schema.py +++ b/scripts/check_app_schema.py @@ -14,11 +14,6 @@ CREATE_TABLE_RE = re.compile(r"^CREATE TABLE (?P[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*) \(", re.MULTILINE) CREATE_FUNCTION_RE = re.compile(r"^CREATE FUNCTION (?P[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*)\(", re.MULTILINE) -FORBIDDEN_SCHEMA_PATHS = [ - SCHEMA_DIR / "local_communication.sql", - SCHEMA_DIR / "canonical", -] - FORBIDDEN_BASELINE_TEXT = [ "OWNER TO", "GRANT ", @@ -57,9 +52,12 @@ def check_schema_tree(repo_root: Path = REPO_ROOT) -> list[str]: manifest_path = schema_dir / "app_schema_manifest.json" violations: list[str] = [] - for path in [schema_dir / "local_communication.sql", schema_dir / "canonical"]: - if path.exists(): - violations.append(f"forbidden schema fork path exists: {path.relative_to(repo_root)}") + schema_subdirs = sorted(path.relative_to(repo_root).as_posix() for path in schema_dir.iterdir() if path.is_dir()) + if schema_subdirs: + violations.append(f"schema directory must be flat; found subdirectories: {', '.join(schema_subdirs)}") + local_schema_path = schema_dir / "local_communication.sql" + if local_schema_path.exists(): + violations.append(f"forbidden schema fork path exists: {local_schema_path.relative_to(repo_root)}") manifest, manifest_errors = _read_manifest(manifest_path) violations.extend(manifest_errors) diff --git a/tests/Unit/backend/test_local_communication_deploy.py b/tests/Unit/backend/test_local_communication_deploy.py index 359c150d8..4e8328d57 100644 --- a/tests/Unit/backend/test_local_communication_deploy.py +++ b/tests/Unit/backend/test_local_communication_deploy.py @@ -1,13 +1,17 @@ from __future__ import annotations +import subprocess +import sys from pathlib import Path +import jwt import yaml ROOT = Path(__file__).resolve().parents[3] SCHEMA_PATH = ROOT / "storage/schema/local_communication.sql" CLI_MINIMAL_COMPOSE = ROOT / "deploy/cli-minimal/compose.yml" CLI_MINIMAL_GATEWAY_CONF = ROOT / "deploy/cli-minimal/rest-gateway.conf" +CLI_MINIMAL_ENV_SCRIPT = ROOT / "deploy/cli-minimal/generate-env.py" DEPLOY_README = ROOT / "deploy/README.md" @@ -79,6 +83,35 @@ def test_cli_minimal_gateway_is_nginx_rest_only_not_supabase_bundle() -> None: assert not {"gotrue", "studio", "realtime", "storage", "analytics", "kong"} & set(services) +def test_cli_minimal_env_generator_emits_postgrest_compatible_jwts() -> None: + result = subprocess.run( + [ + sys.executable, + str(CLI_MINIMAL_ENV_SCRIPT), + "--jwt-secret", + "test-secret", + "--public-url", + "http://127.0.0.1:18442", + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + env = dict(line.split("=", 1) for line in result.stdout.splitlines() if line and not line.startswith("#")) + + assert env["SUPABASE_JWT_SECRET"] == "test-secret" + assert env["MYCEL_PUBLIC_URL"] == "http://127.0.0.1:18442" + assert env["MYCEL_PORT"] == "18442" + assert jwt.decode(env["SUPABASE_ANON_KEY"], "test-secret", algorithms=["HS256"], options={"verify_aud": False})["role"] == "anon" + assert ( + jwt.decode(env["LEON_SUPABASE_SERVICE_ROLE_KEY"], "test-secret", algorithms=["HS256"], options={"verify_aud": False})["role"] + == "service_role" + ) + + def test_deploy_readme_names_cli_minimal_boundary_and_full_deploy_absence() -> None: text = DEPLOY_README.read_text(encoding="utf-8") diff --git a/tests/Unit/storage/test_app_schema_applier.py b/tests/Unit/storage/test_app_schema_applier.py index 1f04c550d..64b97d902 100644 --- a/tests/Unit/storage/test_app_schema_applier.py +++ b/tests/Unit/storage/test_app_schema_applier.py @@ -77,6 +77,17 @@ def test_supabase_role_prelude_is_explicit() -> None: assert "create role anon" in applier.SUPABASE_ROLE_PRELUDE +def test_local_supabase_role_grants_cover_all_app_owned_schemas() -> None: + applier = _load_applier() + + sql = applier.local_supabase_role_grants_sql(["identity", "chat"]) + + assert "grant usage on schema identity, chat to service_role" in sql + assert "grant all privileges on all tables in schema identity, chat to service_role" in sql + assert "grant all privileges on all sequences in schema identity, chat to service_role" in sql + assert "grant all privileges on all functions in schema identity, chat to service_role" in sql + + def test_applier_isolates_session_state_between_schema_files(tmp_path, monkeypatch) -> None: applier = _load_applier() schema_dir = tmp_path / "schema" @@ -133,6 +144,7 @@ def cursor(self) -> Cursor: [applier.APP_SCHEMA_STATE_SQL], ["select set_config('search_path', '', false);"], ["create extension if not exists pgcrypto;"], + [applier.local_supabase_role_grants_sql(["identity"])], ] diff --git a/tests/Unit/storage/test_app_schema_discipline.py b/tests/Unit/storage/test_app_schema_discipline.py index 956cdc943..c6c0f6066 100644 --- a/tests/Unit/storage/test_app_schema_discipline.py +++ b/tests/Unit/storage/test_app_schema_discipline.py @@ -56,6 +56,23 @@ def test_app_schema_checker_passes_current_tree() -> None: assert checker.check_schema_tree(ROOT) == [] +def test_app_schema_checker_rejects_schema_subdirectories(tmp_path) -> None: + checker = _load_checker() + schema_dir = tmp_path / "storage" / "schema" + schema_dir.mkdir(parents=True) + (schema_dir / "nested").mkdir() + (schema_dir / "app_schema_manifest.json").write_text( + '{"baseline": "app_schema.sql", "patches": [], "app_owned_schemas": ["identity"], ' + '"baseline_table_count": 0, "baseline_function_count": 0}', + encoding="utf-8", + ) + (schema_dir / "app_schema.sql").write_text("CREATE SCHEMA identity;", encoding="utf-8") + + violations = checker.check_schema_tree(tmp_path) + + assert violations == ["schema directory must be flat; found subdirectories: storage/schema/nested"] + + def test_app_schema_checker_is_a_loud_cli_gate() -> None: result = subprocess.run( [sys.executable, str(CHECKER_PATH)],