Skip to content

Commit 4e08ddb

Browse files
committed
Fix Lakebase app schema ownership
1 parent 1dfddf1 commit 4e08ddb

9 files changed

Lines changed: 108 additions & 35 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,24 @@ jobs:
137137
run: |
138138
databricks apps get doc-intel-analyst-demo --output json > /tmp/app.json
139139
app_obo_required="$(python -c "import yaml; d=yaml.safe_load(open('databricks.yml')); default=d.get('variables',{}).get('app_obo_required',{}).get('default','true'); value=d.get('targets',{}).get('demo',{}).get('variables',{}).get('app_obo_required', default); print(str(value).lower())")"
140+
lakebase_name="$(python -c "import yaml; d=yaml.safe_load(open('databricks.yml')); print(d.get('targets',{}).get('demo',{}).get('variables',{}).get('lakebase_instance','docintel-demo-state-v1'))")"
141+
python -c "import json; app=json.load(open('/tmp/app.json')); vals=[str(app.get(k)) for k in ('service_principal_client_id','service_principal_name','service_principal_id') if app.get(k) is not None]; print('\n'.join(dict.fromkeys(v for v in vals if v)))" > /tmp/app-sp-candidates.txt
142+
db_granted=0
143+
while IFS= read -r principal; do
144+
grant_json="$(python -c "import json, sys; print(json.dumps({'access_control_list':[{'service_principal_name':sys.argv[1],'permission_level':'CAN_USE'}]}))" "$principal")"
145+
if databricks permissions update database-instances "$lakebase_name" --json "$grant_json"; then
146+
db_granted=1
147+
break
148+
fi
149+
done < /tmp/app-sp-candidates.txt
150+
test "$db_granted" = "1"
140151
if [ "$app_obo_required" = "true" ]; then
141152
# `bundle run` may wipe user_api_scopes (documented destructive-update
142153
# behavior). Fail loudly if required user scopes are missing.
143154
python -c "import json; app=json.load(open('/tmp/app.json')); scopes=set(app.get('user_api_scopes') or []); required={'serving.serving-endpoints','sql'}; missing=required-scopes; assert not missing, f'OBO scopes missing: {sorted(missing)} (got {sorted(scopes)})'"
144155
else
145156
python -c "import json; app=json.load(open('/tmp/app.json')); scopes=app.get('user_api_scopes'); assert not scopes, f'demo App-SP mode expected no user_api_scopes, got {scopes}'"
146157
endpoint_id="$(databricks serving-endpoints get "$AGENT_ENDPOINT_NAME" --output json | python -c "import json, sys; e=json.load(sys.stdin); print(e.get('id') or e.get('name'))")"
147-
python -c "import json; app=json.load(open('/tmp/app.json')); vals=[str(app.get(k)) for k in ('service_principal_client_id','service_principal_name','service_principal_id') if app.get(k) is not None]; print('\n'.join(dict.fromkeys(v for v in vals if v)))" > /tmp/app-sp-candidates.txt
148158
granted=0
149159
while IFS= read -r principal; do
150160
grant_json="$(python -c "import json, sys; print(json.dumps({'access_control_list':[{'service_principal_name':sys.argv[1],'permission_level':'CAN_QUERY'}]}))" "$principal")"

app/README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export DATABRICKS_CLIENT_SECRET=<app-sp-secret>
3535
# Locally, set the same connection fields and let lakebase_client.py mint the
3636
# OAuth database password through the Databricks SDK.
3737
export DOCINTEL_LAKEBASE_INSTANCE=docintel-demo-state-v1
38+
export DOCINTEL_LAKEBASE_SCHEMA=docintel_app
3839
export PGDATABASE=docintel-demo-state-v1
3940
export PGUSER=<app-sp-application-id>
4041
export PGPORT=5432
@@ -49,13 +50,11 @@ streamlit run app/app.py
4950

5051
Local runs do not have the Databricks Apps `x-forwarded-access-token` header, so they cannot validate the Agent Bricks OBO path. Use a deployed OBO-enabled target for prod identity validation.
5152

52-
If you accidentally run Lakebase schema initialization with user creds (`DATABRICKS_CLIENT_ID`/`SECRET` unset), `lakebase_client.init_schema()` logs a warning identifying the mismatch. The tables get created under your user account, not the App SP, and the deployed App will lose write access. Drop the user-owned tables and re-init under the App SP to recover:
53+
If you accidentally run Lakebase schema initialization with user creds (`DATABRICKS_CLIENT_ID`/`SECRET` unset), `lakebase_client.init_schema()` logs a warning identifying the mismatch. The schema gets created under your user account, not the App SP, and the deployed App will lose write access. Drop the user-owned schema and re-init under the App SP to recover:
5354

5455
```sql
5556
-- connected as the App SP via the local-dev env above
56-
DROP TABLE IF EXISTS feedback CASCADE;
57-
DROP TABLE IF EXISTS query_logs CASCADE;
58-
DROP TABLE IF EXISTS conversation_history CASCADE;
57+
DROP SCHEMA IF EXISTS docintel_app CASCADE;
5958
-- next streamlit run will re-init under the App SP
6059
```
6160

app/lakebase_client.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,37 +25,56 @@
2525
from typing import Iterator
2626

2727
import psycopg
28+
from psycopg import sql
2829
from databricks.sdk import WorkspaceClient
2930

3031
_log = logging.getLogger(__name__)
3132

3233

