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.rs and http.rs had drifted by 3–5 lines
(subscribe 151 not 154-158, snapshots 160-169 not 163-172, the query
body starts at 193; http.rs:181-186 is now 218-236). Refreshed above.
- The
subscribe_patterns caveat cited as CHANGELOG.md:49-51 lives 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
Summary
Permissions::subscribe_patternsis a list of ws topics — whatever thedeveloper wrote in
link_to("ws://…").authorize_listandauthorize_querydefault to delegating to
authorize_subscribe, so that same list decidesquestions about record keys. The two are independent namespaces, and
record.list/record.queryreach records that have no ws topic at all, so agrant 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/):authorize_list(record_key)db.list_records()rows (server/dispatch.rs:117-124)authorize_query(pattern)record_name(server/dispatch.rs:193-208)authorize_subscribe(topic)server/dispatch.rs:151-153)subscribeis the outlier, and its namespace is the arbitrary one — the bus iskeyed by the
link_todestination, optionally recomputed per value by aTopicProvider. The other two are keyed by the record's identity. Both defaultssit at
server/auth.rs:193-222.record.listiterates the entire database andrecord.queryreaches anypersisted 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
A client granted
subscribe_patterns: ["public.#"]— intent being "may read thepublic ws feed":
subscribe("public.feed")record.listpublic.ledgerrecord.query {"name":"public.#"}record_nameIt 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.listreturnspublic.ledger— the record the grant was never meant to reach — and omitsfeed.public, the record it was written for, because that record's key does notmatch 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.listandrecord.querythen need no change, and the existing delegation becomes correct."Which records may this client read" is constant for a connection — permissions
are fixed by
authenticatebefore the upgrade (server/http.rs:218-236) and therecord set is fixed at
build(), whereRecordId::new(storages.len() as u32)makes ids a dense
0..Nindex (aimdb-core/src/builder.rs:668-676). So resolveit 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 areasync, and awaiting one inside
broadcast'sself.subs.iter()loop would holdDashMapshard guards across a suspension point. The existing loop alreadyavoids 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 forexactly this — "the shared seam … to thread per-route configuration through to
Connector::publishwithout changing thepublishsignature"(
aimdb-core/src/transport.rs:47-52) — so MQTT, KNX, TCP and serial areuntouched. Free-form ws topics and
with_topic_provideralso stay: records areenumerable 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 11built 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
TopicProvidertopics appear as values arepublished.
authorize_subscribe's meaning changes from "may this client subscribe to thispattern" 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 worthconsidering so it breaks loudly.
Also worth doing
WsSession::snapshotsperforms no authorization of its own — it returnssnapshot_provider.snapshots(topic)(server/dispatch.rs:160-169) and reliesentirely on
authorize_subscribehaving gated the subscription upstream.DynMapSnapshotthen filters atopic → bytescache bytopic_matches(
server/builder.rs:359-371). The moment authorization is per record, this pathstops 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_listcaveat atserver/auth.rs:211-215andaimdb-websocket-connector/CHANGELOG.md:61-64advises granting the record keyinside
subscribe_patterns— that is the conflation itself, and becomes untrueunder this fix. (Filed as
CHANGELOG.md:49-51; the text is in the ws crate'schangelog, not the root one.)
authorize_query's rustdoc atserver/auth.rs:193-198also still says an omittednameasks for"*"; thatbecame
QUERY_ALL_PATTERN="#"in thread 22. The conclusion the doc drawsstill holds — a narrower grant does not contain
"#"either, so it failsclosed — 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 assertpublic.ledgerappears in neitherrecord.listnorrecord.query— that fails against today's defaults. Worthcovering alongside: a record whose ws topic differs from its key reaches a
subscriber granted the key and not one granted only the topic; a
TopicProviderrecord is authorized by its key whichever topic a value lands on (the
tests/e2e.rs:189-197InjectTopicshape); late-join snapshots are filtered onthe 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
287e0fdon 2026-09-11. The prescribed test was written as ascratch integration test in
aimdb-websocket-connector/tests/— the two recordsfrom Impact, a
QueryHandlerFnin Extensions standing in forwith_persistence, and anAuthHandlerreturningsubscribe_patterns: ["public.#"], driven over a real socket liketests/e2e.rsdoes. Output:
Both halves fire in one run:
record.listreturns exactly the record theoperator meant to withhold, and none of the record the grant was written for.
Claim by claim:
authorize_query/authorize_listdefault toauthorize_subscribeauth.rs:199-222authorize_subscribechecks ws topics viaPermissions::can_subscribeauth.rs:174-180,pattern_containsoversubscribe_patternsrecord.listiterates the whole databaseaimdb-core/src/builder.rs:191-200maps everystoragesentrysnapshotsperforms no authorization of its owndispatch.rs:160-169is a straight passthroughDynMapSnapshotfilters a topic→bytes cache bytopic_matchesbuilder.rs:359-371RecordId::new(storages.len())yields a dense0..Nindexaimdb-core/src/builder.rs:676ConnectorConfigis the documented per-route seamtransport.rs:47-52, quoted verbatimauthenticatebefore the upgradehttp.rs:218-236dispatch.rs:151-153; locked by the existingrecord_list_and_query_answer_to_the_client_grantse2e testbroadcastiteratesDashMapwithout awaiting, removals deferredclient_manager.rs:167-193; the await-across-shard-guard concern is realauthorize_queryrustdoc still says an omittednameasks for"*"dispatch.rs:197usesQUERY_ALL_PATTERN(aimdb-core/src/remote/query.rs:41="#")authorize_record, record bitset, or equivalent anywhere in the treeTwo citation corrections, both content-correct as filed:
dispatch.rsandhttp.rshad drifted by 3–5 lines(subscribe
151not154-158, snapshots160-169not163-172, the querybody starts at
193;http.rs:181-186is now218-236). Refreshed above.subscribe_patternscaveat cited asCHANGELOG.md:49-51lives inaimdb-websocket-connector/CHANGELOG.md:61-64, not the root changelog. Theadvice 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