# Approval workflow

> Roles, connection grants, and the lifecycle of a query request from submission through approval to masked, audited results.

- Documentation: Guides
- Updated: 2026-10-31
- Source: https://queryproxy.com/docs/approval-workflow/
- Language: en-US
- Author: Muhammet Şafak

---
Everything in QueryProxy revolves around one object: the **query request**. This
guide walks through who can do what, and what happens to a request at each step.

## Roles

| Role | Can do |
| :-- | :-- |
| **Admin** | Everything, system-wide: teams, users, all settings. Set per user (`is_admin`). |
| **DBA** | Manage connections and masking rules, grant access, approve/reject requests; can also submit queries on any of the team's connections. |
| **Developer** | Submit queries through granted connections, view own (masked) results. |
| **Auditor** | Read-only access to the audit log (and its CSV export) and to the team's Requests list and request detail (SQL, status, requester, reviewer, approval trace, DDL flag); cannot view or download result rows, and has no access to connections, approvals, masking or ChatOps settings. |

Admin is global; DBA, Developer and Auditor are **per team**. A user can belong
to several teams with different roles. Connections, requests, masking rules and
audit logs never cross team boundaries.

The audit log is reserved for the Auditor and the Admin; a DBA has no access to it.

### Suspending and deleting users

An Admin can **suspend** a user on the Users screen. A suspended user cannot sign
in, open sessions end, and their Slack and Teams identities stop working; their
pending requests stay and are decided under the normal rules. Resume restores
access. **Deleting** a user is a soft delete with the same effect, and it also
cancels their `pending` requests (audited as `request.cancelled` with actor
`system`); approved and queued requests are left alone. Request history is kept:
the foreign keys refuse a hard delete, and a deleted user's e-mail address stays
reserved. Nobody can suspend or delete their own account.

## Connections and grants

DBAs register target databases under **Connections**. Credentials (host, port,
database, username, password) are encrypted at rest with AES-256. The password
is never shown again after saving — the edit form leaves it blank, and leaving
it blank keeps the stored one — and credentials never appear in logs or error
messages either.

A developer sees a connection only after a DBA **grants** it to them
(Connections → Grants). Ungranted connections are invisible in Query Studio.

### Time-bound grants and access requests

A grant can last for a period instead of until someone removes it. When a DBA
grants a connection (Connections → Grants) they set:

- the **access level**: `write` (the default) or `read`. With a `read` grant, a developer
  cannot file a request that the SQL guard classifies as a write or DDL; Query Studio
  answers 403.
- the **period**: permanent (the default) or a time span. Access ends at the
  moment it runs out.
- an optional **reason**.

The panel shows each developer's level and the time left, and lets the DBA
replace or revoke a grant. Existing grants stay permanent with full access. A
scheduled job, `connections:expire-grants`, runs every minute, removes expired
rows and records `grant.expired` in the audit log; the record can therefore be
written a little after access actually ended. Requests that were already
`pending` or `approved` when a grant ends are not cancelled, and a grant only
gives the right to submit: every request still needs its own approval.

**Asking for access.** On the dashboard a developer can ask for access to a
connection of their team: pick the level, a period (1 hour, 4 hours, 1 day,
7 days, or a custom amount up to 30 days) and give a reason, which is required.
The request appears on a DBA's **Approvals** page, on the **Access requests** tab,
and the DBA approves it or rejects it with a reason. The grant starts when it is
approved. A developer who holds a timed grant sees its level and the time left and
can ask to extend it.

Rules that keep an approval from widening access by accident:

- Nobody decides their own request, not even a requester who became a DBA while it
  was open.
- A request must match the grant already in force. A request for a different level
  from the grant in force, or for any level while a permanent grant of that level
  exists, is refused when it is filed and when it is approved. To change an
  existing grant, a DBA edits it on the Connections page.
- An approval at the same level only **extends** a timed grant. It never shortens
  it and never turns it into a permanent one.

DBAs are notified in the app and in the team's Slack or Teams channel (without the
free-text reason); the requester is told the decision and the grant that is in
force. The audit events are `grant.requested`, `grant.approved`, `grant.rejected`
and `grant.expired`. Removing a grant by hand still records
`connection.grant_revoked`, and `connection.grant_added` now carries the access
level, the expiry and the reason.

## The request lifecycle

```
pending ──▶ approved ──▶ queued ──▶ running ──▶ completed
   │                                              │
   ├──▶ rejected  (reason required)               └──▶ failed
   └──▶ cancelled (by the requester or an admin)
```

