Skip to content

Releases: ahmetrende/database-queryhub

v1.0.36

Choose a tag to compare

@github-actions github-actions released this 14 Oct 08:00
v1.0.36
46f4c87

Cancel now stops a running write or schema change. An error message no longer
shows a value that the database read from a row. A sweep on real servers closed
the phrasings that the first version missed. Every web response carries an
X-Request-ID. A state change and its audit row commit together. Both services
warn about pending migrations. The mssql CI job tests SQL Server live and found
three defects. This release fixes one. Two stay open. Patch bump.

Upgrading

  • Apply the migrations before you restart, as before. From now on, the web
    process answers /readyz with 503 while a migration file is missing from the
    ledger. Both services log a CRITICAL line at start. This release adds no
    migration.
  • Restart every QueryHub process. The cancel path, the audit writes and the
    web app changed.
  • The text log format of the web process changed. Each line now has [id]
    before the message, and [-] outside a request. Update a log parser that
    matches the old format. A JSON line gets a trace_id field inside a request.

Added

  • Every web response carries an X-Request-ID. The server makes the id and
    ignores one that the client sends. An unhandled 500 also holds it as
    error.traceId. Each log line of a web request carries it as trace_id in
    JSON, or as [id] in text. A user report that quotes the id leads to its log
    lines.
  • Startup guards. Both services log a CRITICAL line when migrations are
    pending. The web app logs one when its base URL is http:// and the host is
    not loopback. /readyz answers 503 while migrations are pending. A check
    stays silent when it cannot know: no migrations directory, no ledger, or a
    login that cannot read the ledger.
  • Live SQL Server tests in CI. The mssql job runs
    tests/test_integration_mssql.py against a throwaway SQL Server 2022 and fails
    if the tests skip. It found three defects. The Fixed section below covers the first. The
    other two are open and written in docs/KNOWN_LIMITATIONS.md: the login of
    deploy/grant_mssql.sql has no SHOWPLAN, so a script of several writes
    commits only its last statement, and Stop cannot reach a running SQL Server
    query.
  • A test fails when a setting has no documentation.
    tests/test_config_docs_sync.py checks every bot_config key and every
    environment variable that the code reads, and every local link in the docs. The
    reference now lists six keys and six variables that it missed.

Changed

  • SQL Server errors show the message itself. The driver tuple and the
    SQLSTATE prefix no longer appear in front of it.

Fixed

  • Cancel stops a running write or DDL request. The signal used the RO login,
    and PostgreSQL refuses it for a backend that runs as another login. The
    request ran until query_timeout_sec. The signal now uses the login of the
    tier that the request runs at, and never a higher one. The runaway watchdog
    stops long writes too. Each cancel opens one more session for that login, so a
    login at its connection limit makes the cancel fail.
  • A state change and its audit row commit together. A password change, an
    IdP sync, the cancel of a scheduled query and the decline of a window request
    were two commits. A failure between them left a change with no audit row, or
    half of an IdP sync. Each now runs in one transaction. tests/test_audit_discipline.py
    fails on a new standalone audit row for a state change.
  • deploy/grant_mssql.sql no longer denies CONTROL SERVER. That denial
    also denied CONNECT SQL, so its logins could not log in. The script now
    revokes it from a login that an older version made.

Security

  • An error message no longer shows a value that the database read from a
    row.
    SELECT full_name::int FROM customers WHERE id = 1 failed with that
    name in the message, and the result file would have masked it. While PII
    masking is on, [hidden] replaces the value. A value that the requester typed
    in the SQL stays visible, and a CSV import shows every value. The list covers
    PostgreSQL, SQL Server, ClickHouse and Athena. Masking still does not stop
    inference with a predicate and a row count (docs/COMPLIANCE.md, section 4).
  • The error scrubber has a time limit. The content detectors took 38 seconds
    on 200 KB of one character, so a large cell could block an executor thread
    while it was cast. scrub() reads at most 8000 characters in the hiding
    step and 1000 in the content detectors.
  • A sweep on real servers closed the phrasings that the first version
    missed.
    It ran 642 failing statements on PostgreSQL 17 and SQL Server 2022,
    with a sentinel value in every column type. Of them, 187 still showed the
    value: 138 of 398 on PostgreSQL and 49 of 244 on SQL Server. A cast to
    regclass or a call of current_setting() with a column puts a value where a
    name goes, so a name that is not in the requester's SQL is hidden too. A typo
    that the requester wrote stays visible. All the cases now pass.
    docs/COMPLIANCE.md lists what stays: the free text of RAISE, ASSERT,
    THROW and RAISERROR.

v1.0.35

Choose a tag to compare

@github-actions github-actions released this 13 Oct 14:56
v1.0.35
72d9c29

QueryHub refuses a mistake in a write or a script when you submit it, and the
pre-flight can no longer run a hidden second statement. An admin sets the owner
teams of a connection, gives a person or a team read access on every server,
and marks a server super-admin-only. A decision on the web retires the Slack
cards. The DM poller survives a database outage. Patch bump.

