diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..cacfc2f --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,7 @@ +SUPABASE_URL="https://your-project.supabase.co" +SUPABASE_KEY="your-service-role-key" +SUPABASE_JWT_SECRET="your-jwt-secret" +TELEGRAM_BOT_TOKEN="your-telegram-bot-token" +GITHUB_CLIENT_ID="your-github-client-id" +ENVIRONMENT="development" +WEBHOOK_URL="https://your-webhook-url.ngrok-free.dev" diff --git a/backend/requirements.txt b/backend/requirements.txt index a69e4ff..086df73 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,4 @@ python-dotenv>=1.0.0,<2.0.0 pydantic>=2.13.4,<3.0.0 httpx>=0.28.1,<1.0.0 slowapi>=0.1.9 +PyJWT>=2.8.0,<3.0.0 diff --git a/backend/supabase_service.py b/backend/supabase_service.py index 866b63f..98c32ee 100644 --- a/backend/supabase_service.py +++ b/backend/supabase_service.py @@ -6,16 +6,40 @@ import os from typing import Optional -from supabase import Client, create_client +import jwt +from supabase import Client, ClientOptions, create_client # --- Client Initialization ------------------------------------------------------------------------------ _supabase: Client | None = None -def get_client() -> Client: +def get_client(telegram_id: str | None = None) -> Client: + """ + Returns a Supabase client. + If telegram_id is provided, returns a client authenticated with a custom JWT for RLS. + Otherwise, returns the global service_role client. + """ + url = os.environ["SUPABASE_URL"] + + if telegram_id: + jwt_secret = os.environ.get("SUPABASE_JWT_SECRET") + if jwt_secret: + payload = { + "role": "authenticated", + "sub": telegram_id, + } + token = jwt.encode(payload, jwt_secret, algorithm="HS256") + return create_client( + url, + os.environ["SUPABASE_KEY"], + options=ClientOptions(headers={"Authorization": f"Bearer {token}"}) + ) + else: + print("[supabase] WARNING: SUPABASE_JWT_SECRET not found, falling back to service_role client") + + # Global service_role client global _supabase if _supabase is None: - url = os.environ["SUPABASE_URL"] key = os.environ["SUPABASE_KEY"] _supabase = create_client(url, key) return _supabase @@ -26,7 +50,7 @@ def get_client() -> Client: def get_user_by_telegram_id(telegram_id: str) -> dict | None: """Returns user row or None if not registered.""" try: - result = get_client().table("users") \ + result = get_client(telegram_id).table("users") \ .select("*") \ .eq("telegram_id", telegram_id) \ .execute() @@ -39,7 +63,8 @@ def get_user_by_telegram_id(telegram_id: str) -> dict | None: def upsert_user(user: dict) -> dict | None: """Insert or update user by telegram_id. Returns saved row.""" try: - result = get_client().table("users") \ + telegram_id = user.get("telegram_id") + result = get_client(telegram_id).table("users") \ .upsert(user, on_conflict="telegram_id") \ .execute() return result.data[0] if result.data else None @@ -51,7 +76,7 @@ def upsert_user(user: dict) -> dict | None: def update_last_active(telegram_id: str) -> None: """Touch last_active timestamp for keepalive tracking.""" try: - get_client().table("users") \ + get_client(telegram_id).table("users") \ .update({"last_active": "now()"}) \ .eq("telegram_id", telegram_id) \ .execute() @@ -69,8 +94,8 @@ def upsert_staged_file(payload: dict) -> dict | None: Returns the saved row. """ try: - db = get_client() telegram_id = payload["telegram_id"] + db = get_client(telegram_id) filepath = payload["filepath"] # Check for existing pending diff for this file @@ -109,7 +134,7 @@ def upsert_staged_file(payload: dict) -> dict | None: def get_pending_files(telegram_id: str) -> list[dict]: """Returns all pending staged files for a user, oldest first.""" try: - result = get_client().table("staged_files") \ + result = get_client(telegram_id).table("staged_files") \ .select("*") \ .eq("telegram_id", telegram_id) \ .eq("status", "pending") \ @@ -127,7 +152,8 @@ def get_pending_files(telegram_id: str) -> list[dict]: def insert_commit_log(log: dict) -> None: """Record a successful commit in the audit log.""" try: - get_client().table("commit_log") \ + telegram_id = log.get("telegram_id") + get_client(telegram_id).table("commit_log") \ .insert(log) \ .execute() except Exception as e: @@ -137,7 +163,7 @@ def insert_commit_log(log: dict) -> None: def get_recent_commits(telegram_id: str, limit: int = 10) -> list[dict]: """Returns the last N commits for a user, newest first.""" try: - result = get_client().table("commit_log") \ + result = get_client(telegram_id).table("commit_log") \ .select("*") \ .eq("telegram_id", telegram_id) \ .order("committed_at", desc=True) \ @@ -154,7 +180,7 @@ def get_recent_commits(telegram_id: str, limit: int = 10) -> list[dict]: def update_active_repo(telegram_id: str, active_repo: str, active_branch: str) -> None: """Update the user's currently active repo/branch (auto-detected from VS Code).""" try: - get_client().table("users") \ + get_client(telegram_id).table("users") \ .update({"active_repo": active_repo, "active_branch": active_branch}) \ .eq("telegram_id", telegram_id) \ .execute() @@ -165,7 +191,7 @@ def update_active_repo(telegram_id: str, active_repo: str, active_branch: str) - def update_branch(telegram_id: str, branch: str) -> None: """Update a user's active branch manually (from /branch command).""" try: - get_client().table("users") \ + get_client(telegram_id).table("users") \ .update({"active_branch": branch, "branch": branch}) \ .eq("telegram_id", telegram_id) \ .execute() @@ -183,7 +209,7 @@ def get_pending_files_by_repo(telegram_id: str) -> dict[str, list[dict]]: user = get_user_by_telegram_id(telegram_id) fallback_repo = (user or {}).get("active_repo") or (user or {}).get("default_repo", "unknown") - result = get_client().table("staged_files") \ + result = get_client(telegram_id).table("staged_files") \ .select("*") \ .eq("telegram_id", telegram_id) \ .eq("status", "pending") \ @@ -204,7 +230,7 @@ def get_pending_files_by_repo(telegram_id: str) -> dict[str, list[dict]]: def unstage_file_by_path(telegram_id: str, filepath: str) -> bool: """Remove a specific pending staged file by filepath. Returns True if found.""" try: - db = get_client() + db = get_client(telegram_id) result = db.table("staged_files") \ .select("id") \ .eq("telegram_id", telegram_id) \ @@ -224,7 +250,7 @@ def unstage_file_by_path(telegram_id: str, filepath: str) -> bool: def clear_all_staged(telegram_id: str) -> int: """Cancel all pending staged files for a user. Returns count cleared.""" try: - db = get_client() + db = get_client(telegram_id) result = db.table("staged_files") \ .select("id") \ .eq("telegram_id", telegram_id) \ @@ -247,7 +273,7 @@ def sync_pending_state(telegram_id: str, current_filepaths: list[str]) -> int: Returns the count of files synchronized. """ try: - db = get_client() + db = get_client(telegram_id) # 1. Get all pending files for this user result = db.table("staged_files") \ .select("id, filepath") \ @@ -389,7 +415,7 @@ def save_device_flow_state(telegram_id: str, state: dict) -> bool: """Store GitHub Device Flow state (device_code, expires_at) in users table.""" try: import json - get_client().table("users") \ + get_client(telegram_id).table("users") \ .update({"device_flow_state": json.dumps(state)}) \ .eq("telegram_id", telegram_id) \ .execute() @@ -403,7 +429,7 @@ def get_device_flow_state(telegram_id: str) -> dict | None: """Retrieve pending Device Flow state for a user.""" try: import json - result = get_client().table("users") \ + result = get_client(telegram_id).table("users") \ .select("device_flow_state") \ .eq("telegram_id", telegram_id) \ .single() \ @@ -420,7 +446,7 @@ def get_device_flow_state(telegram_id: str) -> dict | None: def delete_device_flow_state(telegram_id: str) -> bool: """Clear device flow state after auth completes or expires.""" try: - get_client().table("users") \ + get_client(telegram_id).table("users") \ .update({"device_flow_state": None}) \ .eq("telegram_id", telegram_id) \ .execute() @@ -433,7 +459,7 @@ def delete_device_flow_state(telegram_id: str) -> bool: def update_github_token(telegram_id: str, token: str) -> bool: """Update stored GitHub OAuth token after Device Flow authorization.""" try: - get_client().table("users") \ + get_client(telegram_id).table("users") \ .update({"github_token": token}) \ .eq("telegram_id", telegram_id) \ .execute() diff --git a/backend/tests/test_rls.py b/backend/tests/test_rls.py new file mode 100644 index 0000000..35f97d3 --- /dev/null +++ b/backend/tests/test_rls.py @@ -0,0 +1,66 @@ +import os +import sys +import uuid + +from dotenv import load_dotenv + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +# Load env vars before importing get_client +load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env')) + +from supabase_service import get_client # noqa: E402 + + +def test_rls(): + print("--- Testing RLS Isolation ---") + + user_a = "test_user_a_" + str(uuid.uuid4())[:8] + user_b = "test_user_b_" + str(uuid.uuid4())[:8] + + if not os.environ.get("SUPABASE_JWT_SECRET"): + print("WARNING: SUPABASE_JWT_SECRET is not set. The client will fall back to service_role, and this test will fail.") + print("Please set SUPABASE_JWT_SECRET in your backend/.env file to run this test properly.") + return + + # 1. Create a dummy user and staged file for User A using the global service_role client (bypasses RLS) + service_client = get_client() + + print(f"Creating User A ({user_a}) and User B ({user_b}) via service_role...") + service_client.table("users").insert([ + {"telegram_id": user_a, "github_token": "dummy", "default_repo": "dummy/repo"}, + {"telegram_id": user_b, "github_token": "dummy", "default_repo": "dummy/repo"} + ]).execute() + + # Get User A's internal UUID + user_a_record = service_client.table("users").select("id").eq("telegram_id", user_a).execute() + user_a_id = user_a_record.data[0]["id"] + + print("Inserting a staged file for User A...") + service_client.table("staged_files").insert({ + "user_id": user_a_id, + "telegram_id": user_a, + "filepath": "secret.txt", + "diff": "+ secret data", + "base_sha": "abcdef123" + }).execute() + + # 2. Query using User B's JWT context + print("Attempting to query User A's staged file using User B's JWT context...") + client_b = get_client(user_b) + + # We explicitly ask for User A's data + result = client_b.table("staged_files").select("*").eq("telegram_id", user_a).execute() + + if len(result.data) == 0: + print("SUCCESS: RLS is working! User B was denied access to User A's data (0 rows returned).") + else: + print(f"FAIL: RLS failed! User B retrieved User A's data: {result.data}") + + # Cleanup + print("Cleaning up test data...") + service_client.table("users").delete().in_("telegram_id", [user_a, user_b]).execute() + print("Done.") + +if __name__ == "__main__": + test_rls() diff --git a/public/setup/migration_rls.sql b/public/setup/migration_rls.sql new file mode 100644 index 0000000..90f6485 --- /dev/null +++ b/public/setup/migration_rls.sql @@ -0,0 +1,17 @@ +-- ============================================================ +-- MIGRATION: ENABLE ROW LEVEL SECURITY (RLS) +-- Run this in your Supabase SQL editor to apply RLS to existing tables +-- ============================================================ + +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +-- Drop policy if it exists (for idempotency in reruns) +DROP POLICY IF EXISTS "User isolation" ON users; +CREATE POLICY "User isolation" ON users FOR ALL USING (telegram_id = current_setting('request.jwt.claim.sub', true)); + +ALTER TABLE staged_files ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "User isolation" ON staged_files; +CREATE POLICY "User isolation" ON staged_files FOR ALL USING (telegram_id = current_setting('request.jwt.claim.sub', true)); + +ALTER TABLE commit_log ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "User isolation" ON commit_log; +CREATE POLICY "User isolation" ON commit_log FOR ALL USING (telegram_id = current_setting('request.jwt.claim.sub', true)); diff --git a/public/setup/schema.sql b/public/setup/schema.sql index 146717e..016ced1 100644 --- a/public/setup/schema.sql +++ b/public/setup/schema.sql @@ -89,3 +89,17 @@ CREATE TABLE commit_log ( CREATE INDEX idx_commit_log_user ON commit_log(user_id, committed_at DESC); CREATE INDEX idx_commit_log_telegram ON commit_log(telegram_id, committed_at DESC); + +-- ============================================================ +-- ROW LEVEL SECURITY (RLS) +-- Isolate data so users can only access their own records +-- ============================================================ + +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +CREATE POLICY "User isolation" ON users FOR ALL USING (telegram_id = current_setting('request.jwt.claim.sub', true)); + +ALTER TABLE staged_files ENABLE ROW LEVEL SECURITY; +CREATE POLICY "User isolation" ON staged_files FOR ALL USING (telegram_id = current_setting('request.jwt.claim.sub', true)); + +ALTER TABLE commit_log ENABLE ROW LEVEL SECURITY; +CREATE POLICY "User isolation" ON commit_log FOR ALL USING (telegram_id = current_setting('request.jwt.claim.sub', true));