Skip to content

Feature permissions - #119

Open
alowrydi wants to merge 13 commits into
mainfrom
feature-permissions
Open

Feature permissions#119
alowrydi wants to merge 13 commits into
mainfrom
feature-permissions

Conversation

@alowrydi

@alowrydi alowrydi commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Role-based access control and authentication, di.permissions

Consolidates four TorQ files - code/handlers/permissions.q (.pm), writeaccess.q (.readonly), ldap.q (.ldap), and code/common/execas.q - into a kdb-x module. Owns the exec phase of every message-handling .z.* event via the injected di.handlers dependency, permission-checks each incoming query against a user's roles and groups, and optionally enforces a whole-process read-only mode. controlaccess.q's separate tiered engine (superuser/poweruser/defaultuser) is deliberately deferred - see Design decisions.

Trello ticket - https://trello.com/c/0ZvTnvAa/131-kdb-x-permissions-module


Files created

File Description
di/permissions/init.q Loads permissions.q, reads version from VERSION, defines export of 13 functions
di/permissions/permissions.q Full implementation - RBAC engine, LDAP backend, admin API, connection-lifecycle hooks, root publication
di/permissions/permissions.md Full module documentation - see below
di/permissions/deps.q Empty - no hard module dependencies, everything injected
di/permissions/VERSION Plain-text version string, read at load time
di/permissions/test.csv k4unit unit suite - 534 rows, of which 263 are assertions
di/permissions/test_integration.csv 10 k4unit integration assertions against a real spawned child process and IPC handle

How to test

Unit tests:

k4unit:use`di.k4unit
k4unit.moduletest`di.permissions

