Repository navigation
fix(protocol): permission refusals carry the access_denied error code - #387
Merged
Merged
Conversation
…#364) A program or subscription refused for a permission reason was answered with a message only. Every such refusal now carries the structured code `access_denied`, on `error` frames as on the handler's `ProgramError`: - global role (viewers creating knowledge graphs or managing ontologies) - admin-only commands (.compact, .backup, .user, .apikey) - role on the knowledge graph (viewer writes, owner-only drop and ACLs, no access, the system knowledge graph) - write grants of the writer and decider roles - an API key's scope - a revoked or expired credential - a subscription refused when it opens, or whose access was lost while it opened Messages are unchanged. The AsyncAPI spec, the error reference and the JS, Python and GUI code enums list the new code, and S15 asserts it in place of its known-defect marker.
…n refusals as access_denied
…code" metric note
This was referenced Oct 5, 2026
jsam
added a commit
that referenced
this pull request
Oct 6, 2026
scripts/perf-gate.sh, bench-sessions.sh and bench-views.sh extracted each baseline with `git archive` and built all of them in one shared target/perf-gate/build. Cargo names a workspace crate's artifacts by its path inside the workspace, not by where the workspace is, and rebuilds one only when a source file is newer than the last build. `git archive` dates every file at its commit, which is older than any build already in the directory, so a later baseline reused an earlier one's workspace crates: the build failed where the APIs had diverged (`missing field code in ServerFrame` after #387), linked another commit's `ws-protocol` or `ontology-client` where they had not, and returned the earlier baseline's whole server where no file had been added or removed between the two commits. The three scripts now build baselines through scripts/perf-gate-baseline.sh: each baseline gets a source and target directory of its own under target/perf-gate/baseline-build/, removed once the binary is cached. The cache moves to target/perf-gate/baseline-servers/, so a binary cached by the shared build is never read again, and the three scripts now share one toolchain key (the gate hashed `rustc -V` without its newline, the benchmarks with it). scripts/perf-gate-baseline-selftest.sh builds two baselines that differ in the root crate and in a path dependency, in both orders, and checks each binary reports its own commit. It runs in `make pre-pr-selftest` and in the full suite's perf-gate tool job.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Fix GitHub issue #364 in inputlayer/inputlayer (production readiness): "Permission refusals carry no structured error code (access_denied)".
Found by the scenario suite (S15, #336): when a scoped key is refused (role or per-relation grant), the engine returns only a message, no structured ErrorCode. Clients and the API spec (access_denied) need a stable code. Add an access_denied code to every permission refusal path (roles, grants, admin-only commands), keep messages, and cover it in the scenario.
Deliver it as a small PR through the merge train; done means merged with CI green.
What Changed
access_deniederror code to the wire protocol (ErrorCode::AccessDenied) and made every permission refusal in the handler return it with its message unchanged: global and per-knowledge-graph role checks, admin-only commands, write grants, API key scope, the internal system knowledge graph, and revoked or expired credentials. The authorization paths now return aProgramError(message + code) instead of a bareString.auth_errorandsubscription_errorframes gained an optionalcodefield, set toaccess_deniedwhen a credential may not use the knowledge graph at connect time or a subscriber loses read access; other auth and subscription failures still send no code.access_deniedandreplica_unconfirmedwere also added to the error-code label list insrc/protocol/metrics.rs.access_denieddirectly (itsKnownDefectmarker for Permission refusals carry no structured error code (access_denied) #364 is removed), with new handler-level tests intests/authz_multistatement.rs, WebSocket tests intests/credential_revocation_tests.rs, and serialization tests inws-protocoland the Python client.🤖 Generated with Claude Code
Risk Assessment
✅ Low: The change is an additive error code threaded through the single shared authorization boundary (authorize_program / Principal::identity / finish_subscribe) with messages unchanged and SDK message-based handling unaffected; the only open question is whether auth_error and subscription_error refusals, which have no code field, belong in this issue's scope.
Testing
Built
inputlayer-serverfrom the target commit, started it on a throwaway data directory and port, and drove 35 checks over real/wsconnections with a Node script covering every refusal path plus adversarial non-permission failures; all passed, and/metrics/prometheuscounted the same 20 refusederrorframes underaccess_denied. Also ran the S15 tenancy scenario (spawns a real engine process), which passed. The Python SDK protocol tests passed in a throwaway venv (37 passed), but they are unit tests of the deserializer and not a live run, so the SDK scenario is marked untested. I did not rebuild the base commit to reproduce the pre-fix behaviour, and the JS SDK and GUI type changes were not exercised. No UI surface is changed, so there is no screenshot; the evidence is the wire transcript. The server was stopped, temp directories removed, and the worktree is clean.errorframe with codeaccess_deniedand the original 'Permission….compact,.user create,.user list,.kg drop,.apikey revoke,.kg acl grant,.kg use _internal, and.kg createon a scoped key); each is refused w…claimwritesstock, mixes a granted and ungranted write, or switches to another graph; each is refused withaccess_deniednaming the missing grant or key sco…stockandclaim_internaleach connect and get anauth_errorframe with codeaccess_deniedauth_errorwith nocodefield, so failed credentials are not reported as permission refusalssubscription_errorwith codeaccess_deniedand the unchanged 'keeps its last delivered result' message.subscribe,readframe andsubscribeframe are each refused with anerrorframe codedaccess_deniedvalidation, notaccess_denied, and the admin can run the.compactthe viewer was refused/metrics/prometheusand sees the refusals counted underinputlayer_ws_errors_total{code="access_denied"}errorframes received in the runaccess_denieddirectly on the writer key's refused write, refused graph switch and refused connect to tenant_b, with no known-defect waivercargo test --test scenarios s15_tenants_and_scoped_keys_are_isolated— 1 passedauth_errorandsubscription_errorframes with and without the new optionalcodefieldpytest tests/test_protocol.pyin packages/inputlayer-py (37 passed), a unit test of the SDK deserializer that was not d…Evidence: Live wire transcript of every refusal driven against the running server
Evidence: Per-check results with the received frames (JSON)
Evidence: Driver script used against the disposable server
Evidence: Server log from the disposable instance
Evidence: Representative frames received from the running server
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
🔧 **Rebase** - 8 issues found → auto-fixed ✅
TESTING.md- merge conflict rebasing onto origin/maindocs/spec/asyncapi.yaml- merge conflict rebasing onto origin/maingui/lib/docs-bundle.ts- merge conflict rebasing onto origin/maingui/lib/ws-types.ts- merge conflict rebasing onto origin/mainpackages/inputlayer-js/src/protocol.ts- merge conflict rebasing onto origin/mainpackages/inputlayer-py/src/inputlayer/_protocol.py- merge conflict rebasing onto origin/mainws-protocol/src/error.rs- merge conflict rebasing onto origin/mainws-protocol/src/server/tests.rs- merge conflict rebasing onto origin/main🔧 Fix applied.
✅ Re-checked - no issues remain.
src/protocol/handler.rs:1368- Intent: "Add an access_denied code to every permission refusal path (roles, grants, admin-only commands)". Every refusal answered by anerrorframe is now coded (programs,read,subscribe,.subscribe), but permission refusals delivered on other frame types still carry a message only: (1) src/protocol/handler.rs:1368create_session_with_authreturns a bare "Access denied" (and :1364 the revoked/expired credential text), sent by src/protocol/rest/handlers/ws.rs:350 asauth_error, which has nocodefield - this is the scoped-key-connects-to-another-graph case S15 still checks by message substring (tests/scenarios/tenancy.rs:80); (2) src/protocol/subscription/subscriber.rs:150 the "Access denied to knowledge graph ..."subscription_errorpush sent when read access is lost on a live subscription, whose frame (ws-protocol/src/push.rs:210) has nocodeeither. Coding these means adding acodefield to theauth_errorandsubscription_errorwire frames, spec and SDK types - the remedy, not the defect, needs authorization. Decide whether those two frames are in scope for Permission refusals carry no structured error code (access_denied) #364 or a follow-up.src/protocol/rest/asyncapi_docs.html:203- The recorded rebase decision requires every code list to list bothreplica_unconfirmedandaccess_denied. Two lists edited by this change gainedaccess_deniedbut still omitreplica_unconfirmed(an omission inherited from feat(replication): synchronous shipping so acknowledged writes survive losing the primary #367): src/protocol/rest/asyncapi_docs.html:203 (... precondition_failed or access_denied) and thecodeparagraph in docs/content/docs/guides/websocket-api.mdx:237. Addreplica_unconfirmedbeforeaccess_deniedin both and regenerate gui/lib/docs-bundle.ts withnode gui/scripts/bundle-docs.mjsfrom gui/.tests/credential_revocation_tests.rs:708- The new testpermission_refusals_reply_with_the_access_denied_codewas inserted betweenadmin_executeand its doc comment, so the line "Runprogramover an admin/wssession; itsresultframe." now leads the new test's doc andadmin_executeis undocumented. Move that line back aboveasync fn admin_execute.🔧 Fix applied.
2 issues (1 warning, 1 info) still open:
src/protocol/handler.rs:1368- Intent: "Add an access_denied code to every permission refusal path (roles, grants, admin-only commands)". Every refusal answered by anerrorframe is now coded (programs,read,subscribe,.subscribe), but permission refusals delivered on other frame types still carry a message only: (1) src/protocol/handler.rs:1368create_session_with_authreturns a bare "Access denied" (and :1364 the revoked/expired credential text), sent by src/protocol/rest/handlers/ws.rs:350 asauth_error, which has nocodefield - this is the scoped-key-connects-to-another-graph case S15 still checks by message substring (tests/scenarios/tenancy.rs:80); (2) src/protocol/subscription/subscriber.rs:150 the "Access denied to knowledge graph ..."subscription_errorpush sent when read access is lost on a live subscription, whose frame (ws-protocol/src/push.rs:210) has nocodeeither. Coding these means adding acodefield to theauth_errorandsubscription_errorwire frames, spec and SDK types - the remedy, not the defect, needs authorization. Decide whether those two frames are in scope for Permission refusals carry no structured error code (access_denied) #364 or a follow-up.src/protocol/handler.rs:1361- Fix round 1 changedcreate_session_with_authto returnProgramError, soprincipal.identity()?now maps a revoked/expired credential toaccess_deniedon theauth_errorframe (viaFrom<CredentialEnded>), while docs/spec/asyncapi.yaml:1916 describes the code there as only for a valid credential that may not use the knowledge graph ("Absent for failed credentials"). It is reachable only when the credential ends in the window between theprincipal.identity()check at src/protocol/rest/handlers/ws.rs:329 (which answers an uncoded "Invalid credentials") and session creation, and it matches the code the same refusal already carries onerrorframes and in docs/spec/errors.md, so it stays within the recorded decision ("keeps whatever code fits it today or none"). No action needed.✅ **Test** - passed
✅ No issues found.
errorframe with codeaccess_deniedand the original 'Permission….compact,.user create,.user list,.kg drop,.apikey revoke,.kg acl grant,.kg use _internal, and.kg createon a scoped key); each is refused w…claimwritesstock, mixes a granted and ungranted write, or switches to another graph; each is refused withaccess_deniednaming the missing grant or key sco…stockandclaim_internaleach connect and get anauth_errorframe with codeaccess_deniedauth_errorwith nocodefield, so failed credentials are not reported as permission refusalssubscription_errorwith codeaccess_deniedand the unchanged 'keeps its last delivered result' message.subscribe,readframe andsubscribeframe are each refused with anerrorframe codedaccess_deniedvalidation, notaccess_denied, and the admin can run the.compactthe viewer was refused/metrics/prometheusand sees the refusals counted underinputlayer_ws_errors_total{code="access_denied"}errorframes received in the runaccess_denieddirectly on the writer key's refused write, refused graph switch and refused connect to tenant_b, with no known-defect waivercargo test --test scenarios s15_tenants_and_scoped_keys_are_isolated— 1 passedauth_errorandsubscription_errorframes with and without the new optionalcodefieldpytest tests/test_protocol.pyin packages/inputlayer-py (37 passed), a unit test of the SDK deserializer that was not d…CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 CARGO_INCREMENTAL=0 cargo build --bin inputlayer-serverStartedtarget/debug/inputlayer-server --config <tmp>/engine.tomlon a disposable data dir and free port, with a bootstrap admin password and credentials file under the temp dirnode ~/.no-mistakes/evidence/01M472W3JM7H4W6RPS4NE9JK6N/drive.mjs <port> <admin-key> <admin-password>— 35/35 checks passedcurl http://127.0.0.1:<port>/api/ws-docs— served docs listreplica_unconfirmedthenaccess_deniedGET /metrics/prometheuswith the admin key —inputlayer_ws_errors_total{code="access_denied"} 20, matching the 20 refusederrorframes receivedCARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 CARGO_INCREMENTAL=0 cargo test --test scenarios s15_tenants_and_scoped_keys_are_isolated— passedpytest tests/test_protocol.pyinpackages/inputlayer-pyusing a throwaway venv under /tmp — 37 passed (unit test only, not a live run)✅ **Document** - passed
✅ No issues found.
packages/inputlayer-py/tests/test_protocol.py:125- The new testtest_access_denied_codehas a 102-character line (ruff E501, limit 100). Left as is: this pass must not edit tests, ruff is not part of the Makefile or CI gates, and the package already has 12 other ruff findings on the base.cargo fmt --check, workspace clippy with-D warnings, and the JS SDKtsc --noEmitall pass; gui eslint was not run (no gui/node_modules installed).✅ **Push** - passed
✅ No issues found.
Performance gate
Verdict: PASS. Measured on the benchmark host with
make perf-gate-remote, run20261005T225637Z-5556b5be.5556b5beaa639c883b5e59c1d222a3ddf1e0b237, the head of this PR. Baseline:e732cd5258c74d9247e99b2697a0ae080ccf9042. Profilestandard, 30 rounds, policy provisional.cheap_query.queries_per_sec: baseline 10168/s, candidate 9921/s, cost ratio 1.024, 95% interval 1.015..1.032, budget 1.05.cheap_query.latency_us: p50 0.412 ms to 0.419 ms (ratio 1.012, interval 0.995..1.031, budget 1.05); p99 0.461 ms to 0.464 ms (ratio 1.004, interval 0.989..1.017, budget 1.10).An earlier 10-round run (
20261005T220357Z-4b027ee0, on this branch before it was rebased and before the review fixes) was INCONCLUSIVE on that one metric:cheap_query.queries_per_secratio 1.039, interval 1.018..1.057 against the 1.05 budget. This 30-round run is the rerunperf-gate/README.mdasks for.