Upgrading

  • Run scripts/apply_migrations.py. It applies 141–143. Apply 141 before
    you restart, because the new code reads target_servers.super_admin_only.
  • Restart every QueryHub process. Each one runs its own copy of the
    executor and the pre-flight.
  • A new key, pre_flight_validate_scripts, has the default on
    (migration 143). Set it to off to go back to the old check. It needs no
    restart.

Added

  • QueryHub refuses a mistake in a write or in a script when you submit it.
    It plans every statement and runs none. A wrong column name, a
    missing table or a syntax error that PostgreSQL rejects now stops the
    request before an approver sees it. The message names the statement.
    Set pre_flight_validate_scripts to off to go back to the old check.
  • An admin sets the owner teams of a connection. GET/POST/DELETE /admin/connections/{conn}/owners manage the hand-made owners. QueryHub
    refuses a synced owner and names the sync. The lead of each
    owner team approves requests to that connection from any requester, up to
    the fleet's ceiling (RO today). Each change writes an audit row
    (target_owner_added, target_owner_removed) and runs the lead-approver
    reconcile for that one target. The Connections screen has an Owners dialog,
    and the picker names each team's lead.
  • A person or a team can get read access on every server, and a server can
    be super-admin-only
    (migration 141).
    • GET/POST/DELETE /admin/grants/fleet manage one all_targets row per
      subject, RO only, with an optional fleet-wide waiver in the same
      transaction. A write needs a super-admin and access_model_v2. QueryHub
      refuses a mirrored row and names its source.
    • PUT /admin/connections/{conn}/super-admin-only marks a server. On a
      marked server, fleet-wide grants, fleet-wide waivers and the admin shortcut
      count for no one but a super-admin. A grant or a waiver that names the
      server still counts. Only a super-admin can write one.
  • An access request can be approved on the web. The Connections screen
    lists pending requests with the tier that Provision grants. A decision there
    retires the admin cards in Slack and tells the requester.
  • A super-admin can add a reason to a run that needs no approval. The
    web shows "Add a reason" where no reason is asked for. The audit log keeps
    the reason with the request.

Changed

  • User-facing messages follow Simplified Technical English. Slack, the
    web API and the MCP server give the cause first, then the fix. Only the
    words changed. No check decides differently.
  • A column named tier_name is no longer masked as a person name. The
    name rule now skips a column name that has the word tier. Other name
    columns stay masked.
  • The masking Edit box shows its three settings at once. Replace with the
    same reach saves on the original row. A real replace writes the new row,
    then disables the old one. A refused duplicate says how to change its
    settings.
  • The access-request card has no SQL recipe. Approve writes the per-user
    grant, so the admin does not run SQL first. The card keeps the attempted
    query of the requester.
  • The Scheduled panel says where a run goes. GET /scheduled sends
    runOn and replicaName.
  • The screens use Simplified Technical English. The admin screens,
    sign-in, home and panels carry design's copy. Masking says Disable and
    Enable.
  • Athena connections show the AWS Architecture icon.
    THIRD_PARTY_NOTICES.md names Amazon as the owner of the mark.

Fixed

  • A comment in front of a write no longer hides its row estimate. A
    request that started with -- ... or /* ... */ got no "~N rows affected"
    line, because the check read the comment as the first word. QueryHub also
    refused an EXPLAIN with a comment in front as a syntax error. Both work now.
  • Approving again after a lapse restores access. The approval kept the old
    end date. An active grant of the same tier keeps the end date it has.
  • The DM poller survives a database outage. The loop read its interval in
    the while condition, outside the try. One outage ended the thread, and
    no DM went out until a restart. A failed read now keeps the last good
    interval, and the interval is at least one second.

Security

  • The pre-flight EXPLAIN cannot run a hidden second statement. QueryHub
    now sends it with the extended protocol, and PostgreSQL itself refuses a
    text that holds more than one statement. A tail such as ROLLBACK; DELETE ...
    could leave the read-only transaction when the statement split and the
    server disagreed. Measured on PostgreSQL 18: the tail deleted the row. Now the
    server refuses it, and QueryHub logs a warning.

v1.0.34

Choose a tag to compare

@github-actions github-actions released this 05 Oct 09:39
v1.0.34
7293f43

The web editor undoes every edit, expands * to exactly the columns a
statement reads, and quotes reserved words. For a table that the catalog does
not list, such as a partition, QueryHub reads its columns live from the
target. A super-admin can choose where one query runs, a grant can carry
auto-approve, and a deleted instance shows as deleted. The trusted-portal seam
is ready, and a sync that would disable many people waits for a super-admin.
Patch bump.

