Column-level permission enforcement — current state & implementation plan
Status: PLANNED, implementation deferred (2026-08-05). Full SQL-engine parity currently outranks this; when it is picked up, start at Phase 0.
This document replaces the stale summary in TODO.parity.md (“310 of 389
grants never imported / enforcement missing entirely”) — both halves of that
sentence are out of date. The survey below is what is actually in the tree
today, verified against source and the running test suite.
1. What exists today (verified 2026-08-05)
Table-level (file-level) enforcement — DONE and tested
Confirmed by ~20 passing cases in tests/unit/abi_dd_perms_test.cpp plus the
DataDict unit tests. The pieces:
| Piece | Where |
|---|---|
Permission model: PermissionEntry (object, type, grantee, group flag, bitmask), DD_PERM_SELECT/UPDATE/INSERT/DELETE/EXECUTE/REFERENCE/GRANT/FULL |
src/engine/data_dict.h (~line 338) |
Per-user effective-ops cache (build_perm_cache at connect → O(1) check_perm / get_effective_ops), group-union semantics, cache invalidation on grant |
data_dict.cpp |
Navigational open gate: AdsOpenTable denies per open mode when usCheckRights ≠ 0 (readonly → needs select_, else update_/insert_) |
ace_exports.cpp (search Per-table ACL check) |
SQL statement gate: SELECT/INSERT/UPDATE/DELETE/MERGE each check the op’s effective bit before executing; system.* exempt |
ace_exports.cpp (sql_acl_effective_ops callers) |
Metadata visibility: zero effective bits on an object hides its metadata (dd_can_view_object_metadata, consulted by ~15 catalog/DD-info surfaces) |
ace_exports.cpp |
| GRANT / REVOKE SQL builds bitmasks and enforces immediately | ace_exports.cpp + tests |
adssys / DB:Admin bypass; check_rights = 0 bypass |
tested |
| No-ACL default = open access (matches ADS) | tested |
Column-level — model, import, and the simple-SELECT half of enforcement
Already present (RCB 07/16/2026 work), also tested:
- Model:
PermissionEntry.parentcarries the column’s table;grant_column_permission();permitted_columns(user, table, op_bit)returnsnullopt(no restriction) or the only permitted set — fail-secure: with any column grant present for the user’s op, columns not in the set are hidden even though table-level access exists.has_any_column_acl()is the fast opt-out. - Import:
tools/import_ddstep 5c3 reads SAP’s own decode (system.permissions WHERE Object_Type = 4, per-op bit columns) and writes native column grants. The mp corpus imports 10; re-verify pmsys’ count on next import (the old “310 never imported” figure predates this step). - Enforcement, simple single-table SELECT path only
(
exec_sql_direct_impl, searchallowed_cols):SELECT *→ cursor projection masked to permitted columns;- explicit forbidden column in the projection → denied (AQE 7200 envelope);
- the materialised branch (
TOP/ORDER BYtemp) masks the temp’s columns too. - Test:
abi_dd_perms_test.cpp“Perms: column-level SELECT enforcement”.
2. The gaps (what “enforcement” still leaks)
The enforcement comment in the code says it plainly: “Only reached for simple single-table SELECTs; join/aggregate queries take the materialisation path above.” Everything below bypasses column ACLs today:
| # | Gap | Leak class |
|---|---|---|
| G1 | WHERE on a forbidden column (SELECT RENT FROM t WHERE DEPOSIT > 1000) |
infers hidden values |
| G2 | ORDER BY / GROUP BY / DISTINCT on a forbidden column |
infers ordering/grouping of hidden values |
| G3 | Aggregates over a forbidden column (SUM(DEPOSIT), SUM(a*DEPOSIT)) |
reads hidden values outright |
| G4 | Joins (2-table, N-way): no allowed_cols computed at all — every merged column exposed; join keys unchecked |
full bypass |
| G5 | CASE / scalar-fn / window / script paths: same — no check | full bypass |
| G6 | Column-level writes: UPDATE t SET forbidden = …, INSERT INTO t (forbidden) VALUES … (the import already captures per-column UPDATE/INSERT bits; nothing consumes them) |
writes hidden columns |
| G7 | Navigational ABI: AdsOpenTable + AdsGetString/GetField/SetString on a restricted table exposes every column (table-level open passes, then no per-column check) |
full bypass for ISAM clients (rddads, DA-Web table browse) |
| G8 | Metadata: system.columns / AdsGetFieldName enumeration lists forbidden columns (SAP behaviour unprobed) |
name disclosure |
| G9 | Views / procs reading forbidden columns on behalf of a restricted user (definer- vs invoker-rights — SAP semantics unknown) | indirect read |
| G10 | Remote (serverd) sessions: server executes with the session’s user through the same ABI — expected to inherit whatever the local paths do, but unverified | same as G1–G8 remotely |
3. SAP semantics to probe FIRST (Phase 0)
Every behavioural choice below must be oracle-probed, not guessed — the
date-format and width work both showed the “obvious” model wrong. Scratch
DD + restricted user against F:\ads11\ace64.dll (same harness as
date_probe / wprobe; connect as the restricted user, not adssys):
- P1:
WHERE forbidden = x→ error (which code?) or silently allowed? - P2:
ORDER BY/GROUP BY/DISTINCTon forbidden → ? - P3:
SUM(forbidden)/SUM(a * forbidden)→ ? - P4: join ON a forbidden key;
SELECT *over a join with one side column-restricted → masked like single-table? - P5:
UPDATE SET forbidden,INSERT (forbidden)explicit +INSERTwithout column list touching a forbidden column → error codes. - P6: navigational:
AdsGetString(forbidden)on an open cursor → 7079? 5063? value?AdsGetNumFields/AdsGetFieldNameenumeration — masked or full? - P7:
system.columnsfor the restricted user → rows filtered? - P8: a VIEW selecting forbidden columns, executed by the restricted user.
- P9:
VERIFY_ACCESS_RIGHTSDB property (104) = false → does SAP skip column checks even with grants present? (Table-level analogue too.) - P10: which error code/text for each deny — the AQE envelope must carry SAP’s exact native code (cf. 2121/2124/5063 precedents).
Record the probe matrix in this file when run.
4. Design
Principle: one chokepoint, not eleven patches
The SELECT engine resolves every column reference through a small number of sites (projection build, WHERE compile, ORDER BY compile, GROUP BY resolve, aggregate slot build, join key resolve). Each already produces a field-index; the plan adds a single helper consulted at those sites:
// scriptbridge — one call per resolved column reference.
// Returns ok / AE_ACCESS_DENIED per the Phase-0 probe matrix.
UNSIGNED32 acl_check_column(Connection* c, const std::string& table_alias,
const std::string& column, uint32_t op_bit);
plus the existing permitted_columns() for the masking (SELECT *) cases.
Per-statement caching: resolve permitted_columns once per (table, op)
into the statement context, as allowed_cols already does — the
has_any_column_acl() opt-out keeps the no-column-ACL case at zero cost.
Phases (each lands independently, gates + suite green between)
- Phase 0 — probe matrix (½ day): §3 against SAP; record here.
- Phase 1 — SELECT completeness (the big one): wire the chokepoint
into WHERE/ORDER/GROUP/DISTINCT compiles, aggregate slot builds
(scalar/grouped/join/jgrp/N-way — the slot-build sites are already
uniform after the 2026-08 aggregate work), and the join paths
(merged-schema build masks per-side columns;
jcol_indexdenies). CASE/fn/window path resolves through the same helper. - Phase 2 — writes: UPDATE SET list, INSERT column list (and the no-column-list INSERT once that lands), against per-column UPDATE/INSERT bits. RI cascades and triggers run as definer (SAP precedent expected from P8 — confirm).
- Phase 3 — navigational ABI: on
AdsOpenTableby a column-restricted user, install a cursor projection of the permitted columns (thecursor_projections()mechanism from the naming work does exactly this shape already) so GetField/GetString/FieldName/NumFields see the masked schema;AdsSetStringetc. deny per-column on write bits. Honour the P6-probed enumeration semantics. - Phase 4 — metadata:
system.columnsfiltering per P7;dd_can_view_object_metadatastays object-level, add the column filter where SAP has one. - Phase 5 — remote + DA-Web smoke: verify serverd sessions inherit everything (they route through the same ABI with the session’s user); smoke DA-Web panels as a restricted user (rule: test against Aquarium, never real DDs).
Non-goals / guardrails
adssys+DB:Adminbypass unchanged everywhere.- Fail-secure only where SAP is fail-secure (probe first); OpenADS must not invent stricter behaviour that breaks legacy apps SAP allowed.
- Escape hatch before any hard lockout ships (standing project rule):
the adssys bypass covers recovery, but Phase 3 must land behind a
verified “admin can always open everything” test, and the plan’s first
shipped phase must include a documented recovery path in case an
imported ACL over-restricts (e.g.
import_dd --no-column-permsflag). - Parity gates run as adssys and must stay byte-identical through every phase.
5. Testing
- Extend
abi_dd_perms_test.cppper phase (the existing column test is the template — scratch DD, restrictedbob, per-case assertions), each verified to fail with its phase reverted. - One gate-style probe script comparing SAP vs OA as the restricted user on a scratch DD (the existing gates connect as adssys and cannot see ACL behaviour).
- mp/pmsys: re-run
import_dd, record the imported column-grant counts here, and spot-check one known SAP restriction end-to-end.