Skip to content

feat(auth): add scoped writer and decider roles, per-relation grants, and role-scoped API keys - #279

Merged
jsam merged 3 commits into
mainfrom
fm/en-en6
Oct 4, 2026
Merged

jsam merged 3 commits into
mainfrom
fm/en-en6

Conversation

@jsam

@jsam jsam commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Intent

please continue developing the input layer engine so we make sure that the symbolic engine is ready to go live and completely production ready while we develop the GoTM uh, market.

(Context: approved plan, milestone 'Engine production readiness' (GitHub milestone 7). This task is item EN-6: scoped write roles: fact-writer, decider (claims and decision relations only), per-relation grants (E-11) — why: API roles map to engine identities; a writer key cannot change policy; the decider cannot write facts)

The approved plan's substance for this item: each API role maps to an engine identity per cell. The decider's identity may write only the claim and decision relations, and a writer key may write only facts. Engine-level enforcement of this role split is a beta gate (today the only per-graph write role is editor, which may also register, edit and drop rules, delete any fact, drop relations and install ontologies, and .apikey create only issues keys acting as their admin owner). E-11 asks for a fact-writer role plus per-relation write grants (agents: claim relations such as attempt only; operators: policy facts such as kill_switch, tool_policy; deploy credential: rules and schema), so that an agent key's bypasses (adding a second rule clause, deleting the kill-switch fact, .rel drop of the kill-switch relation) are refused.

What Changed

  • Adds two KG roles, writer and decider (src/auth/access.rs). A writer writes facts in every relation, or only in its granted relations. A decider writes facts only in its granted relations. Neither role can register, edit or drop rules, declare schema, drop relations, clear a prefix, load files, change ontologies, drop the KG or manage its ACL. Every statement in a program is checked before anything commits, including both sides of an update such as a guarded claim. A refusal names the missing grant or role.
  • Adds per-relation grants with .kg acl grant <kg> <user> writer|decider relations a, b. The relation list is stored in _internal.kg_acl_relations and replaced together with the grant in one commit. .kg acl list shows the list, and with no KG argument it now authorizes and lists the current KG. Revoking the grant, dropping the KG or dropping the user removes the stored relation list.
  • Adds role-scoped API keys with .apikey create <label> [<ttl>] role <role> on <kg> [relations ...]. Such a key is limited to one KG and one role (editor, writer, decider or viewer), never acts as admin, cannot create a KG and cannot use any other KG. Its access is the meet of its scope and its owner's access. These keys are stored in scoped_api_keys with an api_key_scopes row, so older servers don't read them as unscoped keys. A key whose stored scope fails to decode isn't loaded. Dropping a KG revokes the keys scoped to it, and .apikey list gains a scope column. The authentication guide and the command and syntax references document the new roles, grants and key options.

Risk Assessment