33-
_SCHEMA = """
34-
CREATE TABLE IF NOT EXISTS conversation_history (
34+
def _lakebase_schema() -> str:
35+
return os.environ.get("DOCINTEL_LAKEBASE_SCHEMA", "docintel_app")
36+
37+
38+
def _table(name: str) -> sql.Identifier:
39+
return sql.Identifier(_lakebase_schema(), name)
40+
41+
42+
def _schema_ddl() -> sql.Composed:
43+
conversation_history = _table("conversation_history")
44+
query_logs = _table("query_logs")
45+
feedback = _table("feedback")
46+
return sql.SQL(
47+
"""
48+
CREATE TABLE IF NOT EXISTS {conversation_history} (
3549
conversation_id UUID PRIMARY KEY,
3650
user_email TEXT NOT NULL,
3751
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
3852
last_turn_at TIMESTAMPTZ NOT NULL DEFAULT now()
3953
);
40-
CREATE TABLE IF NOT EXISTS query_logs (
54+
CREATE TABLE IF NOT EXISTS {query_logs} (
4155
turn_id UUID PRIMARY KEY,
42-
conversation_id UUID REFERENCES conversation_history(conversation_id),
56+
conversation_id UUID REFERENCES {conversation_history}(conversation_id),
4357
question TEXT NOT NULL,
4458
answer TEXT NOT NULL,
4559
citations JSONB NOT NULL,
4660
latency_ms INT NOT NULL,
4761
agent_path TEXT NOT NULL,
4862
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
4963
);
50-
CREATE TABLE IF NOT EXISTS feedback (
64+
CREATE TABLE IF NOT EXISTS {feedback} (
5165
feedback_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
52-
turn_id UUID REFERENCES query_logs(turn_id),
66+
turn_id UUID REFERENCES {query_logs}(turn_id),
5367
user_email TEXT NOT NULL,
5468
rating TEXT NOT NULL CHECK (rating IN ('up','down')),
5569
comment TEXT,
5670
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
5771
);
5872
"""
73+
).format(
74+
conversation_history=conversation_history,
75+
query_logs=query_logs,
76+
feedback=feedback,
77+
)
5978

6079

6180
@contextmanager
@@ -98,7 +117,7 @@ def _generate_lakebase_password() -> str:
98117

99118

100119
def init_schema() -> None:
101-
"""Idempotent CREATE TABLE IF NOT EXISTS. Logs the connected role so
120+
"""Idempotent CREATE SCHEMA/TABLE IF NOT EXISTS. Logs the connected role so
102121
deployed-vs-local identity divergence is debuggable from app logs.
103122
"""
104123
with _conn() as c, c.cursor() as cur:
@@ -115,23 +134,30 @@ def init_schema() -> None:
115134
)
116135
else:
117136
_log.info("Lakebase init connected as %r", connected_user)
118-
cur.execute(_SCHEMA)
137+
cur.execute(
138+
sql.SQL("CREATE SCHEMA IF NOT EXISTS {}").format(sql.Identifier(_lakebase_schema()))
139+
)
140+
cur.execute(_schema_ddl())
119141

120142

121143
def ensure_conversation(conversation_id: uuid.UUID, user_email: str) -> None:
122144
with _conn() as c, c.cursor() as cur:
123145
cur.execute(
124-
"INSERT INTO conversation_history (conversation_id, user_email) VALUES (%s, %s) "
125-
"ON CONFLICT (conversation_id) DO UPDATE SET last_turn_at = now()",
146+
sql.SQL(
147+
"INSERT INTO {table} (conversation_id, user_email) VALUES (%s, %s) "
148+
"ON CONFLICT (conversation_id) DO UPDATE SET last_turn_at = now()"
149+
).format(table=_table("conversation_history")),
126150
(conversation_id, user_email),
127151
)
128152

129153

130154
def log_turn(*, turn_id: str, conversation_id: uuid.UUID, response: dict, question: str) -> None:
131155
with _conn() as c, c.cursor() as cur:
132156
cur.execute(
133-
"INSERT INTO query_logs (turn_id, conversation_id, question, answer, citations, latency_ms, agent_path) "
134-
"VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)",
157+
sql.SQL(
158+
"INSERT INTO {table} (turn_id, conversation_id, question, answer, citations, latency_ms, agent_path) "
159+
"VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)"
160+
).format(table=_table("query_logs")),
135161
(
136162
turn_id,
137163
conversation_id,
@@ -147,6 +173,8 @@ def log_turn(*, turn_id: str, conversation_id: uuid.UUID, response: dict, questi
147173
def write_feedback(*, turn_id: str, user_email: str, rating: str, comment: str | None) -> None:
148174
with _conn() as c, c.cursor() as cur:
149175
cur.execute(
150-
"INSERT INTO feedback (turn_id, user_email, rating, comment) VALUES (%s, %s, %s, %s)",
176+
sql.SQL("INSERT INTO {table} (turn_id, user_email, rating, comment) VALUES (%s, %s, %s, %s)").format(
177+
table=_table("feedback")
178+
),
151179
(turn_id, user_email, rating, comment),
152180
)

docs/runbook.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,10 @@ Metric key names can vary across MLflow/databricks-agents versions. The eval run
8484
| Agent answers ignore user UC permissions in prod | OBO scopes wiped by `bundle run` (documented destructive-update behavior — see [Databricks Apps deploy docs](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/deploy)) | Re-apply scopes to the target app: `databricks apps update <app-name> --user-api-scopes serving.serving-endpoints,sql,iam.access-control:read,iam.current-user:read` |
8585
| Agent deployment cannot grant endpoint query permission | Permissions API was called with endpoint name instead of internal endpoint ID, or the generated endpoint is not ready | Use current `agent/document_intelligence_agent.py`; it waits for readiness and grants by serving endpoint ID |
8686
| Streamlit user sees stale UC permissions | OBO token captured at WebSocket open; never refreshes ([Databricks Apps runtime docs](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/app-runtime)) | Reload the page after permission changes |
87-
| Lakebase tables not writable from deployed App | Local-dev `streamlit run` initialised schema under user identity, not App SP | Connect as App SP and `DROP TABLE feedback, query_logs, conversation_history`; next App run re-creates them under SP. See `app/README.md` |
87+
| Lakebase tables not writable from deployed App | Local-dev `streamlit run` initialised the `docintel_app` schema under user identity, not App SP | Connect as App SP and `DROP SCHEMA docintel_app CASCADE`; next App run re-creates it under SP. See `app/README.md` |
8888
| CLEARS Latency axis fails | Agent Bricks orchestration or Knowledge Assistant source is too broad | Narrow the Knowledge Assistant source, tune Supervisor instructions, or reduce structured-tool fan-out |
8989
| Citation chips render but filenames show `source` | Knowledge Assistant footnote format changed or omitted filename markers | Capture the raw Agent Bricks payload and compare it with `app/agent_bricks_response.py`'s markdown-footnote parser |
90-
| App errors connecting to Lakebase | Database resource binding missing connection fields, or OAuth credential minting failed | Check the `docintel-lakebase` resource binding plus `PGHOST`/`PGPORT`/`PGUSER`/`PGDATABASE`/`DOCINTEL_LAKEBASE_INSTANCE` in the App runtime. `PGPASSWORD` is minted at connection time by `app/lakebase_client.py` |
90+
| App errors connecting to Lakebase | Database resource binding missing connection fields, OAuth credential minting failed, or App SP lacks Lakebase instance `CAN_USE` | Check the `docintel-lakebase` resource binding plus `PGHOST`/`PGPORT`/`PGUSER`/`PGDATABASE`/`DOCINTEL_LAKEBASE_INSTANCE`/`DOCINTEL_LAKEBASE_SCHEMA` in the App runtime. `PGPASSWORD` is minted at connection time by `app/lakebase_client.py` |
9191

9292
## Verifying end-to-end OBO
9393

resources/consumers/analyst.app.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ resources:
1313
value: ${var.app_obo_required}
1414
- name: DOCINTEL_LAKEBASE_INSTANCE
1515
value: ${var.lakebase_instance}
16+
- name: DOCINTEL_LAKEBASE_SCHEMA
17+
value: docintel_app
1618

1719
# Databricks Apps auto-grants Lakebase permissions to the App SP on
1820
# deploy — see https://docs.databricks.com/aws/en/dev-tools/databricks-apps/access-data.

scripts/bootstrap-demo.sh

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -204,19 +204,9 @@ for i in d.get('database_instances', []):
204204
done
205205
}
206206

207-
grant_app_sp_endpoint_query() {
207+
app_sp_principals() {
208208
local app_json="$1"
209-
local endpoint_json endpoint_id principals principal grant_json
210-
endpoint_json=$(databricks serving-endpoints get "$AGENT_ENDPOINT_NAME" --output json)
211-
endpoint_id=$(printf '%s' "$endpoint_json" | "$PYTHON" -c "
212-
import json, sys
213-
endpoint = json.load(sys.stdin)
214-
print(endpoint.get('id') or endpoint.get('name') or '$AGENT_ENDPOINT_NAME')
215-
")
216-
principals=()
217-
while IFS= read -r principal; do
218-
[[ -n "$principal" ]] && principals+=("$principal")
219-
done < <(printf '%s' "$app_json" | "$PYTHON" -c "
209+
printf '%s' "$app_json" | "$PYTHON" -c "
220210
import json, sys
221211
app = json.load(sys.stdin)
222212
seen = set()
@@ -228,7 +218,50 @@ for key in ('service_principal_client_id', 'service_principal_name', 'service_pr
228218
if value and value not in seen:
229219
seen.add(value)
230220
print(value)
221+
"
222+
}
223+
224+
grant_app_sp_lakebase_use() {
225+
local app_json="$1"
226+
local principals principal grant_json
227+
principals=()
228+
while IFS= read -r principal; do
229+
[[ -n "$principal" ]] && principals+=("$principal")
230+
done < <(app_sp_principals "$app_json")
231+
if (( ${#principals[@]} == 0 )); then
232+
die "app service principal was not returned by Databricks Apps API"
233+
fi
234+
for principal in "${principals[@]}"; do
235+
grant_json=$("$PYTHON" -c "
236+
import json, sys
237+
print(json.dumps({
238+
'access_control_list': [{
239+
'service_principal_name': sys.argv[1],
240+
'permission_level': 'CAN_USE',
241+
}]
242+
}))
243+
" "$principal")
244+
if databricks permissions update database-instances "$LAKEBASE_NAME" --json "$grant_json" >/dev/null 2>&1; then
245+
log " granted CAN_USE on Lakebase $LAKEBASE_NAME to App SP $principal"
246+
return 0
247+
fi
248+
done
249+
die "failed to grant CAN_USE on Lakebase $LAKEBASE_NAME to the App service principal"
250+
}
251+
252+
grant_app_sp_endpoint_query() {
253+
local app_json="$1"
254+
local endpoint_json endpoint_id principals principal grant_json
255+
endpoint_json=$(databricks serving-endpoints get "$AGENT_ENDPOINT_NAME" --output json)
256+
endpoint_id=$(printf '%s' "$endpoint_json" | "$PYTHON" -c "
257+
import json, sys
258+
endpoint = json.load(sys.stdin)
259+
print(endpoint.get('id') or endpoint.get('name') or '$AGENT_ENDPOINT_NAME')
231260
")
261+
principals=()
262+
while IFS= read -r principal; do
263+
[[ -n "$principal" ]] && principals+=("$principal")
264+
done < <(app_sp_principals "$app_json")
232265
if (( ${#principals[@]} == 0 )); then
233266
die "app service principal was not returned by Databricks Apps API"
234267
fi
@@ -365,6 +398,7 @@ databricks api patch \
365398

366399
log " verifying app auth mode on $APP_NAME"
367400
if app_state=$(databricks apps get "$APP_NAME" --output json 2>/dev/null); then
401+
grant_app_sp_lakebase_use "$app_state"
368402
if [[ "$APP_OBO_REQUIRED" == "true" ]]; then
369403
"$PYTHON" -c "
370404
import json

specs/001-doc-intel-10k/data-model.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Phase 1 Data Model
22

3-
All Delta tables live under the bundle-parameterized `${var.catalog}.${var.schema}`. Lakebase tables live in the bundle-managed Lakebase database instance `${var.lakebase_instance}`, exposed to SQL dashboards through the UC database catalog `${var.schema}_state`.
3+
All Delta tables live under the bundle-parameterized `${var.catalog}.${var.schema}`. Lakebase tables live in schema `docintel_app` inside the bundle-managed Lakebase database instance `${var.lakebase_instance}`, exposed to SQL dashboards through the UC database catalog `${var.schema}_state`.
44

55
## Bronze
66

specs/001-doc-intel-10k/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ This is a DAB plus Agent Bricks deployment project. SQL pipeline code is at `pip
3838
**⚠️ CRITICAL**: All user stories depend on these.
3939

4040
- [x] T006 Define UC catalog/schema/volume in `resources/foundation/catalog.yml`: `${var.catalog}.${var.schema}` schema + `raw_filings` volume; grant `USE_CATALOG`, `USE_SCHEMA`, `READ_VOLUME` to a configurable analyst group
41-
- [x] T007 [P] Define the Lakebase instance/catalog in `resources/foundation/lakebase_instance.yml` and `resources/consumers/lakebase_catalog.yml`; the UC catalog is `${var.schema}_state`, while `app/lakebase_client.py` creates `conversation_history`, `query_logs`, and `feedback` tables at runtime using App resource binding fields plus Databricks-minted Lakebase OAuth credentials
41+
- [x] T007 [P] Define the Lakebase instance/catalog in `resources/foundation/lakebase_instance.yml` and `resources/consumers/lakebase_catalog.yml`; the UC catalog is `${var.schema}_state`, while `app/lakebase_client.py` creates `docintel_app.conversation_history`, `docintel_app.query_logs`, and `docintel_app.feedback` at runtime using App resource binding fields plus Databricks-minted Lakebase OAuth credentials
4242
- [x] T008 [P] Add JSON contracts under `specs/001-doc-intel-10k/contracts/`: `agent-request.json`, `agent-response.json`, `feedback-event.json`, `kpi-schema.json`
4343

4444
**Checkpoint**: catalog, schema, volume, Lakebase database exist; bundle validates.

src/dashboards/usage.lvdash.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"SELECT created_at::date AS day, agent_path, count(*) AS turns,",
2727
" percentile_approx(latency_ms, 0.95) AS latency_p95_ms,",
2828
" sum(CASE WHEN array_size(citations) = 0 THEN 1 ELSE 0 END) AS ungrounded",
29-
"FROM `__dataset_schema___state`.public.query_logs",
29+
"FROM `__dataset_schema___state`.docintel_app.query_logs",
3030
"GROUP BY 1, 2 ORDER BY 1 DESC"
3131
]
3232
}

0 commit comments

Comments
 (0)