databricks-tellr 0.3.13.dev14__tar.gz → 0.3.13.dev16__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.dev14
3
+ Version: 0.3.13.dev16
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
@@ -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
  )
@@ -660,6 +679,11 @@ def _check_breaking_migrations(
660
679
  Raises:
661
680
  DeploymentError: If the user declines the migration.
662
681
  """
682
+ # MEDIUM-5: schema_name is interpolated into the row-count DDL below
683
+ # (identifiers can't be parameterized); validate before any interpolation,
684
+ # matching the other DDL sites in this module.
685
+ validate_schema_name(schema_name)
686
+
663
687
  try:
664
688
  conn, _ = _get_lakebase_connection(ws, lakebase_name, lakebase_result=lakebase_result)
665
689
  except Exception as e:
@@ -733,32 +757,96 @@ def _check_breaking_migrations(
733
757
  def _read_existing_encryption_key(
734
758
  ws: WorkspaceClient, workspace_path: str
735
759
  ) -> str | None:
736
- """Read the GOOGLE_OAUTH_ENCRYPTION_KEY from an existing deployed app.yaml.
760
+ """Read GOOGLE_OAUTH_ENCRYPTION_KEY from an existing deployed app.yaml.
737
761
 
738
- This preserves the encryption key across updates so that previously encrypted
739
- credentials and tokens remain decryptable.
762
+ Returns the key string, or None when app.yaml is readable but carries no
763
+ key entry (already migrated, or a fresh-era install).
740
764
 
741
- Returns:
742
- The encryption key string, or None if not found.
765
+ Raises DeploymentError when app.yaml cannot be downloaded or parsed:
766
+ silently treating an unreadable app.yaml as "no key" would skip the
767
+ CRITICAL-3 migration and let the new code generate a fresh key,
768
+ orphaning all existing ciphertext.
743
769
  """
744
770
  try:
745
771
  resp = ws.workspace.download(f"{workspace_path}/app.yaml")
746
772
  raw = resp.read() if hasattr(resp, "read") else resp
747
773
  content = raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
748
-
749
774
  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
775
  except Exception as e:
757
- logger.warning("Could not read existing encryption key from app.yaml: %s", e)
776
+ raise DeploymentError(
777
+ f"Could not read the deployed app.yaml at {workspace_path}/app.yaml "
778
+ f"({e}). Aborting: the update must know whether a legacy encryption "
779
+ f"key needs relocating before it overwrites app.yaml."
780
+ ) from e
758
781
 
782
+ for env_entry in (existing or {}).get("env", []):
783
+ if env_entry.get("name") == "GOOGLE_OAUTH_ENCRYPTION_KEY":
784
+ key = env_entry.get("value")
785
+ if key:
786
+ print(" Found legacy encryption key in deployed app.yaml")
787
+ return key
759
788
  return None
760
789
 
761
790
 
791
+ def _migrate_encryption_key_to_lakebase(
792
+ cur: Any,
793
+ schema_name: str,
794
+ client_id: str | None,
795
+ encryption_key: str,
796
+ ) -> None:
797
+ """Relocate the legacy app.yaml Fernet key into <schema>.encryption_keys.
798
+
799
+ SDR-4437 CRITICAL-3, deploy-time migration. Relocate, not rotate: no
800
+ re-encryption of existing rows. Idempotent: re-running with the same key
801
+ is a no-op. A *different* pre-existing key is a hard error — silently
802
+ keeping either key would orphan the ciphertext encrypted under the other.
803
+
804
+ The GRANT is unconditional: the original deployer's ALTER DEFAULT
805
+ PRIVILEGES attaches to the creating role, so when a different identity
806
+ runs the upgrade this explicit grant is the only one that applies.
807
+ INSERT is included so the app's boot seed (insert-when-missing branch)
808
+ can never be privilege-blocked.
809
+
810
+ Takes an open cursor so callers control the endpoint (prod branch,
811
+ devloop fork, ...). Caller owns the connection lifecycle.
812
+ """
813
+ validate_schema_name(schema_name)
814
+ if client_id is not None:
815
+ validate_client_id(client_id)
816
+
817
+ # Matches the SQLAlchemy model (src/database/models/encryption_key.py):
818
+ # id INTEGER PK (no autoincrement), key_value TEXT, created_at TIMESTAMP.
819
+ cur.execute(
820
+ f'CREATE TABLE IF NOT EXISTS "{schema_name}".encryption_keys ('
821
+ "id INTEGER PRIMARY KEY, "
822
+ "key_value TEXT NOT NULL, "
823
+ "created_at TIMESTAMP NOT NULL)"
824
+ )
825
+ cur.execute(
826
+ f'INSERT INTO "{schema_name}".encryption_keys (id, key_value, created_at) '
827
+ "VALUES (1, %s, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING",
828
+ (encryption_key,),
829
+ )
830
+ cur.execute(
831
+ f'SELECT key_value FROM "{schema_name}".encryption_keys WHERE id = 1'
832
+ )
833
+ row = cur.fetchone()
834
+ if not row or row[0] != encryption_key:
835
+ raise DeploymentError(
836
+ "encryption_keys already holds a DIFFERENT key than the deployed "
837
+ "app.yaml. Refusing to continue: proceeding would orphan ciphertext "
838
+ "encrypted under one of the two keys. Determine which key decrypts "
839
+ "the existing google_oauth_tokens/google_global_credentials rows, "
840
+ "fix the encryption_keys row (or the app.yaml) to match, then "
841
+ "re-run the update. See SDR-4437 PR-3 design."
842
+ )
843
+ if client_id:
844
+ cur.execute(
845
+ f'GRANT SELECT, INSERT ON "{schema_name}".encryption_keys '
846
+ f'TO "{client_id}"'
847
+ )
848
+
849
+
762
850
  def _load_deployment_config(config_yaml_path: str) -> dict[str, str]:
763
851
  """Load deployment settings from config/deployment.yaml-style files.
764
852
 
@@ -1277,19 +1365,19 @@ def _write_app_yaml(
1277
1365
  lakebase_name: str,
1278
1366
  schema_name: str,
1279
1367
  seed_databricks_defaults: bool = False,
1280
- encryption_key: str | None = None,
1281
1368
  lakebase_result: dict[str, Any] | None = None,
1282
1369
  mlflow_tracing: dict[str, str] | None = None,
1283
1370
  ) -> None:
1284
1371
  """Generate app.yaml with environment variables.
1285
1372
 
1373
+ The Fernet encryption key is deliberately NOT written here (SDR-4437
1374
+ CRITICAL-3): the app reads/seeds it from the encryption_keys table.
1375
+
1286
1376
  Args:
1287
1377
  staging_dir: Directory to write the app.yaml file
1288
1378
  lakebase_name: Lakebase instance name
1289
1379
  schema_name: Schema name
1290
1380
  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
1381
  lakebase_result: Result dict from _get_or_create_lakebase() with type info.
1294
1382
  mlflow_tracing: Resolved template keys for UC tracing (four entries). If
1295
1383
  omitted, values are taken only from ``TELLR_DEPLOY_MLFLOW_*`` env vars.
@@ -1300,12 +1388,6 @@ def _write_app_yaml(
1300
1388
  else:
1301
1389
  init_call = "init_database()"
1302
1390
 
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
1391
  # Determine lakebase type info for env vars
1310
1392
  lakebase_type = (lakebase_result or {}).get("type", "provisioned")
1311
1393
  lakebase_pg_host = (lakebase_result or {}).get("host", "")
@@ -1320,7 +1402,6 @@ def _write_app_yaml(
1320
1402
  LAKEBASE_INSTANCE=lakebase_name,
1321
1403
  LAKEBASE_SCHEMA=schema_name,
1322
1404
  INIT_DATABASE_CALL=init_call,
1323
- GOOGLE_OAUTH_ENCRYPTION_KEY=encryption_key,
1324
1405
  LAKEBASE_TYPE=lakebase_type,
1325
1406
  LAKEBASE_PG_HOST=lakebase_pg_host,
1326
1407
  LAKEBASE_PROJECT_ID=lakebase_project_id,
@@ -1567,6 +1648,9 @@ def _setup_database_schema(
1567
1648
  print(" Warning: Could not get app client ID - schema setup skipped")
1568
1649
  return
1569
1650
 
1651
+ validate_schema_name(schema_name)
1652
+ validate_client_id(client_id)
1653
+
1570
1654
  conn, _ = _get_lakebase_connection(ws, lakebase_name, lakebase_result=lakebase_result)
1571
1655
 
1572
1656
  try:
@@ -1597,6 +1681,10 @@ def _reset_schema(
1597
1681
  """
1598
1682
  client_id = _get_app_client_id(app)
1599
1683
 
1684
+ validate_schema_name(schema_name)
1685
+ if client_id:
1686
+ validate_client_id(client_id)
1687
+
1600
1688
  conn, _ = _get_lakebase_connection(ws, lakebase_name, lakebase_result=lakebase_result)
1601
1689
 
1602
1690
  try:
@@ -1624,6 +1712,9 @@ def _grant_schema_permissions(cur: Any, schema_name: str, client_id: str) -> Non
1624
1712
 
1625
1713
  This function only grants schema/table permissions — it does NOT create roles.
1626
1714
  """
1715
+ validate_schema_name(schema_name)
1716
+ validate_client_id(client_id)
1717
+
1627
1718
  # Verify the role exists before granting
1628
1719
  cur.execute(
1629
1720
  "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.dev14
3
+ Version: 0.3.13.dev16
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.dev14"
7
+ version = "0.3.13.dev16"
8
8
  description = "Tellr deployment tooling for Databricks Apps"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"