Skip to content

Commit 0a042eb

Browse files
ahmetrendeclaude
andcommitted
keep Athena queries out of auto-approve unless the target allows it
An Athena archive query is reviewed even for a waiver holder: waivers and the fingerprint cache do not apply unless the target sets engine_config.auto_approve to true. What an archive query costs depends on the partitions it reads, so a fleet-wide waiver, or a fingerprint that ignores literal values, would let the expensive variant of a cheap, once-approved query run unreviewed. A super-admin's own submission is unchanged. Every caller that decides or announces auto-approval asks one helper, engines.auto_approve_allowed, and a test scans the package for callers that skip it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7c06e9e commit 0a042eb

12 files changed

Lines changed: 817 additions & 39 deletions

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ frontend and the endpoints it calls are explicitly outside it.
161161

162162
### Fixed
163163

164+
- **Athena archive queries are reviewed, even for someone with auto-approve.**
165+
A fleet-wide waiver or a fingerprint match would have run them with no
166+
review. To allow it, set `engine_config.auto_approve` to `true` on the target
167+
(docs/CONFIGURATION.md). A super-admin's own query is unchanged.
164168
- **"All databases" in the web grant forms wrote a grant on a database named
165169
`*`.** It matched nothing. Any spelling of every database (`*`, empty, `all`,
166170
`any`) now means every database, for person and team grants alike.

‎docs/CONFIGURATION.md‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ default rather than stopping the process.
134134

135135
| Key | Default | What it does |
136136
|---|---|---|
137-
| `fingerprint_cache_enabled` | `on` | Auto-approve a re-submission identical to a previously approved query. |
137+
| `fingerprint_cache_enabled` | `on` | Auto-approve a re-submission identical to a previously approved query. Not on an Athena target unless it allows auto-approve (see "Amazon Athena targets"). |
138138
| `fingerprint_cache_ttl_days` | `30` | How long a fingerprint stays eligible for auto-approve. |
139139
| `require_justification` | `false` | Require a justification note on every submission. |
140140
| `max_open_access_requests_per_user` | `5` | Cap on pending target-access requests per user. |
@@ -228,6 +228,22 @@ described by `target_servers.engine_config`, a JSON object per target:
228228
| `role_arn` | yes | Read-only role the gateway assumes for every call: Glue, Athena and S3. |
229229
| `catalog` | no | Data catalog. Default `AwsDataCatalog`. |
230230
| `freshness_marker` | no | `s3://bucket/key` of the archive's freshness marker. When set, the approver's hint says how far the archive reaches. |
231+
| `auto_approve` | no | Default `false`: auto-approve waivers and the fingerprint approval cache do not apply, so every query goes to an approver. Set `true` to let them apply. |
232+
233+
Auto-approve is off on Athena because a query's cost depends on the partitions
234+
it reads, and neither a waiver nor a fingerprint match sees which ones. Only a
235+
JSON `true` or `false` counts; any other value means the default. A
236+
super-admin's own query is auto-approved either way. With auto-approve off,
237+
neither the Slack badge nor the web editor and connection list say a query
238+
will skip review, and a request for an auto-approve window on the target is
239+
refused. The key works the same on a PostgreSQL, SQL Server or ClickHouse
240+
target, where the default is `true`.
241+
242+
```sql
243+
UPDATE target_servers
244+
SET engine_config = COALESCE(engine_config, '{}') || '{"auto_approve": true}'
245+
WHERE alias = 'example-archive';
246+
```
231247

232248
The freshness marker is one JSON object that the archive writes with a single
233249
PutObject:

‎src/queryhub/auto_approve_requests.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,13 @@ def submit_window(*, principal_id: str, name: str | None, target_id: int,
113113
enforces the same ones: the bot is not halted, the window is one of the
114114
offered lengths, the reason says something, the requester can reach the
115115
target (and the database, when one is named -- the approval carries it into
116-
the waiver), and there is not already a request pending for it.
116+
the waiver), there is not already a request pending for it, and the target
117+
lets a waiver apply at all.
117118
118119
Returns (row, target). Notifying the admins is the caller's, because it
119120
needs a Slack client and the two surfaces hold theirs differently.
120121
"""
121-
from . import auto_approve, core_submit, targets, teams
122+
from . import auto_approve, core_submit, engines, targets, teams
122123
if core_submit.kill_switch_on():
123124
raise WindowRequestRefused("reason", core_submit.kill_switch_message(), 503)
124125
tier = (tier or "ro").strip().lower()
@@ -152,6 +153,13 @@ def submit_window(*, principal_id: str, name: str | None, target_id: int,
152153
t = targets.get(target_id)
153154
if t is None:
154155
raise WindowRequestRefused("target", "That target no longer exists.", 404)
156+
# A window is a waiver, and no waiver applies where the target keeps
157+
# auto-approve off (an Athena archive). Approving one would tell the
158+
# requester their reads skip review there while every one of them waits.
159+
if not engines.auto_approve_allowed(t):
160+
raise WindowRequestRefused(
161+
"target", "Queries on this connection are always reviewed, so a "
162+
"window would not apply.", 409)
155163
if tier == "rw":
156164
# A waiver covers what the access allows and no more, so asking to skip
157165
# review on writes needs write access to start with. Asked of the

‎src/queryhub/core_submit.py‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from datetime import datetime, timedelta, timezone
2626
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
2727

28-
from . import admins, ast_safety, audit, auto_approve, db, pre_flight
28+
from . import admins, ast_safety, audit, auto_approve, db, engines, pre_flight
2929
from . import config as cfg
3030
from . import profile_sync, query_safety, query_secrets, replicas, requesters, targets, teams
3131

@@ -423,16 +423,21 @@ def validate_submission(
423423
# may expire before the run time, in which case the caller falls back to
424424
# normal approval and the reason is needed after all. And any error resolving
425425
# the grant leaves the requirement in place.
426+
#
427+
# A waiver only exempts where create_request will let it decide: on a target
428+
# that keeps auto-approve off (an Athena archive), the request reaches an
429+
# approver whatever the requester holds.
426430
justification = (justification or "").strip() or None
427431
auto_approve_exempt = False
428432
if not justification and not (schedule_date or schedule_time):
429433
try:
430434
auto_approve_exempt = (
431435
admins.is_super_admin(user_id)
432-
or auto_approve.effective_grant(
433-
user_id, required_mode,
434-
target_server_id=target_server_id,
435-
database_name=database) is not None)
436+
or (engines.auto_approve_allowed(target)
437+
and auto_approve.effective_grant(
438+
user_id, required_mode,
439+
target_server_id=target_server_id,
440+
database_name=database) is not None))
436441
except Exception:
437442
log.exception(
438443
"auto-approve lookup failed while deciding whether a "
@@ -797,12 +802,26 @@ def create_request(
797802
# - if scheduled_for is in the future, also evaluate at that moment;
798803
# a grant that expires before the run time must fall back to the
799804
# normal approval flow (with a note to the user).
805+
#
806+
# Neither a waiver nor the fingerprint cache decides on a target that keeps
807+
# auto-approve off (an Athena archive, unless its engine_config says
808+
# otherwise). The request goes to an approver like anybody else's. The
809+
# lookup still runs so that a waiver holder, whose reads skip review
810+
# everywhere else, is told why this one waits.
811+
aa_allowed = engines.auto_approve_allowed(prep.target)
800812
aa_now = auto_approve.effective_grant(
801813
prep.user_id, prep.required_mode,
802814
target_server_id=prep.target.id, database_name=prep.database,
803815
)
804-
aa_grant = aa_now
805816
aa_warn = None
817+
if aa_now is not None and not aa_allowed:
818+
log.info("auto-approve: waiver %s not applied for %s on %s: auto-approve "
819+
"is off for this target (engine %s)", aa_now["id"], prep.user_id,
820+
prep.target.alias, prep.target.engine)
821+
aa_now = None
822+
aa_warn = (":warning: Auto-approve is off for this connection, so this "
823+
"request needs admin approval.")
824+
aa_grant = aa_now
806825
if aa_now is not None and prep.sched_for is not None:
807826
aa_at_sched = auto_approve.effective_grant(
808827
prep.user_id, prep.required_mode,
@@ -828,12 +847,14 @@ def create_request(
828847
# the fingerprint so this request seeds the cache, but a super-admin
829848
# already auto-approves as super — matching the cache on top of that
830849
# would only mislabel the decision as "fingerprint" and send a redundant
831-
# auto-approve notification, so skip the lookup for them.
850+
# auto-approve notification, so skip the lookup for them. Where
851+
# auto-approve is off it is skipped for everybody: the fingerprint ignores
852+
# literal values, and on Athena those decide how much a query scans.
832853
query_fingerprint: str | None = None
833854
fp_hit: dict | None = None
834855
if prep.required_mode == "ro":
835856
query_fingerprint = ast_safety.fingerprint(prep.query, engine=prep.target.engine)
836-
if not super_auto and aa_grant is None and query_fingerprint:
857+
if not super_auto and aa_grant is None and query_fingerprint and aa_allowed:
837858
fp_hit = auto_approve.fingerprint_cache_hit(
838859
prep.user_id, prep.target.id, prep.database, query_fingerprint,
839860
)

‎src/queryhub/engines.py‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@
3838
whose Lambda call is a statement PREFIX rather than something buried
3939
in a SELECT (measured against the parser, 2026-09-19). A federated
4040
catalog is reached by naming it, so the cross-catalog rule written for
41-
SQL Server does that job here unchanged. Spec only; no execution path
42-
yet.
41+
SQL Server does that job here unchanged. Executes through
42+
athena_exec.py. Its queries are reviewed even for a waiver holder,
43+
unless the target turns auto-approve on (`auto_approve_allowed`).
4344
4445
`WIRED_ENGINES` gates execution: an engine can carry a spec (so its
4546
safety profile is enforced the moment a target is tagged with it) before
@@ -142,6 +143,14 @@ class EngineSpec:
142143
# Read by the schema refresh and by the execution dispatch -- not a label.
143144
requires_credentials: bool = True
144145

146+
# --- approval -----------------------------------------------------
147+
# Whether an auto-approve waiver or the fingerprint approval cache may
148+
# skip review on this engine's targets when the target itself says
149+
# nothing. A target overrides it with `engine_config.auto_approve`; read
150+
# it through `auto_approve_allowed`, never from here directly. A
151+
# super-admin's own submission is a separate rule and does not ask.
152+
auto_approve_default: bool = True
153+
145154

146155
# Routines a Postgres RO login may call. pg_proc rather than
147156
# information_schema.routines: the latter hides anything the role cannot execute,
@@ -357,7 +366,7 @@ class EngineSpec:
357366

358367

359368
# ---------------------------------------------------------------------------
360-
# Amazon Athena — read-only (spec only; no execution path yet).
369+
# Amazon Athena — read-only, executed through athena_exec.py.
361370
# ---------------------------------------------------------------------------
362371
#
363372
# Athena is Trino, so `blocked_functions` is EMPTY on purpose rather than by
@@ -379,6 +388,14 @@ class EngineSpec:
379388
# instance; here, the first part names a CATALOG, which is how a federated
380389
# connector (`"lambda:fn".db.tbl`) is addressed. One approved target means one
381390
# catalog, so a reference that names its own is refused with the rest.
391+
#
392+
# `auto_approve_default` is False: an archive query is reviewed unless the
393+
# target turns auto-approve on (operator decision, 2026-09-20). What a query
394+
# costs here depends on the partitions it reads, so the same statement with a
395+
# wider date range scans more data and costs more. A fleet-wide waiver was not
396+
# granted with that in mind, and the fingerprint cache ignores literal values by
397+
# design, so either one would let through the expensive variant of a query
398+
# whose cheap variant was approved once.
382399

383400
ATHENA = EngineSpec(
384401
name="athena",
@@ -396,6 +413,7 @@ class EngineSpec:
396413
driver="athena",
397414
default_port=443, # HTTPS to the regional Athena endpoint
398415
requires_credentials=False, # the gateway assumes a role; nothing is stored
416+
auto_approve_default=False, # reviewed unless engine_config.auto_approve
399417
)
400418

401419

@@ -413,6 +431,29 @@ def spec(engine: str | None) -> EngineSpec:
413431
return _ENGINES.get((engine or "postgres").strip().lower(), POSTGRES)
414432

415433

434+
def auto_approve_allowed(target) -> bool:
435+
"""May an auto-approve waiver or the fingerprint cache skip review here?
436+
437+
The one place that answers it. Everything that decides auto-approval, or
438+
tells someone a query will be auto-approved, asks this first.
439+
440+
`engine_config.auto_approve` on the target wins when it is a JSON boolean.
441+
Anything else there (the string "false", a 0, null) is not an answer and
442+
falls back to the engine's default: a string that reads as false must not
443+
act as true, and a typo must not switch on what the engine keeps off.
444+
445+
No target means no exemption. A super-admin's own submission is a separate
446+
rule, decided by the caller, and does not ask this.
447+
"""
448+
if target is None:
449+
return False
450+
config = getattr(target, "engine_config", None)
451+
override = config.get("auto_approve") if isinstance(config, dict) else None
452+
if isinstance(override, bool):
453+
return override
454+
return spec(getattr(target, "engine", None)).auto_approve_default
455+
456+
416457
# Engines with a WIRED execution path (driver + dispatch + engine-aware
417458
# safety). An engine can carry a spec (safety data) before it can execute.
418459
# A target whose engine is known-but-not-yet-executable must FAIL CLOSED,

‎src/queryhub/slack_app/handlers.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def _kill_switch_message() -> str:
5050
from slack_bolt import Ack, App
5151
from slack_sdk.web import WebClient
5252

53-
from .. import access_requests, admins, audit, auto_approve, auto_approve_requests, bundles, csv_import, db, executor, favorites, grants, idp_sync_guard, manual_runs, pre_flight, profile_sync, query_safety, ratings, requesters, schema_catalog, targets, teams, templates
53+
from .. import access_requests, admins, audit, auto_approve, auto_approve_requests, bundles, csv_import, db, engines, executor, favorites, grants, idp_sync_guard, manual_runs, pre_flight, profile_sync, query_safety, ratings, requesters, schema_catalog, targets, teams, templates
5454
from .. import config as cfg
5555
from .. import core_submit
5656
from .. import core_decide
@@ -1280,6 +1280,12 @@ def _maybe_dm_ro_burst(client: WebClient, principal_id: str, required_mode: str)
12801280
burst = modal._recent_ro_burst(principal_id)
12811281
if not burst or burst["count"] != cfg.get_int("ro_burst_threshold", 3):
12821282
return
1283+
# The nudge offers a window on the burst's own target, so there is
1284+
# nothing to offer where auto-approve is off (an Athena archive): the
1285+
# window would be granted and never apply.
1286+
t = targets.get(burst["target_server_id"])
1287+
if t is None or not engines.auto_approve_allowed(t):
1288+
return
12831289
# Noise only when the burst's OWN database already skips review. A
12841290
# waiver somewhere else says nothing about this one: asking "any
12851291
# waiver at all" silenced the nudge for everyone covered on one server
@@ -1288,13 +1294,11 @@ def _maybe_dm_ro_burst(client: WebClient, principal_id: str, required_mode: str)
12881294
principal_id, "ro", target_server_id=burst["target_server_id"],
12891295
database_name=burst["database_name"]) is not None:
12901296
return
1291-
t = targets.get(burst["target_server_id"])
1292-
alias = t.alias if t else f"target #{burst['target_server_id']}"
12931297
blocks = ro_window.nudge_blocks(
12941298
count=burst["count"],
12951299
window_min=cfg.get_int("ro_burst_window_min", 10),
12961300
window_minutes=cfg.get_int("ro_window_minutes", 60),
1297-
target_alias=alias,
1301+
target_alias=t.alias,
12981302
target_server_id=burst["target_server_id"],
12991303
database_name=burst["database_name"],
13001304
has_active_grant=False,
@@ -1616,10 +1620,18 @@ def handle_batch_submission(ack: Ack, body: dict, client: WebClient) -> None:
16161620
ack()
16171621

16181622
# Per-item auto-approve decision — same logic as single-shot:
1619-
# cover at submit time AND at scheduled run time (if scheduled).
1623+
# cover at submit time AND at scheduled run time (if scheduled), and
1624+
# nothing on a target that keeps auto-approve off (an Athena archive).
16201625
aa_grants: list[dict | None] = []
16211626
aa_expired_warning_items: list[int] = [] # 1-based positions
1627+
aa_allowed: dict[int, bool] = {} # per target, asked once
16221628
for i, vi in enumerate(validated_items, start=1):
1629+
tid = vi["target_server_id"]
1630+
if tid not in aa_allowed:
1631+
aa_allowed[tid] = engines.auto_approve_allowed(targets.get(tid))
1632+
if not aa_allowed[tid]:
1633+
aa_grants.append(None)
1634+
continue
16231635
g = auto_approve.effective_grant(
16241636
user["id"], vi["required_mode"],
16251637
target_server_id=vi["target_server_id"],

0 commit comments

Comments
 (0)