✅ Low: The round-1 fix makes .kg acl list authorize and list the same KG (the program's exec KG), and its new test fails without the fix. Across the full change, I traced the scoped-key and writer/decider enforcement through every entry point (authorize_program, session creation, the WS read-access cache, admin-only commands, KG create/drop, persistence and the compatibility of stored rows) and found no bypass or wrong result.

Testing

I built the inputlayer-server and ran it on a disposable data dir. Over WebSocket I created writer and decider API keys with roles, plus per-relation ACL grants for password users, and then tried every policy bypass named in the intent. Each one was refused with a message naming what was missing, and the kill switch and the empty eligible result held. A mixed program committed nothing. Scoped keys could not reach other KGs, create one, or run admin commands. .kg acl list with no argument listed only the key's own KG. Scopes and grants survived a restart, and dropping the KG revoked its keys. The 8 targeted scoped_roles_tests also pass. Everything passed. The UI was not changed, so there are no screenshots. The server and its temp data were torn down afterwards.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Decider API key (relations attempt, decision) inserts and deletes attempt/decision facts on its KG ✅ pass live 02-decider-key.txt: +attempt, +decision, -attempt all succeed
Agent (decider) key bypasses refused: second rule clause, deleting or inserting kill_switch, .rel drop, -relation, .rule drop, schema, writing order; kill switch still holds ✅ pass live 02-decider-key.txt: each gets 'Permission denied: ...' naming what is missing; the admin view shows kill_switch o1 still present, eligible empty, the rule still 1 clause
Program mixing an allowed write and a forbidden write commits nothing ✅ pass live 02-decider-key.txt: '+attempt o3 / -kill_switch o1' refused, and ?attempt then returns 0 rows
Writer key writes facts in any relation but cannot register rules, drop relations or change ACLs ✅ pass live 03-writer-and-acl-grants.txt (writer key section)
Per-relation writer/decider ACL grants for password users confine writes to the granted relations; invalid relation lists rejected ✅ pass live 03-writer-and-acl-grants.txt: ops writes tool_policy/kill_switch but is refused on attempt; bot writes attempt but is refused on decision/kill_switch; 'decider' without relations and 'viewer relations…
Scoped key cannot use another KG, create a KG, or run admin commands even though its owner is admin ✅ pass live 02-decider-key.txt: .kg use other / .kg create / .user list / .apikey create are refused; connecting bound to other or default gets auth_error Access denied
Review fix: .kg acl list with no argument from a key scoped to shop lists shop, and listing other KGs is refused ✅ pass live 02-decider-key.txt: .kg acl list returns shop's entries; .kg acl list default and .kg acl list other are refused; cargo test scoped_key_lists_only_its_own_kgs_acl passes
.apikey list shows each key's scope (role, KG, relations) ✅ pass live 01-setup.txt: scope column shows 'decider on shop (relations attempt, decision)', 'writer on shop', and null for the bootstrap key
Scoped keys and relation grants survive a server restart ✅ pass live 04-restart-and-drop.txt: after restart the decider key is still refused on kill_switch, and ops is still limited to its relations
Dropping the KG revokes its scoped keys, so a recreated KG with the same name grants them nothing ✅ pass live 04-restart-and-drop.txt: after .kg drop shop and .kg create shop, the agent key is gone from .apikey list and gets auth_error Invalid API key
Evidence: Setup: scoped key creation, decider-without-relations rejection, .apikey list scope column
[auth kg=shop] authenticated role=admin
> +order[("o1")] \n +kill_switch[("o1")] \n +eligible(O) <- order(O), !kill_switch(O)
< {"type":"result","columns":["message"],"rows":[["Inserted 1 fact(s) into 'order'."],["Inserted 1 fact(s) into 'kill_switch'."],["Rule 'eligible' registered."]],"row_count":3,"total_count":3,"truncated":false,"execution_time_ms":11,"row_provenance":["unknown","unknown","unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0},{"index":1,"kind":"insert","inserted":1,"deleted":0}]}
> ?eligible(O)
< {"type":"result","columns":[],"rows":[],"row_count":0,"total_count":0,"truncated":false,"execution_time_ms":3,"timing_breakdown":{"total_us":2837,"parse_us":187,"sip_us":125,"magic_sets_us":59,"ir_build_us":66,"optimize_us":121,"shared_views_us":0},"errors":[]}
> ?kill_switch(X)
< {"type":"result","columns":["X"],"rows":[["o1"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":1,"row_provenance":["unknown"],"timing_breakdown":{"total_us":1336,"parse_us":82,"sip_us":71,"magic_sets_us":27,"ir_build_us":24,"optimize_us":28,"shared_views_us":0},"errors":[]}
> .apikey create agent role decider on shop relations attempt, decision
< {"type":"result","columns":["label","api_key","expires_at"],"rows":[["agent","a7d99f317cd7449f7b488c15a07c51deaa359108d844e4666b547d3ce6c9eb53",null]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
> .apikey create ingest role writer on shop
< {"type":"result","columns":["label","api_key","expires_at"],"rows":[["ingest","08a03cf9293dad3a1415ff595add75150c282c855fe0bf02c4b0029573569ffb",null]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
> .apikey create bad role decider on shop
< {"type":"error","message":"Program has 1 parse error(s)","validation_errors":[{"line":1,"statement_index":0,"error":"The decider role needs the relations it may write: add 'relations <name>, ...'"}],"code":"validation"}
> .apikey list
< {"type":"result","columns":["label","owner","created_at","expires_at","last_used_at","status","scope"],"rows":[["agent","admin",1791142785231,null,null,"active","decider on shop (relations attempt, decision)"],["bootstrap","admin",1791142760843,null,null,"active",null],["ingest","admin",1791142785237,null,null,"active","writer on shop"]],"row_count":3,"total_count":3,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown","unknown","unknown"],"errors":[]}
Evidence: Decider agent key: allowed writes, refused bypasses, KG confinement, .kg acl list, admin view after
\### decider key 'agent' (relations attempt, decision) on shop
[auth kg=shop] authenticated role=editor
> +attempt[("o1", "pay")]
< {"type":"result","columns":["message"],"rows":[["Inserted 1 fact(s) into 'attempt'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":4,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}]}
> +decision[("o1", "approve")]
< {"type":"result","columns":["message"],"rows":[["Inserted 1 fact(s) into 'decision'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":4,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}]}
> ?attempt(O, A)
< {"type":"result","columns":["O","A"],"rows":[["o1","pay"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":2,"row_provenance":["unknown"],"timing_breakdown":{"total_us":1821,"parse_us":133,"sip_us":54,"magic_sets_us":25,"ir_build_us":45,"optimize_us":57,"shared_views_us":0},"errors":[]}
> -attempt("o1", "pay")
< {"type":"result","columns":["message"],"rows":[["Deleted 1 facts from 'attempt'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":1,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"delete","inserted":0,"deleted":1}]}
> +eligible(O) <- order(O)
< {"type":"error","message":"Permission denied: registering a rule needs the editor role on 'shop'; the decider role may only write facts"}
> -kill_switch("o1")
< {"type":"error","message":"Permission denied: the decider role on 'shop' has no write grant for relation 'kill_switch'"}
> +kill_switch[("o2")]
< {"type":"error","message":"Permission denied: the decider role on 'shop' has no write grant for relation 'kill_switch'"}
> .rel drop kill_switch
< {"type":"error","message":"Permission denied: dropping a relation needs the editor role on 'shop'; the decider role may only write facts"}
> -kill_switch
< {"type":"error","message":"Permission denied: dropping a relation or rule needs the editor role on 'shop'; the decider role may only write facts"}
> .rule drop eligible
< {"type":"error","message":"Permission denied: changing a rule needs the editor role on 'shop'; the decider role may only write facts"}
> +kill_switch(name: string)
< {"type":"error","message":"Permission denied: declaring a schema needs the editor role on 'shop'; the decider role may only write facts"}
> +order[("o9")]
< {"type":"error","message":"Permission denied: the decider role on 'shop' has no write grant for relation 'order'"}
> +attempt[("o3", "x")] \n -kill_switch("o1")
< {"type":"error","message":"Permission denied: the decider role on 'shop' has no write grant for relation 'kill_switch'"}
> ?attempt(O, A)
< {"type":"result","columns":[],"rows":[],"row_count":0,"total_count":0,"truncated":false,"execution_time_ms":1,"timing_breakdown":{"total_us":1402,"parse_us":128,"sip_us":52,"magic_sets_us":25,"ir_build_us":42,"optimize_us":55,"shared_views_us":0},"errors":[]}
> .kg acl grant shop agent editor
< {"type":"error","message":"Permission denied: only owners of 'shop' can drop it or manage its access"}
> .kg drop shop
< {"type":"error","message":"Permission denied: only owners of 'shop' can drop it or manage its access"}
> .user list
< {"type":"error","message":"Permission denied: only admins can manage users"}
> .apikey create sneaky
< {"type":"error","message":"Permission denied: only admins can manage API keys"}
> .kg create mine
< {"type":"error","message":"Permission denied: this API key is scoped to knowledge graph 'shop' and cannot create one"}
> .kg use other
< {"type":"error","message":"Access denied: this API key is scoped to knowledge graph 'shop'"}
> .kg acl list
< {"type":"result","columns":["message"],"rows":[["No ACL entries for 'shop'. Admins have implicit owner access."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
> .kg acl list default
< {"type":"error","message":"Access denied: this API key is scoped to knowledge graph 'shop'"}
> .kg acl list other
< {"type":"error","message":"Access denied: this API key is scoped to knowledge graph 'shop'"}
> .session
< {"type":"result","columns":["message"],"rows":[["No session data defined."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
\### admin view after
[auth kg=shop] authenticated role=admin
> ?kill_switch(X)
< {"type":"result","columns":["X"],"rows":[["o1"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":2,"row_provenance":["unknown"],"timing_breakdown":{"total_us":1935,"parse_us":162,"sip_us":54,"magic_sets_us":25,"ir_build_us":51,"optimize_us":62,"shared_views_us":1},"errors":[]}
> ?eligible(O)
< {"type":"result","columns":[],"rows":[],"row_count":0,"total_count":0,"truncated":false,"execution_time_ms":2,"timing_breakdown":{"total_us":2052,"parse_us":156,"sip_us":122,"magic_sets_us":60,"ir_build_us":37,"optimize_us":65,"shared_views_us":0},"errors":[]}
> ?order(O)
< {"type":"result","columns":["O"],"rows":[["o1"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":1,"row_provenance":["unknown"],"timing_breakdown":{"total_us":1156,"parse_us":84,"sip_us":47,"magic_sets_us":24,"ir_build_us":22,"optimize_us":26,"shared_views_us":0},"errors":[]}
> .rule list
< {"type":"result","columns":["message"],"rows":[["Rules:"],["  eligible (1 clause(s))"]],"row_count":2,"total_count":2,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown","unknown"],"errors":[]}
> .rel
< {"type":"result","columns":["message"],"rows":[["Relations:"],["  attempt (arity: 2, columns: [col0: any, col1: any], tuples: 0)"],["  decision (arity: 2, columns: [col0: any, col1: any], tuples: 1)"],["  kill_switch (arity: 1, columns: [col0: any], tuples: 1)"],["  order (arity: 1, columns: [col0: any], tuples: 1)"]],"row_count":5,"total_count":5,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown","unknown","unknown","unknown","unknown"],"errors":[]}
\### decider key connecting bound to other KGs
[auth kg=other] auth_error Access denied
[auth kg=default] auth_error Access denied
Evidence: Writer key and per-relation ACL grants for password users
\### writer key 'ingest' (no relations) on shop
[auth kg=shop] authenticated role=editor
> +order[("o2")]
< {"type":"result","columns":["message"],"rows":[["Inserted 1 fact(s) into 'order'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":2,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}]}
> +kill_switch[("o2")]
< {"type":"result","columns":["message"],"rows":[["Inserted 1 fact(s) into 'kill_switch'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":1,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}]}
> -kill_switch("o2")
< {"type":"result","columns":["message"],"rows":[["Deleted 1 facts from 'kill_switch'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":1,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"delete","inserted":0,"deleted":1}]}
> +eligible(O) <- order(O)
< {"type":"error","message":"Permission denied: registering a rule needs the editor role on 'shop'; the writer role may only write facts"}
> .rel drop order
< {"type":"error","message":"Permission denied: dropping a relation needs the editor role on 'shop'; the writer role may only write facts"}
> .kg acl grant shop admin viewer
< {"type":"error","message":"Permission denied: only owners of 'shop' can drop it or manage its access"}
> .kg acl list
< {"type":"result","columns":["message"],"rows":[["No ACL entries for 'shop'. Admins have implicit owner access."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
\### admin: users with ACL grants
[auth kg=shop] authenticated role=admin
> .user create ops ops-pw-123456 viewer
< {"type":"result","columns":["message"],"rows":[["User 'ops' created with role 'viewer'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
> .user create bot bot-pw-123456 viewer
< {"type":"result","columns":["message"],"rows":[["User 'bot' created with role 'viewer'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
> .user create dev dev-pw-123456 viewer
< {"type":"result","columns":["message"],"rows":[["User 'dev' created with role 'viewer'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
> .kg acl grant shop ops writer relations kill_switch, tool_policy
< {"type":"result","columns":["message"],"rows":[["Granted 'writer (relations kill_switch, tool_policy)' access on 'shop' to 'ops'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
> .kg acl grant shop bot decider relations attempt
< {"type":"result","columns":["message"],"rows":[["Granted 'decider (relations attempt)' access on 'shop' to 'bot'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
> .kg acl grant shop bot decider
< {"type":"error","message":"The decider role needs the relations it may write: add 'relations <name>, ...'"}
> .kg acl grant shop dev viewer relations order
< {"type":"error","message":"Only the writer and decider roles take relations; 'viewer' covers every relation"}
> .kg acl grant shop dev viewer
< {"type":"result","columns":["message"],"rows":[["Granted 'viewer' access on 'shop' to 'dev'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
> .kg acl list
< {"type":"result","columns":["message"],"rows":[["ACL entries for 'shop':\n  ops: writer (relations kill_switch, tool_policy)\n  bot: decider (relations attempt)\n  dev: viewer"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
\### ops (writer relations kill_switch, tool_policy) via password login
[auth kg=shop] authenticated role=viewer
> +tool_policy[("pay", "deny")]
< {"type":"result","columns":["message"],"rows":[["Inserted 1 fact(s) into 'tool_policy'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":6,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}]}
> +kill_switch[("o3")]
< {"type":"result","columns":["message"],"rows":[["Inserted 1 fact(s) into 'kill_switch'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":1,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}]}
> -kill_switch("o3")
< {"type":"result","columns":["message"],"rows":[["Deleted 1 facts from 'kill_switch'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":1,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"delete","inserted":0,"deleted":1}]}
> +attempt[("o1","x")]
< {"type":"error","message":"Permission denied: the writer role on 'shop' has no write grant for relation 'attempt'"}
> +eligible(O) <- order(O)
< {"type":"error","message":"Permission denied: registering a rule needs the editor role on 'shop'; the writer role may only write facts"}
\### bot (decider relations attempt) via password login
[auth kg=shop] authenticated role=viewer
> +attempt[("o5","x")]
< {"type":"result","columns":["message"],"rows":[["Inserted 1 fact(s) into 'attempt'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":3,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}]}
> +decision[("o5","y")]
< {"type":"error","message":"Permission denied: the decider role on 'shop' has no write grant for relation 'decision'"}
> -kill_switch("o1")
< {"type":"error","message":"Permission denied: the decider role on 'shop' has no write grant for relation 'kill_switch'"}
\### dev (viewer) creates? -- scope narrows: admin issues key for viewer-owned? (only admins manage keys)
[auth kg=shop] authenticated role=viewer
> .apikey create devkey role editor on shop
< {"type":"error","message":"Permission denied: only admins can manage API keys"}
> +order[("o7")]
< {"type":"error","message":"Permission denied: you have viewer access to this knowledge graph"}
Evidence: Restart persistence and KG drop revoking scoped keys
\### after restart: decider key still scoped
[auth kg=shop] authenticated role=editor
> +attempt[("o8","y")]
< {"type":"result","columns":["message"],"rows":[["Inserted 1 fact(s) into 'attempt'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":4,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}]}
> -kill_switch("o1")
< {"type":"error","message":"Permission denied: the decider role on 'shop' has no write grant for relation 'kill_switch'"}
> .rel drop kill_switch
< {"type":"error","message":"Permission denied: dropping a relation needs the editor role on 'shop'; the decider role may only write facts"}
\### ops grant still carries its relations after restart
[auth kg=shop] authenticated role=viewer
> +attempt[("o1","x")]
< {"type":"error","message":"Permission denied: the writer role on 'shop' has no write grant for relation 'attempt'"}
> +tool_policy[("refund","deny")]
< {"type":"result","columns":["message"],"rows":[["Inserted 1 fact(s) into 'tool_policy'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":3,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}]}
[auth kg=shop] authenticated role=admin
> .apikey list
< {"type":"result","columns":["label","owner","created_at","expires_at","last_used_at","status","scope"],"rows":[["agent","admin",1791142785231,null,1791142818143,"active","decider on shop (relations attempt, decision)"],["bootstrap","admin",1791142760843,null,null,"active",null],["ingest","admin",1791142785237,null,1791142803466,"active","writer on shop"]],"row_count":3,"total_count":3,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown","unknown","unknown"],"errors":[]}
> .kg acl list
< {"type":"result","columns":["message"],"rows":[["ACL entries for 'shop':\n  bot: decider (relations attempt)\n  dev: viewer\n  ops: writer (relations kill_switch, tool_policy)"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
\### admin drops and recreates shop
[auth kg=default] authenticated role=admin
> .kg drop shop
< {"type":"result","columns":["message"],"rows":[["Knowledge graph 'shop' dropped."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":30,"row_provenance":["unknown"],"errors":[]}
> .kg create shop
< {"type":"result","columns":["message"],"rows":[["Knowledge graph 'shop' created."],["Switched to knowledge graph: shop"]],"row_count":2,"total_count":2,"truncated":false,"execution_time_ms":2,"row_provenance":["unknown","unknown"],"switched_kg":"shop","errors":[]}
> .apikey list
< {"type":"result","columns":["label","owner","created_at","expires_at","last_used_at","status","scope"],"rows":[["bootstrap","admin",1791142760843,null,null,"active",null]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
\### old decider key against recreated shop
[auth kg=shop] auth_error Invalid API key
Evidence: WebSocket driver used for all scenarios
// Minimal WS driver: node ilws.mjs <kg> <auth-json> <program>...  -> prints each reply
const [kg, authJson, ...programs] = process.argv.slice(2);
const ws = new WebSocket(`ws://127.0.0.1:${process.env.PORT}/ws?kg=${encodeURIComponent(kg)}`);
let id = 0; const pending = new Map(); const results = {};
function send(frame){ return new Promise(r=>{ pending.set(frame.id, {r, acc:null}); ws.send(JSON.stringify(frame)); }); }
ws.onmessage = (ev) => {
  const m = JSON.parse(ev.data); const p = pending.get(m.id); if (!p) return;
  if (m.type === 'result_start') { p.acc = {...m, rows: []}; return; }
  if (m.type === 'result_chunk') { p.acc.rows.push(...(m.rows||[])); return; }
  if (m.type === 'result_end') { pending.delete(m.id); p.r({...p.acc, ...m, type:'result'}); return; }
  pending.delete(m.id); p.r(m);
};
ws.onerror = (e) => { console.log('WS ERROR', e.message); process.exit(1); };
ws.onclose = (e) => { for (const p of pending.values()) p.r({type:'closed', code:e.code, reason:e.reason}); };
ws.onopen = async () => {
  const auth = await send({...JSON.parse(authJson), id: String(id++)});
  console.log(`[auth kg=${kg}] ${auth.type}${auth.type==='authenticated' ? ' role='+auth.role : ' '+(auth.message||auth.reason||'')}`);
  if (auth.type !== 'authenticated') { process.exit(0); }
  for (const program of programs) {
    const res = await send({type:'execute', id: String(id++), program});
    const short = {...res}; delete short.id;
    console.log(`> ${program.replace(/\n/g,' \\n ')}\n< ${JSON.stringify(short)}`);
  }
  ws.close(); process.exit(0);
};

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ src/protocol/handler.rs:4626 - .kg acl list with no KG argument skips the per-KG check. In authorize_program, KgAclList(None) produces an empty target list, so kg_access/authorize_kg_operation never runs for it. The executor then lists kg_filter.or(knowledge_graph).unwrap_or(&#34;default&#34;) (handler.rs:4394-4398), which is not the KG that authorization resolved (exec_kg, the session's KG). Concrete path: a key created with role decider on shop connects over WS bound to shop and sends .kg acl list. The program authorizes, and the reply lists every user and their new per-relation grants on default. Over REST, passing knowledge_graph=&lt;other&gt; lists that KG's ACL too. This breaks the new guarantee that a scoped key "may use no other knowledge graph" (authentication.mdx). It also discloses the new kg_acl_relations grant lists to identities with no access to that KG. The gap was already there for non-admin users, but this change adds scoped keys and relation grants that make it matter. Fix: in authorize_program, check KgAclList(None) against kgs (the current KG), as other KG-relative commands do. In the executor, list exec_kg / current_kg instead of knowledge_graph.or(&#34;default&#34;), so the KG that was authorized is the one that gets listed.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Decider API key (relations attempt, decision) inserts and deletes attempt/decision facts on its KG ✅ pass live 02-decider-key.txt: +attempt, +decision, -attempt all succeed
Agent (decider) key bypasses refused: second rule clause, deleting or inserting kill_switch, .rel drop, -relation, .rule drop, schema, writing order; kill switch still holds ✅ pass live 02-decider-key.txt: each gets 'Permission denied: ...' naming what is missing; the admin view shows kill_switch o1 still present, eligible empty, the rule still 1 clause
Program mixing an allowed write and a forbidden write commits nothing ✅ pass live 02-decider-key.txt: '+attempt o3 / -kill_switch o1' refused, and ?attempt then returns 0 rows
Writer key writes facts in any relation but cannot register rules, drop relations or change ACLs ✅ pass live 03-writer-and-acl-grants.txt (writer key section)
Per-relation writer/decider ACL grants for password users confine writes to the granted relations; invalid relation lists rejected ✅ pass live 03-writer-and-acl-grants.txt: ops writes tool_policy/kill_switch but is refused on attempt; bot writes attempt but is refused on decision/kill_switch; 'decider' without relations and 'viewer relations…
Scoped key cannot use another KG, create a KG, or run admin commands even though its owner is admin ✅ pass live 02-decider-key.txt: .kg use other / .kg create / .user list / .apikey create are refused; connecting bound to other or default gets auth_error Access denied
Review fix: .kg acl list with no argument from a key scoped to shop lists shop, and listing other KGs is refused ✅ pass live 02-decider-key.txt: .kg acl list returns shop's entries; .kg acl list default and .kg acl list other are refused; cargo test scoped_key_lists_only_its_own_kgs_acl passes
.apikey list shows each key's scope (role, KG, relations) ✅ pass live 01-setup.txt: scope column shows 'decider on shop (relations attempt, decision)', 'writer on shop', and null for the bootstrap key
Scoped keys and relation grants survive a server restart ✅ pass live 04-restart-and-drop.txt: after restart the decider key is still refused on kill_switch, and ops is still limited to its relations
Dropping the KG revokes its scoped keys, so a recreated KG with the same name grants them nothing ✅ pass live 04-restart-and-drop.txt: after .kg drop shop and .kg create shop, the agent key is gone from .apikey list and gets auth_error Invalid API key
  • cargo build --bin inputlayer-server, then started the server with an isolated config, data dir and credentials file on 127.0.0.1:18766
  • Node WebSocket driver (ilws.mjs) sending login/authenticate/execute frames to /ws?kg=&lt;kg&gt;
  • Admin setup: .kg create shop/other, a kill-switched eligible rule, .apikey create agent role decider on shop relations attempt, decision, .apikey create ingest role writer on shop, .apikey create bad role decider on shop (rejected), .apikey list (scope column)
  • Decider key: wrote and deleted attempt/decision facts; tried a rule, -kill_switch(&#34;o1&#34;), +kill_switch, .rel drop, -kill_switch, .rule drop, a schema, +order, a mixed allowed+forbidden program, ACL grant, .kg drop, .user list, .apikey create, .kg create, .kg use other, .kg acl list [default|other], and connecting bound to other/default
  • Writer key: fact insert and delete in any relation; rule, .rel drop and ACL grant all refused
  • ACL grants with relations for password users ops (writer kill_switch, tool_policy) and bot (decider attempt); bad grants (decider without relations, viewer with relations) rejected; viewer user cannot create keys or write
  • Restarted the server, then re-checked the decider key, the ops grant, .apikey list and .kg acl list; dropped and recreated shop, then retried the old decider key
  • cargo test --lib scoped_roles_tests (8 targeted tests, including the review-fix ACL-list test)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

jsam added 3 commits October 4, 2026 19:29
…tion grants, and API keys created with a role (EN-6)

Until now the only per-graph write role was editor, which may also register,
edit and drop rules, drop relations and install ontologies, and `.apikey create`
could only mint keys that act as their (admin) owner. An agent that needed to
record a claim therefore held a credential that could switch its own policy off.

- Two new KG roles. `writer` writes facts in every relation, or only in its
  granted relations. `decider` writes facts only in its granted relations
  (claims and decision records). Neither may register, edit or drop a rule,
  declare a schema, drop a relation, clear a prefix, load a file, change an
  ontology, drop the KG or manage its access list. Every statement of a
  program is checked before anything commits, including both sides of an
  update (a guarded claim), and the refusal names the missing grant.
- Per-relation grants: `.kg acl grant <kg> <user> writer|decider relations a, b`.
  The relation list is stored in `_internal.kg_acl_relations`, replaced with the
  grant in one commit; `.kg acl list` shows it; revoke, KG drop and user drop
  remove it.
- `.apikey create <label> [<ttl>] role <role> on <kg> [relations ...]` creates a
  key limited to one KG and one role (editor, writer, decider or viewer). A
  scoped key never acts as an admin, cannot create a KG, uses no other KG, and
  its access is the meet of its scope and its owner's own access. Scoped keys
  are stored in `scoped_api_keys` with an `api_key_scopes` row, in one commit,
  so a server from before this change never reads one as an unscoped key; a
  key whose stored scope does not decode is not loaded. Dropping a KG revokes
  the keys scoped to it. `.apikey list` gains a `scope` column.

No new locks: the role check runs on the statements already parsed for
authorization, reading the ACL snapshot it already read; a scoped key's scope
travels on its credential.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant