RoyAalekh commited on
Commit
fcec2b0
·
1 Parent(s): 6f7ff4d

Fix RLS security: switch to service_role client and enable RLS policies

Browse files
migrations/README.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Row Level Security (RLS) Implementation
2
+
3
+ ## Overview
4
+ This migration enables RLS on the `public.trees` table to secure the TreeTrack application against unauthorized database access via the public anon key.
5
+
6
+ ## Security Model
7
+
8
+ ### Before (Insecure)
9
+ - RLS disabled on `public.trees`
10
+ - Backend used `anon_key` for all database operations
11
+ - Anyone with the anon key could access all tree data directly
12
+
13
+ ### After (Secure)
14
+ - RLS enabled with deny-all policy for anon role
15
+ - Backend uses `service_role` key (bypasses RLS)
16
+ - Authorization enforced by FastAPI layer via `AuthManager`
17
+ - Frontend never accesses Supabase directly (all via `/api/*` endpoints)
18
+
19
+ ## Architecture
20
+
21
+ ```
22
+ Frontend (JS)
23
+ ↓ (Bearer token auth)
24
+ FastAPI Endpoints (/api/*)
25
+ ↓ (validates JWT via AuthManager)
26
+ SupabaseDatabase (service_role client)
27
+ ↓ (bypasses RLS)
28
+ Supabase Postgres (RLS enabled, anon blocked)
29
+ ```
30
+
31
+ ## Migration Steps
32
+
33
+ 1. **Execute SQL migration**
34
+ - Open Supabase Dashboard → SQL Editor
35
+ - Run `migrations/enable_rls.sql`
36
+ - Verify output shows `rowsecurity = true`
37
+
38
+ 2. **Code changes** (already applied)
39
+ - `supabase_database.py` now uses `get_service_client()`
40
+ - Removed unnecessary comments throughout module
41
+
42
+ 3. **Test the application**
43
+ ```bash
44
+ # Ensure service_role key is set
45
+ $env:SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
46
+
47
+ # Start the app
48
+ uv run uvicorn app:app --reload
49
+ ```
50
+
51
+ 4. **Verify security**
52
+ - Test CRUD operations through web interface
53
+ - Confirm all operations work normally
54
+ - Verify anon key cannot access data directly (use Supabase API client to test)
55
+
56
+ ## Important Notes
57
+
58
+ - **Service role key must remain server-side only** - never expose in frontend code or logs
59
+ - Frontend authentication handled by custom JWT system (not Supabase Auth)
60
+ - All database authorization checks done by FastAPI before database access
61
+ - RLS policy blocks anon role as defense-in-depth measure
62
+
63
+ ## Rollback
64
+
65
+ If issues occur, disable RLS temporarily:
66
+ ```sql
67
+ ALTER TABLE public.trees DISABLE ROW LEVEL SECURITY;
68
+ ```
69
+
70
+ Then investigate and fix before re-enabling.
migrations/enable_rls.sql ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Enable Row Level Security on public.trees table
2
+ -- Execute this in Supabase SQL Editor
3
+
4
+ -- Step 1: Enable RLS on the trees table
5
+ ALTER TABLE public.trees ENABLE ROW LEVEL SECURITY;
6
+
7
+ -- Step 2: Drop any existing policies (idempotent)
8
+ DROP POLICY IF EXISTS "Deny all access to anon role" ON public.trees;
9
+
10
+ -- Step 3: Create policy to deny all access via anon key
11
+ -- This ensures the anon key cannot be used to access tree data
12
+ -- All operations must go through the FastAPI backend using service_role
13
+ CREATE POLICY "Deny all access to anon role"
14
+ ON public.trees
15
+ FOR ALL
16
+ TO anon
17
+ USING (false);
18
+
19
+ -- Step 4: Verify RLS is enabled
20
+ SELECT tablename, rowsecurity
21
+ FROM pg_tables
22
+ WHERE schemaname = 'public'
23
+ AND tablename = 'trees';
24
+
25
+ -- Expected result: rowsecurity = true
static/index.html CHANGED
@@ -947,7 +947,7 @@
947
  // Force refresh if we detect cached version