- **Submit.** The developer writes SQL in Query Studio. The
  [SQL guards](/docs/sql-guards/) validate it before submission — requests
  that violate a guard never enter the queue.
- **Pending.** The request waits indefinitely until someone decides; there is
  no auto-expiry. Team DBAs are notified in the portal and, if configured, in
  [Slack](/docs/slack-integration/) / [Teams](/docs/teams-integration/). After
  `QUERYPROXY_PENDING_REMIND_AFTER` hours (default `4`; `0` turns it off) the
  team's DBAs and admins get one reminder, and the chat card is posted again
  with a fresh action token. The requester may add an optional note when
  submitting; DBAs see it with the request.
- **Sensitive-filter badge.** When a request filters (`WHERE`, `JOIN`, `HAVING`,
  `GROUP BY`, `ORDER BY`) by a column that the team masks with a column rule, the
  approval screen and the request detail show a badge with the column names. A
  masked column can still leak a value by inference when the query tests
  candidates, so look at it before you approve. It shows column names only. It
  does not resolve column aliases or derived tables, ignores regex and validator
  rules, and requests submitted before the upgrade carry no badge.
- **Approve / Reject.** Any team DBA can decide — except on their **own**
  requests: self-approval is blocked. Admins may override that block, and the
  override is flagged in the audit log. Rejection requires a written reason.
  An override needs a **written reason** (up to 1000 characters) on every
  channel; it is stored in the audit log. Deciding from chat follows the same
  rules, and each Approve / Reject control
  there carries a **single-use token** bound to that one request and valid for
  24 hours, so a decision taken in chat can only answer a message QueryProxy
  actually posted, and only once. The resolved approver must also be a
  **member of the integration's team**.
  The [REST API](/docs/api/) follows the same rules, except that it cannot take an
  override at all: it answers `403` with a link to the web form.
- **Execute.** Approved requests are queued and picked up by the worker. Reads
  stream through a database cursor into a result file, with
  [masking](/docs/data-masking/) applied to every row before it is written.
  Writes report affected row counts; multi-statement requests run atomically
  and roll back entirely if any statement fails. Reads run in a read-only session
  on the target database with a statement and a lock timeout (see
  [Configuration](/docs/configuration/#read-credentials-and-timeouts)). At most
  `QUERYPROXY_CONNECTION_SLOTS` requests (default `2`) run on one connection at a
  time; the others stay `queued` until a slot frees up.
- **Stop.** The requester, the team's DBAs and administrators can press **Stop
  query** on a `running` request whose database session is known (not on SQLite).
  The database cancels the statement and rolls back what it can; changes that
  were already committed stay. The request becomes `cancelled` and the audit log
  records `execution.cancelled`. SQL Server needs extra privileges for this; see
  [Configuration](/docs/configuration/#privileges-for-stop-query).
- **Results.** The requester (and the team's DBAs) can browse results
  page-by-page or download them as CSV. Auditors see request metadata, history
  and approval traces — not the result data itself. Result files are pruned
  automatically after the configured retention period.

## Notifications

Requesters are notified when their request is decided and when execution
finishes; DBAs are notified of new submissions. Notifications appear in the
portal's bell menu. New submissions and decisions are also announced in your chat
channel when it is configured; the execution-finished notification goes to the
requester in the portal only, not to Slack or Teams.

**E-mail.** On the Profile page each user chooses which events also arrive by
e-mail: a request waiting for review, a decision on their request, a finished,
failed or stopped request, the pending-request reminder, an access request waiting
for a DBA, and the decision on an access request. Everything is off by default. An
e-mail never carries query results, a reviewer's reason, a decision note or an
error message, and the SQL in it has its values hidden. Links open the request in
QueryProxy and need a signed-in session; nothing is approved from an e-mail. The
mail goes out from the queue, so the worker must run; a mail failure never blocks
a request. With `MAIL_MAILER=log` nothing is delivered, and the Profile page says
so.

## The audit trail

Every step above writes an audit entry: submission, decision (with channel and
override flag), execution start/finish (with duration and affected rows),
result downloads, plus logins, grants and configuration changes. Audit rows are
immutable — the model layer and database triggers refuse updates and deletes, and
the records form a hash chain that `php artisan audit:verify` checks. Auditors
can filter the trail and export it as CSV. The audit screen and the CSV show the
SQL with literals redacted (`sql_redacted`), never the raw statement. Events can
also be exported to a SIEM: see
[Audit chain and SIEM export](/docs/audit-export/).

Entries record the client IP as QueryProxy sees it. Behind a reverse proxy that
is the proxy's own address unless you list it in `TRUSTED_PROXIES` — see
[Configuration](/docs/configuration/).
