KNOWN_LIMITATIONS.md
Rama main · réplica de lectura del repositorio original.
# Known limitations
A single register of proxy-monster's known limitations, accepted caveats, and
deferred gaps — what the system does not guarantee, where it deliberately
over-denies, and what's tracked for later. This is the one place to look;
detailed design/tracking lives in the linked docs.
Severity legend: 🟢 fail-safe over-deny (a legitimate query may DENY, never a
wrong-ALLOW) · 🟡 fail-closed deny of a legitimate case (feature intentionally
unavailable) · 🔴 known disclosure/leak (not fail-safe — a real gap).
The overriding invariant is fail-closed: a cleartext PII leak is the worst
outcome, over-denying is acceptable. Everything below is consistent with that
except the items explicitly marked 🔴.
Every engine, MySQL, and PostgreSQL mention below is about a **target database**
— what the proxy protects and enforces against. None of it concerns the
control-plane store, which is PostgreSQL only and carries no portability caveat
([`docs/migrations.md`](./docs/migrations.md)).
## Web session lifecycle
- A web session without a stored refresh token cannot be revalidated against the
IdP mid-session. This occurs when `offline_access` is absent or
`PM_RESULT_KEY` is unset. The timer sweep leaves that session alone; identity
staleness is bounded by the configured absolute cap
(`PM_WEB_SESSION_ABSOLUTE`, default 2h), not the 5-minute IdP recheck
interval.
- proxy-monster is a single-instance system. Timer-sweep serialization,
refresh-token use, migrations, and runtime guards assume one control-plane
process. Multi-replica coordination and leader election are out of scope by
design; do not deploy multiple active replicas.
## Daemon session renewal and revocation
- 🟡 Silent `pmon` renewal is bounded by `PM_SESSION_WINDOW` (default 2h): the
daemon's renew loop re-mints ~30 min before token expiry, but with the default
12h TTL that first attempt lands long after the window closed — so a
default-config login still lasts one TTL, then needs `pmon login` again. Raise
the window (or shorten `--ttl`) to get continuous renewal.
- 🔴 Closing a daemon renewal window does not revoke the wire token issued under
it (token TTL, 12h default, outlives the 2h window). The exposure is a
revocation the IdP signals _only_ as `invalid_grant`: group-removal
revocations empty the role set and every statement DENYs, and SCIM
`active=false` / local deactivate revoke the tokens outright
(`revokeActiveCredentialsTx`). Detail:
[`docs/auth-model.md`](./docs/auth-model.md#security-invariants).
## Audit trail
- 🟡 OIDC group reconciliation (`provisionFromOidc`) commits before, and
separately from, the login-audit transaction: a session mint that then fails
leaves the reconciled membership in place; the next login re-reconciles.
OIDC-reconciled membership changes are recorded in no audit trail (only
manual-admin and SCIM membership changes are); the `auth` event covers the
login itself.
## Identifier handling (schema-aware enforcement)
- 🟡 Identifiers containing `.` or `/` cannot be authorized normally. Both are
legal inside a _quoted_ identifier but are the key/EUID delimiters (analyzer
keys join on `.`, Cedar EUIDs on `/`), so a component containing one either
fails identity rendering or is denied during Cedar resource binding.
Production denies; an explicit `exception.unanalyzable` grant may relay a
statement whose dotted identity failed before resource facts were emitted.
## Live namespace tracking
Decisions resolve against each connection's live probed namespace (PostgreSQL
probe-always; MySQL per-statement re-probe). The session settings the masker
depends on are pinned fail-closed:
- 🟡 MySQL `character_set_results` must stay UTF-8 — anything else fails the
session closed. Exception: the Connector/J (DBeaver) session-init
`SET character_set_results = NULL` is rewritten to `utf8mb4` so those clients
work.
- 🟡 The injected namespace probe consumes a transaction's first-statement slot,
so `SET TRANSACTION` right after `BEGIN` fails with SQLSTATE 25001.
Suppressing the probe for a "bare opener" would be a wire-signature guess a
stored `CALL` can mimic while moving `search_path` — fail-open. Use
`BEGIN ISOLATION LEVEL …` instead.
- 🟡 `client_encoding` must remain UTF8 — under another encoding the same bytes
bind different objects on each side, so a non-ASCII identifier could dodge its
mask. It is `GUC_REPORT`: a switch is observed before the next statement and
the relay fails closed.
- 🟡 `standard_conforming_strings` must remain on — with it off, a
backslash-escape literal can hide a statement boundary the admission lexer
cannot see. `GUC_REPORT`: a session turning it off fails closed (SQLSTATE
`0A000`).
## Catalog freshness
Connection-scoped enforcement resolves columns and functions from the fragments
captured on the connection's own held target-DB connection (design:
[`docs/per-connection-catalog.md`](./docs/per-connection-catalog.md)).
- Enforcement-path residuals (detail + severities in
[`docs/per-connection-catalog.md`](./docs/per-connection-catalog.md)): 🔴
rename/redefinition launders name-keyed classifications (re-tag is the admin's
job); an approval grant not bound to the resolved resource; DDL inside a
DML-fired trigger unflagged; a bounded external `A→B→A` revert window; 🟡
MySQL temp-table shadowing; PostgreSQL transactional-DDL probe edges. External
out-of-band DDL is corrected on the next re-check past the staleness bound,
not immediately.
- 🟡 A permitted UDF whose body reads a masked column leaks it:
`SELECT my_udf(id) FROM t` where `my_udf` internally reads `ssn` returns ssn
in the clear. The UDF requires a Cedar Function permit, but the proxy cannot
see reads inside its body on the service-account connection. Passing the
column as an argument is still caught (`SELECT custom_udf(ssn)` DENYs,
[`docs/derived-masking.md`](./docs/derived-masking.md)). A permitted UDF on a
masking datasource must not read PII. Output vouching is backlogged
([`docs/backlog.md`](./docs/backlog.md)).
## Query coverage
- 🟢 sqlglot-go parser gaps route through `exception.unanalyzable` (production
denies; a development exception may relay). Bare `SELECT *` over a
table-function / `LATERAL` / `VALUES` source follows the same gate.
- 🟡 `RENAME TABLE a TO b` is denied — sqlglot-go leaves it an unmodeled
`Command`, and matching the verb would also match `RENAME USER` (privilege
management). Use `ALTER TABLE a RENAME TO b`.
## Authz / policy
- 🟡 A stored FAILED query's diagnostic re-decides against the CURRENT catalog,
not an execution-time snapshot (unlike rows, #229). Example: `users.ssn` is
masked, a write fails with `DETAIL: Failing row contains (1, 010-…)`, then ssn
is dropped — a later view re-decides all-unmasked and releases the raw text.
Needs DDL within the 24h retention window
([`docs/diagnostic-redaction.md`](./docs/diagnostic-redaction.md)).
- 🟡 The analyzer's diagnostic leak set covers only the statement's own tables.
`DELETE FROM orders …` whose FK `ON DELETE CASCADE` hits a constraint on
`order_items` dumps THAT table's row in the `DETAIL` — a column set the check
never considered
([`docs/diagnostic-redaction.md`](./docs/diagnostic-redaction.md)).
- 🟡 A PostgreSQL DEFERRABLE constraint fails at `COMMIT`, and the sanitize flag
is per statement: the `UPDATE` that violated it was marked sanitizing, but the
`COMMIT` (empty leak set) is not, so its
`DETAIL: Key (ssn)=(…) is still referenced` relays raw. The known
transactional-deferral class
([`docs/diagnostic-redaction.md`](./docs/diagnostic-redaction.md)).
- 🟡 Run-as-approver checks the approver's active status at `/execute`, not the
requester's: a requester deprovisioned after approval still has the task
execute (they can't view the result — `/result` checks `isDeactivated`).
Whether the task should run at all is unresolved, flagged rather than silently
decided. Detail: [`docs/task-execution.md`](./docs/task-execution.md).
- 🟡 Open presets — curation is load-bearing. Under `system:development` (and
the permit-by-default system posture), a data-reading system object that the
shipped `system:` classification _forgets to tag_ is exposed. The curated
classification must be thorough + version-maintained. Detail:
[`docs/access-model.md`](./docs/access-model.md).
- 🟡→🔴 No fail-closed manifest-completeness guard: an un-manifested system
table gets no `system:` tag, so a datasource-wide read grant or a
`system:development` permit could read it (config-dependent). Deferred —
[`docs/backlog.md`](./docs/backlog.md).
## Data plane
- 🟡 Binary/prepared results (every JDBC/ORM client) cannot be masked: they deny
by default under a MASK verdict, unless `exception.unmaskable` grants the
unmasked relay. PostgreSQL `COPY` is always denied. Detail:
[`docs/access-model.md`](./docs/access-model.md).
- 🟡 A shutdown drain landing between a pipelined PostgreSQL `Execute`'s relayed
`CommandComplete` and its buffered `Sync` rolls the statement back — a client
that treated `CommandComplete` as commit believes a rolled-back write
succeeded. Narrow window; MySQL's analogue is benign (skipped and retried on
reconnect). Fix (bounded reads through the pending `Sync`) is a follow-up.
- 🟡 A PostgreSQL multi-statement query may control the transaction only by
beginning with `BEGIN` and ending with `COMMIT`; any other `BEGIN`, `COMMIT`,
`ROLLBACK`, `SAVEPOINT`, or `SET TRANSACTION` in it is refused. Send those
statements as their own query.
- A PostgreSQL refusal on the extended protocol outside `BEGIN` does not undo
the statements already executed since the last `Sync`; PostgreSQL would roll
that implicit transaction back. Inside `BEGIN` a refusal fails the transaction
as an error does.
- 🟡 A MySQL multi-statement query with a syntax error or an empty statement
(`;;`) runs nothing, where MySQL runs the statements before it. A MySQL stored
procedure that returns result sets fails (1312): the proxy relays one result
per statement.
## Athena
- 🟡 One datasource reflects and classifies one catalog
(`PM_ATHENA_DEFAULT_CATALOG`). Athena resolves `catalog.database.table` across
catalogs in one query, and the analyzer accepts the qualifier, but a table in
any other catalog has no columns in the snapshot, so the statement is
unanalyzable and denies. Cover a second catalog with a second datasource.
- 🟡 A forwarded `GetQueryResults` page reports no row or byte volume; the audit
completion covers the accepted submission only.
## Wire-cert distribution (direct clients and `pmon`)
A proxy advertises its wire-TLS certificate chain at `Register`;
`GET /api/datasources/{id}/wire-cert` serves it for `psql`/`mysql`/DataGrip
(`verify-full`), and `pmon` uses the same bytes as its upstream root pool.
- 🔴 The advertised chain's root of trust is the shared `PM_SECRET_TOKEN`, not a
proxy identity: whoever holds it can re-register any datasource's address and
chain and capture its wire tokens. Fix is a datasource-bound registrar
identity (per-datasource credential / proxy mTLS). Production requires the
secret at startup; its admin-equivalent scope is the remaining gap
([`docs/datasource-registration.md`](./docs/datasource-registration.md)).
- 🟡 Every certificate in the advertised file becomes a `pmon` trust anchor — a
bundle contaminated with an unrelated CA widens trust instead of failing
closed (refusing at registration would mean no datasource, no catalog, total
outage). Publish only the certificates the proxy actually presents; narrowing
to the verified path is tracked in [`docs/backlog.md`](./docs/backlog.md).
- 🟡 Certificate expiry is never checked before advertising — the client's error
is authoritative — so the console can offer a certificate no client will
accept.
## SQL editor
The web SQL editor executes over the proxy-dialed `EditorExec` channel through
the proxy's normal `Decide`; a persistent per-session stream holds one target-DB
connection so `SET`/`USE`/temp/`BEGIN` persist across queries.
- 🟡 No concurrent-editor-session cap (auth'd DoS surface): each session pins
one unpooled target-DB connection until explicit close or the idle sweep
(30-min cutoff on a 15-min tick → up to ~45 min), so an authenticated user
opening many sessions can exhaust the target's `max_connections`. A
per-principal cap is a follow-up.
## Result caps and rates
See [`docs/result-caps.md`](./docs/result-caps.md).
- 🟢 An editor-session result (the editor and the approval workflow) is bounded
by the channel's own 5,000-row ceiling even under an uncapped verdict. Reading
a larger result unbounded goes over the wire.
- 🟡 Parallel connections can overshoot a rate by up to one statement cap each.
A rate is summed from completion rows, which land after a statement finishes,
so connections that all pass `Decide` before any of them completes see the
same stale total. Charging the statement cap at `Decide` and settling it on
completion would close this.
---
_Add an entry here whenever a fix ships with an accepted caveat or a deferred
gap, rather than burying it in a design doc. Keep detail/tracking in the linked
docs; this file is the index._