948
  (function() {
949
  const currentVersion = '5.1.1';
950
- const timestamp = '1761514190'; // Cache-busting bump
951
  const lastVersion = sessionStorage.getItem('treetrack_version');
952
  const lastTimestamp = sessionStorage.getItem('treetrack_timestamp');
953
 
@@ -1193,7 +1193,7 @@
1193
  </div>
1194
  </div>
1195
 
1196
- <script type="module" src="/static/js/tree-track-app.js?v=5.1.1&t=1761514190"></script>
1197
 
1198
  <script>
1199
  // Idle-time prefetch of map assets to speed up first navigation
 
947
  // Force refresh if we detect cached version
948
  (function() {
949
  const currentVersion = '5.1.1';
950
+ const timestamp = '1762279460'; // Cache-busting bump
951
  const lastVersion = sessionStorage.getItem('treetrack_version');
952
  const lastTimestamp = sessionStorage.getItem('treetrack_timestamp');
953
 
 
1193
  </div>
1194
  </div>
1195
 
1196
+ <script type="module" src="/static/js/tree-track-app.js?v=5.1.1&t=1762279460"></script>
1197
 
1198
  <script>
1199
  // Idle-time prefetch of map assets to speed up first navigation
static/map.html CHANGED
@@ -968,7 +968,7 @@
968
  // Force refresh if we detect cached version
969
  (function() {
970
  const currentVersion = '5.1.1';
971
- const timestamp = '1761514190'; // Current timestamp for cache busting
972
  const lastVersion = sessionStorage.getItem('treetrack_version');
973
  const lastTimestamp = sessionStorage.getItem('treetrack_timestamp');
974
 
@@ -1137,7 +1137,7 @@ const timestamp = '1761514190'; // Current timestamp for cache busting
1137
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
1138
  <!-- Leaflet MarkerCluster JS for performance and grouping -->
1139
  <script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
1140
- <script src="/static/map.js?v=5.1.1&t=1761514190"></script>
1141
  <script>
1142
  console.log('🗺️ Map script loaded successfully');
1143
  </script>
 
968
  // Force refresh if we detect cached version
969
  (function() {
970
  const currentVersion = '5.1.1';
971
+ const timestamp = '1762279460'; // Current timestamp for cache busting
972
  const lastVersion = sessionStorage.getItem('treetrack_version');
973
  const lastTimestamp = sessionStorage.getItem('treetrack_timestamp');
974
 
 
1137
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
1138
  <!-- Leaflet MarkerCluster JS for performance and grouping -->
1139
  <script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
1140
+ <script src="/static/map.js?v=5.1.1&t=1762279460"></script>
1141
  <script>
1142
  console.log('🗺️ Map script loaded successfully');
1143
  </script>
static/sw.js CHANGED
@@ -1,5 +1,5 @@
1
  // TreeTrack Service Worker - PWA and Offline Support
2
- const VERSION = 1761514190; // Cache busting bump - force clients to fetch new static assets and header image change
3
  const CACHE_NAME = `treetrack-v${VERSION}`;
4
  const STATIC_CACHE = `static-v${VERSION}`;
5
  const API_CACHE = `api-v${VERSION}`;
 
1
  // TreeTrack Service Worker - PWA and Offline Support
2
+ const VERSION = 1762279460; // Cache busting bump - force clients to fetch new static assets and header image change
3
  const CACHE_NAME = `treetrack-v${VERSION}`;
4
  const STATIC_CACHE = `static-v${VERSION}`;
5
  const API_CACHE = `api-v${VERSION}`;
supabase_database.py CHANGED
@@ -1,42 +1,36 @@
1
  """
2
  Supabase database implementation for TreeTrack
3
- Standalone implementation for Supabase Postgres operations
4
  """
5
 
6
  import json
7
  import logging
8
  from typing import Dict, List, Optional, Any
9
- from supabase_client import get_supabase_client, get_service_client
10
 
11
  logger = logging.getLogger(__name__)
12
 
13
 
14
  class SupabaseDatabase:
15
- """Supabase implementation of DatabaseInterface"""
16
 
17
  def __init__(self):
18
  try:
19
- self.client = get_supabase_client()
20
  self.connected = True
21
- logger.info("SupabaseDatabase initialized successfully")
22
  except ValueError as e:
23
  logger.warning(f"Supabase not configured: {e}")
24
- logger.warning("Database operations will be disabled until Supabase is properly configured")
25
  self.client = None
26
  self.connected = False
27
 
28
  def _check_connection(self):
29
- """Check if database is connected, raise error if not"""
30
  if not self.connected or not self.client:
31
  raise RuntimeError("Database not connected. Please configure Supabase credentials.")
32
 
33
  def initialize_database(self) -> bool:
34
- """Initialize database tables and indexes (already done via SQL)"""
35
  try:
36
- # Verify trees table
37
  self.client.table('trees').select("id").limit(1).execute()
38
- logger.info("Trees table verified in Supabase")
39
- # Ensure telemetry table exists
40
  self._ensure_telemetry_table()
41
  return True
42
  except Exception as e:
@@ -44,15 +38,12 @@ class SupabaseDatabase:
44
  return False
45
 
46
  def test_connection(self) -> bool:
47
- """Test Supabase connection with enhanced debugging"""
48
  from supabase_client import test_supabase_connection
49
  return test_supabase_connection()
50
 
51
  async def create_tree(self, tree_data: Dict[str, Any]) -> Dict[str, Any]:
52
- """Create a new tree record"""
53
  self._check_connection()
54
  try:
55
- # Supabase handles JSON fields automatically, no need to stringify
56
  result = self.client.table('trees').insert(tree_data).execute()
57
 
58
  if result.data:
@@ -68,27 +59,19 @@ class SupabaseDatabase:
68
 
69
  async def get_trees(self, limit: int = 100, offset: int = 0,
70
  species: str = None, health_status: str = None) -> List[Dict[str, Any]]:
71
- """Get trees with pagination and optional filters"""
72
  self._check_connection()
73
  try:
74
  query = self.client.table('trees').select("*")
75
 
76
- # Apply filters
77
  if species:
78
  query = query.ilike('scientific_name', f'%{species}%')
79
 
80
- if health_status:
81
- # Note: health_status field doesn't exist in our schema
82
- # This is for future compatibility
83
- pass
84
-
85
- # Apply pagination and ordering
86
  result = query.order('updated_at', desc=True) \
87
  .order('created_at', desc=True) \
88
  .range(offset, offset + limit - 1) \
89
  .execute()
90
 
91
- logger.info(f"Retrieved {len(result.data)} trees from Supabase")
92
  return result.data
93
 
94
  except Exception as e:
@@ -96,7 +79,6 @@ class SupabaseDatabase:
96
  raise
97
 
98
  async def get_tree(self, tree_id: int) -> Optional[Dict[str, Any]]:
99
- """Get a specific tree by ID"""
100
  self._check_connection()
101
  try:
102
  result = self.client.table('trees') \
@@ -113,10 +95,8 @@ class SupabaseDatabase:
113
  raise
114
 
115
  async def update_tree(self, tree_id: int, tree_data: Dict[str, Any]) -> Dict[str, Any]:
116
- """Update a tree record"""
117
  self._check_connection()
118
  try:
119
- # Remove id from update data if present
120
  update_data = {k: v for k, v in tree_data.items() if k != 'id'}
121
 
122
  result = self.client.table('trees') \
@@ -136,7 +116,6 @@ class SupabaseDatabase:
136
  raise
137
 
138
  async def delete_tree(self, tree_id: int) -> bool:
139
- """Delete a tree record"""
140
  self._check_connection()
141
  try:
142
  result = self.client.table('trees') \
@@ -152,7 +131,6 @@ class SupabaseDatabase:
152
  raise
153
 
154
  def get_tree_count(self) -> int:
155
- """Get total number of trees"""
156
  try:
157
  result = self.client.table('trees') \
158
  .select("id", count="exact") \
@@ -165,44 +143,25 @@ class SupabaseDatabase:
165
  return 0
166
 
167
  def get_species_distribution(self, limit: int = 20) -> List[Dict[str, Any]]:
168
- """Get trees by species distribution using database aggregation"""
169
  try:
170
- # Try to use Supabase RPC for aggregation first
171
  try:
172
- # This would require a stored procedure in Supabase:
173
- # CREATE OR REPLACE FUNCTION get_species_distribution(record_limit INTEGER DEFAULT 20)
174
- # RETURNS TABLE(species TEXT, count BIGINT) AS $$
175
- # BEGIN
176
- # RETURN QUERY
177
- # SELECT scientific_name, COUNT(*) as count_val
178
- # FROM trees
179
- # WHERE scientific_name IS NOT NULL AND scientific_name != ''
180
- # GROUP BY scientific_name
181
- # ORDER BY count_val DESC
182
- # LIMIT record_limit;
183
- # END;
184
- # $$ LANGUAGE plpgsql;
185
-
186
  result = self.client.rpc('get_species_distribution', {'record_limit': limit}).execute()
187
  if result.data:
188
  return [{"species": row["species"], "count": row["count"]} for row in result.data]
189
  except Exception as rpc_error:
190
- logger.info(f"RPC function not available, falling back to Python aggregation: {rpc_error}")
191
 
192
- # Fallback: Fetch only what we need and aggregate in Python
193
  result = self.client.table('trees') \
194
  .select("scientific_name") \
195
  .not_.is_('scientific_name', 'null') \
196
  .neq('scientific_name', '') \
197
  .execute()
198
 
199
- # Group by species in Python (not optimal but works)
200
  species_count = {}
201
  for tree in result.data:
202
  species = tree.get('scientific_name', 'Unknown')
203
  species_count[species] = species_count.get(species, 0) + 1
204
 
205
- # Convert to list and sort
206
  distribution = [
207
  {"species": species, "count": count}
208
  for species, count in species_count.items()
@@ -216,10 +175,7 @@ class SupabaseDatabase:
216
  return []
217
 
218
  def get_health_distribution(self) -> List[Dict[str, Any]]:
219
- """Get trees by health status distribution"""
220
  try:
221
- # Health status field doesn't exist in our current schema
222
- # Return empty for compatibility
223
  return []
224
 
225
  except Exception as e:
@@ -227,9 +183,7 @@ class SupabaseDatabase:
227
  return []
228
 
229
  def get_average_measurements(self) -> Dict[str, float]:
230
- """Get average height and diameter"""
231
  try:
232
- # Note: we have 'width' field, not 'diameter'
233
  result = self.client.table('trees') \
234
  .select("height, width") \
235
  .not_.is_('height', 'null') \
@@ -247,7 +201,7 @@ class SupabaseDatabase:
247
 
248
  return {
249
  "average_height": round(avg_height, 2),
250
- "average_diameter": round(avg_width, 2) # Using width as diameter
251
  }
252
 
253
  except Exception as e:
@@ -255,30 +209,20 @@ class SupabaseDatabase:
255
  return {"average_height": 0, "average_diameter": 0}
256
 
257
  def backup_database(self) -> bool:
258
- """Backup database (not needed for Supabase - automatically backed up)"""
259
- logger.info("Supabase automatically backs up data - no manual backup needed")
260
  return True
261
 
262
  def restore_database(self) -> bool:
263
- """Restore database (not needed for Supabase)"""
264
- logger.info("Supabase data is persistent - no restore needed")
265
  return True
266
 
267
- # Telemetry support
268
  def _ensure_telemetry_table(self) -> None:
269
- """Ensure telemetry_events table exists via SQL RPC if available."""
270
  try:
271
- # Attempt to select; if it fails, try to create via RPC or ignore
272
  self.client.table('telemetry_events').select('id').limit(1).execute()
273
- logger.info("Telemetry table verified in Supabase")
274
  except Exception as e:
275
  logger.info(f"telemetry_events table not accessible: {e}")
276
- # As we may not have a generic SQL RPC, we rely on manual creation documented.
277
- # We'll create a fallback table via a stored RPC if present; otherwise, first insert will fail gracefully.
278
  pass
279
 
280
  def log_telemetry(self, event: Dict[str, Any]) -> bool:
281
- """Insert a telemetry event into Supabase telemetry_events table."""
282
  try:
283
  payload = {
284
  'event_type': event.get('event_type'),
@@ -297,7 +241,6 @@ class SupabaseDatabase:
297
  return False
298
 
299
  def get_recent_telemetry(self, limit: int = 100) -> List[Dict[str, Any]]:
300
- """Fetch recent telemetry events from Supabase."""
301
  try:
302
  result = self.client.table('telemetry_events') \
303
  .select('*') \
 
1
  """
2
  Supabase database implementation for TreeTrack
 
3
  """
4
 
5
  import json
6
  import logging
7
  from typing import Dict, List, Optional, Any
8
+ from supabase_client import get_service_client
9
 
10
  logger = logging.getLogger(__name__)
11
 
12
 
13
  class SupabaseDatabase:
14
+ """Uses service_role client to bypass RLS. Authorization handled by FastAPI."""
15
 
16
  def __init__(self):
17
  try:
18
+ self.client = get_service_client()
19
  self.connected = True
20
+ logger.info("SupabaseDatabase initialized with service_role client")
21
  except ValueError as e:
22
  logger.warning(f"Supabase not configured: {e}")
 
23
  self.client = None
24
  self.connected = False
25
 
26
  def _check_connection(self):
 
27
  if not self.connected or not self.client:
28
  raise RuntimeError("Database not connected. Please configure Supabase credentials.")
29
 
30
  def initialize_database(self) -> bool:
 
31
  try:
 
32
  self.client.table('trees').select("id").limit(1).execute()
33
+ logger.info("Trees table verified")
 
34
  self._ensure_telemetry_table()
35
  return True
36
  except Exception as e:
 
38
  return False
39
 
40
  def test_connection(self) -> bool:
 
41
  from supabase_client import test_supabase_connection
42
  return test_supabase_connection()
43
 
44
  async def create_tree(self, tree_data: Dict[str, Any]) -> Dict[str, Any]:
 
45
  self._check_connection()
46
  try:
 
47
  result = self.client.table('trees').insert(tree_data).execute()
48
 
49
  if result.data:
 
59
 
60
  async def get_trees(self, limit: int = 100, offset: int = 0,
61
  species: str = None, health_status: str = None) -> List[Dict[str, Any]]:
 
62
  self._check_connection()
63
  try:
64
  query = self.client.table('trees').select("*")
65
 
 
66
  if species:
67
  query = query.ilike('scientific_name', f'%{species}%')
68
 
 
 
 
 
 
 
69
  result = query.order('updated_at', desc=True) \
70
  .order('created_at', desc=True) \
71
  .range(offset, offset + limit - 1) \
72
  .execute()
73
 
74
+ logger.info(f"Retrieved {len(result.data)} trees")
75
  return result.data
76
 
77
  except Exception as e:
 
79
  raise
80
 
81
  async def get_tree(self, tree_id: int) -> Optional[Dict[str, Any]]:
 
82
  self._check_connection()
83
  try:
84
  result = self.client.table('trees') \
 
95
  raise
96
 
97
  async def update_tree(self, tree_id: int, tree_data: Dict[str, Any]) -> Dict[str, Any]:
 
98
  self._check_connection()
99
  try:
 
100
  update_data = {k: v for k, v in tree_data.items() if k != 'id'}
101
 
102
  result = self.client.table('trees') \
 
116
  raise
117
 
118
  async def delete_tree(self, tree_id: int) -> bool:
 
119
  self._check_connection()
120
  try:
121
  result = self.client.table('trees') \
 
131
  raise
132
 
133
  def get_tree_count(self) -> int:
 
134
  try:
135
  result = self.client.table('trees') \
136
  .select("id", count="exact") \
 
143
  return 0
144
 
145
  def get_species_distribution(self, limit: int = 20) -> List[Dict[str, Any]]:
 
146
  try:
 
147
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  result = self.client.rpc('get_species_distribution', {'record_limit': limit}).execute()
149
  if result.data:
150
  return [{"species": row["species"], "count": row["count"]} for row in result.data]
151
  except Exception as rpc_error:
152
+ logger.info(f"RPC not available, using Python aggregation")
153
 
 
154
  result = self.client.table('trees') \
155
  .select("scientific_name") \
156
  .not_.is_('scientific_name', 'null') \
157
  .neq('scientific_name', '') \
158
  .execute()
159
 
 
160
  species_count = {}
161
  for tree in result.data:
162
  species = tree.get('scientific_name', 'Unknown')
163
  species_count[species] = species_count.get(species, 0) + 1
164
 
 
165
  distribution = [
166
  {"species": species, "count": count}
167
  for species, count in species_count.items()
 
175
  return []
176
 
177
  def get_health_distribution(self) -> List[Dict[str, Any]]:
 
178
  try:
 
 
179
  return []
180
 
181
  except Exception as e:
 
183
  return []
184
 
185
  def get_average_measurements(self) -> Dict[str, float]:
 
186
  try:
 
187
  result = self.client.table('trees') \
188
  .select("height, width") \
189
  .not_.is_('height', 'null') \
 
201
 
202
  return {
203
  "average_height": round(avg_height, 2),
204
+ "average_diameter": round(avg_width, 2)
205
  }
206
 
207
  except Exception as e:
 
209
  return {"average_height": 0, "average_diameter": 0}
210
 
211
  def backup_database(self) -> bool:
 
 
212
  return True
213
 
214
  def restore_database(self) -> bool:
 
 
215
  return True
216
 
 
217
  def _ensure_telemetry_table(self) -> None:
 
218
  try:
 
219
  self.client.table('telemetry_events').select('id').limit(1).execute()
220
+ logger.info("Telemetry table verified")
221
  except Exception as e:
222
  logger.info(f"telemetry_events table not accessible: {e}")
 
 
223
  pass
224
 
225
  def log_telemetry(self, event: Dict[str, Any]) -> bool:
 
226
  try:
227
  payload = {
228
  'event_type': event.get('event_type'),
 
241
  return False
242
 
243
  def get_recent_telemetry(self, limit: int = 100) -> List[Dict[str, Any]]:
 
244
  try:
245
  result = self.client.table('telemetry_events') \
246
  .select('*') \
version.json CHANGED
@@ -1,4 +1,4 @@
1
  {
2
  "version": "5.1.1",
3
- "timestamp": 1761514190
4
  }
 
1
  {
2
  "version": "5.1.1",
3
+ "timestamp": 1762279460
4
  }