databricks-tellr 0.3.13.dev3__tar.gz → 0.3.13.dev4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-tellr
3
- Version: 0.3.13.dev3
3
+ Version: 0.3.13.dev4
4
4
  Summary: Tellr deployment tooling for Databricks Apps
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -27,8 +27,6 @@ env:
27
27
  value: "${LAKEBASE_INSTANCE}"
28
28
  - name: LAKEBASE_SCHEMA
29
29
  value: "${LAKEBASE_SCHEMA}"
30
- - name: GOOGLE_OAUTH_ENCRYPTION_KEY
31
- value: "${GOOGLE_OAUTH_ENCRYPTION_KEY}"
32
30
  - name: LAKEBASE_TYPE
33
31
  value: "${LAKEBASE_TYPE}"
34
32
  - name: LAKEBASE_PG_HOST
@@ -39,8 +37,6 @@ env:
39
37
  value: "${LAKEBASE_ENDPOINT_NAME}"
40
38
  - name: DATABRICKS_HOST
41
39
  valueFrom: "system.databricks_host"
42
- - name: DATABRICKS_TOKEN
43
- valueFrom: "system.databricks_token"
44
40
  # Opt-in to trust x-forwarded-email / x-forwarded-user on the MCP endpoint
45
41
  # for app-to-app calls. Safe inside Databricks Apps because the proxy sets
46
42
  # these headers itself and strips any caller-supplied versions. See
@@ -30,6 +30,8 @@ from databricks.sdk.service.apps import (
30
30
  from databricks.sdk.service.database import DatabaseInstance
31
31
  from databricks.sdk.service.workspace import ImportFormat
32
32
 
33
+ from databricks_tellr.identifiers import validate_client_id, validate_schema_name
34
+
33
35
  # Autoscaling imports (Lakebase next-gen)
34
36
  try:
35
37
  from databricks.sdk.service.postgres import (
@@ -195,7 +197,6 @@ def create(
195
197
  client: WorkspaceClient | None = None,
196
198
  profile: str | None = None,
197
199
  config_yaml_path: str | None = None,
198
- encryption_key: str | None = None,
199
200
  mlflow_tracing: dict[str, str] | None = None,
200
201
  ) -> dict[str, Any]:
201
202
  """Deploy Tellr to Databricks Apps.
@@ -225,7 +226,6 @@ def create(
225
226
  client: External WorkspaceClient (optional)
226
227
  profile: Databricks CLI profile name (optional)
227
228
  config_yaml_path: Path to deployment config YAML (mutually exclusive with other args)
228
- encryption_key: Fernet key for Google OAuth encryption. Auto-generated if not provided.
229
229
  mlflow_tracing: Optional overrides for UC tracing env vars in generated ``app.yaml``.
230
230
  Keys: ``MLFLOW_TRACING_SQL_WAREHOUSE_ID``, ``TELLR_MLFLOW_UC_CATALOG``,
231
231
  ``TELLR_MLFLOW_UC_SCHEMA``, ``TELLR_MLFLOW_UC_TABLE_PREFIX``. With
@@ -258,7 +258,6 @@ def create(
258
258
  profile=profile,
259
259
  config_yaml_path=config_yaml_path,
260
260
  seed_databricks_defaults=False,
261
- encryption_key=encryption_key,
262
261
  mlflow_tracing=mlflow_tracing,
263
262
  )
264
263
 
@@ -288,8 +287,9 @@ def update(
288
287
  reset_database: If True, drop and recreate the schema (tables recreated on app startup)
289
288
  client: External WorkspaceClient (optional)
290
289
  profile: Databricks CLI profile name (optional)
291
- encryption_key: Fernet key for Google OAuth encryption. If not provided, the
292
- existing key is read from the deployed app.yaml to preserve encrypted data.
290
+ encryption_key: Override for the legacy Fernet key to relocate into
291
+ the encryption_keys table. Default: read from the deployed
292
+ app.yaml. The key is no longer written to app.yaml.
293
293
  mlflow_tracing: Optional overrides for UC tracing env vars (same keys as ``create``).
294
294
  Values from deployment YAML are not loaded on update; use this argument or
295
295
  ``TELLR_DEPLOY_MLFLOW_*`` environment variables.
@@ -333,7 +333,6 @@ def _create_databricks(
333
333
  profile: str | None = None,
334
334
  config_yaml_path: str | None = None,
335
335
  seed_databricks_defaults: bool = True,
336
- encryption_key: str | None = None,
337
336
  mlflow_tracing: dict[str, str] | None = None,
338
337
  ) -> dict[str, Any]:
339
338
  """Deploy Tellr to Databricks Apps with configurable seeding.
@@ -353,7 +352,6 @@ def _create_databricks(
353
352
  profile: Databricks CLI profile name (optional)
354
353
  config_yaml_path: Path to deployment config YAML (mutually exclusive with other args)
355
354
  seed_databricks_defaults: If True, seed Databricks-specific content on startup
356
- encryption_key: Fernet key for Google OAuth encryption. Auto-generated if not provided.
357
355
  mlflow_tracing: Optional overrides for UC tracing placeholders in ``app.yaml``.
358
356
 
359
357
  Returns:
@@ -417,11 +415,10 @@ def _create_databricks(
417
415
  lakebase_name,
418
416
  schema_name,
419
417
  seed_databricks_defaults=seed_databricks_defaults,
420
- encryption_key=encryption_key,
421
418
  lakebase_result=lakebase_result,
422
419
  mlflow_tracing=mlflow_subs,
423
420
  )
424
- print(" Generated app.yaml (with encryption key)")
421
+ print(" Generated app.yaml")
425
422
 
426
423
  print(f"Uploading to: {app_file_workspace_path}")
427
424
  _upload_files(ws, staging, app_file_workspace_path)
@@ -507,8 +504,9 @@ def _update_databricks(
507
504
  client: External WorkspaceClient (optional)
508
505
  profile: Databricks CLI profile name (optional)
509
506
  seed_databricks_defaults: If True, seed Databricks-specific content on startup
510
- encryption_key: Fernet key for Google OAuth encryption. If not provided, reads
511
- the existing key from the deployed app.yaml to preserve encrypted data.
507
+ encryption_key: Override for the legacy Fernet key to relocate into
508
+ the encryption_keys table. Default: read from the deployed
509
+ app.yaml. The key is no longer written to app.yaml.
512
510
  mlflow_tracing: Optional overrides for UC tracing placeholders in ``app.yaml``.
513
511
 
514
512
  Returns:
@@ -547,6 +545,28 @@ def _update_databricks(
547
545
  print(f" Schema '{schema_name}' reset (tables will be recreated on app startup)")
548
546
  print()
549
547
 
548
+ # CRITICAL-3 migration: relocate the legacy app.yaml key into the
549
+ # encryption_keys table BEFORE the new (keyless) app.yaml overwrites
550
+ # it. Runs at most once per install: after it succeeds, the deployed
551
+ # app.yaml has no key entry and encryption_key is None on re-runs.
552
+ if encryption_key:
553
+ print("Relocating encryption key into Lakebase (encryption_keys)...")
554
+ app_for_grant = ws.apps.get(name=app_name)
555
+ grant_client_id = _get_app_client_id(app_for_grant)
556
+ if not grant_client_id:
557
+ print(" Warning: no app client ID — table grant will be skipped")
558
+ mig_conn, _ = _get_lakebase_connection(
559
+ ws, lakebase_name, lakebase_result=lakebase_result
560
+ )
561
+ try:
562
+ with mig_conn.cursor() as cur:
563
+ _migrate_encryption_key_to_lakebase(
564
+ cur, schema_name, grant_client_id, encryption_key
565
+ )
566
+ finally:
567
+ mig_conn.close()
568
+ print(" Key relocated (relocate, not rotate — no re-encryption)")
569
+
550
570
  # Generate and upload updated files
551
571
  with _staging_dir() as staging:
552
572
  _write_requirements(staging, app_version)
@@ -555,7 +575,6 @@ def _update_databricks(
555
575
  lakebase_name,
556
576
  schema_name,
557
577
  seed_databricks_defaults=seed_databricks_defaults,
558
- encryption_key=encryption_key,
559
578
  lakebase_result=lakebase_result,
560
579
  mlflow_tracing=mlflow_subs,
561
580
  )
@@ -733,32 +752,96 @@ def _check_breaking_migrations(
733
752
  def _read_existing_encryption_key(
734
753
  ws: WorkspaceClient, workspace_path: str
735
754
  ) -> str | None:
736
- """Read the GOOGLE_OAUTH_ENCRYPTION_KEY from an existing deployed app.yaml.
755
+ """Read GOOGLE_OAUTH_ENCRYPTION_KEY from an existing deployed app.yaml.
737
756
 
738
- This preserves the encryption key across updates so that previously encrypted
739
- credentials and tokens remain decryptable.
757
+ Returns the key string, or None when app.yaml is readable but carries no
758
+ key entry (already migrated, or a fresh-era install).
740
759
 
741
- Returns:
742
- The encryption key string, or None if not found.
760
+ Raises DeploymentError when app.yaml cannot be downloaded or parsed:
761
+ silently treating an unreadable app.yaml as "no key" would skip the
762
+ CRITICAL-3 migration and let the new code generate a fresh key,
763
+ orphaning all existing ciphertext.
743
764
  """
744
765
  try:
745
766
  resp = ws.workspace.download(f"{workspace_path}/app.yaml")
746
767
  raw = resp.read() if hasattr(resp, "read") else resp
747
768
  content = raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
748
-
749
769
  existing = yaml.safe_load(content)
750
- for env_entry in existing.get("env", []):
751
- if env_entry.get("name") == "GOOGLE_OAUTH_ENCRYPTION_KEY":
752
- key = env_entry.get("value")
753
- if key:
754
- print(" Preserved existing encryption key from deployed app.yaml")
755
- return key
756
770
  except Exception as e:
757
- logger.warning("Could not read existing encryption key from app.yaml: %s", e)
771
+ raise DeploymentError(
772
+ f"Could not read the deployed app.yaml at {workspace_path}/app.yaml "
773
+ f"({e}). Aborting: the update must know whether a legacy encryption "
774
+ f"key needs relocating before it overwrites app.yaml."
775
+ ) from e
758
776
 
777
+ for env_entry in (existing or {}).get("env", []):
778
+ if env_entry.get("name") == "GOOGLE_OAUTH_ENCRYPTION_KEY":
779
+ key = env_entry.get("value")
780
+ if key:
781
+ print(" Found legacy encryption key in deployed app.yaml")
782
+ return key
759
783
  return None
760
784
 
761
785
 
786
+ def _migrate_encryption_key_to_lakebase(
787
+ cur: Any,
788
+ schema_name: str,
789
+ client_id: str | None,
790
+ encryption_key: str,
791
+ ) -> None:
792
+ """Relocate the legacy app.yaml Fernet key into <schema>.encryption_keys.
793
+
794
+ SDR-4437 CRITICAL-3, deploy-time migration. Relocate, not rotate: no
795
+ re-encryption of existing rows. Idempotent: re-running with the same key
796
+ is a no-op. A *different* pre-existing key is a hard error — silently
797
+ keeping either key would orphan the ciphertext encrypted under the other.
798
+
799
+ The GRANT is unconditional: the original deployer's ALTER DEFAULT
800
+ PRIVILEGES attaches to the creating role, so when a different identity
801
+ runs the upgrade this explicit grant is the only one that applies.
802
+ INSERT is included so the app's boot seed (insert-when-missing branch)
803
+ can never be privilege-blocked.
804
+
805
+ Takes an open cursor so callers control the endpoint (prod branch,
806
+ devloop fork, ...). Caller owns the connection lifecycle.
807
+ """
808
+ validate_schema_name(schema_name)
809
+ if client_id is not None:
810
+ validate_client_id(client_id)
811
+
812
+ # Matches the SQLAlchemy model (src/database/models/encryption_key.py):
813
+ # id INTEGER PK (no autoincrement), key_value TEXT, created_at TIMESTAMP.
814
+ cur.execute(
815
+ f'CREATE TABLE IF NOT EXISTS "{schema_name}".encryption_keys ('
816
+ "id INTEGER PRIMARY KEY, "
817
+ "key_value TEXT NOT NULL, "
818
+ "created_at TIMESTAMP NOT NULL)"
819
+ )
820
+ cur.execute(
821
+ f'INSERT INTO "{schema_name}".encryption_keys (id, key_value, created_at) '
822
+ "VALUES (1, %s, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING",
823
+ (encryption_key,),
824
+ )
825
+ cur.execute(
826
+ f'SELECT key_value FROM "{schema_name}".encryption_keys WHERE id = 1'
827
+ )
828
+ row = cur.fetchone()
829
+ if not row or row[0] != encryption_key:
830
+ raise DeploymentError(
831
+ "encryption_keys already holds a DIFFERENT key than the deployed "
832
+ "app.yaml. Refusing to continue: proceeding would orphan ciphertext "
833
+ "encrypted under one of the two keys. Determine which key decrypts "
834
+ "the existing google_oauth_tokens/google_global_credentials rows, "
835
+ "fix the encryption_keys row (or the app.yaml) to match, then "
836
+ "re-run the update. See SDR-4437 PR-3 design."
837
+ )
838
+ if client_id:
839
+ cur.execute(
840
+ f'GRANT SELECT, INSERT ON "{schema_name}".encryption_keys '
841
+ f'TO "{client_id}"'
842
+ )
843
+
844
+
762
845
  def _load_deployment_config(config_yaml_path: str) -> dict[str, str]:
763
846
  """Load deployment settings from config/deployment.yaml-style files.
764
847
 
@@ -1277,19 +1360,19 @@ def _write_app_yaml(
1277
1360
  lakebase_name: str,
1278
1361
  schema_name: str,
1279
1362
  seed_databricks_defaults: bool = False,
1280
- encryption_key: str | None = None,
1281
1363
  lakebase_result: dict[str, Any] | None = None,
1282
1364
  mlflow_tracing: dict[str, str] | None = None,
1283
1365
  ) -> None:
1284
1366
  """Generate app.yaml with environment variables.
1285
1367
 
1368
+ The Fernet encryption key is deliberately NOT written here (SDR-4437
1369
+ CRITICAL-3): the app reads/seeds it from the encryption_keys table.
1370
+
1286
1371
  Args:
1287
1372
  staging_dir: Directory to write the app.yaml file
1288
1373
  lakebase_name: Lakebase instance name
1289
1374
  schema_name: Schema name
1290
1375
  seed_databricks_defaults: If True, include Databricks-specific content seeding
1291
- encryption_key: Fernet encryption key for Google OAuth credentials/tokens.
1292
- Auto-generated if not provided.
1293
1376
  lakebase_result: Result dict from _get_or_create_lakebase() with type info.
1294
1377
  mlflow_tracing: Resolved template keys for UC tracing (four entries). If
1295
1378
  omitted, values are taken only from ``TELLR_DEPLOY_MLFLOW_*`` env vars.
@@ -1300,12 +1383,6 @@ def _write_app_yaml(
1300
1383
  else:
1301
1384
  init_call = "init_database()"
1302
1385
 
1303
- if not encryption_key:
1304
- from cryptography.fernet import Fernet
1305
-
1306
- encryption_key = Fernet.generate_key().decode()
1307
- logger.info("Auto-generated GOOGLE_OAUTH_ENCRYPTION_KEY for deployment")
1308
-
1309
1386
  # Determine lakebase type info for env vars
1310
1387
  lakebase_type = (lakebase_result or {}).get("type", "provisioned")
1311
1388
  lakebase_pg_host = (lakebase_result or {}).get("host", "")
@@ -1320,7 +1397,6 @@ def _write_app_yaml(
1320
1397
  LAKEBASE_INSTANCE=lakebase_name,
1321
1398
  LAKEBASE_SCHEMA=schema_name,
1322
1399
  INIT_DATABASE_CALL=init_call,
1323
- GOOGLE_OAUTH_ENCRYPTION_KEY=encryption_key,
1324
1400
  LAKEBASE_TYPE=lakebase_type,
1325
1401
  LAKEBASE_PG_HOST=lakebase_pg_host,
1326
1402
  LAKEBASE_PROJECT_ID=lakebase_project_id,
@@ -1567,6 +1643,9 @@ def _setup_database_schema(
1567
1643
  print(" Warning: Could not get app client ID - schema setup skipped")
1568
1644
  return
1569
1645
 
1646
+ validate_schema_name(schema_name)
1647
+ validate_client_id(client_id)
1648
+
1570
1649
  conn, _ = _get_lakebase_connection(ws, lakebase_name, lakebase_result=lakebase_result)
1571
1650
 
1572
1651
  try:
@@ -1597,6 +1676,10 @@ def _reset_schema(
1597
1676
  """
1598
1677
  client_id = _get_app_client_id(app)
1599
1678
 
1679
+ validate_schema_name(schema_name)
1680
+ if client_id:
1681
+ validate_client_id(client_id)
1682
+
1600
1683
  conn, _ = _get_lakebase_connection(ws, lakebase_name, lakebase_result=lakebase_result)
1601
1684
 
1602
1685
  try:
@@ -1624,6 +1707,9 @@ def _grant_schema_permissions(cur: Any, schema_name: str, client_id: str) -> Non
1624
1707
 
1625
1708
  This function only grants schema/table permissions — it does NOT create roles.
1626
1709
  """
1710
+ validate_schema_name(schema_name)
1711
+ validate_client_id(client_id)
1712
+
1627
1713
  # Verify the role exists before granting
1628
1714
  cur.execute(
1629
1715
  "SELECT 1 FROM pg_roles WHERE rolname = %s", (client_id,)
@@ -0,0 +1,37 @@
1
+ """Validation for identifiers interpolated into Postgres DDL (SDR-4437 MEDIUM-5).
2
+
3
+ Both inputs are config/platform-derived today (schema_name from deploy
4
+ config, client_id from the app SP), so this is hardening against future
5
+ user-derived values, not a live injection.
6
+
7
+ Lives only in this (deploy-tool) distribution: the app distribution has no
8
+ DDL-identifier interpolation site once the dead setup_lakebase_schema is
9
+ removed, so there is no counterpart to keep in sync.
10
+ """
11
+
12
+ import re
13
+
14
+ _SCHEMA_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
15
+ # UUID (App SP client id) OR all-digits (str(service_principal_id) fallback
16
+ # in _get_app_client_id). Both are injection-safe charsets.
17
+ _CLIENT_ID_RE = re.compile(
18
+ r"^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
19
+ r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9]+)$"
20
+ )
21
+
22
+
23
+ def validate_schema_name(schema: str) -> str:
24
+ """Return *schema* if it is a safe Postgres schema identifier; else raise."""
25
+ if not isinstance(schema, str) or not _SCHEMA_RE.match(schema):
26
+ raise ValueError(f"Invalid Postgres schema name: {schema!r}")
27
+ return schema
28
+
29
+
30
+ def validate_client_id(client_id: str) -> str:
31
+ """Return *client_id* if it is a UUID or numeric SP id; else raise."""
32
+ if not isinstance(client_id, str) or not _CLIENT_ID_RE.match(client_id):
33
+ raise ValueError(
34
+ f"Invalid service-principal client id (expected UUID or numeric id): "
35
+ f"{client_id!r}"
36
+ )
37
+ return client_id
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-tellr
3
- Version: 0.3.13.dev3
3
+ Version: 0.3.13.dev4
4
4
  Summary: Tellr deployment tooling for Databricks Apps
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  databricks_tellr/__init__.py
4
4
  databricks_tellr/deploy.py
5
+ databricks_tellr/identifiers.py
5
6
  databricks_tellr.egg-info/PKG-INFO
6
7
  databricks_tellr.egg-info/SOURCES.txt
7
8
  databricks_tellr.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "databricks-tellr"
7
- version = "0.3.13.dev3"
7
+ version = "0.3.13.dev4"
8
8
  description = "Tellr deployment tooling for Databricks Apps"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"