2026.08.06T15:39:19.795 start
2026.08.06T15:39:19.795 :/home/alowry/bin/local-kdbx-modules/di/permissions/test.csv 534 test(s)
...
action ms bytes lang code                                                            ... ok
fail   0  0     q    perms.init[(::)]                                                    1
fail   0  0     q    perms.init[`log`handlers!(42;mockh)]                                1
fail   0  0     q    perms.init[deps,enlist[`engine]!enlist `tiered]                     1
true   0  0     q    (enlist`log)~key logging.logdict                                    1
run    0  0     q    perms.init[logging.logdict,`handlers`enabled!(mockh;1b)]            1
true   0  0     q    10h=type perms.version                                              1
true   0  0     q    6=count select from .pt.reg where phase=`exec                       1
true   0  0     q    perms.allowed[`alice;"select from .pt.trade"]                       1
true   0  0     q    not perms.allowed[`alice;"select from .pt.secret"]                  1
fail   0  0     q    perms.requ[`alice;"select p:count .pt.secret from .pt.trade"]       1
true   0  0     q    16=count .pt.bad                                                    1
...
2026.08.06T15:39:22.286 end
All tests passed
q)exec sum ok from k4unit.getresults[] // 534/534 rows pass
534i

k4unit counts every row it executes, so 534 includes the before, run and comment setup rows. Of those, 263 are assertions - 188 true and 75 fail - with 0 failures. Coverage includes: users/groups/roles and transitive group membership, query classification (select/update/delete, bare references, named function calls, .q-keyword calls including joins, lambda expressions), virtual tables, result size capping, read-only mode toggled at runtime, the full admin API including revoke/remove, LDAP dispatch and the injected-ldapbind cache/lockout/expiry path, root-name publication and teardown, dependency-versus-config key separation, and a systematic sweep asserting every admin entry point rejects a wrong-typed argument with a module-prefixed, logged error.

Integration tests (spawns a real child q process on an OS-assigned port; moduletest only ever loads test.csv, so this suite is loaded and run directly):

k4unit:use`di.k4unit
.m.di.0k4unit.KUltf .Q.dd[hsym`$.Q.m.mp`di.permissions;`test_integration.csv]
.m.di.0k4unit.KUrt[]
k4unit.getresults[]

2026.08.06T15:36:07.002 start
2026.08.06T15:36:07.002 :/home/alowry/bin/local-kdbx-modules/di/permissions/test_integration.csv 10 test(s)
2026.08.06T15:36:07.132 end
action code                                         ok
------------------------------------------------------
true   .pi.up                                       1
true   .pi.realh                                    1
true   $[.pi.up;3=count .pi.read;0b]                1
true   $[.pi.up;10h=type .pi.write;0b]              1
true   $[.pi.up;.pi.write like "*noupdate*";0b]     1
true   $[.pi.up;0=.pi.ggafter;0b]                   1
true   $[.pi.up;101~.pi.tree;0b]                    1
true   $[.pi.up;10h=type .pi.treewrite;0b]          1
true   $[.pi.up;.pi.treewrite like "*noupdate*";0b] 1
true   $[.pi.up;0=.pi.ggtree;0b]                    1

10/10 passing. This suite exists because reval's read-only restriction is not applied at .z.w=0 - a unit test asserting a blocked write would pass against broken code. It proves read-only enforcement and parse-tree call handling over a real IPC handle.

It wires the real di.handlers, which ships on this branch, so it does exercise real phase and exec-ownership dispatch. Which path ran is asserted, not assumed: the .pi.realh row requires the child to report 1b, so a silent fall-through to the inline stand-in fails the suite rather than passing quietly. Verified both ways - with di.handlers removed from QPATH that row fails, leaving 9/10.


Bugs fixed, all inherited from TorQ

Each is a real defect in the legacy code, not a refactor, and each has a test that fails against the unfixed version.

  1. A select was checked on its target table only. Its where, by and columns clauses could name other tables and executed unchecked, so a user granted any single table could read any other: select p:first secret`pin from open returned secret's data. Every readable object named in a clause is now grant-checked, using the same predicate a bare reference already uses.
  2. cloneuser built and evaluated a string to hash the new password. It throws on any password containing a space or a backtick, and evaluates caller-supplied text in an auth path. Replaced with a direct md5 p.
  3. -public throws 'type instead of refusing. Legacy evaluates if["B"$(.Q.opt .z.x)[`public][0;0]]; with no -public flag that's if[`boolean$()], so on any process started without the flag, login throws for every unknown user rather than returning 0b. Replaced with a public boolean config key.
  4. Read-only could not be toggled at runtime. val/valp were bound once at load time; now resolved per call.
  5. valp threw on every parse tree under read-only - the standard sync-IPC call shape. Legacy calls parse on it, which requires a string and throws 'type on a list, so a read-only process (canonically an HDB) rejected the commonest client idiom outright.
  6. Anonymous provisioning threw instead of connecting. assignrole/addtogroup refuse undefined names, and the provisioning path assigned publicuser/public without ever creating them. TorQ has the identical gap, unreachable there only because bug 3 threw first.
  7. Role and group descriptions collapsed their schema column. description:() is a general list; q collapses it to a typed vector once every row holds only atoms, so a one-character description turned the whole column into a char vector and the next multi-character description threw a bare 'type.
  8. .ldap.server/.ldap.port are undefined. The cache upsert writes them; the real setting is servers, plural. Both columns are dropped rather than fixed, since nothing ever reads them back.

Design decisions

1. Owns the exec phase of six phased events - di.permissions registers exec on .z.pw, .z.pg, .z.ps, .z.pi, .z.pp and .z.ws, plus a simple .z.pc observer, all under the stable name di.permissions so re-init reclaims rather than collides. di.handlers rejects a pre/post registration when no exec owner exists, so a pre-only design cannot register at all. It's also the only way to reproduce, inside a single-owner model, the three incompatible composition idioms TorQ uses on .z.pw alone (flat replace in permissions.q, gate-and-call-through in controlaccess.q, AND-compose in ldap.q).

2. .z.ph is deliberately not claimed; .h.val is assigned directly - On kdb+ 3.5+, HTTP GET permissioning happens at .h.val, which is what permissions.q itself sets - not .z.ph. .h.val isn't a .z.* event, so di.handlers rejects the symbol by design; the module assigns it directly, capturing the original once (guarded, since init is idempotent) and restoring it on teardown. Verified end to end over real HTTP: a granted user's GET returns data, an ungranted table and a system call are both refused, an unauthenticated GET is rejected at .z.pw.

3. Legacy .pm.* names are republished at root, but only when enabled - use mangles module code into a private namespace, and config/permissions/*.q grant files are executable q calling .pm.addrole, .pm.grantfunction, .pm.ALL and others at root on their first line. Publishing the seven grant-script functions plus the wildcard constant is what lets an unmodified TorQ grant file load, following the TorqX convention for .gw.*/.u.upd/.hdb.reload. Diverges from TorQ in publishing only when enabled - safe, since gateway.q already guards on existence, and better, since a disabled module shouldn't advertise admin functions that gate nothing. Verified against the real, unmodified TorQ/config/permissions/default.q.

4. RBAC only; controlaccess.q's tiered engine is deferred - The engine config key ships from v1, defaults rbac, and rejects tiered with a clear message, so the second engine can land later without reshaping the config schema. Evidence for deferring: outside controlaccess.q itself, there is no functional caller of .access.* anywhere in TorQ - the three references that exist are a broken mutual-exclusion guard in permissions.q (@[1b;...] returns 0b even when the flag is set), API descriptions in apidetails.q (dissolved in the modular world), and a write-only setting nothing reads back. The module ships enabled:0b by default, and tiered is rejected outright.

  1. No hard di.* module dependencies - di.permissions is STANDALONE, not FRAMEWORK as the modularisation plan's tier table lists it - The plan's tree diagram shows di.permissions -> di.handlers as a hard edge, directly contradicting its own prose two sections earlier, which states that logging, timer and handler dependencies "disappear from the tree entirely since they're injected." Checked against di.subscriptions, which also injects di.handlers (for its own .z.pc observer) and is correctly excluded from the hard-dependency diagram for that same reason - confirming di.permissions' tree entry is the documented outlier, not the rule. The one candidate hard dependency considered and rejected was di.api, needed by lamq's lambda-expression permission check for namespace/variable introspection - di.api turned out to be registry-only (it collects getapimeta; it does not expose varnames/allns), so that introspection is reimplemented internally rather than depended on. With handlers injected and no other candidate, di.permissions has zero hard dependencies. Flagged as a correction for whoever owns the plan page - not asserted here as already fixed, since it isn't this module's document to edit.

6. Select clauses are permission-checked using the same predicate as a bare reference - rbac.checkclauses reuses rbac.isdefinedvar, the identical function the bare-reference and lambda paths already use, minus the target table's own column names. An object can no longer be reachable through a clause while a direct reference to it is refused - the inconsistency an earlier, table-only version of this check left in place. Verified against both real leaks (a plain vector and a plain dict smuggled through a columns expression) and six ordinary queries that must not be over-blocked, including one where a column name collides with an unrelated global.

7. ignorelist defaults empty, unlike zpsignore.q - TorQ ships it enabled with (`upd;"upd";`.u.upd;".u.upd"). Silently exempting upd from permission checks is not a safe default for an access-control module. A process taking .u.upd-shaped feed traffic must set it explicitly, and the docs say so prominently.

8. LDAP is optional and off by default, with an injectable ldapbind seam - Matches TorQ's shipped settings, not ldap.q's own file default, so the whole suite runs with no .so present. init accepts an optional ldapbind key that replaces the native library outright - a dependency, not a config value, since deps are process wiring the module already trusts absolutely. It exists so the cache, lockout and expiry logic can be exercised without a directory server; the native path is separately verified against a real kdbldap.so - all four symbols bind at the arities this module uses, initialise opens a session, and a real kdbldap_bind_s executes with lockout bookkeeping intact.

9. Public-user detection keeps TorQ's first-row lookup, not a full membership check - Looks like a bug and was implemented as one during the port, then reverted. It's correct by construction: the provisioning branch puts an anonymous user in exactly one group. A full check would change who's authorized in both directions - a real user also in public could be rejected while presenting a valid password, or have their user row silently upserted over. That's an account-takeover path, so the narrower legacy check is the safer contract.

10. exit 1 is not reproduced - permissions.q's mutual-exclusion guard against controlaccess.q calls exit 1. A module that can kill the process at load time is untestable; the equivalent guard here raises a normal, catchable error instead.

11. All 21 admin functions ship, including the full revoke/remove half - Legacy permissions.q defines all of them; apidetails.q simply doesn't advertise nine. An access-control module that can grant but not revoke at runtime is a real operational gap during an incident.

12. init takes a single dict, like every other module - init[deps], with dependency keys (log, handlers, optional ldapbind) and configuration keys sharing one flat dict. Every di.* module that has an init takes one argument, and di.log ships logdict as a ready-made deps dict precisely because di.torq passes one. A two-argument init cannot be wired at all, and fails silently rather than loudly - q returns a projection instead of throwing, so the module would register nothing while reporting no error. Dependency keys are stripped before the configuration is stored, so they never reach status[] or the unrecognised-key warning. A test wires the module straight off di.log's logdict, the shape di.torq will use.


Deliberate omissions

  • .access.loginvalid - references an undefined handle and tables that exist nowhere in the repo, and is never called. Its counterpart .access.FILE is set by dotz.q and read by nobody. Both removed.
  • Seven of eleven LDAP native library bindings - only init, setOption, bind_s and err2string are ever called; each binding is a load-time failure point.
  • .pm.cando - no callers anywhere, differed from allowed only by parsing first, which allowed now does itself.

Caveats worth disclosing up front

  • ldapdebug is a breaking config change - was an int (0i), is now a boolean (0b). A caller passing ldapdebug:1i will now fail init. The value was only ever read as an on/off flag; the int type was a lie the validator now refuses.
  • rbac.checkclauses's shape guard is defence in depth, not reachable through the public API - 2_q assumes a query tree of at least 5 elements; a change to rbac.isq's threshold elsewhere could silently make the check permit rather than refuse. Now fails loudly and is tested directly against the module's own namespace.
  • di.handlers prints (not throws) a bare 'type when several phased exec owners are removed in sequence. Reproducible with di.handlers alone, no di.permissions involved; teardown completes correctly regardless. Raised separately against that module.

Checklist

  • 263/263 unit test assertions passing (534/534 k4unit rows)
  • 10/10 integration tests passing, against the real di.handlers
  • Follows consistency.md and style.md
  • Follows dependency injection guidelines
  • permissions.md documents all exported functions, config, usage examples and notes
  • Verified against the real, unmodified TorQ config/permissions/default.q
  • di.depcheck audit clean - 0 failures, 0 warnings

Documentation

See permissions.md for full reference including the dependency table, configuration table, exported function documentation with examples, clause-checking rationale and false-positive measurements, LDAP coverage notes, root-name publication details, migration guidance from .pm/.access, and notes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant