Skip to content

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

Description

@lxsaah

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.

Metadata

Metadata

Assignees

No one assigned

    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