[BUG] ws grants are ws-topic patterns — record.list/record.query authorize with them in key space
@solus161 is already working on this.
Since Sep 11, 2026.
- Dominant language
- Rust
- Stars
- 100
- Forks
- 18
- Avg merge
- 19h 47m
- Merged PRs (30d)
- 33
Description
Verified still reproducing on
287e0fd(2026-09-11). Nothing has been
fixed; the mechanism below is intact. Line references have been refreshed to
the current tree and one citation corrected — see
Verification at the bottom for the reproduction and the
claim-by-claim check.
Summary
Permissions::subscribe_patterns is a list of ws topics — whatever the
developer wrote in link_to("ws://…"). authorize_list and authorize_query
default to delegating to authorize_subscribe, so that same list decides
questions about record keys. The two are independent namespaces, and
record.list/record.query reach records that have no ws topic at all, so a
grant can authorize reads the operator had no way to express or deny.
The mechanism
Three read paths, two namespaces (paths relative to
aimdb-websocket-connector/src/):
| Hook | Checked against | Namespace |
|---|---|---|
authorize_list(record_key) |
db.list_records() rows (server/dispatch.rs:117-124) |
record keys |
authorize_query(pattern) |
persistence record_name (server/dispatch.rs:193-208) |
record keys |
authorize_subscribe(topic) |
fan-out bus (server/dispatch.rs:151-153) |
ws topics |
subscribe is the outlier, and its namespace is the arbitrary one — the bus is
keyed by the link_to destination, optionally recomputed per value by a
TopicProvider. The other two are keyed by the record's identity. Both defaults
sit at server/auth.rs:193-222.
record.list iterates the entire database and record.query reaches any
persisted record, including records never linked to ws. Those have no ws topic,
so no grant written in the namespace the operator is configuring can name them.
Impact
// Exposed over ws under a developer-chosen topic.
sb.configure::<Feed>("feed.public", |reg| {
reg.buffer(BufferCfg::SingleLatest)
.with_remote_access()
.link_to("ws://public.feed")
.with_serializer(/* … */)
.finish();
});
// Never linked to ws. Persisted. No ws topic exists for it.
sb.configure::<Ledger>("public.ledger", |reg| {
reg.buffer(BufferCfg::SingleLatest).persist("public.ledger").finish();
});
A client granted subscribe_patterns: ["public.#"] — intent being "may read the
public ws feed":
| Operation | Checked against | Result |
|---|---|---|
subscribe("public.feed") |
ws topic | allowed, as intended |
record.list |
key public.ledger |
row returned |
record.query {"name":"public.#"} |
persistence record_name |
history returned |
It inverts too — a grant naming a ws topic whose record key differs denies
list/query for a record the client can subscribe to. Which way it goes depends
on incidental naming, which is the problem: one grant string means three things.
In the example above both happen at once: record.list returns
public.ledger — the record the grant was never meant to reach — and omits
feed.public, the record it was written for, because that record's key does not
match the topic it is published under. Confirmed by running it; see
Verification.
Fix
Move the outlier into the namespace the other two already use, so a grant names
the record rather than the address it is published under. record.list and
record.query then need no change, and the existing delegation becomes correct.
"Which records may this client read" is constant for a connection — permissions
are fixed by authenticate before the upgrade (server/http.rs:218-236) and the
record set is fixed at build(), where RecordId::new(storages.len() as u32)
makes ids a dense 0..N index (aimdb-core/src/builder.rs:668-676). So resolve
it once at the upgrade into a bitset over record_id (1000 records = 125 bytes)
and have delivery test one bit.
Resolving up front rather than lazily is deliberate: AuthHandler's methods are
async, and awaiting one inside broadcast's self.subs.iter() loop would hold
DashMap shard guards across a suspension point. The existing loop already
avoids mutating during iteration for the same reason — removals are deferred
until after it (server/client_manager.rs:167-193).
The record id reaches the sink through ConnectorConfig, which exists for
exactly this — "the shared seam … to thread per-route configuration through to
Connector::publish without changing the publish signature"
(aimdb-core/src/transport.rs:47-52) — so MQTT, KNX, TCP and serial are
untouched. Free-form ws topics and with_topic_provider also stay: records are
enumerable even when their topics are not.
Two behaviours to keep apart. A client authorized for zero records should be
denied at subscribe, as it is today (server/dispatch.rs:151-153) — thread 11
built the client plumbing that reports that, and it should not be traded for
silence. A client whose pattern merely matches no topic yet should be accepted
and receive nothing, since under TopicProvider topics appear as values are
published.
authorize_subscribe's meaning changes from "may this client subscribe to this
pattern" to "may this client read this record", which is breaking with no compile
error behind it — a handler matching ws topics keeps compiling and silently
starts matching record keys. Renaming it (authorize_record?) is worth
considering so it breaks loudly.
Also worth doing
WsSession::snapshots performs no authorization of its own — it returns
snapshot_provider.snapshots(topic) (server/dispatch.rs:160-169) and relies
entirely on authorize_subscribe having gated the subscription upstream.
DynMapSnapshot then filters a topic → bytes cache by topic_matches
(server/builder.rs:359-371). The moment authorization is per record, this path
stops being covered and a late-joining client receives snapshots for records it
may not see. Easy to miss, because nothing about it looks like an ACL today.
The authorize_list caveat at server/auth.rs:211-215 and
aimdb-websocket-connector/CHANGELOG.md:61-64 advises granting the record key
inside subscribe_patterns — that is the conflation itself, and becomes untrue
under this fix. (Filed as CHANGELOG.md:49-51; the text is in the ws crate's
changelog, not the root one.) authorize_query's rustdoc at
server/auth.rs:193-198 also still says an omitted name asks for "*"; that
became QUERY_ALL_PATTERN = "#" in thread 22. The conclusion the doc draws
still holds — a narrower grant does not contain "#" either, so it fails
closed — only the literal is wrong. The changelog entry above already says "#".
Test
Register the two records above, authenticate a client with
subscribe_patterns: ["public.#"], and assert public.ledger appears in neither
record.list nor record.query — that fails against today's defaults. Worth
covering alongside: a record whose ws topic differs from its key reaches a
subscriber granted the key and not one granted only the topic; a TopicProvider
record is authorized by its key whichever topic a value lands on (the
tests/e2e.rs:189-197 InjectTopic shape); late-join snapshots are filtered on
the same basis as live delivery; and a client authorized for zero records is
denied while one whose pattern matches nothing yet is not.
Verification
Checked against 287e0fd on 2026-09-11. The prescribed test was written as a
scratch integration test in aimdb-websocket-connector/tests/ — the two records
from Impact, a QueryHandlerFn in Extensions standing in for
with_persistence, and an AuthHandler returning
subscribe_patterns: ["public.#"], driven over a real socket like tests/e2e.rs
does. Output:
SUBSCRIBE public.feed -> {"sub":"1","t":"subscribed"}
RECORD.LIST -> ["public.ledger"]
RECORD.QUERY public.# -> {"records":[{"topic":"public.ledger","payload":42,"ts":1}],"total":1}
Both halves fire in one run: record.list returns exactly the record the
operator meant to withhold, and none of the record the grant was written for.
Claim by claim:
| Claim | Status |
|---|---|
authorize_query / authorize_list default to authorize_subscribe |
holds — auth.rs:199-222 |
authorize_subscribe checks ws topics via Permissions::can_subscribe |
holds — auth.rs:174-180, pattern_contains over subscribe_patterns |
record.list iterates the whole database |
holds — aimdb-core/src/builder.rs:191-200 maps every storages entry |
snapshots performs no authorization of its own |
holds — dispatch.rs:160-169 is a straight passthrough |
DynMapSnapshot filters a topic→bytes cache by topic_matches |
holds — builder.rs:359-371 |
RecordId::new(storages.len()) yields a dense 0..N index |
holds — aimdb-core/src/builder.rs:676 |
ConnectorConfig is the documented per-route seam |
holds — transport.rs:47-52, quoted verbatim |
Permissions fixed by authenticate before the upgrade |
holds — http.rs:218-236 |
| Zero-grant client denied at subscribe | holds — dispatch.rs:151-153; locked by the existing record_list_and_query_answer_to_the_client_grants e2e test |
broadcast iterates DashMap without awaiting, removals deferred |
holds — client_manager.rs:167-193; the await-across-shard-guard concern is real |
authorize_query rustdoc still says an omitted name asks for "*" |
holds — stale; dispatch.rs:197 uses QUERY_ALL_PATTERN (aimdb-core/src/remote/query.rs:41 = "#") |
| No fix in progress | holds — no authorize_record, record bitset, or equivalent anywhere in the tree |
Two citation corrections, both content-correct as filed:
- Line references in
dispatch.rsandhttp.rshad drifted by 3–5 lines
(subscribe151not154-158, snapshots160-169not163-172, the query
body starts at193;http.rs:181-186is now218-236). Refreshed above. - The
subscribe_patternscaveat cited asCHANGELOG.md:49-51lives in
aimdb-websocket-connector/CHANGELOG.md:61-64, not the root changelog. The
advice there is as described, and the reproduction shows why following it
backfires: granting the record key breaks the subscribe the grant was written
for.
Verification section generated by Claude Code
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.