Upgrading

  • Run scripts/apply_migrations.py. It applies 132–140.
  • WEB_SESSION_SECRET must be at least 32 bytes when you set it, or the
    web process does not start. If you do not set it, QueryHub derives the key
    from the master key, as before.
  • An unreadable secrets.enc stops the process. It used to read the
    plaintext environment instead. QH_SECRETS_PLAINTEXT_FALLBACK=1 allows that
    on purpose.
  • New bot_config keys: web_slack_team_id, target_ssl_verify_hosts,
    target_ssl_verify_exempt_hosts, web_employment_grace_hours,
    idp_clock_skew_seconds, idp_outbox_enabled, idp_outbox_retention_days,
    idp_sync_max_disable and athena_freshness_stale_hours. See
    docs/CONFIGURATION.md.
  • The * expansion reads a relation's columns live through
    GET /connections/{conn}/databases/{db}/columns, with the RO login
    (PostgreSQL only). It needs no new privilege: every login can read
    pg_attribute.

Added

  • A grant can carry auto-approve, in the same transaction. The web grant
    form has an Auto-approve box with a tier (RO by default, never above the
    grant's tier, never DDL). /sql grant has an "Auto-approve read-only
    queries" box. POST /admin/grants takes autoApprove / autoApproveTier.

    • QueryHub writes one waiver per granted database, with the grant's expiry.
      If a waiver fails to write, QueryHub reverts the grant.
    • QueryHub does not write a waiver when the subject already holds an equal
      or broader one. The response (autoApprove), the toast and the Slack
      summary name the held waiver that covers the grant.
    • A person's waiver goes to auto_approve_grants (the migration-109 mirror
      projects it). A team's waiver is an access_grant row in the grant's
      transaction.
  • A super-admin can choose where one query runs: Auto (as before), the
    primary, or a named read replica (runOn on POST /queries, migration 137).
    See OPERATIONS.md §26.

    • A chosen replica skips the automatic rules. If it cannot run the query,
      the request fails and says why. The request never runs on the primary
      instead.
    • QueryHub audits each honoured choice (execution_run_on_forced). The
      status poll and history rows say where a query ran (ranOn).
    • A scheduled request keeps the choice, and now keeps unmasked too. The
      scheduler used to drop it, so a scheduled unmasked result arrived masked.
  • An Athena approver sees how far the archive reaches. The hint adds
    "Archive complete up to UTC", with any known gaps, or "Archive coverage
    unknown". It comes from the marker that the archive writes
    (engine_config.freshness_marker). See docs/CONFIGURATION.md.

    • The hourly catalog refresh reads it, for enabled and disabled targets, and
      stores the verdict in target_servers.archive_freshness (migration 140).
    • If the archive does not rewrite the marker within
      athena_freshness_stale_hours (36), the hint adds a warning.
    • If QueryHub cannot read the whole marker, the hint says unknown, never
      complete.

Changed

  • QueryHub offers auto-approve only where the subject can already query.
    A waiver skips review. It grants no access. A waiver written anywhere else
    did nothing until the subject got access there. On that day, the access
    arrived already approved.

    • The Auto-approve form lists nothing until you pick a person or team. Then
      it lists only the connections and databases they reach, with the tier
      capped at what they hold there.
    • POST /admin/auto-grants and /auto-grants/bulk refuse a target that the
      subject cannot query, and a tier above the one the subject holds. They
      answer 409, name what they refuse and write nothing. Each refusal carries
      connectionId / databaseId, so the form marks the row that caused it.
    • A fleet-wide row reads "every server they can reach", not "all databases".
  • A target whose instance was deleted shows as deleted, and no one can
    enable it.
    It looked like any other disabled target, and the admin screen
    offered to enable it. Measured 2026-09-30: 26 of 68 disabled targets were
    deleted instances. Every one had an endpoint that no longer resolved.

    • Migration 139 adds target_servers.deleted_at / deleted_reason, with a
      CHECK that forbids a target that is both deleted and enabled. So no path
      can enable a deleted target.
    • The hourly inventory sync marks any target whose instance is gone, enabled
      or not. If the endpoint returns, the sync clears the mark but does not
      enable the target.
    • The admin connection list shows deleted targets last, under their own
      heading, with no controls. A request to enable one gets 409 deleted.
    • Fixed: an instance recreated under the same name gives its endpoint both a
      deleted and a live inventory row. QueryHub judged such an endpoint by
      whichever row came last, so it could disable a live target as deleted. The
      live row now wins.
  • A pod lead approves requests on their pod's servers from anyone. The
    lead's approver role used to require the request to come from the lead's own
    pod as well. So a person with access to another pod's database reached no
    approver except the admins. This applied to 58 of 120 single-server personal
    grants, and to 28 of 268 requests decided in 30 days.
    scripts/sync_team_approvers.py now writes each owned-server row for any
    requester (all_teams). The ceiling (RO by default) and the rule that no one
    approves their own request stay the same.

  • The trusted-portal seam is ready for its first connection (AUTH.md §1.2).
    Everything here is inert while idp_assertion_enabled is off.

    • QueryHub accepts an assertion with up to idp_clock_skew_seconds (10) of
      clock skew. It refuses an assertion when no email domain is configured or
      when the assertion lives longer than 120 s. It also refuses any assertion
      on a websocket.
    • The reconcile has a dry run. It never disables an admin, a role holder or
      the sync account.
    • The reconcile and the outbox answer only the sync principal. That
      principal needs no admin role.
    • QueryHub writes the notification outbox only while idp_outbox_enabled is
      on. The daily cleanup trims it.
  • A portal sync that would disable more than five requesters waits for a
    super-admin.
    The reconcile disables everyone who is not on the panel's
    list. So a wrong list would block that many people from Slack, the web and
    MCP within one tick. For example, the list is wrong when a role is not
    granted yet. A run over the limit now changes nothing and answers
    409 approval_required. Every super-admin gets one Slack card that asks
    whether they approve.

    • Approve covers those people, once, for 24 hours. Reject keeps the list
      blocked until it changes.
    • The limit is idp_sync_max_disable (default 5, migration 138). All of this
      is inert while idp_assertion_enabled is off.
  • The S3 metrics dashboard matches the admin panel's Metrics view: the
    same typeface, cards, section labels and colours. The build inlines the
    typeface, so the page stays one file. As in the panel, RW is blue and
    rejected is amber. Status mixes are stacked bars, and the per-team, top-user
    and admin-workload charts are the panel's labelled bars. The filters and
    every figure stay the same.

  • A super-admin can end a session, run a mixed script and set
    search_path.
    pg_terminate_backend / pg_cancel_backend ask first and
    run as the elevated role. A script that mixes tiers runs as one request at
    its highest tier. SET search_path takes a list of schema names for the one
    request. QueryHub still refuses all three for everyone else. See
    OPERATIONS.md §25.

  • ClickHouse system and information_schema tables are readable. A
    requester with a grant on a ClickHouse target can query its partition sizes,
    columns, running merges and query log. Before, QueryHub refused them as
    server internals. Table functions stay default-deny.

  • QueryHub records a statement that the web refuses in
    submission_failures, as it already did for the Slack modal's refusals. The
    record holds the SQL (with passwords masked) and the reason. QueryHub does
    not record a confirmation prompt.

  • The MCP describe_database search picks its tables in SQL. A loose
    filter used to read every column of every matching table and keep 25. On the
    largest catalogue here, that read 31,412 rows in 124 ms. The search now reads
    353 rows in 4 ms.

  • An Excel download of a CSV result needs only one conversion. QueryHub
    keeps the workbook beside the result and deletes it with the result.

  • The container image shows its build. The build stamp was empty in the
    image, which has no .git. The release stamps the commit and version, and
    QH_IMAGE_DIGEST adds the image digest.

  • Every admin screen stops at 1600 px on a wide monitor, centred beside
    the nav. The approval queue's detail pane stops at 1120 px. This replaces the
    Audit log's own 1480 px cap from 1.0.33. Nothing changes below about 1860 px
    wide.

  • The admin panel is one click away at the head of the left column, with
    the approval badge on it. The Developer / Admin tabs left the top bar. When
    the sidebar is hidden, the entry becomes an icon beside its toggle.

  • The admin panel opens in your real role. The "Viewing as" switch is gone.

  • Only a super-admin sees the kill switch's button. Other admins still see
    it...

Read more

v1.0.33

Choose a tag to compare

@github-actions github-actions released this 23 Sep 14:41
v1.0.33
499b9f6

Two engines execute: ClickHouse and Amazon Athena, both read-only. A read-only
query can run on a healthy read replica. A submitted password is no longer
stored anywhere. Patch bump: every new bot_config key keeps the old behaviour
by default, and the audit contract is unchanged. New actions are added, and no
existing row changes meaning.

Upgrading

  • Run scripts/apply_migrations.py. It applies 126–131. 129 changes how a
    password in submitted SQL is stored. 130 corrects 129's trigger, which named
    a status the enum does not have, and so failed every write to requests. The
    runner applies both together; do not stop between them.
  • Optional extras. pip install '.[clickhouse]' for ClickHouse targets.
    Athena uses '.[aws]' (boto3). Neither is needed if you run neither engine.
  • New bot_config keys, each defaulting to what happened before:
    replica_routing (off), replica_max_lag_seconds,
    replica_health_ttl_seconds, replica_read_your_writes_minutes, and
    <engine>_blocked_functions. See docs/CONFIGURATION.md.
  • SQL copied back out of history may be refused. A statement stored with
    PASSWORD '***REDACTED***' is refused until the password is typed again.

Security

  • A submitted password is never stored. requests.query is written with
    every password literal masked. The original is kept Fernet-encrypted in
    requests.query_secret only while the request can still run, and dropped
    when it ends, including when it is handed to a DBA. Saved workspaces,
    templates, favourites and refused submissions keep the masked text. So do
    Slack messages, snippets, upload comments and log lines.
  • ClickHouse runs read-only by construction. The login is readonly=1,
    table functions are refused unless allowed, SETTINGS clauses are refused,
    and system.* and information_schema are blocked. An operator can refuse
    more functions with clickhouse_blocked_functions, and cannot allow any.
  • An Athena approver sees what a query can scan before approving it. The
    bound is read from the objects the query can touch, not from catalog
    statistics, which were measured five weeks stale.

