Feat/ws grants bug 215 - #260
Conversation
The record key pattern now rule permissions for read, while write permissions still based on topic pattern.
|
Hi @lxsaah, plz check the PR. The large part of added code is test to cover different behaviors stem from decoupled record and topic. Btw I missed your collab invite, could you send again? |
There was a problem hiding this comment.
Review
Nice piece of work. The decoupling is the right call and the central property holds. Verified locally on dc7dad4: make check passes in full (all 11 targets) and I wrote the negative test that clients_disjoint_grants leaves commented out — a secret.# client subscribed to the shared public_info topic receives nothing across three publishes, while still getting its own record. The record gate works.
I also confirmed the index invariant the whole model rests on: AimDbInner::list_records() enumerates storages and sets record_id = i (builder.rs:191), the same enumeration collect_outbound_routes uses, and connector build() runs after all record registration (builder.rs:819). Out-of-range returns false, so appending records later is fail-closed.
Four things I'd want addressed before merge, then some design questions.
Blocking
1. record.query denies names that match no registered record, even under a full grant — dispatch.rs:230-240
Verified: with grant public.#, querying name: "public.archived" returns {"err":"denied"}. But record.query is a historical query against persistence — a record retired from the current config still has rows in the store, and is now unqueryable. The old authorize_query checked containment against the grant only, not against the live record list.
It also conflates "you have no permission" with "nothing matched", returning an authorization error for what is really an empty result. Suggest: Denied only when the pattern falls entirely outside the grant; otherwise {records: [], total: 0}.
2. The handler's total is silently discarded — dispatch.rs:245, dispatch.rs:300
let (records, _total) = handler.handle_query(...) // :245
// ...
Ok(json!({ "records": records, "total": records.len() })) // :300QueryHandler::handle_query is public and documented to return (records, total_count). With limit set, total was the full match count for pagination; it's now the filtered page length. The built-in persistence handler already returned len() (aimdb-persistence/src/builder_ext.rs:85), so only custom handlers are affected — but the trait doc still promises the old contract. Either keep the handler's total, or update the trait doc.
3. write_patterns is documented as record keys but still matched against the topic
auth.rs:58 says "Record name patterns the client may write to" and the module doc says "gate which records a client may write to" — but dispatch.rs:191 passes the AimX write frame's topic to can_write, whose own parameter is still named topic. So read = record key, write = topic.
The asymmetry is defensible (the PR description says as much), but the doc comments now state the opposite, and an operator granting a record key gets a silent denial. The tests can't catch it: the cfg fixture uses link_from("ws://cfg") on record key cfg, so key == topic.
4. No CHANGELOG entry
aimdb-websocket-connector/CHANGELOG.md ## [Unreleased] is empty, but this is a breaking public-API change right after v2.0.0:
Permissions::subscribe_patterns→read_patterns,can_subscribe→can_readAuthHandler::authorize_subscribe/authorize_query/authorize_listremoved- new pub field on
ClientInfo ClientManager::subscribe/broadcastsignatures changed (both re-exported from the crate root)ConnectorConfig::record_indexin core
The existing changelog documents changes at exactly this granularity.
Design questions
5. record_index as a typed field on core's ConnectorConfig — transport.rs:36
That struct's own doc says protocol-specific knobs travel in protocol_options "without polluting the base struct". It's also breaking on a public, non-#[non_exhaustive] struct, and record_index silently becomes a reserved key in the public with_config(key, value) API (safe — the authoritative push is last and from_query is last-wins — but undocumented and unvalidated).
The core change itself is unavoidable: the index only exists in core, and topics are many-to-one with records, so nothing downstream can recover it. But the string encoding is avoidable. An alternative, implemented and run against make check:
// builder.rs — typed field on the route, no synthetic config pair
pub struct OutboundRoute { /* … */ pub record_index: usize }
// pump.rs — the join
let mut cfg = ConnectorConfig::from_query(&config);
cfg.record_index = Some(record_index);Plus dropping the "record_index" arm from from_query and adding #[non_exhaustive] to ConnectorConfig while it's already breaking. Connector::publish is untouched, so no churn across the five implementors. Exactly one in-tree site needed updating (pump.rs); session/client.rs already uses ... Net −15 lines in the route test, which also gets to assert the real invariant (meta[route.record_index].record_key == key) instead of a string round-trip.
Happy for this to land as a follow-up rather than in this PR. Also fixes #6 below.
6. WsBusSink::publish returns Ok(()) and silently drops when record_index is None
Unreachable via pump_sink today (the value round-trips a usize, so the parse can't fail), so this is structural rather than live — but it's the failure mode of a security boundary, and the only signal is a warn! behind the tracing feature. Prefer Err(PublishError::InvalidDestination): pump_sink already does log_error! on publish failure, so it surfaces without a feature gate.
7. Permissions are now frozen at upgrade
Removing the async hooks means no mid-connection revocation and no external/dynamic ACL lookup — the deleted AsyncTopicAuth e2e test existed specifically to prove that was supported. The O(1) bitmask is a good trade, but it's a capability removal that belongs in the PR body and the changelog. Is revocation planned?
8. from_value::<Vec<QueryRecord>> narrows the QueryHandlerFn contract — dispatch.rs:274
The old code passed the handler's JSON through verbatim. Now a handler returning a different shape — or omitting "records" on an empty result, e.g. {"total": 0} — gets RpcError::Internal instead of a result.
Tests
9. clients_disjoint_grants doesn't test what its comment claims. The "Secret client does not receive public.ledger" assertion is commented out and replaced with a positive read of the secret client's own event — but that client is subscribed to secret_info, so plain topic matching already excludes the public record and the record gate is never exercised. Also leaves a stray println! and dead commented code.
Fix: subscribe the secret client to public_info and assert nothing arrives. I wrote this and it passes — so it's an assertion gap, not a bug, but it's the single most important property in the PR.
10. Uncovered behaviors the description claims: subscribe denial for a client with zero read grants (the has_permissions() path, dispatch.rs:156), and partial record.query results where the grant covers only part of the pattern. RecordsBits also only tests set(len + 1), not the set(len) boundary.
Nits
auth.rs:120—(0..blocks).into_iter().map(|_| 0u8).collect()→vec[0u8; blocks]; theif length > 0guard is dead since0.div_ceil(8) == 0.is_empty()(no records) andhas_permissions()(no bits set) read alike but mean different things.is_emptyexists only to satisfy clippy'slen_without_is_empty— worth a doc line, or rename tono_grants().block_index(&self, …)takes&selfwithout using it, whileoffsetis associated.offsetis justindex % 8.builder.rs:373— truncated doc comment:/// The struct hold.- Typos in new comments: "decice", "ultimatly", "subcribes", "Extrat", "record keyw", "Ouf of index", "our-of-index", "diffent".
- Zero-record server with
allow_all→ every subscribe getsDeniedviahas_permissions(). Degenerate, but the error is misleading. RecordsBitsisn't re-exported from the crate root, thoughClientInfo(which carriesArc<RecordsBits>) andClientManager(whose publicsubscribetakes one) both are.- Registration order is now load-bearing for security, not just lookup. Appends are fail-closed, but a future reorder or removal in
storageswould silently re-point every live client's bitmask. Worth adebug_assert!or a note atAimDbInner.storagespinning the invariant.
What's new: - `record.query` now does not block unregistered records. Records in store and allowed by grants flow through; - Add docs on `total` of `handle_query` - Fix docs on `write_patterns` to make it consists with `write_patterns` speaking topic; - `WsBusSink::publish` now return `Err` in case of invalid destination record id; - `from_value::<Vec<QueryRecord>>` returns error in case of malformed records - Several other fixes to tests such as `clients_disjoint_grants`, `RecordsBits`'s test, and partial `record.query`
|
Hi @lxsaah , highly appreciate you detailed reply. Indeed I am not very familiar with the code base and design, yet, so seveval fixes are not optimal. On 5: Agree on the suggestion, I'll create an issue accordingly. The round trip On 7: Frankly, the revocation was not in my mind. The removal of So I suggest to add mid-session ACL update together with dedicated admin/operator API for 7. That's another issue an another PR. Another thing, |
|
Thanks a lot for this @solus161. Read grants now follow record keys instead of topics, which closes the cross-record leak. Great contribution! |
Description
Issue 215 pointed out the tangled up nature of record keys and topics. This PR decouples these two namespaces by adding distinct logics (paths are relative to
aimdb-websocket-connectioncrate by default). The core idea is to keep this logic clear: clients keep speaking topic, server speaks record key, and the two namespaces joined at outbound route:/src/server/auth::Permissionsnow hasread_patternsrenamed fromsubscribe_pattern. This indicates a change of authorization logic from topic to record key. Meanwhile,write_patternsremains with topic. This is followed by a change offn can_subscribe()tofn can_read(), which is naming change not logic change;/src/server/auth::RecordsBitsadded to encode per-client read permissions. Each bit decice whether the client having read access to a record. Bit indexes are the same as record indexes which are registration order (inAimDbInner.storage.RecordsBitsis built during ws upgrade process, and carried byClientInfo;/src/server/auth: several AuthHandler fn become obsolete due to this logic change and being removed, includingfn authorize_subscribe()(authorization is resolved at upgrade and enforced at delivery),fn authorize_query()(the filtering logic is now based on record permissions), andfn authorize_list();src/server/http: thefn ws_upgrade_handler()now build the per-client permission bitmask based on the server's registered records and configuredPermissions. The handler does not deny the upgrade even if the client has no grant (permission bitmask is empty or all bits are zero)./src/server/connector::SnapshotCacheis updated to carry record id logic. A snapshot is now identified by record id and associated topic, instead of just topic.trait SnapshotProvideroutput also changes accordingly;aimdb-core/src/builder::AimDb.collect_outbound_routes()now buildOutboundRoutewith extra key-value pair of "record_index"-"{record_id}" in.config: ConnectorConfigattr.ConnectorConfigcarries that pair till/src/server/connector::WsBusSink.publish()where the snapshot cache is built and message is broadcasted. It is theClientManagerdeciding which subscriptions/clients get the message;WsSession.subscribe(),/src/server/dispatch.rs;record.listreturns only records that the client has permissions for;record.querycould return a set of records smaller than what the name pattern asks for. If the asked name and grant bits do not overlap, the query is denied;Also, several tests added to ensure these behaviors hold.
Related Issue
Checklist
make check).