fix(#3987): join the agent-status key with a dot so a hive's write grant can be scoped
A KV entry publishes to $KV.<bucket>.<key> and NATS wildcards match whole
.-delimited tokens, so a /-joined {hive}/{agent} key is a single token: the
only expressible write grants are one exact subject per agent (needs a roster
in the auth responder, which Policy::hive_name argues against) or a bucket-wide
wildcard that lets any hive overwrite any other hive's agents.
Joining with a dot puts the hive in its own token, so hive_subjects can grant
$KV.agent-status.<hive>.* — every agent of one hive and nothing else, the same
containment hive-status already has.
The grant lands with the bucket-open pair (STREAM.INFO + STREAM.CREATE):
open_or_create resolves the bucket before it writes, so alone the publish
subject is unreachable and the sweep fails one step later instead.
Verified the client accepts a dotted key rather than assuming it: async-nats
0.50.0 VALID_KEY_RE is \A[-/_=.a-zA-Z0-9]+\z and is_valid_key rejects only
empty / leading / trailing dot; the subject is prefix + key verbatim.
This commit is contained in:
parent
c9d8628794
commit
3b038425f2
2 changed files with 109 additions and 34 deletions
|
|
@ -34,10 +34,19 @@ pub const BUCKET: &str = "agent-status";
|
|||
|
||||
/// The bucket key for one agent on one hive.
|
||||
///
|
||||
/// `/`-joined rather than NATS's usual `.`-joined subject style: this is a
|
||||
/// KV key, not a subject, and neither `hive` nor `agent` names can contain
|
||||
/// `/` (`hive_types::Ident`'s charset is `[a-z0-9-]`), so the join is
|
||||
/// unambiguous to split back apart if a consumer ever needs to.
|
||||
/// `.`-joined, and the separator is load-bearing rather than cosmetic: a KV
|
||||
/// entry is published to `$KV.<bucket>.<key>`, and NATS wildcards match
|
||||
/// whole `.`-delimited tokens. Two tokens make a hive's write grant
|
||||
/// expressible as `$KV.agent-status.<hive>.*` — every agent of one hive and
|
||||
/// nothing else. Joined by any character that is not `.`, the key is a
|
||||
/// single token, and the only grants available are one exact subject per
|
||||
/// agent or a bucket-wide wildcard that lets any hive overwrite any other
|
||||
/// hive's agents.
|
||||
///
|
||||
/// Neither name can be empty or contain a `.` (`hive_types::Ident` is
|
||||
/// `[a-z0-9-]`, non-empty), so the join stays unambiguous to split back
|
||||
/// apart — and a leading or trailing `.` would be rejected outright by the
|
||||
/// client's own KV key validation.
|
||||
///
|
||||
/// Plain code span above, deliberately not an intra-doc link — this crate
|
||||
/// doesn't depend on `hive_types`, and a bracketed reference to its `Ident`
|
||||
|
|
@ -46,20 +55,20 @@ pub const BUCKET: &str = "agent-status";
|
|||
/// errors.
|
||||
#[must_use]
|
||||
pub fn key(hive: &str, agent: &str) -> String {
|
||||
format!("{hive}/{agent}")
|
||||
format!("{hive}.{agent}")
|
||||
}
|
||||
|
||||
/// The inverse of [`key`]: split a bucket key back into `(hive, agent)`.
|
||||
///
|
||||
/// `None` for a key with zero or more than one `/` — a hive publishing
|
||||
/// `None` for a key with zero or more than one `.` — a hive publishing
|
||||
/// under [`key`] never produces one, so a malformed key means something
|
||||
/// else wrote this bucket. Splitting on the *first* `/` would be equally
|
||||
/// else wrote this bucket. Splitting on the *first* `.` would be equally
|
||||
/// valid today (neither name can contain one), but this rejects rather
|
||||
/// than guesses, so a future name-charset change can't silently start
|
||||
/// misreading old keys.
|
||||
#[must_use]
|
||||
pub fn split_key(key: &str) -> Option<(&str, &str)> {
|
||||
let mut parts = key.splitn(3, '/');
|
||||
let mut parts = key.splitn(3, '.');
|
||||
let hive = parts.next()?;
|
||||
let agent = parts.next()?;
|
||||
if parts.next().is_some() {
|
||||
|
|
@ -125,11 +134,26 @@ pub async fn open_or_create(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AgentStatus, key, split_key};
|
||||
use super::{AgentStatus, BUCKET, key, split_key};
|
||||
|
||||
#[test]
|
||||
fn key_joins_hive_and_agent_with_a_slash() {
|
||||
assert_eq!(key("prod", "iris"), "prod/iris");
|
||||
fn key_joins_hive_and_agent_with_a_dot() {
|
||||
assert_eq!(key("prod", "iris"), "prod.iris");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_published_subject_carries_the_hive_as_its_own_token() {
|
||||
// This is what the hive's write grant `$KV.agent-status.<hive>.*`
|
||||
// matches on, so the separator is pinned here rather than left as a
|
||||
// property of `key`'s formatting string.
|
||||
assert_eq!(
|
||||
format!("$KV.{BUCKET}.{}", key("prod", "iris")),
|
||||
"$KV.agent-status.prod.iris"
|
||||
);
|
||||
// Control: a single-token join lands in the same bucket and would
|
||||
// pass any assertion that only checked the prefix — it is the hive
|
||||
// token that the grant needs, and only the `.` produces one.
|
||||
assert!(!format!("$KV.{BUCKET}.prod/iris").starts_with("$KV.agent-status.prod."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -138,13 +162,13 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn split_key_rejects_a_key_with_no_slash() {
|
||||
fn split_key_rejects_a_key_with_no_dot() {
|
||||
assert_eq!(split_key("iris"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_key_rejects_a_key_with_two_slashes() {
|
||||
assert_eq!(split_key("prod/iris/extra"), None);
|
||||
fn split_key_rejects_a_key_with_two_dots() {
|
||||
assert_eq!(split_key("prod.iris.extra"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue