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:120-124) |
record keys |
authorize_query(pattern) |
persistence record_name (server/dispatch.rs:209-236) |
record keys |
authorize_subscribe(topic) |
fan-out bus (server/dispatch.rs:154-158) |
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:196-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.
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:181-186) 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:657-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
(server/client_manager.rs:130-140).
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:154-156) — 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:163-172) and relies
entirely on authorize_subscribe having gated the subscription upstream.
DynMapSnapshot then filters a topic → bytes cache by topic_matches
(server/builder.rs:341-353). 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:207-215 and CHANGELOG.md:49-51
advises granting the record key inside subscribe_patterns — that is the
conflation itself, and becomes untrue under this fix. authorize_query's doc at
server/auth.rs:196-201 also still says an omitted name asks for "*"; that
became QUERY_ALL_PATTERN = "#" in thread 22.
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-192 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.
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:120-124)authorize_query(pattern)record_name(server/dispatch.rs:209-236)authorize_subscribe(topic)server/dispatch.rs:154-158)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:196-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.
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:181-186) and therecord set is fixed at
build(), whereRecordId::new(storages.len() as u32)makes ids a dense
0..Nindex (aimdb-core/src/builder.rs:657-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
(
server/client_manager.rs:130-140).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:154-156) — 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:163-172) and reliesentirely on
authorize_subscribehaving gated the subscription upstream.DynMapSnapshotthen filters atopic → bytescache bytopic_matches(
server/builder.rs:341-353). 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:207-215andCHANGELOG.md:49-51advises granting the record key inside
subscribe_patterns— that is theconflation itself, and becomes untrue under this fix.
authorize_query's doc atserver/auth.rs:196-201also still says an omittednameasks for"*"; thatbecame
QUERY_ALL_PATTERN="#"in thread 22.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-192InjectTopicshape); 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.