Added

  • ClickHouse executes, over the native protocol (9440, TLS). The read-only
    login cannot set server-side limits, so a watchdog enforces the timeout and
    a cancel by closing the connection, which stops the query on the server.
    The schema catalog is read at most daily, and only when the inventory says
    the service is running: an idle service is billed when a query wakes it.
    Import with import_targets_from_inventory.py --clickhouse.
  • Amazon Athena executes: Glue-backed schema, result values kept as the
    exact strings Athena returns, and the bytes scanned recorded in the audit
    trail.
  • Read replicas. A read-only PostgreSQL request runs on a healthy, enabled
    replica of its target, and on the primary otherwise. Replicas are linked
    from the inventory (target_servers.replica_of, migration 131) and never
    appear in a picker. Some reads stay on the primary: those about the server
    itself (pg_stat_*, pg_locks, WAL functions), and a requester's reads
    just after their own write there. A replica that fails a read hands it to
    the primary once. The result says it came from a replica, and
    requests.executed_target_id records which one. Off until
    replica_routing = 'on'; see docs/OPERATIONS.md §26.
  • Admin: enable, disable or set a credential on many connections at once,
    all or nothing. Edit a role in place. See one team's effective access, and
    a person's, resolved per database. Add auto-approve for a team or for many
    targets in one step. A queue lists requests waiting for a DBA to run by
    hand, and closes them from the web.
  • Requesters can ask for auto-approve from the web, and an admin decides
    there or in Slack.
  • Imported targets are labelled with the cloud they run on (tags.provider).

Changed

  • A new connection can take the name a disabled one holds. The disabled
    one takes its engine's suffix (orders → orders-pg). An enabled holder
    keeps its name: the importers suffix the newcomer, and the admin form
    refuses the name.
  • "Ask to skip review" is "Request auto-approve", and every screen says
    auto-approve for the same idea.
  • A team's auto-approve applies to its members when they submit, as the
    screens already showed.
  • A connection test shows green or red. A connection an admin enables or
    adds appears in the list without a page reload.
  • The audit log stops at 1480 px on a wide screen, so the actor and the
    request id stay next to the action they belong to.

Fixed

  • A request handed to a DBA no longer shows as running forever on the
    web. It says "Needs a DBA", with no spinner and no Stop button.
  • The RDS import step no longer stops on a name clash.
  • The ClickHouse catalog compares the inventory's naive timestamps in the
    inventory's own time zone, and stores its flags as booleans.

v1.0.32

Choose a tag to compare

@github-actions github-actions released this 17 Sep 21:30
v1.0.32
36b0c39

The panel names people instead of logins, the masking ladder's second rung is
held to its schema, and two screens that were broken only in the built bundle
work again. Patch bump: the bot_config keys and the audit contract are
unchanged.

Security

  • A table-level masking exemption is held to its schema. The ladder's table
    rung compared BARE table names, so a row written for one schema's table also
    covered a same-named table in every other schema of that database — the fault
    the column rung had, on the louder rung: this one returns skip_all, masking
    off for the ENTIRE result rather than one column. The rung now runs through
    the same _schema_admits check, which can only ever REFUSE: a row with no
    schema, a statement that writes the table unqualified, and SQL that will not
    parse are all admitted exactly as before. Measured the way the column rung
    was — the resolver replayed over every completed request that recorded its
    result columns, 3,545 requests and 911 invocations, with and without the
    schema on table rows: no answer changed. 1.0.31's notes described this fix
    a release early; the code is here.

Added

  • Masking exemptions can be edited from the panel. PATCH /admin/mask-exemptions/{id} now carries the reason, the strength, the join
    behaviour and the audience, not just enabled. Reach stays immutable — a
    row's reason describes that row's reach, so a row whose reach moved carries a
    reason that is a lie about production data. Narrowing is still
    turn-off-and-create. Scope fields are DECLARED and REFUSED (400 reach_immutable, naming which ones) rather than dropped, because a client
    that believes it just narrowed a row, against a server that ignored the
    field, shows an operator a protection they do not have. Migration 125 adds
    updated_by / updated_at.
  • A target whose schema catalog stops refreshing says so. The hourly
    refresh logged a warning and returned the same either way, so a target could
    serve a stale snapshot for days — an expired credential, a host that moved —
    and the first symptom was somebody asking why a new table is missing from the
    picker. Migration 124 adds schema_refresh_health, one row per target,
    overwritten each run. A target counts as failed only when NOTHING was
    snapshotted.

