Skip to content

[BUG] ws grants are ws-topic patterns — record.list/record.query authorize with them in key space #215

Description

@lxsaah

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working🔌 bridgesProtocol bridges

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions