Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 46 additions & 20 deletions backend/supabase_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
ErebAsh marked this conversation as resolved.

# 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
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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") \
Expand All @@ -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:
Expand All @@ -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) \
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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") \
Expand All @@ -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) \
Expand All @@ -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) \
Expand All @@ -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") \
Expand Down Expand Up @@ -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()
Expand All @@ -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() \
Expand All @@ -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()
Expand All @@ -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()
Expand Down
66 changes: 66 additions & 0 deletions backend/tests/test_rls.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
ErebAsh marked this conversation as resolved.

# 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()
17 changes: 17 additions & 0 deletions public/setup/migration_rls.sql
Original file line number Diff line number Diff line change
@@ -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));
14 changes: 14 additions & 0 deletions public/setup/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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));