cinchdb 0.1.17__py3-none-any.whl → 0.1.18__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cinchdb/cli/commands/tenant.py +50 -2
- cinchdb/core/connection.py +55 -18
- cinchdb/core/database.py +24 -9
- cinchdb/core/initializer.py +25 -40
- cinchdb/core/path_utils.py +192 -17
- cinchdb/managers/change_applier.py +1 -1
- cinchdb/managers/codegen.py +372 -15
- cinchdb/managers/column.py +7 -4
- cinchdb/managers/data.py +16 -13
- cinchdb/managers/query.py +8 -5
- cinchdb/managers/table.py +8 -5
- cinchdb/managers/tenant.py +227 -71
- cinchdb/utils/name_validator.py +22 -12
- {cinchdb-0.1.17.dist-info → cinchdb-0.1.18.dist-info}/METADATA +35 -1
- {cinchdb-0.1.17.dist-info → cinchdb-0.1.18.dist-info}/RECORD +18 -18
- {cinchdb-0.1.17.dist-info → cinchdb-0.1.18.dist-info}/WHEEL +0 -0
- {cinchdb-0.1.17.dist-info → cinchdb-0.1.18.dist-info}/entry_points.txt +0 -0
- {cinchdb-0.1.17.dist-info → cinchdb-0.1.18.dist-info}/licenses/LICENSE +0 -0
cinchdb/managers/tenant.py
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
"""Tenant management for CinchDB."""
|
2
2
|
|
3
3
|
import hashlib
|
4
|
+
import logging
|
5
|
+
import os
|
4
6
|
import shutil
|
5
7
|
import sqlite3
|
6
8
|
import uuid
|
@@ -13,6 +15,13 @@ from cinchdb.core.path_utils import (
|
|
13
15
|
get_branch_path,
|
14
16
|
get_tenant_db_path,
|
15
17
|
list_tenants,
|
18
|
+
# New tenant-first path utilities
|
19
|
+
get_context_root,
|
20
|
+
get_tenant_db_path_in_context,
|
21
|
+
ensure_context_directory,
|
22
|
+
ensure_tenant_db_path,
|
23
|
+
calculate_shard,
|
24
|
+
invalidate_cache,
|
16
25
|
)
|
17
26
|
from cinchdb.core.connection import DatabaseConnection
|
18
27
|
from cinchdb.core.maintenance_utils import check_maintenance_mode
|
@@ -20,22 +29,32 @@ from cinchdb.utils.name_validator import validate_name
|
|
20
29
|
from cinchdb.infrastructure.metadata_db import MetadataDB
|
21
30
|
from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
|
22
31
|
|
32
|
+
logger = logging.getLogger(__name__)
|
33
|
+
|
23
34
|
|
24
35
|
class TenantManager:
|
25
36
|
"""Manages tenants within a branch."""
|
26
37
|
|
27
|
-
def __init__(self, project_root: Path, database: str, branch: str):
|
38
|
+
def __init__(self, project_root: Path, database: str, branch: str, encryption_manager=None):
|
28
39
|
"""Initialize tenant manager.
|
29
40
|
|
30
41
|
Args:
|
31
42
|
project_root: Path to project root
|
32
43
|
database: Database name
|
33
44
|
branch: Branch name
|
45
|
+
encryption_manager: EncryptionManager instance for encrypted connections
|
34
46
|
"""
|
35
47
|
self.project_root = Path(project_root)
|
36
48
|
self.database = database
|
37
49
|
self.branch = branch
|
50
|
+
self.encryption_manager = encryption_manager
|
51
|
+
|
52
|
+
# New tenant-first approach: use context root
|
53
|
+
self.context_root = get_context_root(self.project_root, database, branch)
|
54
|
+
|
55
|
+
# Legacy support: keep branch_path for backward compatibility
|
38
56
|
self.branch_path = get_branch_path(self.project_root, database, branch)
|
57
|
+
|
39
58
|
self._empty_tenant_name = "__empty__" # Reserved name for lazy tenant template
|
40
59
|
|
41
60
|
# Lazy-initialized pooled connection
|
@@ -98,7 +117,8 @@ class TenantManager:
|
|
98
117
|
return tenants
|
99
118
|
|
100
119
|
def create_tenant(
|
101
|
-
self, tenant_name: str, description: Optional[str] = None, lazy: bool = True
|
120
|
+
self, tenant_name: str, description: Optional[str] = None, lazy: bool = True,
|
121
|
+
encrypt: bool = False, encryption_key: Optional[str] = None
|
102
122
|
) -> Tenant:
|
103
123
|
"""Create a new tenant by copying schema from main tenant.
|
104
124
|
|
@@ -106,6 +126,8 @@ class TenantManager:
|
|
106
126
|
tenant_name: Name for the new tenant
|
107
127
|
description: Optional description
|
108
128
|
lazy: If True, don't create database file until first use
|
129
|
+
encrypt: If True, create encrypted tenant database
|
130
|
+
encryption_key: Encryption key for encrypted tenant (required if encrypt=True)
|
109
131
|
|
110
132
|
Returns:
|
111
133
|
Created Tenant object
|
@@ -115,6 +137,12 @@ class TenantManager:
|
|
115
137
|
InvalidNameError: If tenant name is invalid
|
116
138
|
MaintenanceError: If branch is in maintenance mode
|
117
139
|
"""
|
140
|
+
# Validate encryption parameters
|
141
|
+
if encrypt and not encryption_key:
|
142
|
+
raise ValueError("encryption_key is required when encrypt=True")
|
143
|
+
if encryption_key and not encrypt:
|
144
|
+
raise ValueError("encrypt=True is required when providing encryption_key")
|
145
|
+
|
118
146
|
# Check for reserved name
|
119
147
|
if tenant_name == self._empty_tenant_name:
|
120
148
|
raise ValueError(f"'{self._empty_tenant_name}' is a reserved tenant name")
|
@@ -140,28 +168,37 @@ class TenantManager:
|
|
140
168
|
tenant_id = str(uuid.uuid4())
|
141
169
|
|
142
170
|
# Calculate shard for tenant
|
143
|
-
shard =
|
171
|
+
shard = calculate_shard(tenant_name)
|
144
172
|
|
145
173
|
# Create tenant in metadata database
|
146
174
|
metadata = {
|
147
175
|
"description": description,
|
148
176
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
177
|
+
"encrypted": encrypt,
|
149
178
|
}
|
150
179
|
self.metadata_db.create_tenant(tenant_id, self.branch_id, tenant_name, shard, metadata)
|
151
180
|
|
181
|
+
# Generate encryption key if plugged is available and encryption is enabled
|
182
|
+
self._maybe_generate_tenant_key(tenant_name)
|
183
|
+
|
152
184
|
if not lazy:
|
153
|
-
# Ensure __empty__ tenant exists with current schema
|
154
|
-
self._ensure_empty_tenant()
|
155
|
-
|
156
185
|
# Create actual database file using sharded paths
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
186
|
+
# Use ensure_tenant_db_path to create directories if needed
|
187
|
+
new_db_path = ensure_tenant_db_path(
|
188
|
+
self.project_root, self.database, self.branch, tenant_name
|
189
|
+
)
|
161
190
|
|
162
|
-
|
163
|
-
|
164
|
-
|
191
|
+
if encrypt:
|
192
|
+
# Create encrypted database with schema from main tenant
|
193
|
+
self._create_encrypted_tenant_database(new_db_path, encryption_key)
|
194
|
+
else:
|
195
|
+
# Ensure __empty__ tenant exists with current schema
|
196
|
+
self._ensure_empty_tenant()
|
197
|
+
|
198
|
+
# Copy __empty__ tenant database to new tenant
|
199
|
+
# __empty__ already has 512-byte pages and no data
|
200
|
+
empty_db_path = self._get_sharded_tenant_db_path(self._empty_tenant_name)
|
201
|
+
shutil.copy2(empty_db_path, new_db_path)
|
165
202
|
|
166
203
|
# Mark as materialized in metadata
|
167
204
|
self.metadata_db.mark_tenant_materialized(tenant_id)
|
@@ -174,49 +211,35 @@ class TenantManager:
|
|
174
211
|
is_main=False,
|
175
212
|
)
|
176
213
|
|
177
|
-
|
178
|
-
|
214
|
+
|
215
|
+
def _get_tenant_db_path(self, tenant_name: str) -> Path:
|
216
|
+
"""Get the database path for a tenant using new tenant-first approach.
|
179
217
|
|
180
218
|
Args:
|
181
219
|
tenant_name: Name of the tenant
|
182
220
|
|
183
221
|
Returns:
|
184
|
-
|
222
|
+
Path to the tenant database file in the context
|
185
223
|
"""
|
186
|
-
|
187
|
-
|
224
|
+
# Ensure context directory exists
|
225
|
+
ensure_context_directory(self.project_root, self.database, self.branch)
|
226
|
+
|
227
|
+
# Use new tenant-first path resolution
|
228
|
+
return get_tenant_db_path_in_context(self.context_root, tenant_name)
|
188
229
|
|
189
230
|
def _get_sharded_tenant_db_path(self, tenant_name: str) -> Path:
|
190
|
-
"""Get the sharded database path for a tenant
|
231
|
+
"""Get the sharded database path for a tenant (legacy compatibility).
|
232
|
+
|
233
|
+
This method is kept for backward compatibility but now uses the new
|
234
|
+
tenant-first storage approach internally.
|
191
235
|
|
192
236
|
Args:
|
193
237
|
tenant_name: Name of the tenant
|
194
238
|
|
195
239
|
Returns:
|
196
240
|
Path to the tenant database file in its shard directory
|
197
|
-
|
198
|
-
Raises:
|
199
|
-
ValueError: If tenant doesn't exist in metadata
|
200
241
|
"""
|
201
|
-
|
202
|
-
if tenant_name == self._empty_tenant_name:
|
203
|
-
shard = self._calculate_shard(tenant_name)
|
204
|
-
else:
|
205
|
-
# Look up shard from metadata DB
|
206
|
-
tenant_info = self.metadata_db.get_tenant(self.branch_id, tenant_name)
|
207
|
-
if not tenant_info or not tenant_info.get('shard'):
|
208
|
-
raise ValueError(f"Tenant '{tenant_name}' not found in metadata or missing shard info")
|
209
|
-
shard = tenant_info['shard']
|
210
|
-
|
211
|
-
# Build sharded path
|
212
|
-
branch_path = get_branch_path(self.project_root, self.database, self.branch)
|
213
|
-
tenants_dir = branch_path / "tenants"
|
214
|
-
shard_dir = tenants_dir / shard
|
215
|
-
|
216
|
-
# Ensure shard directory exists
|
217
|
-
shard_dir.mkdir(parents=True, exist_ok=True)
|
218
|
-
|
219
|
-
return shard_dir / f"{tenant_name}.db"
|
242
|
+
return self._get_tenant_db_path(tenant_name)
|
220
243
|
|
221
244
|
def _ensure_empty_tenant(self) -> None:
|
222
245
|
"""Ensure the __empty__ tenant exists with current schema.
|
@@ -238,7 +261,7 @@ class TenantManager:
|
|
238
261
|
# Create in metadata if doesn't exist (should already be created during branch/database init)
|
239
262
|
if not empty_tenant:
|
240
263
|
tenant_id = str(uuid.uuid4())
|
241
|
-
shard =
|
264
|
+
shard = calculate_shard(self._empty_tenant_name)
|
242
265
|
self.metadata_db.create_tenant(
|
243
266
|
tenant_id, self.branch_id, self._empty_tenant_name, shard,
|
244
267
|
metadata={"system": True, "description": "Template for lazy tenants"}
|
@@ -248,7 +271,10 @@ class TenantManager:
|
|
248
271
|
|
249
272
|
# If __empty__ database doesn't exist, create it by copying from main tenant
|
250
273
|
if not empty_db_path.exists():
|
251
|
-
|
274
|
+
# Use centralized function to ensure directory exists
|
275
|
+
ensure_tenant_db_path(
|
276
|
+
self.project_root, self.database, self.branch, "__empty__"
|
277
|
+
)
|
252
278
|
|
253
279
|
# Get main tenant database path (may need to materialize it first)
|
254
280
|
main_db_path = self._get_sharded_tenant_db_path("main")
|
@@ -258,7 +284,7 @@ class TenantManager:
|
|
258
284
|
shutil.copy2(main_db_path, empty_db_path)
|
259
285
|
|
260
286
|
# Clear all data from tables (keep schema only)
|
261
|
-
with DatabaseConnection(empty_db_path) as conn:
|
287
|
+
with DatabaseConnection(empty_db_path, encryption_manager=self.encryption_manager) as conn:
|
262
288
|
# Get all tables
|
263
289
|
result = conn.execute("""
|
264
290
|
SELECT name FROM sqlite_master
|
@@ -275,7 +301,7 @@ class TenantManager:
|
|
275
301
|
else:
|
276
302
|
# If main doesn't exist either, create empty database
|
277
303
|
empty_db_path.touch()
|
278
|
-
with DatabaseConnection(empty_db_path):
|
304
|
+
with DatabaseConnection(empty_db_path, encryption_manager=self.encryption_manager):
|
279
305
|
pass # Just initialize with PRAGMAs
|
280
306
|
|
281
307
|
# Set reasonable default page size for template
|
@@ -304,16 +330,20 @@ class TenantManager:
|
|
304
330
|
|
305
331
|
Raises:
|
306
332
|
ValueError: If tenant doesn't exist, is main tenant, or is reserved
|
333
|
+
InvalidNameError: If tenant name is invalid
|
307
334
|
MaintenanceError: If branch is in maintenance mode
|
308
335
|
"""
|
309
|
-
#
|
310
|
-
check_maintenance_mode(self.project_root, self.database, self.branch)
|
311
|
-
|
312
|
-
# Can't delete main or __empty__ tenants
|
336
|
+
# Can't delete main or __empty__ tenants (check before validation)
|
313
337
|
if tenant_name == "main":
|
314
338
|
raise ValueError("Cannot delete the main tenant")
|
315
339
|
if tenant_name == self._empty_tenant_name:
|
316
340
|
raise ValueError(f"Cannot delete the reserved '{self._empty_tenant_name}' tenant")
|
341
|
+
|
342
|
+
# Validate tenant name for security
|
343
|
+
validate_name(tenant_name, "tenant")
|
344
|
+
|
345
|
+
# Check maintenance mode
|
346
|
+
check_maintenance_mode(self.project_root, self.database, self.branch)
|
317
347
|
|
318
348
|
# Ensure initialization
|
319
349
|
self._ensure_initialized()
|
@@ -349,6 +379,9 @@ class TenantManager:
|
|
349
379
|
wal_path.unlink()
|
350
380
|
if shm_path.exists():
|
351
381
|
shm_path.unlink()
|
382
|
+
|
383
|
+
# Invalidate cache for this tenant
|
384
|
+
invalidate_cache(tenant=tenant_name)
|
352
385
|
|
353
386
|
|
354
387
|
def materialize_tenant(self, tenant_name: str) -> None:
|
@@ -359,7 +392,11 @@ class TenantManager:
|
|
359
392
|
|
360
393
|
Raises:
|
361
394
|
ValueError: If tenant doesn't exist or is already materialized
|
395
|
+
InvalidNameError: If tenant name is invalid
|
362
396
|
"""
|
397
|
+
# Validate tenant name first for security
|
398
|
+
validate_name(tenant_name, "tenant")
|
399
|
+
|
363
400
|
# Ensure initialization
|
364
401
|
self._ensure_initialized()
|
365
402
|
|
@@ -375,17 +412,15 @@ class TenantManager:
|
|
375
412
|
if tenant_info['materialized']:
|
376
413
|
return # Already materialized
|
377
414
|
|
378
|
-
|
415
|
+
# Use centralized function to get path and ensure directory exists
|
416
|
+
db_path = ensure_tenant_db_path(
|
379
417
|
self.project_root, self.database, self.branch, tenant_name
|
380
418
|
)
|
381
419
|
|
382
|
-
# Ensure tenants directory exists
|
383
|
-
db_path.parent.mkdir(parents=True, exist_ok=True)
|
384
|
-
|
385
420
|
# Ensure __empty__ tenant exists with current schema
|
386
421
|
self._ensure_empty_tenant()
|
387
422
|
|
388
|
-
# Get __empty__ tenant path for schema copy
|
423
|
+
# Get __empty__ tenant path for schema copy using new structure
|
389
424
|
empty_db_path = get_tenant_db_path(
|
390
425
|
self.project_root, self.database, self.branch, self._empty_tenant_name
|
391
426
|
)
|
@@ -399,6 +434,7 @@ class TenantManager:
|
|
399
434
|
# Mark as materialized in metadata database
|
400
435
|
self.metadata_db.mark_tenant_materialized(tenant_info['id'])
|
401
436
|
|
437
|
+
|
402
438
|
def copy_tenant(self, source_tenant: str, target_tenant: str) -> Tenant:
|
403
439
|
"""Copy a tenant to a new tenant.
|
404
440
|
|
@@ -438,7 +474,7 @@ class TenantManager:
|
|
438
474
|
|
439
475
|
# Create tenant in metadata database first with shard
|
440
476
|
tenant_id = str(uuid.uuid4())
|
441
|
-
target_shard =
|
477
|
+
target_shard = calculate_shard(target_tenant)
|
442
478
|
metadata = {
|
443
479
|
"description": f"Copied from {source_tenant}",
|
444
480
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
@@ -447,9 +483,10 @@ class TenantManager:
|
|
447
483
|
|
448
484
|
# Get paths using sharded approach
|
449
485
|
source_path = self._get_sharded_tenant_db_path(source_tenant)
|
450
|
-
|
451
|
-
|
452
|
-
|
486
|
+
# Use ensure function to create directory if needed
|
487
|
+
target_path = ensure_tenant_db_path(
|
488
|
+
self.project_root, self.database, self.branch, target_tenant
|
489
|
+
)
|
453
490
|
|
454
491
|
# Copy database file
|
455
492
|
shutil.copy2(source_path, target_path)
|
@@ -473,9 +510,10 @@ class TenantManager:
|
|
473
510
|
|
474
511
|
Raises:
|
475
512
|
ValueError: If old doesn't exist, new already exists, or trying to rename main
|
476
|
-
InvalidNameError: If
|
513
|
+
InvalidNameError: If either tenant name is invalid
|
477
514
|
"""
|
478
|
-
# Validate
|
515
|
+
# Validate both names for security (prevent path traversal)
|
516
|
+
validate_name(old_name, "tenant")
|
479
517
|
validate_name(new_name, "tenant")
|
480
518
|
|
481
519
|
# Can't rename main tenant
|
@@ -507,18 +545,18 @@ class TenantManager:
|
|
507
545
|
if tenant_info['materialized']:
|
508
546
|
# Calculate paths before metadata update
|
509
547
|
old_shard = tenant_info['shard']
|
510
|
-
new_shard =
|
548
|
+
new_shard = calculate_shard(new_name)
|
511
549
|
|
512
|
-
|
513
|
-
tenants_dir = branch_path / "tenants"
|
550
|
+
context_root = get_context_root(self.project_root, self.database, self.branch)
|
514
551
|
|
515
|
-
old_path =
|
516
|
-
|
517
|
-
|
518
|
-
|
552
|
+
old_path = context_root / old_shard / f"{old_name}.db"
|
553
|
+
# Use centralized function to ensure new directory exists
|
554
|
+
new_path = ensure_tenant_db_path(
|
555
|
+
self.project_root, self.database, self.branch, new_name
|
556
|
+
)
|
519
557
|
|
520
558
|
# Update metadata database
|
521
|
-
new_shard =
|
559
|
+
new_shard = calculate_shard(new_name)
|
522
560
|
with self.metadata_db.conn:
|
523
561
|
self.metadata_db.conn.execute(
|
524
562
|
"UPDATE tenants SET name = ?, shard = ? WHERE id = ?",
|
@@ -530,7 +568,7 @@ class TenantManager:
|
|
530
568
|
|
531
569
|
# Rename database file if it exists
|
532
570
|
if old_path.exists():
|
533
|
-
|
571
|
+
# Directory already created by ensure_tenant_db_path above
|
534
572
|
old_path.rename(new_path)
|
535
573
|
|
536
574
|
# Also rename WAL and SHM files if they exist
|
@@ -747,13 +785,19 @@ class TenantManager:
|
|
747
785
|
|
748
786
|
Raises:
|
749
787
|
ValueError: If tenant doesn't exist
|
788
|
+
InvalidNameError: If tenant name is invalid
|
750
789
|
|
751
790
|
Example:
|
752
791
|
with tenant_manager.get_tenant_connection("main") as conn:
|
753
792
|
conn.execute("SELECT * FROM table")
|
754
793
|
"""
|
794
|
+
# Validate tenant name first for security (prevent path traversal)
|
795
|
+
# Exception: __empty__ and main are system tenants
|
796
|
+
if tenant_name not in ("__empty__", "main"):
|
797
|
+
validate_name(tenant_name, "tenant")
|
798
|
+
|
755
799
|
db_path = self.get_tenant_db_path_for_operation(tenant_name, is_write)
|
756
|
-
return DatabaseConnection(db_path)
|
800
|
+
return DatabaseConnection(db_path, tenant_id=tenant_name, encryption_manager=self.encryption_manager)
|
757
801
|
|
758
802
|
def vacuum_tenant(self, tenant_name: str) -> dict:
|
759
803
|
"""Run VACUUM operation on a specific tenant to reclaim space and optimize performance.
|
@@ -812,7 +856,7 @@ class TenantManager:
|
|
812
856
|
error_message = None
|
813
857
|
|
814
858
|
try:
|
815
|
-
with DatabaseConnection(db_path) as conn:
|
859
|
+
with DatabaseConnection(db_path, tenant_id=tenant_name, encryption_manager=self.encryption_manager) as conn:
|
816
860
|
# Run VACUUM command
|
817
861
|
conn.execute("VACUUM")
|
818
862
|
success = True
|
@@ -839,3 +883,115 @@ class TenantManager:
|
|
839
883
|
result["error"] = error_message
|
840
884
|
|
841
885
|
return result
|
886
|
+
|
887
|
+
def _maybe_generate_tenant_key(self, tenant_name: str) -> None:
|
888
|
+
"""Generate encryption key for tenant if plugged is available and encryption enabled."""
|
889
|
+
try:
|
890
|
+
# Try to import plugged TenantKeyManager
|
891
|
+
from plugged.tenant_key_manager import TenantKeyManager
|
892
|
+
|
893
|
+
# Create tenant ID for plugged (using same format as cinchdb)
|
894
|
+
tenant_id = f"{self.database}-{self.branch}-{tenant_name}"
|
895
|
+
|
896
|
+
# Initialize key manager with our metadata database
|
897
|
+
key_manager = TenantKeyManager(self.metadata_db)
|
898
|
+
|
899
|
+
# Generate encryption key for the new tenant
|
900
|
+
encryption_key = key_manager.generate_tenant_key(tenant_id)
|
901
|
+
|
902
|
+
logger.info(f"Generated encryption key for tenant {tenant_name} (version 1)")
|
903
|
+
|
904
|
+
except ImportError:
|
905
|
+
# Plugged not available - continue without encryption
|
906
|
+
logger.debug(f"Plugged not available, skipping key generation for tenant {tenant_name}")
|
907
|
+
except Exception as e:
|
908
|
+
# Key generation failed - log warning but don't fail tenant creation
|
909
|
+
logger.warning(f"Failed to generate encryption key for tenant {tenant_name}: {e}")
|
910
|
+
|
911
|
+
def rotate_tenant_key(self, tenant_name: str) -> str:
|
912
|
+
"""Rotate encryption key for tenant if plugged is available."""
|
913
|
+
try:
|
914
|
+
from plugged.tenant_key_manager import TenantKeyManager
|
915
|
+
|
916
|
+
tenant_id = f"{self.database}-{self.branch}-{tenant_name}"
|
917
|
+
key_manager = TenantKeyManager(self.metadata_db)
|
918
|
+
|
919
|
+
# Generate new key version
|
920
|
+
new_key = key_manager.generate_tenant_key(tenant_id)
|
921
|
+
|
922
|
+
logger.info(f"Rotated encryption key for tenant {tenant_name}")
|
923
|
+
return new_key
|
924
|
+
|
925
|
+
except ImportError:
|
926
|
+
raise ValueError("Plugged extension not available - encryption key rotation requires plugged")
|
927
|
+
except Exception as e:
|
928
|
+
raise ValueError(f"Failed to rotate key for tenant {tenant_name}: {e}")
|
929
|
+
|
930
|
+
def _create_encrypted_tenant_database(self, db_path: str, encryption_key: str) -> None:
|
931
|
+
"""Create an encrypted tenant database with current schema.
|
932
|
+
|
933
|
+
Args:
|
934
|
+
db_path: Path where the encrypted database should be created
|
935
|
+
encryption_key: Encryption key for the database
|
936
|
+
"""
|
937
|
+
try:
|
938
|
+
# Import sqlite3 to check for SQLCipher support
|
939
|
+
import sqlite3
|
940
|
+
|
941
|
+
# Create encrypted database connection
|
942
|
+
conn = sqlite3.connect(db_path)
|
943
|
+
|
944
|
+
# Try to set encryption key (this will fail if SQLCipher is not available)
|
945
|
+
try:
|
946
|
+
conn.execute(f"PRAGMA key = '{encryption_key}'")
|
947
|
+
except sqlite3.OperationalError as e:
|
948
|
+
conn.close()
|
949
|
+
# Remove the created file since it's not encrypted
|
950
|
+
if os.path.exists(db_path):
|
951
|
+
os.remove(db_path)
|
952
|
+
raise ValueError(
|
953
|
+
"SQLCipher is required for encryption but not available. "
|
954
|
+
"Please install pysqlcipher3 or sqlite3 with SQLCipher support."
|
955
|
+
) from e
|
956
|
+
|
957
|
+
# Set SQLite optimization settings for new database
|
958
|
+
conn.execute("PRAGMA page_size = 512")
|
959
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
960
|
+
|
961
|
+
# Get schema from main tenant to replicate
|
962
|
+
main_db_path = self._get_sharded_tenant_db_path("main")
|
963
|
+
if os.path.exists(main_db_path):
|
964
|
+
# Connect to main tenant to get schema
|
965
|
+
main_conn = sqlite3.connect(main_db_path)
|
966
|
+
|
967
|
+
# Get all table creation statements
|
968
|
+
cursor = main_conn.execute(
|
969
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
970
|
+
)
|
971
|
+
|
972
|
+
# Create tables in encrypted database
|
973
|
+
for (sql,) in cursor.fetchall():
|
974
|
+
if sql: # Skip empty SQL statements
|
975
|
+
conn.execute(sql)
|
976
|
+
|
977
|
+
# Get all index creation statements
|
978
|
+
cursor = main_conn.execute(
|
979
|
+
"SELECT sql FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'"
|
980
|
+
)
|
981
|
+
|
982
|
+
# Create indexes in encrypted database
|
983
|
+
for (sql,) in cursor.fetchall():
|
984
|
+
if sql: # Skip empty SQL statements
|
985
|
+
conn.execute(sql)
|
986
|
+
|
987
|
+
main_conn.close()
|
988
|
+
|
989
|
+
# Commit changes and close
|
990
|
+
conn.commit()
|
991
|
+
conn.close()
|
992
|
+
|
993
|
+
except Exception as e:
|
994
|
+
# Clean up on failure
|
995
|
+
if os.path.exists(db_path):
|
996
|
+
os.remove(db_path)
|
997
|
+
raise ValueError(f"Failed to create encrypted database: {e}") from e
|
cinchdb/utils/name_validator.py
CHANGED
@@ -7,8 +7,9 @@ and follow consistent naming conventions.
|
|
7
7
|
import re
|
8
8
|
|
9
9
|
|
10
|
-
# Regex pattern for valid names: lowercase letters, numbers, dash, underscore
|
11
|
-
|
10
|
+
# Regex pattern for valid names: lowercase letters, numbers, dash, underscore
|
11
|
+
# Period removed to prevent directory traversal attempts like "../"
|
12
|
+
VALID_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-_]*[a-z0-9]$|^[a-z0-9]$")
|
12
13
|
|
13
14
|
# Reserved names that cannot be used
|
14
15
|
RESERVED_NAMES = {
|
@@ -52,6 +53,7 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
|
|
52
53
|
- Be at least 1 character long
|
53
54
|
- Not exceed 255 characters (filesystem limit)
|
54
55
|
- Not be a reserved name
|
56
|
+
- Not contain path traversal sequences
|
55
57
|
|
56
58
|
Args:
|
57
59
|
name: The name to validate
|
@@ -67,6 +69,19 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
|
|
67
69
|
raise InvalidNameError(
|
68
70
|
f"{entity_type.capitalize()} name cannot exceed 255 characters"
|
69
71
|
)
|
72
|
+
|
73
|
+
# Critical: Check for path traversal attempts
|
74
|
+
if ".." in name or "/" in name or "\\" in name or "~" in name:
|
75
|
+
raise InvalidNameError(
|
76
|
+
f"Security violation: {entity_type} name '{name}' contains "
|
77
|
+
f"forbidden path traversal characters"
|
78
|
+
)
|
79
|
+
|
80
|
+
# Check for null bytes and other control characters
|
81
|
+
if "\x00" in name or any(ord(c) < 32 for c in name):
|
82
|
+
raise InvalidNameError(
|
83
|
+
f"Security violation: {entity_type} name contains invalid control characters"
|
84
|
+
)
|
70
85
|
|
71
86
|
# Check for lowercase requirement
|
72
87
|
if name != name.lower():
|
@@ -80,7 +95,7 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
|
|
80
95
|
raise InvalidNameError(
|
81
96
|
f"Invalid {entity_type} name '{name}'. "
|
82
97
|
f"Names must contain only lowercase letters (a-z), numbers (0-9), "
|
83
|
-
f"dash (-), underscore (_)
|
98
|
+
f"dash (-), and underscore (_). "
|
84
99
|
f"Names must start and end with alphanumeric characters."
|
85
100
|
)
|
86
101
|
|
@@ -90,11 +105,6 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
|
|
90
105
|
or "__" in name
|
91
106
|
or "-_" in name
|
92
107
|
or "_-" in name
|
93
|
-
or ".." in name
|
94
|
-
or ".-" in name
|
95
|
-
or "-." in name
|
96
|
-
or "._" in name
|
97
|
-
or "_." in name
|
98
108
|
):
|
99
109
|
raise InvalidNameError(
|
100
110
|
f"Invalid {entity_type} name '{name}'. "
|
@@ -132,14 +142,14 @@ def clean_name(name: str) -> str:
|
|
132
142
|
# Replace spaces with dashes
|
133
143
|
cleaned = cleaned.replace(" ", "-")
|
134
144
|
|
135
|
-
# Remove invalid characters
|
136
|
-
cleaned = re.sub(r"[^a-z0-9\-_
|
145
|
+
# Remove invalid characters (period no longer allowed)
|
146
|
+
cleaned = re.sub(r"[^a-z0-9\-_]", "", cleaned)
|
137
147
|
|
138
148
|
# Remove consecutive special characters
|
139
|
-
cleaned = re.sub(r"[-_
|
149
|
+
cleaned = re.sub(r"[-_]{2,}", "-", cleaned)
|
140
150
|
|
141
151
|
# Remove leading/trailing special characters
|
142
|
-
cleaned = cleaned.strip("-_
|
152
|
+
cleaned = cleaned.strip("-_")
|
143
153
|
|
144
154
|
return cleaned
|
145
155
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: cinchdb
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.18
|
4
4
|
Summary: A Git-like SQLite database management system with branching and multi-tenancy
|
5
5
|
Project-URL: Homepage, https://github.com/russellromney/cinchdb
|
6
6
|
Project-URL: Documentation, https://russellromney.github.io/cinchdb
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
20
20
|
Requires-Python: >=3.10
|
21
|
+
Requires-Dist: httpx>=0.25.0
|
21
22
|
Requires-Dist: pydantic>=2.0.0
|
22
23
|
Requires-Dist: requests>=2.28.0
|
23
24
|
Requires-Dist: rich>=13.0.0
|
@@ -68,6 +69,10 @@ cinch branch merge-into-main feature
|
|
68
69
|
cinch tenant create customer_a
|
69
70
|
cinch query "SELECT * FROM users" --tenant customer_a
|
70
71
|
|
72
|
+
# Tenant encryption (bring your own keys)
|
73
|
+
cinch tenant create secure_customer --encrypt --key="your-secret-key"
|
74
|
+
cinch query "SELECT * FROM users" --tenant secure_customer --key="your-secret-key"
|
75
|
+
|
71
76
|
# Future: Remote connectivity planned for production deployment
|
72
77
|
|
73
78
|
# Autogenerate Python SDK from database
|
@@ -157,6 +162,35 @@ db.update("posts", post_id, {"content": "Updated content"})
|
|
157
162
|
|
158
163
|
## Architecture
|
159
164
|
|
165
|
+
### Storage Architecture
|
166
|
+
|
167
|
+
CinchDB uses a **tenant-first storage model** where database and branch are organizational metadata concepts, while tenants represent the actual isolated data stores:
|
168
|
+
|
169
|
+
```
|
170
|
+
.cinchdb/
|
171
|
+
├── metadata.db # Organizational metadata
|
172
|
+
└── {database}-{branch}/ # Context root (e.g., main-main, prod-feature)
|
173
|
+
├── {shard}/ # SHA256-based sharding (first 2 chars)
|
174
|
+
│ ├── {tenant}.db # Actual SQLite database
|
175
|
+
│ └── {tenant}.db-wal # WAL file
|
176
|
+
└── ...
|
177
|
+
```
|
178
|
+
|
179
|
+
**Key Design Decisions:**
|
180
|
+
- **Tenant-first**: Each tenant gets its own SQLite database file
|
181
|
+
- **Flat hierarchy**: Database/branch form a single context root, avoiding deep nesting
|
182
|
+
- **Hash sharding**: Tenants are distributed across 256 shards using SHA256 for scalability
|
183
|
+
- **Lazy initialization**: Tenant databases are created on first access, not on tenant creation
|
184
|
+
- **WAL mode**: All databases use Write-Ahead Logging for better concurrency
|
185
|
+
|
186
|
+
This architecture enables:
|
187
|
+
- True multi-tenant isolation at the file system level
|
188
|
+
- Efficient branching without duplicating tenant data
|
189
|
+
- Simple backup/restore per tenant
|
190
|
+
- Horizontal scaling through sharding
|
191
|
+
|
192
|
+
### Components
|
193
|
+
|
160
194
|
- **Python SDK**: Core functionality for local development
|
161
195
|
- **CLI**: Full-featured command-line interface
|
162
196
|
|