From 5068f9641bd9c510e16c30fbdc5e0778be2c422f Mon Sep 17 00:00:00 2001 From: Himanshu Raj <156138261+ErebAsh@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:55:01 +0530 Subject: [PATCH 1/3] feat(security): Implement Row-Level Security (RLS) for Tenant Isolation --- backend/.env.example | 7 ++++ backend/requirements.txt | 1 + backend/supabase_service.py | 68 +++++++++++++++++++++++----------- backend/tests/test_rls.py | 64 ++++++++++++++++++++++++++++++++ public/setup/migration_rls.sql | 17 +++++++++ public/setup/schema.sql | 14 +++++++ 6 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/tests/test_rls.py create mode 100644 public/setup/migration_rls.sql 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 90a2edc..e619ece 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 9bd8256..08354f3 100644 --- a/backend/supabase_service.py +++ b/backend/supabase_service.py @@ -5,17 +5,41 @@ import os -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["YOUR_SUPABASE_URL"] - key = os.environ["YOUR_SUPABASE_KEY"] + key = os.environ["SUPABASE_KEY"] _supabase = create_client(url, key) return _supabase @@ -25,7 +49,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() @@ -38,7 +62,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 @@ -50,7 +75,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() @@ -68,8 +93,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 @@ -108,7 +133,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") \ @@ -126,7 +151,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: @@ -136,7 +162,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) \ @@ -153,7 +179,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() @@ -164,7 +190,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() @@ -182,7 +208,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") \ @@ -203,7 +229,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) \ @@ -223,7 +249,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) \ @@ -246,7 +272,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") \ @@ -388,7 +414,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() @@ -402,7 +428,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() \ @@ -419,7 +445,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() @@ -432,7 +458,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..3c8e0ff --- /dev/null +++ b/backend/tests/test_rls.py @@ -0,0 +1,64 @@ +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 + +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)); From 6d5799dd230219d1bcc8bf33ac35ddc3ba0e05c1 Mon Sep 17 00:00:00 2001 From: Himanshu Raj <156138261+ErebAsh@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:09:32 +0530 Subject: [PATCH 2/3] chore: fix ruff formatting issues in test and service --- backend/supabase_service.py | 6 +++--- backend/tests/test_rls.py | 20 +++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/backend/supabase_service.py b/backend/supabase_service.py index 08354f3..4400fad 100644 --- a/backend/supabase_service.py +++ b/backend/supabase_service.py @@ -19,7 +19,7 @@ def get_client(telegram_id: str | None = None) -> Client: 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: @@ -29,8 +29,8 @@ def get_client(telegram_id: str | None = None) -> Client: } token = jwt.encode(payload, jwt_secret, algorithm="HS256") return create_client( - url, - os.environ["SUPABASE_KEY"], + url, + os.environ["SUPABASE_KEY"], options=ClientOptions(headers={"Authorization": f"Bearer {token}"}) ) else: diff --git a/backend/tests/test_rls.py b/backend/tests/test_rls.py index 3c8e0ff..a49f81d 100644 --- a/backend/tests/test_rls.py +++ b/backend/tests/test_rls.py @@ -1,6 +1,7 @@ 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__), '..'))) @@ -10,12 +11,13 @@ from supabase_service import get_client + 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.") @@ -23,17 +25,17 @@ def test_rls(): # 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, @@ -42,19 +44,19 @@ def test_rls(): "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() From 635399a496f79d1110f6110fc0474cc19a011798 Mon Sep 17 00:00:00 2001 From: Himanshu Raj <156138261+ErebAsh@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:11:35 +0530 Subject: [PATCH 3/3] chore: fix remaining ruff lint issues --- backend/supabase_service.py | 2 +- backend/tests/test_rls.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/supabase_service.py b/backend/supabase_service.py index 4400fad..0659070 100644 --- a/backend/supabase_service.py +++ b/backend/supabase_service.py @@ -14,7 +14,7 @@ def get_client(telegram_id: str | None = None) -> Client: """ - Returns a Supabase 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. """ diff --git a/backend/tests/test_rls.py b/backend/tests/test_rls.py index a49f81d..35f97d3 100644 --- a/backend/tests/test_rls.py +++ b/backend/tests/test_rls.py @@ -9,7 +9,7 @@ # Load env vars before importing get_client load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env')) -from supabase_service import get_client +from supabase_service import get_client # noqa: E402 def test_rls():