diff --git a/di/permissions/VERSION b/di/permissions/VERSION new file mode 100644 index 00000000..6c6aa7cb --- /dev/null +++ b/di/permissions/VERSION @@ -0,0 +1 @@ +0.1.0 \ No newline at end of file diff --git a/di/permissions/deps.q b/di/permissions/deps.q new file mode 100644 index 00000000..8fd4c189 --- /dev/null +++ b/di/permissions/deps.q @@ -0,0 +1,7 @@ +/ hard module dependencies and their minimum versions, validated by di.depcheck +/ di.permissions has NO hard dependencies - it is a standalone module: +/ - log and handlers are injected via init as dictionaries of functions +/ - lamq's variable introspection is handled internally rather than via di.api, which is +/ registry-only and does not expose varnames/allns. it does not reproduce TorQ's namespace walk: +/ the query is tokenised first and only those tokens tested, which is O(tokens) not O(all names) +deps:(`$())!(); diff --git a/di/permissions/init.q b/di/permissions/init.q new file mode 100644 index 00000000..8c3f6d7e --- /dev/null +++ b/di/permissions/init.q @@ -0,0 +1,16 @@ +/ di.permissions - role-based access control and authentication for a KDB-X process +/ consolidates TorQ's permissions.q (.pm), writeaccess.q (.readonly), ldap.q (.ldap) and common/execas.q +/ owns the exec phase of the message-handling .z.* events via the injected di.handlers dependency + +\l ::permissions.q + +/ Module version +version:first read0`:::VERSION + +/ NB: export:([...]) EVALUATES each name, so it can only list names that already exist - the export +/ list and the implementation therefore cannot drift apart in this direction. +/ init and getapimeta are framework plumbing di.torq calls by convention; every other name here has a +/ getapimeta row, which the test suite asserts +export:([init;teardown;version;getapimeta;status; + allowed;requ;val;valp;execas; + admin;loadpermissions;unblock]) diff --git a/di/permissions/permissions.md b/di/permissions/permissions.md new file mode 100644 index 00000000..db33108d --- /dev/null +++ b/di/permissions/permissions.md @@ -0,0 +1,654 @@ +# di.permissions + +Role-based access control and authentication for a KDB-X process. It 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. + +Consolidates four TorQ files: `code/handlers/permissions.q` (`.pm`), `writeaccess.q` (`.readonly`), +`ldap.q` (`.ldap`), and `code/common/execas.q`. TorQ's `controlaccess.q` tiered engine is **deferred** - +see [Engine scope](#engine-scope). + +--- + +## Features + +- **Users, groups and roles.** Roles grant the right to call *functions* (gated by a paramcheck + lambda); groups grant read/write access to *tables and variables*. Group membership is transitive - + a group may itself be a member of another group. +- **Query interception.** Select/update/delete, bare variable references, named function calls, + `.q`-keyword calls (including joins, whose table arguments are checked recursively) and lambda + expressions are each classified and checked appropriately. A select is checked on its **where, by + and columns clauses as well as its target table**, using the same predicate as a bare reference - + see [Clause checking](#clause-checking). +- **Virtual tables.** A named view of a table with an implicit where-clause spliced into any select + against it, so a group can be granted a filtered slice rather than the whole table. +- **Pluggable authentication.** `local` (md5 hash) and `ldap` backends, selected per user by the + `authtype` on their user row. +- **Read-only mode.** A runtime flag that routes evaluation through `reval` instead of `eval`. +- **Result size cap.** Serialized results larger than `maxsize` are refused. +- **Anonymous access.** Optional auto-provisioning of public users, torn down on disconnect. + +--- + +## Dependencies + +| Dependency | Key | Required | Description | +|---|---|---|---| +| logger | `` `log `` | yes | dict with `info`, `warn`, `error`, each binary `{[c;m]}` - symbol context, string message | +| handlers | `` `handlers `` | yes | dict with `register` and `remove` - see `di.handlers`. A full `di.handlers` dict (which also carries `list`) is fine; only the two keys this module calls are required | +| ldap bind | `` `ldapbind `` | **no** | `{[session;dict]}` returning a dict with a `` `ReturnCode `` key (`0i` = success). Replaces the native LDAP library entirely when supplied - see [LDAP coverage](#ldap-coverage) | + +**No hard dependencies on other `di.*` modules** - `deps.q` is empty and the module is standalone. + +Both dependencies are **required and never defaulted**; `init` throws immediately if either is absent, +malformed, or missing keys. There is no fallback logger. That matters more here than elsewhere: legacy +`permissions.q` logs nothing at all, so every rejected login and denied query is currently silent, and +a silent fallback would make that silence look deliberate. + +> **Note on `di.api`.** TorQ's `lamq` enumerated every variable in every root namespace via +> `.api.varnames`/`.api.allns`, then intersected that list with the tokens in the query. `di.api` is +> registry-only and does not expose those functions, by design - module code lives in each module's +> private `.z.m`, so a root-namespace scan would find nothing useful. +> +> This module does **not** reimplement that walk. It inverts the algorithm: tokenise the query first, +> then test only those tokens for being defined root variables. Same result, but O(tokens) rather than +> O(all names) - measured at 0.065 ms per lambda query against 2.9 ms for the walk on a process with +> 5000 root names. + +--- + +## Initialisation + +```q +perms:use`di.permissions +handlers:use`di.handlers + +logdep:`info`warn`error!( + {[c;m] -1 string[c],": INFO ",m;}; + {[c;m] -1 string[c],": WARN ",m;}; + {[c;m] -2 string[c],": ERROR ",m;}); + +handlers.init[enlist[`log]!enlist logdep]; +handlersdep:`register`remove`list!(handlers.register;handlers.remove;handlers.list); + +perms.init[(`log`handlers!(logdep;handlersdep)),`enabled`readonly!(1b;0b)]; +``` + +`init` takes a **single dictionary** carrying both the dependencies and the configuration, the same +call shape every `di.*` module takes. Dependency keys (`` `log ``, `` `handlers ``, `` `ldapbind ``) +and configuration keys share one flat namespace; no configuration key collides with a dependency key, +and the dependency keys are stripped before the configuration is stored, so they never appear in +`status[]` or trigger the unrecognised-key warning. + +> **Watch the join.** `` enlist[`k]!enlist somedict `` puts a *table* on the value side (a one-element +> list of dictionaries is a table), so joining two of them throws `` 'mismatch ``. Join `di.log`'s +> `logdict` with **one** multi-key dictionary rather than chaining single-key ones: +> ```q +> perms.init[logging.logdict,`handlers`enabled`readonly!(handlersdep;1b;0b)] / works +> perms.init[logging.logdict,(enlist[`handlers]!enlist handlersdep),...] / 'mismatch +> ``` + +`init` must be called before any other function. It is **idempotent**: a second call re-wires the +dependencies and config and reclaims the same handler registrations, leaving grant data intact. + +When `enabled` is `0b` (the default) `init` wires the logger, logs that it is disabled, and stops - no +handlers are registered and nothing is published at root. + +--- + +## Configuration + +| Key | Default | Description | +|---|---|---| +| `enabled` | `0b` | master switch; when off, nothing is registered or published | +| `engine` | `` `rbac `` | authorization engine. Only `rbac` is implemented - `` `tiered `` is rejected | +| `maxsize` | `200000000` | maximum serialized size of any returned result | +| `runmode` | `1b` | `1b` executes the query, `0b` returns a boolean verdict only | +| `permissivemode` | `0b` | when `1b`, an object with no grants at all is readable by default | +| `readonly` | `0b` | route evaluation through `reval`, blocking writes | +| `public` | `0b` | allow anonymous users to be auto-provisioned on login | +| `ignorelist` | `()` | message heads that bypass the check on `.z.ps` - see below | +| `grantdirs` | `()` | directories holding grant files, loaded by `loadpermissions` | +| `proctype` | `` ` `` | process type, selects `{proctype}.q` in the grant cascade | +| `procname` | `` ` `` | process name, selects `{procname}.q` in the grant cascade | +| `publishroot` | `1b` | expose the legacy `.pm.*` names at root. Set `0b` if you have no legacy grant files - the module still enforces, it just leaves the root namespace untouched | +| `ldapenabled` | `0b` | enable the LDAP backend and load its native library | +| `ldaplibpath` | `""` | path to the LDAP `.so`; falls back to `$KDBLIB` | +| `ldapdebug` | `0b` | log LDAP chatter at info level | +| `ldapservers` | `` enlist `$"ldap://localhost:0" `` | LDAP server URIs | +| `ldapversion` | `3` | LDAP protocol version | +| `ldapblocktime` | `0D00:30:00` | how long a locked-out user stays locked out; null means forever | +| `ldapchecklimit` | `3` | failed attempts before lockout | +| `ldapchecktime` | `0D00:05` | window in which a repeat login skips the server | +| `ldapbuilddnsuf` | `""` | suffix used when building the bind DN | +| `ldapbuilddn` | `{"uid=",string[x],",",...}` | function building the bind DN from a username | + +Unrecognised keys are **warned about**, not silently dropped. Every key is uniquely named so it +survives `di.config`'s flat cascade - note the `ldap*` prefixes, which exist because legacy ships four +separate `enabled` settings that would otherwise collapse onto one another. + +> **NB Breaking config change: `ldapdebug` is now a boolean.** It was an int (`0i`); it is now `0b`. +> `init` type-checks every setting whose shape is fixed, so a caller passing `ldapdebug:1i` **will now +> fail `init`** with `config key(s) ldapdebug must be a boolean (1b or 0b)`. The value was only ever +> read as an on/off flag (`if[.z.m.config`ldapdebug;...]` in `ldap.debuglog`) - nothing graded it as a +> level - so the int type was a lie the validator now refuses. Set `ldapdebug:1b` instead. + +### `ignorelist` defaults to empty, unlike TorQ + +TorQ's `zpsignore.q` ships **enabled** with `` (`upd;"upd";`.u.upd;".u.upd") ``, exempting those from +permission checks on `.z.ps`. Silently exempting `upd` is not a safe default for an access-control +module, so this ships empty. **A process that receives `.u.upd`-shaped feed traffic must set it +explicitly**, or that traffic will be permission-checked and rejected: + +```q +perms.init[deps,`enabled`ignorelist!(1b;(`upd;"upd";`.u.upd;".u.upd"))] +``` + +It is a **mixed** list - the head of an incoming message is matched against both symbol and string +forms. It applies to `.z.ps` only, matching TorQ; `.z.pg` is never exempted. + +--- + +## Exported functions + +### `init[deps]` +Wire dependencies, resolve config, and (when enabled) publish root names, load grants and register +handlers. Idempotent - see [Initialisation](#initialisation) for the full worked example. +```q +perms.init[(`log`handlers!(logdep;handlersdep)),`enabled`readonly!(1b;0b)] +/ or with defaults only (module loads but stays disabled): +perms.init[`log`handlers!(logdep;handlersdep)] +``` + +### `teardown[]` +Release everything `init` installed: handler registrations, `.h.val`, and the published `.pm.*` root +names. Grant data survives, so a later `init` re-registers and re-publishes cleanly. +```q +count (key `.pm) except ` / 8 - the published root names +perms.teardown[] +count (key `.pm) except ` / 0 - all removed +perms.status[][`enabled] / 0b - and the module reports itself disabled +``` + +### `allowed[user;query]` +Would this user be permitted to run this query? Never executes it. +```q +perms.allowed[`alice;"select from trade"] / 1b - alices group is granted read on trade +perms.allowed[`alice;"select from secret"] / 0b - no grant, and nothing was executed +``` + +> **`allowed` is a true predicate.** It returns a boolean and never executes the query. Three earlier +> caveats have been fixed: it now descends into `.q`-keyword joins (so it agrees with `requ` rather +> than permitting joins `requ` refuses), a forbidden lambda expression returns `0b` instead of raising, +> and it honours `permissivemode` rather than pinning it off as TorQ does (`allowed:mainexpr[;;0b;0b]`), +> so it no longer denies things `requ` permits. + +### `requ[user;query]` +Permission-check a query as a user and execute it. Passes the query through untouched when the module +is disabled. This is the authority - `allowed` is the dry run. +```q +perms.requ[`alice;"select from trade"] +/ sym px +/ --------- +/ a 1 +/ a 2 +/ b 3 + +perms.requ[`alice;"select from secret"] +/ 'di.permissions: query: no read permission on [secret] +``` + +### `val[expr]` / `valp[expr]` +Evaluate a parse tree / a string or parse tree, under `reval` when read-only mode is on. TorQ binds +these at **load** time (`val:$[readonly;reval;eval]`), so read-only could not be toggled without a +restart; here the choice resolves per call. +```q +perms.valp "2+2" / 4 +perms.val parse "2+2" / 4 +``` +Both are used by `di.gateway`, which copies the function value; neither performs a permission check on +its own - that is `requ`'s job. + +### `execas[query;user]` +Run a query as another user, subject to that user's permissions. +```q +perms.execas["select from trade";`alice] +/ sym px +/ --------- +/ a 1 +/ a 2 +/ b 3 + +perms.execas["select from secret";`alice] +/ 'di.permissions: query: no read permission on [secret] +``` + +### `admin` +The grant-administration sub-API. `admin.wildcard` is the wildcard object (`` `$"*" ``) - grant against +it for superuser rights. + +| Group | Functions | +|---|---| +| users | `adduser` `removeuser` `cloneuser` | +| groups | `addgroup` `removegroup` `addtogroup` `removefromgroup` | +| roles | `addrole` `removerole` `assignrole` `unassignrole` | +| functions | `addfunction` `removefunction` `grantfunction` `revokefunction` | +| tables | `grantaccess` `revokeaccess` | +| virtual tables | `createvirtualtable` `removevirtualtable` | +| anonymous | `addpublic` `removepublic` | + +```q +perms.admin.addrole[`reader;"may select"]; +perms.admin.grantfunction[`select;`reader;{1b}]; +perms.admin.addgroup[`traders;"trading desk"]; +perms.admin.grantaccess[`trade;`traders;`read]; +perms.admin.adduser[`alice;`local;`md5;md5 "secret"]; +perms.admin.assignrole[`alice;`reader]; +perms.admin.addtogroup[`alice;`traders]; + +perms.admin.wildcard / `* - the wildcard object +perms.admin.grantfunction[perms.admin.wildcard;`admin;{1b}]; / superuser: any function +``` + +> **Paramchecks must be functions.** They are applied to the call's parameter dict under protection, +> and any non-boolean result is coerced to `0b` - so a literal `1b` stored as a paramcheck **fails +> closed**. `grantfunction` rejects a non-function outright. + +### `loadpermissions[]` +Load the grant cascade - `default` -> `proctype` -> `procname` - from every configured `grantdirs` +directory. Missing files are skipped with an info log. Called automatically by `init`; call it again to +reload after editing a grant file. +```q +perms.loadpermissions[] +/ with no grantdirs configured this logs "nothing to load" and returns +``` + +### `unblock[user]` +Clear a user's cached LDAP state - both a lockout and any cached successful authentication. +```q +perms.unblock[`alice] +/ a user with no LDAP record logs "no ldap login record for user alice" and returns +``` + +> TorQ's equivalent returns early unless the user is actually *blocked*, which leaves no way to force +> re-authentication for a user who is merely cached: after a password change their cached success +> stands until `ldapchecktime` elapses. This clears `success` too, so the next login always reaches +> the server. + +### `status[]` +What the module is currently enforcing: engine, read-only state, permissive mode, run mode, maxsize, +public access, and LDAP availability. Legacy has no equivalent introspection. +```q +perms.status[] +/ enabled | 1b +/ engine | `rbac +/ readonly | 0b +/ permissivemode| 0b +/ runmode | 1b +/ maxsize | 200000000 +/ public | 0b +/ publishroot | 1b +/ ldapenabled | 0b +/ ldapavailable | 0b +``` +`ldapavailable` reports whether a bind is actually reachable - the native library having loaded, or an +`ldapbind` having been injected - not merely that `ldapenabled` is set. + +### `version` +The module version string. +```q +perms.version / "0.1.0" +``` + +Read at load time from the **`VERSION` file** in the module directory (`version:first read0`:::VERSION` +in `init.q`) rather than being hardcoded as a q literal, so a release bump touches one plain-text file. +This follows the TorqX module convention. + +`version` remains in the **export dictionary**. `di.depcheck` resolves a dependency's version from its +export dict (`checkdepversion`) and reports `"... exports no version"` - failing the dependency check - +if a module omits it. Moving the *value* to a file does not move the *export*. + +### `getapimeta[]` +This module's api metadata - one `` `name`public`descrip`params`return `` row per callable export, for +`di.torq` to collect and register with `di.api`. `init` and `getapimeta` are deliberately absent: they +are framework plumbing `di.torq` calls by convention rather than discovers. Needs no `init`. +```q +cols perms.getapimeta[] / `name`public`descrip`params`return +count perms.getapimeta[] / 33 - 11 top-level exports plus the 22 admin.* members + +3 sublist select name,public,descrip from perms.getapimeta[] +/ name public descrip +/ --------------------------------------------------------------------- +/ teardown 1b "release handler registrations, .h.val and the publi.. +/ version 1b "module version string" +/ status 1b "what this module is currently enforcing - engine, r.. +``` +The `admin.*` members carry `public:0b` - they are real callables registered in `di.api`'s full view +but kept out of the public summary, which lists `admin` itself. + +--- + +## Input validation + +Every public entry point validates its arguments and reports failures the same way as everything else +- prefixed `di.permissions:`, naming the offending argument and the expected type, and logged at +`error` before being signalled: + +```q +perms.allowed["alice";"1+1"] +/ 'di.permissions: allowed: user must be a symbol, got 10h +perms.admin.grantaccess[`t;`g;`sideways] +/ 'di.permissions: grantaccess: level must be `read or `write, got `sideways +``` + +This matters because the alternative is a raw `'type` or `'length` thrown from a downstream `upsert` - +unprefixed, unlogged, and giving no indication which argument was wrong. **Malformed client queries +are covered too**: an unparseable string yields +`di.permissions: mainexpr: could not parse query: ...` rather than q's bare parse error, so a client +probing with garbage still leaves an audit trail. + +"Every" is enforced rather than asserted: the suite drives one wrong-typed call at **each** admin +entry point and requires all of them to raise a `di.permissions:`-prefixed error, so an admin function +added later cannot quietly skip validation. It also checks that the rejection reached the injected +logger, not merely that it was thrown. + +--- + +## Clause checking + +TorQ permission-checks a select on its **target table only** (`permissions.q`'s `query` inspects +nothing but `first q[1]`). A select's where, by and columns clauses can name *other* tables, and those +executed unchecked - so a user granted any single table could read any other: + +```q +/ alice is granted `open and has NO grant on `secret +select p:first secret`pin from open / TorQ: returns 1234. here: refused +select p:count secret from open / TorQ: returns the row count. here: refused +select from open where id in exec id from secret / TorQ: executes. here: refused +``` + +This module checks every readable object named anywhere in the where, by and columns clauses, and +refuses the query unless the user has read access to each. `allowed` applies the same check, so it +agrees with `requ` rather than permitting a query `requ` would refuse. + +**The predicate is `rbac.isdefinedvar` - the same one a bare reference and a lambda expression use.** +That is the point: all three paths agree on what counts as a readable object, so an object cannot be +readable through a select clause while a bare reference to it is refused. An earlier revision checked +only *table*-valued symbols, which left exactly that inconsistency: + +```q +.pt.secretvec / refused +select p:first .pt.secretvec from .pt.trade / returned its contents +``` + +**The target table's own column names are removed first.** Inside a select, a symbol matching a column +denotes that column, not a same-named global - so checking it would deny ordinary queries. Without the +exclusion, `select id,v from open` is refused on any process that also happens to define globals `id` +or `v`; with it, that query is clean and an ungranted global of a colliding name still cannot be read +(the suite asserts both, via a `zz` column and a `zz` root global). + +## Handler registration + +| Event | Phase | Behaviour | +|---|---|---| +| `.z.pw` | `exec` | authenticate; dispatches on the user's `authtype` | +| `.z.pg` | `exec` | permission check, read-only selection, maxsize | +| `.z.ps` | `exec` | as `.z.pg`, plus the ignore-list bypass | +| `.z.pi` | `exec` | console input, console-formatted results | +| `.z.pp` | `exec` | HTTP POST refused outright | +| `.z.ws` | `exec` | websocket messages refused outright | +| `.z.pc` | `` ` `` | simple observer - anonymous user cleanup | +| `.h.val` | - | assigned directly; **not** a `.z.*` event | + +All registrations use the stable name `` `di.permissions ``, so re-init reclaims rather than collides. + +**`.z.ph` is deliberately not claimed.** HTTP GET permissioning happens via `.h.val` on kdb+ 3.5+, +which is what `permissions.q` itself does; claiming `.z.ph`'s `exec` would replace KDB-X's built-in +HTTP handler wholesale, including its response formatting. + +`exec` ownership is not a preference. `di.handlers` rejects a `pre`/`post` registration when no `exec` +owner exists, and on a bare process nothing owns `.z.pg` - so a `pre`-only design cannot register at +all. It is also the only way to reproduce the three structurally different composition idioms legacy +used on `.z.pw` alone (flat replace, gate-and-call-through, AND-compose) within a single-owner model. + +--- + +## Root-name publication + +`use` mangles module code into a private namespace, so anything an evaluated config file must reach +has to be published at a real root name - the convention TorqX applies for `.gw.*`, `.u.upd` and +`.hdb.reload`. + +Legacy grant files (`config/permissions/*.q`) are **executable q** calling `.pm.addrole`, +`.pm.grantfunction`, `.pm.ALL` and so on at root. `init` therefore publishes eight names - the seven +grant-script functions plus `ALL` - under `.pm`, so a legacy grant file loads unmodified: + +``` +.pm.ALL .pm.adduser .pm.addgroup .pm.addrole +.pm.addtogroup .pm.assignrole .pm.grantaccess .pm.grantfunction +``` + +Published **permanently during `init`**, but **only when `enabled`**, and removed by `teardown`. + +> This last point diverges from TorQ, which defines `.pm.*` regardless of `enabled`. It is safe +> because `gateway.q` guards on existence (`` `.pm.valp ~ key `.pm.valp ``) and falls back cleanly, and +> it is better: a disabled permissions module should not advertise admin functions that gate nothing. + +The query API (`requ`, `allowed`, `val`, `valp`, `execas`) is **not** published at root - `di.gateway` +holds a module handle and calls it in-process. + +--- + +## Engine scope + +TorQ ships two independent authorization systems. Only `permissions.q`'s **RBAC** engine is ported. +`controlaccess.q`'s tiered model (superuser/poweruser/defaultuser, host allowlisting, per-user token +lists) is deferred, and `` engine:`tiered `` is rejected with a clear message. + +The evidence: **nothing outside `controlaccess.q` references `.access.*` anywhere in TorQ**, against +`.pm`'s three real external callers (`gateway.q`, `execas.q`, `apidetails.q`). Both engines ship +disabled. The `engine` config key exists from v1 so the tiered engine can land later without reshaping +this schema. + +--- + +## Usage Example + +```q +/ log dep must already match the binary {[c;m]} contract - write your own, or use di.log: +/ logging:use`di.log +/ logdep:logging.logdict +logdep:`info`warn`error!({[c;m]};{[c;m]};{[c;m]}) + +/ di.handlers is INJECTED, not imported - build its dep dict and hand it over +perms:use`di.permissions +handlers:use`di.handlers +handlers.init[enlist[`log]!enlist logdep]; +handlersdep:`register`remove`list!(handlers.register;handlers.remove;handlers.list); + +perms.init[(`log`handlers!(logdep;handlersdep)),enlist[`enabled]!enlist 1b]; + +/ set up a reader who may select from trade and nothing else +trade:([]sym:`a`a`b;px:1 2 3.0); +secret:([]pin:1234 5678); +perms.admin.addrole[`reader;"may select"]; +perms.admin.grantfunction[`select;`reader;{1b}]; +perms.admin.addgroup[`traders;"trading desk"]; +perms.admin.grantaccess[`trade;`traders;`read]; +perms.admin.adduser[`alice;`local;`md5;md5 "secret"]; +perms.admin.assignrole[`alice;`reader]; +perms.admin.addtogroup[`alice;`traders]; + +/ dry run - allowed never throws, it returns a verdict +perms.allowed[`alice;"select from trade"] / 1b +perms.allowed[`alice;"select from secret"] / 0b + +/ execute - requ returns the result, or RAISES on a refusal +perms.requ[`alice;"select from trade"] / the three rows +/ perms.requ[`alice;"select from secret"] / 'di.permissions: query: no read permission on [secret] +/ ^ left commented out: a refusal raises, which would stop this block. catch it if you want to run it: +@[perms.requ[`alice;];"select from secret";{[e] -1 "refused: ",e;}]; + +/ what is being enforced right now +perms.status[] + +/ release everything on the way out +perms.teardown[]; +``` + +From here a real process would load its grants from files rather than by hand - set `grantdirs` and +let `init` run the `default` -> `proctype` -> `procname` cascade. See +[Root-name publication](#root-name-publication) for why an unmodified TorQ grant file works unchanged. + +--- + +## Running tests + +> **Note on suite structure.** Rows share accumulated state (users, groups and grants created by +> earlier rows), so an individual assertion cannot be run in isolation and an early failure cascades - +> a property of the k4unit CSV format rather than a choice here. When debugging, read upward from the +> first failing row, not just the row itself. +> +> The suite **is** verified re-runnable: calling `moduletest` twice in one session passes both times. +> That was not true initially - grant data and LDAP cache state surviving `teardown` broke ten +> assertions on a second run, which is what surfaced the `unblock` gap documented above. + +**Unit suite** (`test.csv`) - no sockets and no native library required. Run it for the current row +and assertion counts rather than trusting a figure quoted here: +```q +k4unit:use`di.k4unit +k4unit.moduletest`di.permissions +``` + +### LDAP coverage + +`deps` accepts an **optional `ldapbind`** - a function `{[session;dict]}` returning a dict with a +`` `ReturnCode `` key. When supplied it replaces the native library outright, and the `.so` is never +resolved. This exists so the caching and lockout logic can be exercised without a directory server: + +```q +fakebind:{[sess;d] enlist[`ReturnCode]!enlist 0i} / 0i = success, anything else = failure +perms.init[(`log`handlers`ldapbind!(logdep;handlersdep;fakebind)),`enabled`ldapenabled!(1b;1b)] +``` + +This is a **`deps` injection, not a config value** - deps are process wiring code the module already +trusts completely (the injected `log` and `handlers` could subvert it just as thoroughly), so it adds +no trust boundary that did not already exist. Operator-editable settings files cannot reach it. + +Now covered by the suite: a successful bind; the skip-the-server path (a repeat login inside +`ldapchecktime` with the same password does **not** reach the server, verified by counting calls); a +different password going back to the server; failed attempts accumulating to `ldapchecklimit` and +locking the user out; a locked-out user being refused **without** reaching the server; `unblock` +clearing a lockout; `ldapblocktime` expiry releasing one without an explicit unblock; and a bind that +throws failing closed rather than propagating. + +The bind's **return shape is validated**: it must be a dictionary containing a `` `ReturnCode `` key. +This is a safety check, not tidiness - `result[\`ReturnCode]` on an integer is *handle apply* in q, so +a bind mistakenly returning `42` would attempt an IPC write to file descriptor 42. Anything of the +wrong shape now fails closed with a clear message. + +> **NB Security note on the cache.** After a successful bind, a repeat login by the same user with the +> same password inside `ldapchecktime` (default 5 minutes) is served **from the cache without +> contacting the server**. That is TorQ's behaviour and it is deliberate - it exists to spare the +> directory server load - but it means **revoking an account server-side is not effective until the +> window elapses**. Set `ldapchecktime` to `0D00:00` to disable the optimisation and force every login +> to the server. + +**The native path has since been verified against a real `kdbldap.so`** found on this machine: all +four symbols bind at the arities this module uses (`kdbldap_init`/2, `kdbldap_set_option`/3, +`kdbldap_bind_s`/4, `kdbldap_err2string`/1), `initialise` opens a session, a real `kdbldap_bind_s` +executes, and its failure is decoded by the native `err2string` into +`"Can't contact LDAP server"`. The lockout bookkeeping was driven by those genuine failures - three +attempts, then lockout, then refusal without contacting the server. + +Still not covered, and genuinely needing a live directory: a **successful** bind, and +directory-specific behaviour such as referrals or TLS negotiation. + +**Integration suite** (`test_integration.csv`) - stands up a real child q process on an OS-assigned +port and drives a real connection. It exists because **`reval`'s read-only restriction is not applied +when `.z.w=0`**: at the console `reval parse "g::1"` happily sets `g`, but over a real handle the same +call throws `'noupdate`. k4unit runs in-process at handle 0, so a unit test asserting a blocked write +would fail against correct code. + +> **NB The invocation below is a workaround, not the supported interface.** `di.k4unit.moduletest` +> hardcodes the filename `test.csv` (`di/k4unit/init.q:9`) and its `KUltf`/`KUrt` primitives are not +> exported, so there is **no supported way to run a second suite in a module**. The lines below reach +> into `di.k4unit`'s private, loader-mangled namespace and will break if that mangling changes. +> **Upstream ask: a `moduletest[module;filename]` overload on `di.k4unit`**, after which this reduces +> to ``k4unit.moduletest[`di.permissions;`test_integration.csv]``. + +```q +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[] +``` +Run it in a fresh session - running it after `moduletest` re-runs the still-loaded unit tests against +dirty module state. + +> **What this suite does and does not prove.** It verifies read-only enforcement and parse-tree +> handling over a **real child process and a real IPC handle** - the things that cannot be tested +> in-process, because `reval` does not enforce at `.z.w=0`. +> +> It wires the **real `di.handlers`**, which ships alongside this module on the same branch, so it +> does exercise real phase and `exec`-ownership dispatch - not a stand-in. A minimal inline stand-in +> (a bare `set[ev;f]`) exists only as a fallback for a `QPATH` that lacks `di.handlers`. +> +> Which path ran is **asserted, not assumed**: the suite requires the child to report +> `realhandlers` as `1b`, so a silent fall-through to the stand-in **fails** the suite rather than +> passing quietly. That assertion exists because the "prefer real `di.handlers`" branch was silently +> dead for weeks - `h.init` on a function-local throws `'h.init` (module dot-sugar resolves only +> against a *global* name), and the protected apply swallowed it, so the stand-in always ran while +> the suite reported green. If you run this on a `QPATH` without `di.handlers`, expect that one row +> to fail; that is the point of it. + +--- + +## Migrating from `.pm` / `.access` + +- `.pm.allowed`, `.pm.requ`, `.pm.val`, `.pm.valp`, `.pm.execas` -> call through the module handle. + `val`/`valp` keep the same *shape* (unary functions), so a consumer that copies the function value + is unaffected; only the read-only decision moved from load time to call time. +- `.pm.cando` is **dropped** - it had no callers anywhere and differed from `allowed` only by parsing + first, which `allowed` now does itself. +- Grant scripts need **no change** - the names they call are republished at root. +- `.access.*` has no equivalent; see [Engine scope](#engine-scope). +- The `-public` command-line flag is replaced by the `public` config key. +- **Rejection messages changed prefix.** TorQ emits `"pm: no read permission on [x]"`; this module + emits `"di.permissions: : no read permission on [x]"`. Any log scraping or alerting that + greps for `pm:` needs updating - the new prefix does not contain it. +- **`.pm.*` at root is a compatibility shim, not the API.** The eight published names exist so legacy + grant files load unmodified. New code should hold a module handle and call `perms.admin.*`; the root + namespace looks like legacy TorQ but is a strict subset of it. + +--- + +## Notes + +- `init` must be called before any other function; every other export checks and errors clearly. +- All errors raised after `init` are logged at `error` before being signalled. +- Console input (`.z.pi`) routes through the same permission check as a network query - being local is + not an exemption. The `.z.w=0` bypass inside the query path is what keeps the console usable. +- `.z.pp` and `.z.ws` are refused outright rather than permission-checked, matching TorQ. +- Group membership is chased to a fixed point for *authorization* checks, so nested groups work. +- **Public-user detection deliberately does not do that.** It keeps TorQ's first-row lookup + (`` `public~(1!usergroup)[u]`groupname ``), which is correct by construction - an anonymous user is + provisioned into exactly one group. Generalising it to a full membership check would be a privilege + change rather than a fix: a real user who is in `public` alongside other groups would then be + rejected when presenting a valid password, or, with an empty password, have their user row upserted + over and their role demoted to `publicuser`. The narrower check is the safer contract. +- LDAP is entirely optional: with `ldapenabled:0b` (the default, matching TorQ's shipped settings) the + native library is never resolved and the whole test suite runs with no `.so` present. +- **Config values are type-checked at `init`.** A mistyped setting (`maxsize:"big"`, `readonly:"yes"`, + a non-timespan `ldapblocktime`) is rejected immediately, naming every offending key, rather than + surfacing later as a confusing runtime error far from its cause. +- **A failed `init` installs nothing.** The one step that depends on external state - resolving the + LDAP native library - runs before anything is published or registered, so a missing `.so` leaves the + process untouched rather than half-configured. +- **A grant made against a virtual table's *name* survives `removevirtualtable`.** The two are + independent objects, so `allowed` will still permit the name until the grant is revoked separately; + execution then fails because the name no longer resolves. Revoke the grant as well as removing the + view. +- `grantdirs` accepts a single directory as a bare string or a list of directories. `ignorelist` is a + mixed list, matching how message heads arrive. diff --git a/di/permissions/permissions.q b/di/permissions/permissions.q new file mode 100644 index 00000000..b50ab2f9 --- /dev/null +++ b/di/permissions/permissions.q @@ -0,0 +1,1078 @@ +/ role-based access control and authentication. ported from TorQ's permissions.q, writeaccess.q, +/ ldap.q and common/execas.q - see permissions.md for scope, omissions and migration notes. +/ the version lives in the VERSION file and is read by init.q + +/ constants (load-time) + +/ grants against this mean "any function" / "any table", i.e. superuser. republished as .pm.ALL +wildcard:`$"*"; + +/ the admin functions a legacy grant file calls at root - publishroot exposes exactly these +grantscriptnames:`adduser`addgroup`addrole`addtogroup`assignrole`grantaccess`grantfunction; + +/ the engines this module knows about; only rbac is implemented in v1 +knownengines:`rbac`tiered; + +/ rejection messages, keyed by reason. no prefix - raiseerror composes one +err:(`symbol$())!(); +err[`func]:{"user role does not permit running function [",string[x],"]"}; +err[`selt]:{"no read permission on [",string[x],"]"}; +err[`selx]:{"unsupported select statement, superuser only"}; +err[`updt]:{"no write permission on [",string[x],"]"}; +err[`expr]:{"unsupported expression, superuser only"}; +err[`quer]:{"free text queries not permissioned for this user"}; +err[`size]:{"returned value exceeds maximum permitted size"}; + +/ schema (load-time templates - the live copies are .z.m.*, populated in init) + +userschema:([id:`symbol$()]authtype:`symbol$();hashtype:`symbol$();password:()); +groupinfoschema:([name:`symbol$()]description:()); +roleinfoschema:([name:`symbol$()]description:()); +usergroupschema:([]user:`symbol$();groupname:`symbol$()); +userroleschema:([]user:`symbol$();role:`symbol$()); +functiongroupschema:([]function:`symbol$();fgroup:`symbol$()); +accessschema:([]object:`symbol$();entity:`symbol$();level:`symbol$()); +functionschema:([]object:`symbol$();role:`symbol$();paramcheck:()); +virtualtableschema:([name:`symbol$()]table:`symbol$();whereclause:()); +publictrackschema:([name:`symbol$()]handle:`int$()); + +/ ldap login-attempt cache. TorQ's server/port columns are dropped - write-only, and fed by a +/ write that throws (it reads .ldap.server, which ldap.q never defines) +ldapcacheschema:([user:`symbol$()]pass:();time:`timestamp$(); + attempts:`long$();success:`boolean$();blocked:`boolean$()); + +/ config defaults + +/ every accepted key with its default; init warns on anything else. keys are uniquely named to +/ survive di.config's flat cascade. see permissions.md for what each one does. +/ NB ignorelist is a MIXED list (symbols and strings) - zpsignore.q matches an async message head +/ against both forms - so it cannot be a typed symbol vector +configdefaults:`enabled`engine`maxsize`runmode`permissivemode`readonly`public`ignorelist`grantdirs`proctype`procname`publishroot! + (0b;`rbac;200000000;1b;0b;0b;0b;();();`;`;1b); + +/ ldap settings; ldapenabled defaults OFF so the suite runs with no native library present. +/ every ldap setting is read from .z.m.config at call time, including by the default ldapbuilddn +ldapconfigdefaults:`ldapenabled`ldaplibpath`ldapdebug`ldapservers`ldapversion`ldapblocktime`ldapchecklimit`ldapchecktime`ldapbuilddnsuf`ldapbuilddn! + (0b;"";0b;enlist `$"ldap://localhost:0";3;0D00:30:00;3;0D00:05;"";{"uid=",string[x],",",.z.m.config`ldapbuilddnsuf}); + +/ internal helpers + +requiresym:{[ctx;nm;x] + / validate a public-api argument that must be a symbol + if[-11h<>type x; + raiseerror[ctx;nm," must be a symbol, got ",.Q.s1 type x]]; + }; + +requirestring:{[ctx;nm;x] + / validate a public-api argument that must be a string (char vector) + if[10h<>type x; + raiseerror[ctx;nm," must be a string, got ",.Q.s1 type x]]; + }; + +normdescription:{[ctx;x] + / normalise a description to a char VECTOR - load-bearing, not cosmetic. description:() is a + / general column and q collapses one to a typed vector once it holds only atoms; a 1-char + / description is a char ATOM, so it would collapse the column and the next longer one throws 'type + if[-10h=type x;:enlist x]; + if[10h<>type x; + raiseerror[ctx;"description must be a string, got ",.Q.s1 type x]]; + :x; + }; + +requireint:{[ctx;nm;x] + / validate a public-api argument that must be an integer handle + if[not type[x] in -6 -7h; + raiseerror[ctx;nm," must be an integer, got ",.Q.s1 type x]]; + }; + +requirequery:{[ctx;q] + / a query must be a string or a parse tree - not an atom, and not a bare symbol + if[type[q] within -19 -1h; + raiseerror[ctx;"query must be a string or a parse tree, got an atom of type ",.Q.s1 type q]]; + }; + +raiseerror:{[ctx;msg] + / log an error under ctx then signal it, so a failure is observable in the log and not only as a throw + .z.m.logerr[ctx;msg]; + '"di.permissions: ",string[ctx],": ",msg; + }; + +initialised:{[] + / has init run? a direct (module-rewritten) reference detects prior setup without touching root + :@[{.z.m.enabled;1b};::;0b]; + }; + +requireinit:{[ctx] + / every exported function except init depends on init having wired the logger and the tables + if[not initialised[]; + '"di.permissions: ",string[ctx],": init must be called before any other function"]; + }; + +/ init + +/ the keys of the single init dict that are dependencies rather than config - everything else in +/ that dict is a config setting. no config key shares a name with one of these +depkeys:`log`handlers`ldapbind; + +validatedeps:{[deps] + / log and handlers are both required and never defaulted - there is no fallback logger + if[99h<>type deps; + '"di.permissions: deps must be a dict with `log and `handlers keys - see di.log, di.handlers"]; + if[not all `log`handlers in key deps; + '"di.permissions: log and handlers dependencies are required; pass `log (`info`warn`error) ", + "and `handlers (`register`remove) - see di.log, di.handlers; got: ",(", " sv string key deps)]; + if[99h<>type deps`log; + '"di.permissions: log value must be a dict; pass `info`warn`error functions - see di.log"]; + if[not all `info`warn`error in key deps`log; + '"di.permissions: log dict must have `info`warn`error keys; got: ",(", " sv string key deps`log)]; + if[99h<>type deps`handlers; + '"di.permissions: handlers value must be a dict; pass `register`remove functions - see di.handlers"]; + / only register/remove are required - this module calls no others. unrelated to di.depcheck's + / handlers contract, which checks the PROVIDER's export dict, not a consumer's deps dict + if[not all `register`remove in key deps`handlers; + '"di.permissions: handlers dict must have `register`remove keys; got: ",(", " sv string key deps`handlers)]; + / ldapbind is OPTIONAL - when supplied it replaces the native bind entirely (see ldap.bind) + if[`ldapbind in key deps; + if[not type[deps`ldapbind] within 100 112h; + '"di.permissions: ldapbind must be a function taking (session;dict) and returning a dict with a `ReturnCode key"]]; + }; + +resolveconfig:{[deps] + / take the config half of the single init dict and merge it over the known-key defaults, warning + / about anything unrecognised rather than dropping it silently. depkeys are dependencies, not + / config, so they are dropped before the unknown-key check and never reach .z.m.config + defaults:configdefaults,ldapconfigdefaults; + config:(key[deps] except depkeys)#deps; + if[count unknown:(key config) except key defaults; + .z.m.logwarn[`init;"ignoring unrecognised config key(s): ",", " sv string unknown]]; + :defaults,(key[defaults] inter key config)#config; + }; + +/ expected value shapes, grouped by check. engine, ignorelist and grantdirs are deliberately +/ absent - engine gets a better message from validateengine, the other two are normalised +boolconfigkeys:`enabled`readonly`permissivemode`runmode`public`ldapenabled`publishroot`ldapdebug; +intconfigkeys:`maxsize`ldapversion`ldapchecklimit; +symconfigkeys:`proctype`procname; +strconfigkeys:`ldaplibpath`ldapbuilddnsuf; +spanconfigkeys:`ldapblocktime`ldapchecktime; + +validateconfig:{[cfg] + / type-check every setting whose shape is fixed, reporting all offenders of a kind at once + / NB the parameter is `ks`, NOT `keys` - a `keys` parameter throws 'nyi at CALL time, and + / (`keys in .Q.res) is 0b, so .Q.res will not warn you + chk:{[cfg;ks;ok;what] + bad:ks where not ok each cfg ks; + if[count bad; + raiseerror[`init;"config key(s) ",(", " sv string bad)," must be ",what]]; + }; + chk[cfg;boolconfigkeys;{-1h=type x};"a boolean (1b or 0b)"]; + chk[cfg;intconfigkeys;{type[x] in -6 -7h};"an integer"]; + chk[cfg;symconfigkeys;{-11h=type x};"a symbol"]; + chk[cfg;strconfigkeys;{10h=type x};"a string"]; + chk[cfg;spanconfigkeys;{-16h=type x};"a timespan (e.g. 0D00:30:00)"]; + chk[cfg;enlist`ldapbuilddn;{type[x] within 100 112h};"a function taking a username"]; + chk[cfg;enlist`ldapservers;{11h=abs type x};"a symbol or symbol list"]; + }; + +validateengine:{[eng] + / v1 implements rbac only; the key ships now so tiered can land without reshaping the schema + if[not -11h=type eng; + raiseerror[`init;"engine must be a symbol, one of: ",", " sv string knownengines]]; + if[eng~`tiered; + raiseerror[`init;"engine `tiered (TorQ controlaccess.q) is not implemented in this version; use `rbac"]]; + if[not eng in knownengines; + raiseerror[`init;"unknown engine ",string[eng],"; expected one of: ",", " sv string knownengines]]; + }; + +resettables:{[] + / install fresh copies of every schema - called on first init only, so a re-init preserves grants + .z.m.user:userschema; + .z.m.groupinfo:groupinfoschema; + .z.m.roleinfo:roleinfoschema; + .z.m.usergroup:usergroupschema; + .z.m.userrole:userroleschema; + .z.m.functiongroup:functiongroupschema; + .z.m.access:accessschema; + .z.m.function:functionschema; + .z.m.virtualtable:virtualtableschema; + .z.m.publictrack:publictrackschema; + .z.m.ldapcache:ldapcacheschema; + / defaulted so an injected bind (which skips the native library) still has a session to pass. + / ldapready is EXPLICIT - inferring readiness from ldapsession existing would always report ready + .z.m.ldapsession:0i; + .z.m.ldapready:0b; + }; + +init:{[deps] + / wire deps, resolve config, and (when enabled) claim the message-handling .z.* events. + / deps: ONE dict carrying `log and `handlers (required), `ldapbind (optional), and any config + / settings alongside them - the same call shape every di.* module takes, so di.torq can wire it. + / e.g. perms.init[(`log`handlers!(logdep;handlersdep)),`enabled`readonly!(1b;1b)] + / idempotent - a second call reclaims the same registrations and leaves grant data intact + validatedeps[deps]; + .z.m.loginfo:(deps`log)`info; + .z.m.logwarn:(deps`log)`warn; + .z.m.logerr:(deps`log)`error; + .z.m.register:(deps`handlers)`register; + .z.m.removehandler:(deps`handlers)`remove; + cfg:resolveconfig[deps]; + validateconfig[cfg]; + validateengine[cfg`engine]; + if[not initialised[];resettables[]]; + .z.m.config:cfg; + .z.m.enabled:cfg`enabled; + .z.m.engine:cfg`engine; + .z.m.maxsize:cfg`maxsize; + .z.m.runmode:cfg`runmode; + .z.m.permissivemode:cfg`permissivemode; + .z.m.readonly:cfg`readonly; + .z.m.public:cfg`public; + .z.m.ignorelist:cfg`ignorelist; + / set before the disabled bail below, so status[] can report it either way + .z.m.ldapbind:$[`ldapbind in key deps;deps`ldapbind;(::)]; + if[not .z.m.enabled; + .z.m.loginfo[`init;"di.permissions loaded but disabled - no handlers registered, nothing published at root"]; + :(::)]; + / resolve the native library FIRST - it is the one step that can fail on external state, so a + / missing .so leaves the process untouched rather than half-configured. an injected bind skips it + if[(cfg`ldapenabled) and (::)~.z.m.ldapbind;ldap.initialise[ldap.resolvelibpath[]]]; + / root names next: grant files call .pm.addrole etc. on their first line + $[cfg`publishroot;publishroot[]; + .z.m.loginfo[`init;"publishroot is 0b - .pm.* not exposed at root; legacy grant files will not load"]]; + / a grant file is arbitrary q and may throw - unwind root publication before rethrowing, so a + / failed init never leaves .pm.* published with nothing registered + @[loadpermissions;::;{[e] + unpublishroot[]; + .z.m.enabled:0b; + raiseerror[`init;"grant file failed to load, root names unpublished: ",e]}]; + ensurepublicscaffolding[]; + registerhandlers[]; + .z.m.loginfo[`init;"di.permissions initialised - engine ",string[.z.m.engine],", readonly ",("disabled";"enabled").z.m.readonly]; + }; + +ensurepublicscaffolding:{[] + / the anonymous path assigns `publicuser and adds to `public, and assignrole/addtogroup REFUSE an + / undefined name - so without these every anonymous login throws out of .z.pw. created only when + / ABSENT (a grant file's own definitions win) and EMPTY, so an anonymous user is fail-closed + if[not .z.m.public;:(::)]; + if[not `publicuser in key .z.m.roleinfo; + admin.addrole[`publicuser;"anonymous users - created by di.permissions, holds no grants by default"]; + .z.m.loginfo[`init;"public access is enabled and no publicuser role was defined - created an empty one"]]; + if[not `public in key .z.m.groupinfo; + admin.addgroup[`public;"anonymous users - created by di.permissions, holds no grants by default"]; + .z.m.loginfo[`init;"public access is enabled and no public group was defined - created an empty one"]]; + }; + +status:{[] + / a snapshot of what this module is currently enforcing - legacy has no equivalent introspection + requireinit[`status]; + :`enabled`engine`readonly`permissivemode`runmode`maxsize`public`publishroot`ldapenabled`ldapavailable! + (.z.m.enabled;.z.m.engine;.z.m.readonly;.z.m.permissivemode;.z.m.runmode;.z.m.maxsize;.z.m.public; + .z.m.config`publishroot; + .z.m.config`ldapenabled;$[(::)~.z.m.ldapbind;@[{.z.m.ldapready};::;0b];1b]); + }; + +/ admin api - grant data management (the admin.* dotted group) +/ what a legacy grant file calls, and what publishroot exposes at .pm.*. all idempotent +admin.wildcard:wildcard; + +admin.adduser:{[u;authtype;hashtype;password] + / register a user with an authentication method and a hashed password + requireinit[`adduser]; + requiresym[`adduser;"user id";u]; + requiresym[`adduser;"authtype";authtype]; + requiresym[`adduser;"hashtype";hashtype]; + if[u in key .z.m.groupinfo;raiseerror[`adduser;"cannot add user with same name as existing group: ",string u]]; + .z.m.user:.z.m.user upsert (u;authtype;hashtype;password); + }; + +admin.removeuser:{[u] + requireinit[`removeuser]; + requiresym[`removeuser;"user id";u]; + .z.m.user:.[.z.m.user;();_;u]; + }; + +admin.addgroup:{[n;d] + requireinit[`addgroup]; + requiresym[`addgroup;"group name";n]; + d:normdescription[`addgroup;d]; + if[n in key .z.m.user;raiseerror[`addgroup;"cannot add group with same name as existing user: ",string n]]; + .z.m.groupinfo:.z.m.groupinfo upsert (n;d); + }; + +admin.removegroup:{[n] + requireinit[`removegroup]; + requiresym[`removegroup;"group name";n]; + .z.m.groupinfo:.[.z.m.groupinfo;();_;n]; + }; + +admin.addrole:{[n;d] + requireinit[`addrole]; + requiresym[`addrole;"role name";n]; + d:normdescription[`addrole;d]; + .z.m.roleinfo:.z.m.roleinfo upsert (n;d); + }; + +admin.removerole:{[n] + requireinit[`removerole]; + requiresym[`removerole;"role name";n]; + .z.m.roleinfo:.[.z.m.roleinfo;();_;n]; + }; + +admin.addtogroup:{[u;g] + / add a user to a group, giving them the group's table-level access + requireinit[`addtogroup]; + requiresym[`addtogroup;"user";u]; + requiresym[`addtogroup;"group name";g]; + if[not g in key .z.m.groupinfo;raiseerror[`addtogroup;"no such group, call admin.addgroup first: ",string g]]; + / NB upsert, not join: `.z.m.x:.z.m.x,(...)` flattens an EMPTY table to a plain list + if[not (u;g) in .z.m.usergroup;.z.m.usergroup:.z.m.usergroup upsert (u;g)]; + }; + +admin.removefromgroup:{[u;g] + requireinit[`removefromgroup]; + requiresym[`removefromgroup;"user";u]; + requiresym[`removefromgroup;"group name";g]; + if[(u;g) in .z.m.usergroup;.z.m.usergroup:.[.z.m.usergroup;();_;.z.m.usergroup?(u;g)]]; + }; + +admin.assignrole:{[u;r] + / assign a user a role, giving them the role's function-level access + requireinit[`assignrole]; + requiresym[`assignrole;"user";u]; + requiresym[`assignrole;"role";r]; + if[not r in key .z.m.roleinfo;raiseerror[`assignrole;"no such role, call admin.addrole first: ",string r]]; + if[not (u;r) in .z.m.userrole;.z.m.userrole:.z.m.userrole upsert (u;r)]; + }; + +admin.unassignrole:{[u;r] + requireinit[`unassignrole]; + requiresym[`unassignrole;"user";u]; + requiresym[`unassignrole;"role";r]; + if[(u;r) in .z.m.userrole;.z.m.userrole:.[.z.m.userrole;();_;.z.m.userrole?(u;r)]]; + }; + +admin.addfunction:{[f;g] + / put a function into a function group, so a grant against the group covers it + requireinit[`addfunction]; + requiresym[`addfunction;"function";f]; + requiresym[`addfunction;"function group";g]; + if[not (f;g) in .z.m.functiongroup;.z.m.functiongroup:.z.m.functiongroup upsert (f;g)]; + }; + +admin.removefunction:{[f;g] + requireinit[`removefunction]; + requiresym[`removefunction;"function";f]; + requiresym[`removefunction;"function group";g]; + if[(f;g) in .z.m.functiongroup;.z.m.functiongroup:.[.z.m.functiongroup;();_;.z.m.functiongroup?(f;g)]]; + }; + +admin.grantaccess:{[o;e;l] + / grant an entity (user or group) read or write access to a table or variable + requireinit[`grantaccess]; + requiresym[`grantaccess;"object";o]; + requiresym[`grantaccess;"entity";e]; + / NB type-check BEFORE the membership test - ("read" in `read`write) throws a raw 'type + requiresym[`grantaccess;"level";l]; + if[not l in `read`write; + raiseerror[`grantaccess;"level must be `read or `write, got ",.Q.s1 l]]; + if[not (o;e;l) in .z.m.access;.z.m.access:.z.m.access upsert (o;e;l)]; + }; + +admin.revokeaccess:{[o;e;l] + requireinit[`revokeaccess]; + requiresym[`revokeaccess;"object";o]; + requiresym[`revokeaccess;"entity";e]; + requiresym[`revokeaccess;"level";l]; + if[(o;e;l) in .z.m.access;.z.m.access:.[.z.m.access;();_;.z.m.access?(o;e;l)]]; + }; + +admin.grantfunction:{[o;r;p] + / grant a role the right to call a function, gated by paramcheck p. + / p MUST be a function - a non-boolean paramcheck result is coerced to 0b, so a literal 1b would + / fail closed rather than grant access + requireinit[`grantfunction]; + if[not type[p] within 100 112h;raiseerror[`grantfunction;"paramcheck must be a function; a literal fails closed"]]; + if[not (o;r;p) in .z.m.function;.z.m.function:.z.m.function upsert (o;r;p)]; + }; + +admin.revokefunction:{[o;r] + requireinit[`revokefunction]; + requiresym[`revokefunction;"object";o]; + requiresym[`revokefunction;"role";r]; + t:`object`role#.z.m.function; + if[(o;r) in t;.z.m.function:.[.z.m.function;();_;t?(o;r)]]; + }; + +admin.createvirtualtable:{[n;t;w] + / expose a named view of a table with an implicit where-clause spliced into any select against it + requireinit[`createvirtualtable]; + requiresym[`createvirtualtable;"name";n]; + requiresym[`createvirtualtable;"table";t]; + if[not n in key .z.m.virtualtable;.z.m.virtualtable:.z.m.virtualtable upsert (n;t;w)]; + }; + +admin.removevirtualtable:{[n] + requireinit[`removevirtualtable]; + requiresym[`removevirtualtable;"name";n]; + if[n in key .z.m.virtualtable;.z.m.virtualtable:.[.z.m.virtualtable;();_;n]]; + }; + +admin.addpublic:{[u;w] + / track an auto-provisioned anonymous user against the handle that created it + requireinit[`addpublic]; + requiresym[`addpublic;"user";u]; + requireint[`addpublic;"handle";w]; + .z.m.publictrack:.z.m.publictrack upsert (u;w); + }; + +admin.removepublic:{[u] + requireinit[`removepublic]; + requiresym[`removepublic;"user";u]; + .z.m.publictrack:.[.z.m.publictrack;();_;u]; + }; + +admin.cloneuser:{[u;unew;p] + / copy a user's auth method plus group and role membership onto a new user id + requireinit[`cloneuser]; + requiresym[`cloneuser;"source user";u]; + requiresym[`cloneuser;"new user id";unew]; + requirestring[`cloneuser;"password";p]; + if[not u in key .z.m.user;raiseerror[`cloneuser;"no such user to clone: ",string u]]; + ul:raze exec authtype,hashtype from .z.m.user where id=u; + / NB hash directly - TorQ builds and EVALUATES a string here, which throws on a password + / containing a space or a backtick, and evaluates caller-supplied text in an auth path + if[not `md5~ul 1; + raiseerror[`cloneuser;"cannot clone user with unsupported hashtype ",string[ul 1],"; only md5 is supported"]]; + admin.adduser[unew;ul 0;ul 1;md5 p]; + admin.addtogroup[unew;] each exec groupname from .z.m.usergroup where user=u; + admin.assignrole[unew;] each exec role from .z.m.userrole where user=u; + }; + +/ rbac engine - permission checks + +rbac.pdict:{[f;a] + / build a parameter-name -> value dict for a call, so a paramcheck can inspect arguments by name + / handles bare calls, select, and projections (rebuilding the full argument list from the + / projection's captured args plus the new ones) + d:enlist[`]!enlist[::]; + d:d,$[not ca:count a; (); + f~`select; (); + (1=count a) and (99h=type first a); first a; + 104h=type value f; [fnfp:value value f; (value[fnfp 0][1])!fnfp[1],a]; + 101h<>type fp:value[value[f]][1]; fp!a; + ((),(`$string til ca))!a + ]; + :d; + }; + +rbac.fchk:{[u;f;a] + / may user u call function f with args a? + / any one satisfied paramcheck is sufficient - a wildcard (superuser) grant therefore trumps a + / failed paramcheck from another role + r:exec role from .z.m.userrole where user=u; + o:wildcard,f,exec fgroup from .z.m.functiongroup where function=f; + c:exec paramcheck from .z.m.function where (object in o),role in r; + k:@[;rbac.pdict[f;a];::] each c; + k:`boolean$@[k;where not -1h=type each k;:;0b]; + :max k; + }; + +rbac.achk:{[u;t;rw;pr] + / may user u read/write table t? pr is permissive mode - an object with no grants at all is allowed + if[rbac.fchk[u;wildcard;()]; :1b]; + if[pr and not t in key 1!.z.m.access; :1b]; + t:wildcard,t; + / groups can contain groups - chase membership to a fixed point + g:raze over (exec groupname by user from .z.m.usergroup)\[u]; + :exec 0=5; + }; + +rbac.xdq:{[x] + / is the head of this expression a .q keyword? + :first[x] in .q; + }; + +rbac.qexe:{[x] + / evaluate a parse tree and enforce the result-size cap + v:val x; + if[.z.m.maxsize<-22!v;raiseerror[`qexe;err[`size][]]]; + :v; + }; + +rbac.exe:{[x] + / evaluate an expression, choosing parse-tree vs string evaluation by the head's type. + / NB only operator/iterator/composition heads reach val; symbol (11h) and lambda (100h) heads fall + / through to valp, as does a string - valp handles all three + v:$[(104<>a)&100=5 in a DIFFERENT function, and on a short + / list 2_q silently yields () - nothing checked, query PERMITTED. fail loudly rather than fail open + if[5>count q; + raiseerror[`query;"malformed query tree - expected at least 5 elements, got ",string count q]]; + tgt:$[11h=abs type q 1;first q 1;`]; + refs:distinct rbac.symsin 2_q; + refs:refs except @[{cols get x};tgt;`$()]; + refs:refs where rbac.isdefinedvar each refs; + / public objects are always readable, as in lamq + refs:refs except distinct exec object from .z.m.access where entity=`public; + bad:refs where not rbac.achk[u;;`read;pr] each refs; + if[count bad; + $[b;raiseerror[`query;" | " sv err[`selt] each bad];:0b]]; + :1b; + }; + +rbac.query:{[u;q;b;pr] + / permission-check a select/update/delete-shaped query + / b: execute (1b) vs return a boolean verdict (0b). pr: permissive mode + if[not rbac.fchk[u;`select;()];$[b;raiseerror[`query;err[`quer][]];:0b]]; + / update or delete in place - needs write access on the target + if[((!)~q 0) and 11h=type q 1; + if[not rbac.achk[u;first q 1;`write;pr];$[b;raiseerror[`query;err[`updt][first q 1]];:0b]]; + if[not rbac.checkclauses[u;q;b;pr];:0b]; + :$[b;rbac.qexe q;1b]]; + / nested query - recurse into the inner select + if[rbac.isq q 1;:$[b;rbac.qexe @[q;1;rbac.expr[u]];1b]]; + / select on a named table, resolving virtual-table indirection first + if[11h=abs type q 1; + t:first q 1; + if[t in key .z.m.virtualtable; + vt:.z.m.virtualtable t; + q:@[q;1;:;vt`table]; + q:@[q;2;:;enlist first[q 2],vt`whereclause]]; + if[not rbac.achk[u;t;`read;pr];$[b;raiseerror[`query;err[`selt][t]];:0b]]; + if[not rbac.checkclauses[u;q;b;pr];:0b]; + :$[b;rbac.qexe q;1b]]; + / anything else - superuser only + if[not rbac.fchk[u;wildcard;()];$[b;raiseerror[`query;err[`selx][]];:0b]]; + :$[b;rbac.qexe q;1b]; + }; + +/ dispatch table for .q-namespace calls. the join entries substitute a permission-checking +/ evaluator into their table arguments, so nested table references still get checked +rbac.dotqd:enlist[`]!enlist{[u;e;b;pr] + if[not (rbac.fchk[u;wildcard;()] or rbac.fchk[u;`$string first e;()]);$[b;raiseerror[`dotqf;err[`expr][]];:0b]]; + :$[b;rbac.qexe e;1b]; + }; +/ NB the dry-run branch checks the join's table arguments; TorQ returned 1b unconditionally, so +/ `allowed` permitted joins `requ` then refused +rbac.dotqd[`lj`ij`pj`uj]:{[u;e;b;pr] :$[b;val @[e;1 2;rbac.expr[u]];all rbac.mainexpr[u;;0b;pr] each e 1 2]}; +rbac.dotqd[`aj`ej]:{[u;e;b;pr] :$[b;val @[e;2 3;rbac.expr[u]];all rbac.mainexpr[u;;0b;pr] each e 2 3]}; +rbac.dotqd[`wj`wj1]:{[u;e;b;pr] :$[b;val @[e;2;rbac.expr[u]];rbac.mainexpr[u;e 2;0b;pr]]}; + +rbac.dotqf:{[u;q;b;pr] + / route a .q-keyword call to its handler in the dispatch table + qf:.q?q 0; + p:$[null p:rbac.dotqd qf;rbac.dotqd`;p]; + :p[u;q;b;pr]; + }; + +/ rbac engine - lambda expressions + +rbac.flatten:{[x] + / flatten an arbitrary nested structure, keeping strings intact as single units + :raze $[10h=type x;enlist enlist x;1=count x;x;.z.s'[x]]; + }; + +rbac.str:{$[10h=type x;;string]x}'; + +rbac.isdefinedvar:{[s] + / is this symbol the name of a currently defined non-function variable at root? + / protected: an undefined name throws, which simply means "not a variable" + / NB the null-symbol guard is load-bearing: the tokeniser emits ` for whitespace and (get `) + / returns the ROOT NAMESPACE DICT (99h), which would read as a variable and deny every lambda query + if[-11h<>type s;:0b]; + if[null s;:0b]; + / a view must be permission-checked, but `get` would EVALUATE it - letting an unpermissioned + / caller trigger arbitrary computation before any check runs. recognise it by name instead + if[s in views[];:1b]; + :@[{100h>type get x};s;0b]; + }; + +rbac.lamq:{[u;e;b;pr] + / read-check every defined root variable a lambda references, reporting all failures at once. + / NB tokenises FIRST and tests only those tokens - O(tokens), not TorQ's O(all root names) + pq:`$distinct -4!raze(rbac.str rbac.flatten e),'" "; + rqt:pq where rbac.isdefinedvar each pq; + / public objects are always readable + rqt:rqt except distinct exec object from .z.m.access where entity=`public; + prohibited:rqt where not rbac.achk[u;;`read;pr] each rqt; + / a dry run reports a verdict; only a real execution raises (TorQ raises either way) + if[count prohibited; + $[b;raiseerror[`lamq;" | " sv err[`selt] each prohibited];:0b]]; + :$[b;rbac.exe e;1b]; + }; + +/ rbac engine - top-level classifier + +rbac.isvar:{[x] + / is x a symbol naming an existing non-function variable? + :$[-11h<>type x;0b;100h>type @[get;x;{[n;e] raiseerror[`isvar;err[`selt] n]}[x]]]; + }; + +rbac.mainexpr:{[u;e;b;pr] + / classify an expression and permission-check it accordingly + ie:e; + / guard the parse: client input is untrusted, and an unparseable query would otherwise escape as a + / bare q error (e.g. ' ) - unprefixed, unlogged, and with no audit trail of who sent it + e:$[10=type e;@[parse;e;{[m] raiseerror[`mainexpr;"could not parse query: ",m]}];e]; + / a bare variable reference - read check, through any virtual-table indirection + if[rbac.isvar f:first e; + if[not rbac.achk[u;f;`read;pr];$[b;raiseerror[`mainexpr;err[`selt] f];:0b]]; + :$[b;rbac.qexe $[f in key .z.m.virtualtable;exec (?;table;enlist whereclause;0b;()) from .z.m.virtualtable f;e];1b]]; + / a named function call + if[-11h=type f; + if[not rbac.fchk[u;f;1_e];$[b;raiseerror[`mainexpr;err[`func] f];:0b]]; + :$[b;rbac.exe ie;1b]]; + / select / update / delete + if[rbac.isq e;:rbac.query[u;e;b;pr]]; + / .q keyword call + if[rbac.xdq e;:rbac.dotqf[u;e;b;pr]]; + / lambda - value any dict args before razing + if[any (100 104h) in type each raze @[e;where 99h=type'[e];value];:rbac.lamq[u;ie;b;pr]]; + / unrecognised - superuser only + if[not (rbac.fchk[u;wildcard;()] or rbac.fchk[u;`$string first e;()]);$[b;raiseerror[`mainexpr;err[`expr] f];:0b]]; + :$[b;rbac.exe ie;1b]; + }; + +/ execute-and-check. reads runmode/permissivemode at CALL time, so both are tunable at runtime +rbac.expr:{[u;e] :rbac.mainexpr[u;e;.z.m.runmode;.z.m.permissivemode]}; + +/ query normalisation and the public entry points + +rbac.destringf:{[x] + :$[(s:`$x) in key `.q;.q s;s~`insert;insert;any (100h;104h)=type first f:@[parse;x;0];f;s]; + }; + +rbac.parsequery:{[q] + / normalise a string or .q-keyword-headed query into its resolved parse tree + :$[10=type q;q;10h=abs type f:first q;rbac.destringf[f],1_q;q]; + }; + +val:{[x] + / evaluate a parse tree, under reval when read-only mode is on. resolved per CALL, not at load + / time as TorQ does, so read-only is togglable without a restart. + / NB TESTING: reval does NOT enforce at .z.w=0 (the console), only over a real handle - so a unit + / test asserting a blocked write FAILS against correct code. enforcement lives in the integration suite + requireinit[`val]; + :$[.z.m.readonly;reval x;eval x]; + }; + +valp:{[x] + / evaluate a string or parse tree, under reval when read-only mode is on. + / NB parse only a STRING - it throws 'type on a list, and rbac.exe routes parse trees here, which + / is the standard h(`func;arg) IPC shape + requireinit[`valp]; + if[not .z.m.readonly;:value x]; + / NB a PARSE TREE must keep `value` semantics (head resolved, arguments NOT). handing it straight + / to `reval` uses EVAL semantics, which resolve a symbol argument to its variable: (`echo;`secret) + / would return secret's contents to a caller with no grant on it, since only the head is checked. + / applying value to the tree as a literal inside reval keeps value's semantics and reval's write ban + :$[10h=type x;reval parse x;reval (value;enlist x)]; + }; + +allowed:{[u;q] + / would user u be permitted to run q? a dry-run verdict - never executes. + / NB reads the CONFIGURED permissive mode (TorQ pins it off), so allowed and requ agree + requireinit[`allowed]; + requiresym[`allowed;"user";u]; + requirequery[`allowed;q]; + :rbac.mainexpr[u;rbac.parsequery q;0b;.z.m.permissivemode]; + }; + +requ:{[u;q] + / permission-check q as user u and execute it; passes through untouched when disabled + requireinit[`requ]; + requiresym[`requ;"user";u]; + requirequery[`requ;q]; + q:rbac.parsequery q; + :$[.z.m.enabled;rbac.expr[u;q];valp q]; + }; + +execas:{[f;u] + / run f as user u, subject to that user's permissions + requireinit[`execas]; + requiresym[`execas;"user";u]; + requirequery[`execas;f]; + :requ[u;f]; + }; + +/ ldap authentication backend (the ldap.* dotted group) +/ the native library is OPTIONAL - nothing here is touched unless ldapenabled is set + +ldap.resolvelibpath:{[] + / the native library, from the ldaplibpath setting, falling back to $KDBLIB + p:.z.m.config`ldaplibpath; + :$[0r;raiseerror[`ldap;"error initialising ldap: ",.z.m.ldaperr2string r]]; + s:.z.m.ldapsetoption[.z.m.ldapsession;`LDAP_OPT_PROTOCOL_VERSION;.z.m.config`ldapversion]; + if[0<>s;raiseerror[`ldap;"error setting ldap protocol version: ",.z.m.ldaperr2string s]]; + .z.m.ldapready:1b; + .z.m.loginfo[`ldap;"ldap initialised against ",", " sv string .z.m.config`ldapservers]; + }; + +ldap.bind:{[sess;customdict] + / thin wrapper over the native bind, merging caller overrides onto the default dn/cred/mech dict + defaultkeys:`dn`cred`mech; + if[customdict~(::);customdict:()!()]; + if[99h<>type customdict;raiseerror[`ldap;"bind overrides must be (::) or a dictionary"]]; + upddict:(defaultkeys!```),customdict; + / dispatch through the injected bind when supplied, else the native library. the injected form + / lets the caching and lockout logic be exercised without a directory server + r:$[(::)~.z.m.ldapbind;.z.m.ldapbindnative[sess;;;]. upddict defaultkeys;.z.m.ldapbind[sess;upddict]]; + / NB validate the shape: r[`ReturnCode] on an INTEGER is handle apply, so a bind returning 42 + / would attempt an IPC write to file descriptor 42. fail closed instead + if[99h<>type r; + raiseerror[`ldap;"bind returned a ",(.Q.s1 type r)," - expected a dictionary with a `ReturnCode key"]]; + if[not `ReturnCode in key r; + raiseerror[`ldap;"bind returned a dictionary with no `ReturnCode key; got: ",(", " sv string key r)]]; + :r; + }; + +ldap.errstring:{[rc] + / describe a bind return code, falling back to the raw code when no native library is loaded + :$[(::)~.z.m.ldapbind;.z.m.ldaperr2string rc;"return code ",string rc]; + }; + +ldap.blocked:{[usr;incache] + / is this user currently locked out? clears an expired lockout as a side effect + / a null blocktime means the lockout never expires + if[not incache`blocked;:0b]; + if[null .z.m.config`ldapblocktime; + ldap.debuglog["authentication attempts for user ",(string usr)," are blocked"]; + :1b]; + bt:incache[`time]+.z.m.config`ldapblocktime; + if[.z.p.z.p-.z.m.config`ldapchecktime;incache[`pass]~np); + enlist[`ReturnCode]!enlist 0i; + .[ldap.bind;(.z.m.ldapsession;`dn`cred!(dn;pass));enlist[`ReturnCode]!enlist -2i]]; + ok:authorised[`ReturnCode]~0i; + .z.m.ldapcache:.z.m.ldapcache upsert (usr;np;.z.p;$[ok;0;1+0^incache`attempts];ok;0b); + $[ok; + ldap.debuglog["successfully authenticated user ",dn]; + ldap.debuglog["failed to authenticate user ",dn,": ",ldap.errstring authorised`ReturnCode]]; + if[(.z.m.config`ldapchecklimit)<=.z.m.ldapcache[usr]`attempts; + .z.m.ldapcache:update blocked:1b from .z.m.ldapcache where user=usr; + .z.m.logwarn[`ldap;"attempt limit reached, user ",dn," has been locked out"]]; + :ok; + }; + +unblock:{[usr] + / clear a user's ldap lockout - the admin escape hatch + requireinit[`unblock]; + requiresym[`unblock;"user";usr]; + if[not usr in key .z.m.ldapcache; + .z.m.loginfo[`unblock;"no ldap login record for user ",string usr]; + :(::)]; + / clear the whole cached state, not only a lockout - clearing `success` means the next login + / always reaches the server, which is what makes this usable after a password change + wasblocked:.z.m.ldapcache[usr]`blocked; + .z.m.ldapcache:update attempts:0,success:0b,blocked:0b from .z.m.ldapcache where user=usr; + .z.m.loginfo[`unblock;$[wasblocked;"unblocked user ";"cleared cached ldap state for user "],string usr]; + }; + +/ authentication backends (the auth.* dotted group) +/ one function per authtype, so a user row's authtype selects its backend + +auth.local:{[u;p] + / compare a hashed password against the stored hash. md5 is the only hashtype TorQ supports + ud:.z.m.user u; + :$[`md5~ud`hashtype;(md5 p)~ud`password;0b]; + }; + +auth.ldap:{[u;p] + / delegate to the ldap backend, which is a no-op returning 0b when ldap is disabled + :$[.z.m.config`ldapenabled;ldap.login[u;p];0b]; + }; + +/ connection lifecycle - the bodies di.handlers registers + +authenticate:{[u;p] + / the .z.pw body: authenticate a connecting user, optionally auto-provisioning an anonymous one. + / anonymous access is gated by the `public` config key, replacing TorQ's -public command-line read + requireinit[`authenticate]; + / NB FIRST-ROW lookup is deliberate, not a bug. correct by construction - the branch below puts an + / anonymous user in exactly one group - and a full membership check would be a privilege CHANGE: + / a real user also in `public would be rejected with a valid password, or have their row upserted + / over and role demoted. that is an account-takeover path. see permissions.md + known:u in key .z.m.user; + ingrouppublic:`public~(1!.z.m.usergroup)[u]`groupname; + if[(not known) or ingrouppublic; + if[not .z.m.public; + .z.m.logwarn[`authenticate;"rejected unknown user ",(string u)," (public access disabled)"]; + :0b]; + if[not ""~p; + .z.m.logwarn[`authenticate;"rejected anonymous login for ",(string u)," (a password was supplied)"]; + :0b]; + admin.adduser[u;`local;`md5;md5 p]; + admin.assignrole[u;`publicuser]; + admin.addtogroup[u;`public]; + admin.addpublic[u;.z.w]; + .z.m.loginfo[`authenticate;"provisioned anonymous user ",string u]; + :1b]; + ud:.z.m.user u; + if[not ud[`authtype] in key auth; + .z.m.logwarn[`authenticate;"rejected ",(string u),": unknown authtype ",string ud`authtype]; + :0b]; + / log both outcomes - a warn-only trail cannot answer "who connected" + ok:auth[ud`authtype][u;p]; + $[ok;.z.m.loginfo[`authenticate;"authenticated user ",string u]; + .z.m.logwarn[`authenticate;"failed authentication for user ",string u]]; + :ok; + }; + +droppublic:{[w] + / the .z.pc body: tear down an auto-provisioned anonymous user when its connection closes + requireinit[`droppublic]; + if[not .z.m.public;:(::)]; + tracked:exec name from .z.m.publictrack where handle=w; + if[0=count tracked;:(::)]; + u:first tracked; + admin.removeuser[u]; + admin.unassignrole[u;`publicuser]; + admin.removefromgroup[u;`public]; + admin.removepublic[u]; + .z.m.loginfo[`droppublic;"dropped anonymous user ",string u]; + }; + +/ handler bodies - registered with di.handlers, never exported +/ passed to register BY VALUE, so they need no public name + +hooks.sync:{[x] + / .z.pg exec: permission-check and run a synchronous message + / handle 0 (the console / this process itself) bypasses checking entirely, as in TorQ + :$[.z.w=0;value x;requ[.z.u;x]]; + }; + +hooks.async:{[x] + / .z.ps exec: as hooks.sync, but first honouring the ignore-list (zpsignore.q, folded inline - + / di.handlers has no skip-exec path, so the bypass cannot be a phase). .z.ps ONLY, as in TorQ + if[any first[x]~/:.z.m.ignorelist;:value x]; + :$[.z.w=0;value x;requ[.z.u;x]]; + }; + +hooks.console:{[x] + / .z.pi exec: console input. blank lines skip the check; results are console-formatted. + / console input IS permission-checked - the .z.w=0 bypass in hooks.sync is what keeps it usable + :$[x in (1#"\n";"");.Q.s value x;.Q.s $[.z.w=0;value x;requ[.z.u;x]]]; + }; + +hooks.rejectpost:{[x] + / .z.pp exec: TorQ disables HTTP POST outright when permissions are on - not a check, a refusal + raiseerror[`http;"HTTP POST requests are not permitted"]; + }; + +hooks.rejectws:{[x] + / .z.ws exec: TorQ disables websocket messages outright when permissions are on + raiseerror[`websocket;"websocket access is not permitted"]; + }; + +/ event -> exec body. .z.pw is binary, the rest unary; .z.pc is a simple observer, registered separately +execbodies:`.z.pw`.z.pg`.z.ps`.z.pi`.z.pp`.z.ws! + (authenticate;hooks.sync;hooks.async;hooks.console;hooks.rejectpost;hooks.rejectws); + +/ root-name publication +/ `use` mangles module code into a private namespace, so anything an evaluated grant file must +/ reach has to be published at a real root name - legacy files call .pm.addrole etc on line one. +/ published during init but ONLY when enabled, unlike TorQ; safe because gateway.q guards on +/ existence. see permissions.md, "Root-name publication" + +publishroot:{[] + / expose the wildcard constant and the grant-script admin functions at .pm.* + set[`.pm.ALL;wildcard]; + {[n] set[` sv `.pm,n;admin n]} each grantscriptnames; + .z.m.loginfo[`publishroot;"published ",(string 1+count grantscriptnames)," names under .pm for legacy grant scripts"]; + }; + +unpublishroot:{[] + / remove every name publishroot created, leaving no root residue behind + present:(key `.pm) inter `ALL,grantscriptnames; + if[count present;![`.pm;();0b;present]]; + }; + +/ grant data loading + +loadgrantfile:{[path] + / load one grant file at ROOT (not via `use`) so its .pm.* calls resolve against the published names + if[()~key hsym `$path; + .z.m.loginfo[`loadpermissions;"grant file not found, skipping: ",path]; + :(::)]; + .z.m.loginfo[`loadpermissions;"loading grant file ",path]; + system "l ",path; + }; + +loadpermissions:{[] + / load the grant cascade: default -> proctype -> procname, under each configured directory + requireinit[`loadpermissions]; + / NB normalise: a bare string is ONE directory - (),"path" would make each char a directory + dirs:.z.m.config`grantdirs; + dirs:$[10h=type dirs;enlist dirs;(),dirs]; + if[0=count dirs; + .z.m.loginfo[`loadpermissions;"no grantdirs configured, nothing to load"]; + :(::)]; + names:`default,(.z.m.config`proctype),.z.m.config`procname; + names:names where not null names; + / NB nested each, NOT cross - cross joins with `,`, concatenating the path STRING with the + / symbol instead of pairing them, giving a mixed list that rank-errors on dot-apply + {[nms;d] {[d;n] loadgrantfile[d,"/",(string n),".q"]}[d;] each nms}[names;] each dirs; + }; + +/ registration and teardown + +registerhandlers:{[] + / claim the exec phase of every message-handling event, plus a .z.pc observer for cleanup. + / a stable name means a re-init reclaims the same events rather than colliding + {[e] .z.m.register[e;`exec;`di.permissions;0;execbodies e]} each key execbodies; + / priority 0 - lower runs first, so this cleanup precedes any other observer, matching TorQ's + / {droppublic[y];@[x;y]} order. TorqX registers gateway bookkeeping at 10, behind this + .z.m.register[`.z.pc;`;`di.permissions;0;droppublic]; + / .h.val is where HTTP GET permissioning happens on kdb+ 3.5+. not a .z.* event, so di.handlers + / would reject it - assign directly. .z.ph is NOT claimed: an exec owner there replaces the + / built-in handler wholesale. + / NB capture the original ONCE - init is idempotent, so an unguarded capture on a second init + / would record our own hooks.sync as the "original" + if[not @[{.z.m.hvaloriginal;1b};::;0b];.z.m.hvaloriginal:@[get;`.h.val;{(::)}]]; + set[`.h.val;hooks.sync]; + .z.m.loginfo[`init;"registered exec on ",(", " sv string key execbodies),", observer on .z.pc, and .h.val"]; + }; + +teardown:{[] + / release everything init installed: handler registrations, .h.val, and the published root names + / leaves grant data intact, so a subsequent init re-registers and re-publishes cleanly + requireinit[`teardown]; + if[not .z.m.enabled; + .z.m.loginfo[`teardown;"di.permissions is disabled, nothing to release"]; + :(::)]; + / NB dot-apply, not @ - `@[f;(a;b;c);h]` passes the LIST as one argument, rank-errors into the + / handler, and every removal silently "succeeds" + {[e] .[.z.m.removehandler;(e;`exec;`di.permissions);{[e2] .z.m.logwarn[`teardown;"could not remove exec handler: ",e2]}]} each key execbodies; + .[.z.m.removehandler;(`.z.pc;`;`di.permissions);{[e2] .z.m.logwarn[`teardown;"could not remove .z.pc observer: ",e2]}]; + $[(::)~.z.m.hvaloriginal;@[{![`.h;();0b;enlist`val];};::;{[e2]}];set[`.h.val;.z.m.hvaloriginal]]; + unpublishroot[]; + .z.m.enabled:0b; + .z.m.loginfo[`teardown;"di.permissions released - handlers, .h.val and .pm.* root names removed"]; + }; + +/ api metadata + +getapimeta:{[] + / one row per CALLABLE export, for di.torq to register with di.api. init and getapimeta are + / omitted as plumbing; teardown is a real lifecycle operation and gets a row. names are bare + :flip `name`public`descrip`params`return!flip( + (`teardown; 1b; "release handler registrations, .h.val and the published .pm.* root names"; + "[]"; "null"); + (`version; 1b; "module version string"; + "[]"; "string: version"); + (`status; 1b; "what this module is currently enforcing - engine, readonly, ldap state"; + "[]"; "dict: setting -> value"); + (`allowed; 1b; "would this user be permitted to run this query? never executes it"; + "[symbol: user; string|parse tree: query]"; "boolean: permitted"); + (`requ; 1b; "permission-check a query as a user and execute it"; + "[symbol: user; string|parse tree: query]"; "any: the query result"); + (`val; 1b; "evaluate a parse tree, under reval when read-only mode is on"; + "[parse tree: expression]"; "any: the result"); + (`valp; 1b; "evaluate a string or parse tree, under reval when read-only mode is on"; + "[string|parse tree: expression]"; "any: the result"); + (`execas; 1b; "run a query as another user, subject to that user's permissions"; + "[string|parse tree: query; symbol: user]"; "any: the query result"); + (`admin; 1b; "grant administration sub-api - see the admin.* rows below for members"; + "[dict of functions, keyed by name]"; "dict: the admin functions"); + (`loadpermissions; 1b; "load the grant cascade (default, proctype, procname) from grantdirs"; + "[]"; "null"); + (`unblock; 1b; "clear a user's ldap lockout"; + "[symbol: user]"; "null"); + / the admin sub-api, enumerated rather than hidden behind one opaque entry. public:0b - real + / callables in di.api's full view, kept out of the public summary, which lists `admin itself + (`admin.adduser; 0b; "register a user with an authentication method and a hashed password"; + "[symbol: user; symbol: authtype (local|ldap); symbol: hashtype (md5); string: hashed password]"; "null"); + (`admin.removeuser; 0b; "remove a user entirely"; "[symbol: user]"; "null"); + (`admin.cloneuser; 0b; "copy a user's auth method plus group and role membership onto a new id"; + "[symbol: source; symbol: new user; string: password]"; "null"); + (`admin.addgroup; 0b; "create a group, which grants table and variable access"; "[symbol: group; string: description]"; "null"); + (`admin.removegroup; 0b; "remove a group"; "[symbol: group]"; "null"); + (`admin.addtogroup; 0b; "add a user (or a group) to a group; membership is transitive"; + "[symbol: user or group; symbol: group]"; "null"); + (`admin.removefromgroup; 0b; "remove a user or group from a group"; "[symbol: user or group; symbol: group]"; "null"); + (`admin.addrole; 0b; "create a role, which grants the right to call functions"; "[symbol: role; string: description]"; "null"); + (`admin.removerole; 0b; "remove a role"; "[symbol: role]"; "null"); + (`admin.assignrole; 0b; "give a user a role"; "[symbol: user; symbol: role]"; "null"); + (`admin.unassignrole; 0b; "take a role away from a user"; "[symbol: user; symbol: role]"; "null"); + (`admin.addfunction; 0b; "put a function into a function group"; "[symbol: function; symbol: function group]"; "null"); + (`admin.removefunction; 0b; "take a function out of a function group"; "[symbol: function; symbol: function group]"; "null"); + (`admin.grantaccess; 0b; "grant an entity read or write access to a table or variable"; + "[symbol: object; symbol: entity; symbol: read or write]"; "null"); + (`admin.revokeaccess; 0b; "revoke a previously granted access"; "[symbol: object; symbol: entity; symbol: read or write]"; "null"); + (`admin.grantfunction; 0b; "grant a role the right to call a function, gated by a paramcheck"; + "[symbol: object; symbol: role; function: paramcheck]"; "null"); + (`admin.revokefunction; 0b; "revoke a function grant from a role"; "[symbol: object; symbol: role]"; "null"); + (`admin.createvirtualtable; 0b; "expose a filtered view of a table under a new name"; + "[symbol: name; symbol: table; list: where clause]"; "null"); + (`admin.removevirtualtable; 0b; "remove a virtual table"; "[symbol: name]"; "null"); + (`admin.addpublic; 0b; "track an auto provisioned anonymous user against its handle"; "[symbol: user; int: handle]"; "null"); + (`admin.removepublic; 0b; "stop tracking an anonymous user"; "[symbol: user]"; "null"); + (`admin.wildcard; 0b; "the wildcard object - grant against it for superuser rights"; "[]"; "symbol: the wildcard")); + }; diff --git a/di/permissions/test.csv b/di/permissions/test.csv new file mode 100644 index 00000000..87729263 --- /dev/null +++ b/di/permissions/test.csv @@ -0,0 +1,665 @@ +action,ms,bytes,lang,code,repeat,minver,comment +comment,,,,,,,setup - load the module with a capturing logger and a capturing handlers mock +before,0,0,q,perms:use`di.permissions,1,1,load di.permissions +before,0,0,q,.pt.cap:([]lvl:`symbol$();ctx:`symbol$();msg:()),1,1,log capture table for assertions +before,0,0,q,caplog:`info`warn`error!({[c;m] `.pt.cap insert (`info;c;m)};{[c;m] `.pt.cap insert (`warn;c;m)};{[c;m] `.pt.cap insert (`error;c;m)}),1,1,capturing binary logger {[c;m]} +before,0,0,q,.pt.reg:([]event:`symbol$();phase:`symbol$();nm:`symbol$();pri:`long$();f:()),1,1,handler registration capture table +before,0,0,q,"mockh:`register`remove`list!({[e;p;n;pr;f] `.pt.reg insert (e;p;n;pr;f)};{[e;p;n] delete from `.pt.reg where event=e,phase=p,nm=n; (::)};{[e] select from .pt.reg where event=e})",1,1,handlers mock capturing every registration +before,0,0,q,deps:`log`handlers!(caplog;mockh),1,1,the injected dependency dict + +comment,,,,,,,init - dependency validation (log and handlers are required and never defaulted) +fail,0,0,q,perms.init[(::)],1,1,init rejects a non-dict deps +fail,0,0,q,perms.init[()!()],1,1,init rejects deps missing both keys +fail,0,0,q,perms.init[enlist[`log]!enlist caplog],1,1,init rejects deps missing the handlers key +fail,0,0,q,perms.init[enlist[`handlers]!enlist mockh],1,1,init rejects deps missing the log key +fail,0,0,q,perms.init[`log`handlers!(42;mockh)],1,1,init rejects a non-dict log value +fail,0,0,q,perms.init[`log`handlers!((enlist[`info]!enlist caplog`info);mockh)],1,1,init rejects a log dict missing warn and error +fail,0,0,q,perms.init[`log`handlers!(caplog;enlist[`register]!enlist mockh`register)],1,1,init rejects a handlers dict missing remove and list +run,0,0,q,.pt.errstr:@[{perms.init[()!()]};(::);{x}],1,1,capture the error string from a bad init +true,0,0,q,".pt.errstr like ""di.permissions:*""",1,1,init errors are prefixed di.permissions: +true,0,0,q,".pt.errstr like ""*di.log*""",1,1,the error names which module supplies the missing dependency + +comment,,,,,,,init - config validation +fail,0,0,q,"perms.init[deps,enlist[`engine]!enlist `tiered]",1,1,the tiered engine is not implemented and is rejected +fail,0,0,q,"perms.init[deps,enlist[`engine]!enlist `nosuch]",1,1,an unknown engine is rejected +fail,0,0,q,"perms.init[deps,enlist[`engine]!enlist 42]",1,1,a non-symbol engine is rejected +fail,0,0,q,perms.init[42],1,1,a non-dict argument is rejected +run,0,0,q,delete from `.pt.cap,1,1,clear the log capture +run,0,0,q,"perms.init[deps,enlist[`nosuchkey]!enlist 1b]",1,1,an unrecognised config key is accepted +true,0,0,q,1=count select from .pt.cap where lvl=`warn,1,1,an unrecognised config key warns rather than being dropped silently +true,0,0,q,"any (exec msg from .pt.cap where lvl=`warn) like\: ""*nosuchkey*""",1,1,the warning names the offending key +run,0,0,q,delete from `.pt.cap,1,1,clear the log capture +run,0,0,q,"perms.init[deps,enlist[`enabled]!enlist 1b]",1,1,init with the dependency keys alongside a known config key +true,0,0,q,0=count select from .pt.cap where lvl=`warn,1,1,dependency keys are never reported as an unrecognised config key +run,0,0,q,".pt.mns0:first (key `.m.di) where (string key `.m.di) like ""*permissions*""",1,1,locate the module private namespace +run,0,0,q,".pt.cfg0:get `$"".m.di."",(string .pt.mns0),"".config""",1,1,read the resolved config back out of module state +true,0,0,q,"not any `log`handlers`ldapbind in key .pt.cfg0",1,1,dependency keys never reach the stored config +run,0,0,q,perms.teardown[],1,1,release before the next block + +comment,,,,,,,init - the single-dict call shape di.torq wires every module through +run,0,0,q,logging:use`di.log,1,1,load di.log for its ready-made logdict dependency +true,0,0,q,"(enlist`log)~key logging.logdict",1,1,logdict is a one-key dependency dict ready to pass straight to init +run,0,0,q,"perms.init[logging.logdict,`handlers`enabled!(mockh;1b)]",1,1,the single-dict shape di.torq wires each module through +true,0,0,q,0=5 before rbac.query runs so no client input can present a short tree. +comment,,,,,,,"the guard exists because 2_q on a short list yields () - refs empty, nothing checked, query" +comment,,,,,,,PERMITTED. a silent fail-open is the one direction a permission check must never take +run,0,0,q,".pt.mns:first (key `.m.di) where (string key `.m.di) like ""*permissions*""",1,1,derive this modules mangled namespace rather than hardcoding it +run,0,0,q,".pt.cc:get `$"".m.di."",(string .pt.mns),"".rbac.checkclauses""",1,1,reach the private checkclauses - there is no public path to a malformed tree +true,0,0,q,100h=type .pt.cc,1,1,resolved the internal function +fail,0,0,q,.pt.cc[`alice;(?;`t);1b;0b],1,1,a tree shorter than 5 elements RAISES rather than silently permitting +fail,0,0,q,.pt.cc[`alice;(?;`t);0b;0b],1,1,and the dry run form raises too - a malformed tree is not a permission verdict +run,0,0,q,".pt.ccerr:@[{.pt.cc[`alice;(?;`t);1b;0b]};(::);{x}]",1,1,capture the refusal text +true,0,0,q,"0 "",.pi.dir,""/child.log 2>&1 &""",1,1,spawn the child in the background +before,0,0,q,"{[f] do[50;if[not ()~key hsym `$f;:(::)];system ""sleep 0.1""]}[.pi.portfile]",1,1,wait up to 5 seconds for the child to publish its port +before,0,0,q,".pi.port:@[{""J""$first read0 hsym `$x};.pi.portfile;{[e] 0N}]",1,1,read the OS-assigned port +before,0,0,q,".pi.h:$[null .pi.port;0Ni;@[hopen;`$""::"",string .pi.port;{[e] 0Ni}]]",1,1,connect to the child - NB :: not : which would open a FILE +before,0,0,q,.pi.up:not null .pi.h,1,1,did the child come up? +before,0,0,q,".pi.childlog:@[{read0 hsym `$x};.pi.dir,""/child.log"";{[e] enlist ""no child log""}]",1,1,keep the child log so a failure to start is diagnosable +comment,,,,,,,a skip must be VISIBLE - a suite reporting 0 failures while testing nothing is worse than a failure +true,0,0,q,.pi.up,1,1,the child process started; if this fails read .pi.childlog for why +comment,,,,,,,which handlers implementation did the child actually wire? REPORTED not asserted 1b - +comment,,,,,,,di.handlers lives on feature-handlers so a checkout of this branch alone legitimately runs +comment,,,,,,,"the stand-in. the point is that it can never again be SILENT: this branch was dead for weeks" +comment,,,,,,,because h.init on a function-local throws and the protected apply swallowed it +before,0,0,q,".pi.realh:$[.pi.up;@[{.pi.h""realhandlers""};::;{[e] 0b}];0b]",1,1,ask the child which handlers path it took +true,0,0,q,.pi.realh,1,1,the child wired the REAL di.handlers - a silent fall-through to the stand-in fails here +comment,,,,,,,read only enforcement over a REAL handle - the whole reason this suite exists +before,0,0,q,".pi.read:$[.pi.up;@[{.pi.h""select from ([]a:1 2 3)""};::;{[e] `ERR}];`SKIP]",1,1,a permitted read through the child +before,0,0,q,".pi.write:$[.pi.up;@[{.pi.h""gg::42""};::;{[e] e}];""SKIP""]",1,1,a global write through the child +before,0,0,q,".pi.ggafter:$[.pi.up;@[{.pi.h""gg""};::;{[e] 0N}];0N]",1,1,read gg back to prove the write never landed +true,0,0,q,$[.pi.up;3=count .pi.read;0b],1,1,a permitted read succeeds through the real handler chain +true,0,0,q,$[.pi.up;10h=type .pi.write;0b],1,1,the write was refused - an error string came back not a result +true,0,0,q,"$[.pi.up;.pi.write like ""*noupdate*"";0b]",1,1,the refusal is revals noupdate - read only is genuinely enforced +true,0,0,q,$[.pi.up;0=.pi.ggafter;0b],1,1,gg is untouched - the write did not land +comment,,,,,,,parse tree queries under readonly - the h(`func;arg) IPC idiom over a REAL handle +comment,,,,,,,"TorQ valp did reval parse x under readonly and parse throws type on a list, so this" +comment,,,,,,,exact call - the commonest sync IPC shape - failed on any readonly process such as an hdb +before,0,0,q,".pi.tree:$[.pi.up;@[{.pi.h (`rofn;1)};::;{[e] `$""ERR: "",e}];`SKIP]",1,1,call a named function by parse tree across the handle +true,0,0,q,$[.pi.up;101~.pi.tree;0b],1,1,a symbol headed parse tree evaluates under readonly rather than throwing type +comment,,,,,,,a parse tree call must STILL be write banned - valp applies value inside reval not beside it +comment,,,,,,,this is the assertion that would catch reval being dropped from the parse tree branch +before,0,0,q,".pi.treewrite:$[.pi.up;@[{.pi.h (`wr;7)};::;{[e] e}];""SKIP""]",1,1,attempt a write through a parse tree call +before,0,0,q,".pi.ggtree:$[.pi.up;@[{.pi.h""gg""};::;{[e] 0N}];0N]",1,1,read gg back after the tree write attempt +true,0,0,q,$[.pi.up;10h=type .pi.treewrite;0b],1,1,the parse tree write was refused - an error string came back +true,0,0,q,"$[.pi.up;.pi.treewrite like ""*noupdate*"";0b]",1,1,refused with noupdate - read only still governs a parse tree call +true,0,0,q,$[.pi.up;0=.pi.ggtree;0b],1,1,gg is untouched by the parse tree write +comment,,,,,,,cleanup +before,0,0,q,"if[.pi.up;@[{.pi.h""exit 0""};::;{[e]}]]",1,1,ask the child to exit +before,0,0,q,if[.pi.up;@[hclose;.pi.h;{[e]}]],1,1,close the handle +before,0,0,q,"system ""rm -rf "",.pi.dir",1,1,remove the scratch directory