Changed

  • People are shown by name, not by login. A person reached the API as three
    different strings — a Slack id, a first.last login, and an ascii-folded
    display name — and every screen printed whichever one it got. They are now
    resolved once, from the two people tables: an id by id, a login by the local
    part of the stored email, and a stored name by its ascii fold. Measured over
    180 days of requests: 47 of 49 distinct principals resolve, 38 of them to a
    different string than the screen printed — including the ranked lists, where
    the folded and unfolded spellings of one colleague were two rows with the
    count split between them. What cannot be resolved is returned unchanged: a
    service principal belongs to no person, and the auto-approver is not sent
    through the lookup at all.
  • The audit trail names the requester, not the actor. Its one name slot
    held the admin who pressed Approve, which for a run of auto-approvals is the
    same three words repeated; the person who asked for the query is what an
    auditor scanning the trail is looking for, and 76% of the rows the screen
    shows by default carry one. The actor is still in the rail, one click away.
  • The Slack sign-in names the workspace. The authorize request never sent
    team, so Slack rendered the account picker — and because it had to ask, it
    asked for consent too, on an app the workspace had already authorised. It is
    the stricter flow as well as the quieter one: somebody in two workspaces
    could previously pick the wrong one and learn it later from
    wrong_workspace.
  • The masking screen was redesigned. Two lines per exemption grouped by
    server, with the full story one click away; add another column without
    walking the whole form again; several columns of one table at once under one
    reason; and the form shows how that table is really queried before you decide
    about joins.

Fixed

  • A view is listed under Views, not under Tables as well. GET /connections dropped the relation kind, so the tree drew every relation
    under "Tables" and then drew the views again once the lazy schema load
    answered. One real database read "Tables 41" for 2 tables and 39 monitoring
    views; 77 of 84 catalogued databases were mixed that way, 2,968 views in
    total. The refs now carry the catalog's own relkind and one shared helper
    decides the branch, so the tree and the fleet search cannot drift apart.
  • The admin Connections screen stopped throwing. qhEndpointHover is not defined, every open since 2026-09-10: the helper was declared in one file,
    called from another, and never put on window. The raw prototype hides that
    completely — every top-level declaration is a global there — while the built
    bundle gives each file its own module scope. Two guard tests now read the
    shipped sources and fail on a name that is used but neither declared nor
    exported, and on two files that declare the same top-level name.
  • A foreign table is listed alongside ordinary tables in the browser.
  • An edit that changes nothing says so. The endpoint answered
    changed: false, writing and auditing nothing, while the screen announced
    "Exemption updated." — which is a screen about reducing protection claiming
    an edit that did not happen. The audit row is still written for every edit,
    including a reason corrected to itself; only a change to what the exemption
    reaches sends a notification.

v1.0.31

Choose a tag to compare

@github-actions github-actions released this 10 Sep 16:32
220b7ff

Masking exemptions became writable from the panel, and two ways masking was
wrong in the stored model were found while building it. Patch bump: the
bot_config keys and the audit contract are unchanged.

Security

  • A schema on a narrow exemption no longer widens it. pii_masking_exemptions
    rows that name a table or a column also carry a schema, to NARROW what they
    cover. One branch read it as though the row were schema-scoped, so a single
    exemption on one column lifted masking for EVERY statement whose tables were
    all schema-qualified in that schema. Measured on the live fleet before the
    fix: three such rows on two targets, firing on 25 real requests since
    2026-08-13. The columns those requests returned were already exempt by their
    own rows, so nothing measurable was exposed — the hole was in what the next
    query would have got. It mattered more than its age suggests because the new
    Add-exemption form makes a schema mandatory on the column rung, which would
    have made the accident the default.
  • A schema now narrows what it names. The mirror of the same confusion:
    table_name matches the bare name, so an exemption for one schema's table
    also freed a same-named table in another. A row that names a schema is held
    to it, and the rule can only ever refuse — a statement that writes the table
    unqualified, or that will not parse, is unchanged, because refusing there
    would start masking data that comes back unmasked today. Replayed over 3,211
    completed requests: no answer changed.

Added

  • Masking exemptions can be written from the panel. Create, switch off,
    remove, the catalog the pickers read, and a preview. Super-admin only; every
    change is audited under data protection. The rung is the contract: scope is
    what the operator confirmed on screen and everything narrower is forced to
    NULL, so a stale form field cannot store a different row than the sentence
    that was agreed. Fleet-wide comes from the scope, never from a missing
    connection.
  • A preview that reads one real row. A generated SELECT … LIMIT 1 on the
    read-only credential, in a read-only transaction, at most six columns, and
    only for a table a real query has touched in the last 30 days — that query is
    evidence the table is live, never re-executed. Both sides of the diff run
    through the real masker, so the screen cannot promise an outcome the executor
    would not produce.
  • Masking changes notify the admins (migration 122). Twenty-one triggers
    covered the tables deciding who may READ what; the table deciding what they
    SEE had none. It is the one with no subject of its own, so the event carries
    no user and the poller fans it out to the people who can undo it.
  • Targets carry the cloud account they live in. Resolved from the RDS
    inventory of every reachable account rather than from the endpoint, whose
    middle token is an opaque resource id and not an account number. Shown as one
    line on the hover already under a connection name.

Fixed

  • A database that is not on the server named with it is refused at submit.
    Nothing checked the pair; the catalog knew and the check was already written
    for auto-approve grants. Pre-flight also stopped failing open on
    database "x" does not exist, which arrives as a connection error and was
    being treated like a timeout — an answer that can never change waited on an
    approver and then failed at execution.
  • The database select is read by name, not by a prefix scan. Its action_id
    is salted with the target so Slack re-fetches its options, and the block can
    then hold more than one entry.
  • The connections payload asks four questions, not one per row. 313
    statements and 656ms became 9 and 61ms for a 50-connection reader; the admin
    listing went from 121/231ms to 5/18ms, byte-identical output.
  • The hourly catalog refresh diffs instead of rewriting (migration 123). It
    deleted and re-inserted every row each hour: 172,450,815 schema_columns
    rows written over the life of a table holding 156,736. A full fleet refresh
    now writes none of them.
  • The audit search uses an index instead of reading every row.
  • Every authority question has one answer whichever door it is asked
    through.

v1.0.30

Choose a tag to compare

@github-actions github-actions released this 09 Sep 23:00
fdd6077

The audit trail rebuilt on a derived vocabulary, and a masking ladder that can
express every reach the model already stored.

Added

  • The Audit trail, on categories nobody has to maintain. The screen used to
    need a chip per action name, which is why it showed 37 of 132. Every row now
    carries a category and an effect derived server-side from its action name by
    an ordered pattern table (migration 118), so a new action type inherits a
    category for free and one nothing claims is counted as unclassified rather
    than filed under something it is not. Measured: 47 patterns classify 121 of
    132 action types, and the 8 that stay unclassified are visible on screen as a
    count instead of being absorbed.
  • Three dimensions instead of one. Seven kinds (requests, data protection,
    access, connections, configuration, usage, unclassified), six effects (added,
    changed, removed, read, decided, ran) and three actor kinds (a person, the
    auto-approver, a background job). Data protection x Read is every time
    somebody looked at unmasked personal data — a question the old vocabulary
    could not ask. Requests x the auto-approver is what was approved without
    anybody looking, which is 78% of that category.
  • Every count carries its denominator, and every exclusion says its size.
    Two slices are excluded by default and both are stated with a control to
    include them: the per-request lifecycle the queue and history screens already
    show, and product usage. Including the first triples the list, so it says so.
    Chips state the count they would produce, because a filter whose size you can
    only learn by clicking it is how a bucket stays unexamined.
  • A detail rail for the payload, with the request block first — 77% of rows
    have one — the raw action name shown as the evidence the category was derived
    from, and the authority a machine acted under named where it can be resolved.
  • Two more rungs on the masking ladder: a whole table, whose absence forced
    the wider "whole schema" choice, and fleet-wide, which the stored model always
    allowed and the screen could not produce. Fleet-wide is gated harder than
    anything else there: off the top of the ladder, a typed confirmation, and
    restricted to super-admins to start.

Fixed

  • The masking-exemptions screen listed every reach except the two it could not
    name, so one live exemption could be read but not reproduced.

v1.0.29

Choose a tag to compare

@github-actions github-actions released this 09 Sep 19:45
c1aba16

The release the access-model rewrite landed in. Authorization now resolves from
nine tables instead of seventeen, a third door opened for programs, and the
screens that read the new model stopped disagreeing with the routes that write
it.

Added

  • The nine-table access model, switched on. principal,
    principal_identity, principal_credential, team, team_member,
    access_grant, role_assignment, principal_setting and tier replace
    seventeen tables whose relationships were implied rather than declared. The
    old tables were copied in, verified row by row against a snapshot tool built
    for the purpose, and reads moved over behind one bot_config key
    (access_model_v2) so the switch could be thrown and thrown back. Legacy
    writes are projected into the new tables for as long as both exist, and
    authorization-change notifications fire from either side.
  • A scoped approver is told a request is waiting for them. An approver whose
    authority covers one team or one target used to learn about a pending request
    only by opening the queue.
  • The Roles screen. Who can approve what, as sentences rather than three
    chips the reader has to assemble: fleet-wide admins, team-scoped approvers,
    their tier ceilings and their expiry dates, with the rows that are mirrored
    from the old table marked as such. Readable by any admin; writable by a
    super-admin.
  • An org-structure importer. A generic loader for team/person/service
    structure exported from an internal service catalog, plus the inventory tables
    it lands in, so team membership and the team that owns a target can be
    reconciled from the system of record instead of typed twice. A target's owning
    team is now a relation, not a join computed at request time.
  • A team's lead is synced as an approver for what that team owns, and may
    approve their own team's requests and only their own.
  • An MCP surface — a third door, for programs. Alongside Slack and the web
    UI, QueryHub speaks the Model Context Protocol over stdio: six tools
    (list_connections, describe_database, classify_sql, submit_query,
    query_status, fetch_result). It resolves the caller's own grants, refuses
    what they may not run, masks what comes back and audits every step. Off by
    default (mcp_enabled), read-only by default (mcp_max_tier), and an
    auto-approved query returns its first rows inside the same call — measured at
    1.4 s end to end.
  • A masking-exemptions screen. Every place PII masking is deliberately
    switched off, super-admin only: what each exemption reaches, whether it also
    stops reading the values, whether it survives a join, which name rule caught
    the column in the first place, and the reason somebody wrote for it. Read-only
    in this release.
  • The browser Back button now walks the app instead of leaving it, and dismisses
    the topmost modal before navigating.

Changed

  • Admin tables scroll inside their own box, so a wide table no longer scrolls
    the page sideways.
  • describe_database and the schema response ask about PII once per response
    once per response instead of once per column, so the rule catalog is read once
    no matter how wide the table is.

Fixed

  • A withdrawn request stayed on the admin queue when it was withdrawn from
    the web: the buttons were still on the card, and pressing one answered
    "already decided". The web path now retires the admin cards the way the Slack
    path does, and the withdrawal is written to the audit trail.
  • The audit screen was hiding most of what it exists to show. It filtered
    audit_log through a hand-written list of 37 action names against 132 real
    actions, so most of them were invisible — a requester withdrawing a request,
    masking being switched off for a column, every auto-approve grant. The filter is
    inverted: everything is shown except the per-request lifecycle the queue and
    history screens already show. Visible action types went from 37 to 123.
  • Six admin routes wrote to the legacy tables while the screens above them read
    the new model — creating, renaming and deleting a team, setting a person's
    teams, copying access, and effective access. An emptied table answers
    "nothing" rather than failing, so none of them surfaced as an error. The
    Teams list was the dangerous one: it served new-model ids against an old
    sequence, so a few more teams would have made a rename edit a different team
    than the one on screen, silently.
  • /sql whoami and /sql roles named no teams at all after the switch: the
    view behind them read only the legacy tables. It unions both models now, which
    needs no switch to be correct — whichever side is empty contributes nothing.
  • Halting the fleet from Slack wrote no audit row, though the same action from
    the web did.
  • The access-request card's copy-paste recipe named tables that no longer exist,
    and the flow accepted a request against the control-plane database it must
    refuse.
  • An admin's own auto-approve waiver was ignored, and a drift gate counted
    tombstoned rows as live.

Security

  • A tier ceiling admitted every tier. A ceiling that could not be read
    resolved to rank 99 — a value chosen to fail closed on the request side of
    the comparison, which on the ceiling side meant "anything is below this".
    Both sides now fail closed, and the request's tier is derived by the engine-
    aware shared rule rather than from a display label that reports ro for a
    blocked statement.
  • Two repository gates stopped telling the truth after the access-model cutover:
    the sensitive-data scanner's dynamic denylist and the copy-verification gate
    both read only the old tables, so a name that existed only in the new model
    was not on the denylist. Both read both models now, so a name that lives only in
    the new one is covered.

v1.0.28

Choose a tag to compare

@github-actions github-actions released this 06 Sep 21:07
v1.0.28
b2d0429

Added

  • Identity assertions from an internal developer portal. The /api routes
    accept an Ed25519-signed X-IDP-Assertion header bound to the exact request
    (method, path and query, body) and act as the asserted principal, so a portal
    can proxy QueryHub for its users without a browser session. Off by default
    (idp_assertion_enabled), keys in idp_public_keys, replayed assertions
    refused through a short-lived jti ledger (migration 101).
  • A principal reconcile endpoint that the portal's sync account, and only
    that account, may call to enable and disable requesters from its directory.
    It never writes to the admins table, refuses a list that would disable
    everyone, reports addresses it cannot resolve, and writes an audit row
    (idp_principal_sync) on every run.
  • A notification outbox (migration 102) recording each pending request and
    the admins told about it, with two admin-gated routes for the portal to poll
    and acknowledge, so a portal can show an approvals queue without owning an
    admins table of its own. Slack notifications are unchanged.
  • Requests that arrive this way carry origin = idp in the request record and
    the audit trail, next to slack and web.

v1.0.27

Choose a tag to compare

@github-actions github-actions released this 01 Sep 15:41
v1.0.27
1397460

Added

  • The person-first access screen. Open a person and see everywhere they stand
    — their own grants, what their teams give them, what they reach as an admin,
    and any auto-approve window — resolved by the same resolver a submission uses,
    so the screen cannot disagree with what runs. Granting the same access to
    several people is one panel and one call, and a refusal keeps the list on
    screen because nothing was written. Copying someone's access shows exactly what
    would be written and revoked, by server name, before anything happens.

Fixed

  • A modal taller than the viewport trapped its own buttons. The overlay is
    fixed so the page cannot scroll, the modal clipped, and the body had no
    overflow of its own — so editing a connection put Save and Cancel off-screen
    with nothing to reach them. One rule on .qh-modal, shared by all eight.
  • Two fields the new screen reads were missing or the wrong shape: the
    copy-access dry run did not return wouldCopyAutoApprove (an absent field
    draws silence, which reads as "none" for the one option that skips human
    review), and autoApproveCopied returned names where the client counted. It is
    a count now, with autoApproveCopiedTargets beside it.