The agent read the operator-profiles bucket with a keys() listing, an ordered consumer whose grant (STREAM.INFO, CONSUMER.CREATE) and flow-control reply had never run against a permissioned queue. The controller now writes the whole list as one JSON array under one key, only when it changed, and the agent reads it with one DIRECT.GET the way agent_inference::read_own reads its key. The agent grant shrinks to that one subject.
2092 lines
92 KiB
Rust
2092 lines
92 KiB
Rust
//! What an admitted client is allowed to do.
|
|
//!
|
|
//! [`crate::introspect`] answers *whether* to admit and *as whom*. This
|
|
//! answers *what that identity may touch* — the two are deliberately separate
|
|
//! decisions: admission is the `IdP`'s, authorisation is the swarm's.
|
|
//!
|
|
//! # Deny is the default, and that is a choice with a measurement behind it
|
|
//!
|
|
//! A client whose id matches no rule gets **no grant at all**, not an
|
|
//! unrestricted one. Every other shape here fails open: an unmatched client
|
|
//! that kept today's unscoped grant would make the whole policy advisory, and
|
|
//! the failure would be silent. Denying costs a loud refusal the first time a
|
|
//! new consumer appears, which is a config line to fix.
|
|
//!
|
|
//! Each subject set below is *minimal by removal*, and carries its own note
|
|
//! saying what breaks without it — the reasoning lives next to the list it
|
|
//! constrains rather than in one block here, because that is where a reader
|
|
//! about to edit the list will meet it.
|
|
|
|
/// The subjects an admitted client may publish and subscribe to.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub struct Permissions {
|
|
/// Subjects allowed for publish. Never empty: an empty allow-list is a
|
|
/// grant that can do nothing, which is a denial wearing a grant's shape.
|
|
pub publish: Vec<String>,
|
|
/// Subjects allowed for subscribe, or `None` for every subject in the
|
|
/// account.
|
|
///
|
|
/// Every grant [`Policy`] makes is `Some`: the subjects that client
|
|
/// subscribes to plus its own reply inbox. Never `Some(vec![])`: NATS
|
|
/// reads an empty allow-list as no restriction at all.
|
|
pub subscribe: Option<Vec<String>>,
|
|
/// How long the client may answer a request it received, on that
|
|
/// request's reply subject, or `None` for no such allowance. NATS'
|
|
/// `allow_responses`: the reply subject need not be in `publish`.
|
|
pub responses: Option<std::time::Duration>,
|
|
}
|
|
|
|
/// Which client ids get which permissions.
|
|
///
|
|
/// Constructed from configuration so that adding a subject a hive may publish
|
|
/// — a second event stream, say — is a deployment change rather than a change
|
|
/// to this responder.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Policy {
|
|
hive_prefix: String,
|
|
agent_suffix: String,
|
|
bucket: String,
|
|
readers: Vec<String>,
|
|
extra_hive_subjects: Vec<String>,
|
|
agent_token_subjects: Vec<String>,
|
|
review_classifiers: Vec<String>,
|
|
builders: Vec<String>,
|
|
claude_logins: Vec<String>,
|
|
}
|
|
|
|
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
|
|
const HIVE_PLACEHOLDER: &str = "{hive}";
|
|
|
|
/// Placeholder replaced with the agent's own name in `agent_token_subjects`.
|
|
const AGENT_PLACEHOLDER: &str = "{agent}";
|
|
|
|
impl Policy {
|
|
/// `hive_prefix` is the client-id prefix that marks a hive and
|
|
/// `agent_suffix` what a hive's agent containers carry **on top of** it —
|
|
/// `hive-<name>` and `hive-<name>-agent`. `bucket` is the KV bucket hives
|
|
/// report status in, `readers` the client ids allowed to read every hive's
|
|
/// key, and `extra_hive_subjects` additional subjects a hive may publish to
|
|
/// (with `{hive}` standing for the hive's own name).
|
|
///
|
|
/// The agent id is deliberately the hive's id extended, not a second
|
|
/// top-level prefix: `agent-<name>` reads as *the agent called `<name>`*,
|
|
/// which is the one thing this identity does not carry.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// An extra subject with no `{hive}` in it is refused. Such a template
|
|
/// expands to the **same** subject for every hive, which is not a per-hive
|
|
/// namespace — it is the absence of one, arrived at through the option
|
|
/// whose only purpose is to provide one. Returning a `Result` rather than
|
|
/// checking at the call site is deliberate: it makes an unscoped policy
|
|
/// unconstructible instead of merely unlikely.
|
|
///
|
|
/// An empty `agent_suffix` is refused for the same reason: every hive id
|
|
/// would also parse as its own agent id, and the arms are tried in order,
|
|
/// so the overlap does not error at match time — it silently hands one
|
|
/// principal the other's grant.
|
|
pub fn new(
|
|
hive_prefix: String,
|
|
agent_suffix: String,
|
|
bucket: String,
|
|
readers: Vec<String>,
|
|
extra_hive_subjects: Vec<String>,
|
|
) -> anyhow::Result<Self> {
|
|
if let Some(bad) = extra_hive_subjects
|
|
.iter()
|
|
.find(|s| !s.contains(HIVE_PLACEHOLDER))
|
|
{
|
|
anyhow::bail!(
|
|
"--hive-publish-subject {bad:?} contains no {HIVE_PLACEHOLDER}: every hive \
|
|
would be granted that exact subject, so it is a shared channel rather than \
|
|
a per-hive namespace"
|
|
);
|
|
}
|
|
// Checked here rather than trusted from nix, because these are two
|
|
// flags and nothing downstream compares them. An agent id is a hive id
|
|
// plus this suffix, so an empty one makes `strip_suffix` succeed on
|
|
// every hive id and the two principals become the same string. The
|
|
// agent arm runs first and refuses, so every hive would be refused as
|
|
// its own agents' client.
|
|
if agent_suffix.is_empty() {
|
|
anyhow::bail!(
|
|
"--agent-client-suffix is empty: an agent id is a hive id plus this suffix, \
|
|
so every hive would also parse as its own agents' client and be refused"
|
|
);
|
|
}
|
|
Ok(Self {
|
|
hive_prefix,
|
|
agent_suffix,
|
|
bucket,
|
|
readers,
|
|
extra_hive_subjects,
|
|
agent_token_subjects: Vec::new(),
|
|
review_classifiers: Vec::new(),
|
|
builders: Vec::new(),
|
|
claude_logins: Vec::new(),
|
|
})
|
|
}
|
|
|
|
/// Set the subjects an agent that proved its own credential may publish
|
|
/// to, with `{agent}` standing for its name.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// A template with no `{agent}` in it is refused: it would be one subject
|
|
/// shared by every agent in the swarm rather than the agent's own.
|
|
pub fn with_agent_token_subjects(mut self, subjects: Vec<String>) -> anyhow::Result<Self> {
|
|
if let Some(bad) = subjects.iter().find(|s| !s.contains(AGENT_PLACEHOLDER)) {
|
|
anyhow::bail!(
|
|
"--agent-token-publish-subject {bad:?} contains no {AGENT_PLACEHOLDER}: every \
|
|
agent in the swarm would be granted that exact subject"
|
|
);
|
|
}
|
|
self.agent_token_subjects = subjects;
|
|
Ok(self)
|
|
}
|
|
|
|
/// Set the client ids that answer the review classifier's try-outs.
|
|
#[must_use]
|
|
pub fn with_review_classifiers(mut self, clients: Vec<String>) -> Self {
|
|
self.review_classifiers = clients;
|
|
self
|
|
}
|
|
|
|
/// Set the client ids that answer the swarm builder's build requests.
|
|
#[must_use]
|
|
pub fn with_builders(mut self, clients: Vec<String>) -> Self {
|
|
self.builders = clients;
|
|
self
|
|
}
|
|
|
|
/// Set the client ids that answer the claude login minter's requests.
|
|
#[must_use]
|
|
pub fn with_claude_logins(mut self, clients: Vec<String>) -> Self {
|
|
self.claude_logins = clients;
|
|
self
|
|
}
|
|
|
|
/// The permissions for an agent whose own credential was verified, or
|
|
/// `None` when none are configured.
|
|
///
|
|
/// Keyed on the agent alone: its identity is not tied to a hive. `agent`
|
|
/// must already be a single `[A-Za-z0-9_-]` segment, which the token parse
|
|
/// guarantees, so it cannot widen a subject with `.`, `*` or `>`.
|
|
pub fn agent_token_permissions(&self, agent: &str) -> Option<Permissions> {
|
|
let mut publish: Vec<String> = self
|
|
.agent_token_subjects
|
|
.iter()
|
|
.map(|s| s.replace(AGENT_PLACEHOLDER, agent))
|
|
.collect();
|
|
if publish.is_empty() {
|
|
return None;
|
|
}
|
|
// The operator list (`swarm_queue_client::operator_profiles`), which
|
|
// every agent renders into its system prompt: one direct get on the
|
|
// bucket's one key, so no `STREAM.INFO`, no `CONSUMER.CREATE` and no
|
|
// `$KV.` write. Every agent reads the same key, so it is no `{agent}`
|
|
// template and is granted here rather than through
|
|
// `--agent-token-publish-subject`.
|
|
publish.push(swarm_queue_client::operator_profiles::direct_get_subject());
|
|
Some(Permissions {
|
|
publish,
|
|
subscribe: Some(vec![
|
|
format!("{}.>", swarm_queue_client::agent_inbox_prefix(agent)),
|
|
swarm_queue_client::agent_input_subject(agent),
|
|
swarm_queue_client::agent_control_subject(agent),
|
|
]),
|
|
// The controller sends a control command as a request, and the
|
|
// harness answers it. `allow_responses` lets it reply to that one
|
|
// request without a publish grant that would name the
|
|
// controller's inbox, which the agent has no business reaching.
|
|
// Operator text input is sent the same way but never answered
|
|
// (see `hive-agent`'s `swarm_input`), so the reply right is
|
|
// unused there.
|
|
responses: Some(swarm_queue_client::AGENT_CONTROL_REPLY_DEADLINE),
|
|
})
|
|
}
|
|
|
|
/// The permissions for `client_id`, or `None` when no rule matches.
|
|
///
|
|
/// `None` is a denial. It is not "grant nothing and let them connect":
|
|
/// a connected client with no permissions still holds a slot and still
|
|
/// looks admitted in the logs, which is a worse answer than a refusal.
|
|
pub fn permissions(&self, client_id: &str) -> Option<Permissions> {
|
|
// ⚠️ THE AGENT ARM RUNS FIRST, AND THE ORDER IS LOAD-BEARING. An agent
|
|
// id is a hive id with a suffix, so `hive-foo-agent` satisfies the hive
|
|
// arm too — it strips to the hive `foo-agent`. Hive-first would
|
|
// therefore hand every agent its hive's grant, including writing that
|
|
// hive's status key, and nothing would report it: the client
|
|
// authenticates, connects, and is simply able to do more than it
|
|
// should. Most specific wins, so the more specific test is the one
|
|
// that has to be asked first.
|
|
//
|
|
// A hive's shared agent client is refused outright. Agents connect with
|
|
// their own credential (`Self::agent_token_permissions`); this identity
|
|
// names the hive and never the agent (see `Self::agent_hive`), so any
|
|
// grant to it would let one agent publish as its siblings.
|
|
if self.agent_hive(client_id).is_some() {
|
|
return None;
|
|
}
|
|
if let Some(hive) = self.hive_name(client_id) {
|
|
return Some(Permissions {
|
|
publish: self.hive_subjects(hive),
|
|
subscribe: Some(Self::hive_subscribe(client_id, hive)),
|
|
responses: None,
|
|
});
|
|
}
|
|
if self.readers.iter().any(|r| r == client_id) {
|
|
return Some(Permissions {
|
|
publish: self.reader_subjects(),
|
|
subscribe: Some(Self::reader_subscribe(client_id)),
|
|
// The answer to a hive's permissions import. NATS lets it
|
|
// answer only a message it received, once.
|
|
responses: Some(swarm_queue_client::agent_permissions::IMPORT_REPLY_DEADLINE),
|
|
});
|
|
}
|
|
if self.review_classifiers.iter().any(|c| c == client_id) {
|
|
return Some(Self::review_classifier_permissions(client_id));
|
|
}
|
|
if self.builders.iter().any(|c| c == client_id) {
|
|
return Some(Self::builder_permissions(client_id));
|
|
}
|
|
if self.claude_logins.iter().any(|c| c == client_id) {
|
|
return Some(Self::claude_login_permissions(client_id));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// What the review classifier may do: hear try-out requests and answer
|
|
/// each on its reply subject, for as long as a run may take.
|
|
///
|
|
/// Its publish list is its own inbox, which reaches no other client:
|
|
/// NATS reads an empty allow-list as every subject, so the list carries
|
|
/// one entry even though the answers go out under `responses`.
|
|
fn review_classifier_permissions(client_id: &str) -> Permissions {
|
|
Permissions {
|
|
publish: vec![Self::inbox(client_id)],
|
|
subscribe: Some(vec![
|
|
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
|
|
Self::inbox(client_id),
|
|
]),
|
|
responses: Some(swarm_queue_client::review_classifier::RUN_DEADLINE),
|
|
}
|
|
}
|
|
|
|
/// What the builder may do: hear build and archetype evaluation requests
|
|
/// and answer each on its reply subject, for as long as a build may take.
|
|
/// Its publish list is its own inbox, for
|
|
/// [`Self::review_classifier_permissions`]' reason.
|
|
fn builder_permissions(client_id: &str) -> Permissions {
|
|
Permissions {
|
|
publish: vec![Self::inbox(client_id)],
|
|
subscribe: Some(vec![
|
|
swarm_queue_client::builder::SUBJECT.to_owned(),
|
|
swarm_queue_client::builder::ARCHETYPES_SUBJECT.to_owned(),
|
|
Self::inbox(client_id),
|
|
]),
|
|
responses: Some(swarm_queue_client::builder::REPLY_DEADLINE),
|
|
}
|
|
}
|
|
|
|
/// What the claude login minter may do: hear login steps and answer each
|
|
/// on its reply subject. Its publish list is its own inbox, for
|
|
/// [`Self::review_classifier_permissions`]' reason.
|
|
fn claude_login_permissions(client_id: &str) -> Permissions {
|
|
Permissions {
|
|
publish: vec![Self::inbox(client_id)],
|
|
subscribe: Some(vec![
|
|
swarm_queue_client::claude_login::SUBJECT.to_owned(),
|
|
Self::inbox(client_id),
|
|
]),
|
|
responses: Some(swarm_queue_client::claude_login::REPLY_DEADLINE),
|
|
}
|
|
}
|
|
|
|
/// What a hive may subscribe to: the two events the controller sends it,
|
|
/// and its own reply inbox.
|
|
///
|
|
/// The inbox carries every `JetStream` reply and the wanted-bucket watch,
|
|
/// whose consumer delivers to a fresh inbox subject. The client sets the
|
|
/// matching prefix in `swarm_queue_client::connect`. A refused subscribe
|
|
/// is reported on the connection, not to the caller, so a subject missing
|
|
/// here shows up as a hive that silently stops hearing it.
|
|
fn hive_subscribe(client_id: &str, hive: &str) -> Vec<String> {
|
|
vec![
|
|
swarm_queue_client::KNOWLEDGE_SUBJECT.to_owned(),
|
|
swarm_queue_client::deploy_subject(hive),
|
|
Self::inbox(client_id),
|
|
]
|
|
}
|
|
|
|
/// What a reader may subscribe to: every agent's terminal, its
|
|
/// subagents' terminals and its turn state, which the controller relays
|
|
/// to swarm-ui, every hive's permissions import, and its own reply inbox.
|
|
///
|
|
/// The three families are the publishers' (`hive-agent`'s `swarm_term`
|
|
/// and `swarm_agent_state`, `swarm_queue_client::subagent_term`), spelled
|
|
/// by the same `swarm_queue_client` helpers. Operator input, deploys and
|
|
/// knowledge are absent: the reader publishes those.
|
|
fn reader_subscribe(client_id: &str) -> Vec<String> {
|
|
vec![
|
|
swarm_queue_client::term_subject_wildcard(),
|
|
swarm_queue_client::subagent_term::subject_wildcard(),
|
|
swarm_queue_client::agent_state_subject_wildcard(),
|
|
swarm_queue_client::agent_permissions::import_subject_wildcard(),
|
|
Self::inbox(client_id),
|
|
]
|
|
}
|
|
|
|
fn inbox(client_id: &str) -> String {
|
|
format!("{}.>", swarm_queue_client::client_inbox_prefix(client_id))
|
|
}
|
|
|
|
/// The hive a client id names, when it names one.
|
|
///
|
|
/// The prefix is configuration, not a pattern this module guesses at: the
|
|
/// authelia client is `hive-<name>` while the KV key is the bare `<name>`,
|
|
/// and stripping by eye is how a client id that merely *looks*
|
|
/// hive-shaped ends up with a hive's permissions. An id that is the prefix
|
|
/// and nothing else names no hive and is refused — `$KV.<bucket>.` is not
|
|
/// a narrower subject than `$KV.<bucket>.<name>`, it is a different one.
|
|
///
|
|
/// # This matches the prefix; it does not verify the roster
|
|
///
|
|
/// A client id is a hive here because it *starts with the prefix*, not
|
|
/// because it appears in `services.hyperhive.swarm.hives`. This responder
|
|
/// runs inside a container and has no view of the roster, so an
|
|
/// operator-declared client called `hive-anything` would be granted
|
|
/// `$KV.<bucket>.anything` — a key no real hive owns, which the controller
|
|
/// renders as a hive it does not recognise.
|
|
///
|
|
/// Passing the roster in would close that, and would also be a **second
|
|
/// place deciding who may connect as what**, which `crate::introspect`'s
|
|
/// docs argue against for the same reason admission lives in one place.
|
|
/// The prefix is a contract with `swarm-authelia.nix`, and this is the end
|
|
/// of it that can be checked from in here.
|
|
fn hive_name<'a>(&self, client_id: &'a str) -> Option<&'a str> {
|
|
client_id
|
|
.strip_prefix(&self.hive_prefix)
|
|
.filter(|name| !name.is_empty())
|
|
}
|
|
|
|
/// The hive whose agents a client id names, when it names one.
|
|
///
|
|
/// [`Self::hive_name`]'s caveats all apply — this is that parse with the
|
|
/// suffix stripped as well, so `hive-foo-agent` answers `foo`. Two more on
|
|
/// top:
|
|
///
|
|
/// - The agent client is minted per **hive**, so this answers *whose
|
|
/// agents*, never *which agent*. Two agents on one hive are
|
|
/// indistinguishable here — not a gap in the parsing but the identity
|
|
/// itself, which does not carry the agent.
|
|
/// - A hive genuinely named `foo-agent` mints `hive-foo-agent` and is
|
|
/// therefore **indistinguishable from hive `foo`'s agents** right here.
|
|
/// Unguardable from inside this responder, which has no roster (see
|
|
/// [`Self::hive_name`]) — `swarm-authelia.nix` asserts it at eval, where
|
|
/// the roster is known, so the deployment fails to build rather than
|
|
/// minting two principals with one id.
|
|
fn agent_hive<'a>(&self, client_id: &'a str) -> Option<&'a str> {
|
|
client_id
|
|
.strip_prefix(&self.hive_prefix)?
|
|
.strip_suffix(&self.agent_suffix)
|
|
.filter(|name| !name.is_empty())
|
|
}
|
|
|
|
fn stream(&self) -> String {
|
|
format!("KV_{}", self.bucket)
|
|
}
|
|
|
|
/// The stream backing one hive's wanted-state bucket.
|
|
///
|
|
/// Derived from the crate's own name function rather than from a
|
|
/// configurable bucket name: `wanted::bucket` is what the writer and the
|
|
/// reader both call, so a flag here would let two deployments disagree
|
|
/// about a name they must share.
|
|
fn wanted_stream(hive: &str) -> String {
|
|
format!("KV_{}", swarm_queue_client::wanted::bucket(hive))
|
|
}
|
|
|
|
/// The stream backing the per-agent status bucket.
|
|
///
|
|
/// Same shape and same reason as [`Self::wanted_stream`]: derived from
|
|
/// the crate constant rather than a third configurable bucket name.
|
|
fn agent_status_stream() -> String {
|
|
format!("KV_{}", swarm_queue_client::agent_status::BUCKET)
|
|
}
|
|
|
|
/// What *any* `JetStream` client must be able to ask before it can do
|
|
/// anything at all, bucket-specific or not.
|
|
///
|
|
/// Both were measured from the server's own refusals, not reasoned about:
|
|
/// a grant carrying every bucket-specific subject and neither of these
|
|
/// cannot even create the bucket — the client times out on `$JS.API.INFO`
|
|
/// long before it reaches a subject that was granted.
|
|
///
|
|
/// - `$JS.API.INFO` — account-level `JetStream` info, requested on connect.
|
|
/// - `$JS.API.STREAM.NAMES` — how a client finds the stream backing a
|
|
/// bucket. It lets a client enumerate stream names in the account, which
|
|
/// in an account holding one bucket discloses a name both ends already
|
|
/// share.
|
|
///
|
|
/// 🩸 Earlier measurements missed both, because they either granted
|
|
/// `$JS.API.>` wholesale or ran against a bucket the *setup* had already
|
|
/// created while unscoped. A minimum established against an existing
|
|
/// bucket is not the minimum for making one.
|
|
fn jetstream_minimum() -> [String; 2] {
|
|
["$JS.API.INFO".to_owned(), "$JS.API.STREAM.NAMES".to_owned()]
|
|
}
|
|
|
|
/// Creating the bucket, which **both** ends need.
|
|
///
|
|
/// `swarm_queue_client::status::open_or_create` is called by the hive that
|
|
/// writes and the controller that reads, because either may arrive first
|
|
/// on a fresh swarm — a bucket that must pre-exist turns "deployed in the
|
|
/// wrong order" into a permanent, silent absence of data. Whichever
|
|
/// connects first therefore has to be able to create it.
|
|
///
|
|
/// Narrower than it looks: this is `CREATE` on one named stream, not
|
|
/// `UPDATE` and not the `$JS.API.>` wildcard. Measured rather than
|
|
/// assumed — a hive holding this grant and running `stream edit` leaves
|
|
/// the stream's config untouched, and no `$JS.API.STREAM.UPDATE` is ever
|
|
/// published. Worth stating because the failure it would hide is quiet: a
|
|
/// hive able to reshape the shared bucket could set `MaxMsgs: 1` and evict
|
|
/// every other hive's status without ever touching `STREAM.DELETE`, and
|
|
/// per-key scoping would still look intact.
|
|
fn create(&self) -> String {
|
|
format!("$JS.API.STREAM.CREATE.{}", self.stream())
|
|
}
|
|
|
|
/// What every hive needs to open the shared lifecycle-notices stream:
|
|
/// look it up, and create it if this hive is the first to arrive.
|
|
///
|
|
/// **Not per-hive templated, unlike `extra_hive_subjects`.** The KV
|
|
/// bucket case above namespaces a shared resource *within* itself
|
|
/// (`$KV.<bucket>.<hive>`, one key per hive); the notices stream has
|
|
/// no such per-hive split at the `STREAM.INFO`/`STREAM.CREATE` layer
|
|
/// — the stream itself, not a slice of it, is what every hive's
|
|
/// `open_or_create` needs to reach before it can publish to its own
|
|
/// `hive-notices.<hive>` subject, which [`Self::hive_subjects`] grants
|
|
/// beside these from the same crate constant. Granting these two subjects
|
|
/// to every hive is therefore correct, not a widening: it is
|
|
/// `CREATE`/`INFO` on one named stream, the same shape already
|
|
/// measured safe for the hive-status bucket in [`Self::create`] —
|
|
/// create-if-absent, never `STREAM.UPDATE`, so no hive can reshape a
|
|
/// stream another hive (or the swarm-controller, once it reads from
|
|
/// this stream) already created.
|
|
fn notices_subjects() -> [String; 2] {
|
|
// Unlike `Self::stream()` above, no `KV_` prefix: the notices
|
|
// stream is a plain `JetStream` stream, not a KV bucket, so its
|
|
// NATS stream name *is* `swarm_queue_client::notices::STREAM`
|
|
// verbatim — the `KV_` prefix is `create_key_value`'s own
|
|
// convention, not something every stream carries.
|
|
let stream = swarm_queue_client::notices::STREAM;
|
|
[
|
|
format!("$JS.API.STREAM.INFO.{stream}"),
|
|
format!("$JS.API.STREAM.CREATE.{stream}"),
|
|
]
|
|
}
|
|
|
|
/// What one hive may publish: the account minimum, the bucket lookup,
|
|
/// creation, and its **own** key.
|
|
///
|
|
/// Two things a reader would reasonably assume, both false and both
|
|
/// measured (`state/attack-3297-js-api-door.sh` in atlas's notes):
|
|
///
|
|
/// - `$KV.<bucket>.<key>` alone does **not** let a client write that key.
|
|
/// The client resolves the bucket first, so the `STREAM.INFO` subject is
|
|
/// part of the minimum for a plain write.
|
|
/// - `$JS.API.>` is not "the `JetStream` permission". It also covers
|
|
/// `$JS.API.STREAM.DELETE`, with which a hive correctly refused on a
|
|
/// neighbour's individual key can destroy the whole bucket — every
|
|
/// hive's data. Granting it would make per-key scoping decorative, which
|
|
/// is why these are named one at a time and a test asserts the wildcard
|
|
/// never returns as a convenience.
|
|
fn hive_subjects(&self, hive: &str) -> Vec<String> {
|
|
let mut subjects = Self::jetstream_minimum().to_vec();
|
|
subjects.extend([
|
|
format!("$JS.API.STREAM.INFO.{}", self.stream()),
|
|
self.create(),
|
|
format!("$KV.{}.{hive}", self.bucket),
|
|
]);
|
|
subjects.extend(Self::notices_subjects());
|
|
// The hive's own slice of the stream the subjects above let it open.
|
|
// Named from the defining crate so a rename cannot leave the grant
|
|
// pointing at a subject nobody publishes to. Not `extra_hive_subjects`:
|
|
// that is for streams this crate does not know about.
|
|
subjects.push(swarm_queue_client::notices::subject(hive));
|
|
// Reading the wanted-state bucket, which is **this hive's own**.
|
|
//
|
|
// One bucket per hive rather than one keyed by hive, so that a *watch*
|
|
// can be granted without widening the read: a consumer's filter travels
|
|
// in the request payload, so `CONSUMER.CREATE` grants a whole stream and
|
|
// cannot be scoped to a key the way `DIRECT.GET` can.
|
|
//
|
|
// `$KV.<bucket>.<hive>` stays deliberately absent — the controller
|
|
// declares this and a hive converges to it, so a hive that could write
|
|
// its own key could declare its own desired state.
|
|
let wanted = Self::wanted_stream(hive);
|
|
subjects.extend([
|
|
format!("$JS.API.STREAM.INFO.{wanted}"),
|
|
format!(
|
|
"$JS.API.DIRECT.GET.{wanted}.$KV.{}.{hive}",
|
|
swarm_queue_client::wanted::bucket(hive)
|
|
),
|
|
// The watch the paragraph above buys: a KV watch is a consumer, so
|
|
// `DIRECT.GET` covers the boot-time read and nothing after it.
|
|
// Both spellings for the same reason the status buckets grant both
|
|
// — an ephemeral consumer's subject carries no name, and `>` never
|
|
// matches zero tokens.
|
|
format!("$JS.API.CONSUMER.CREATE.{wanted}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{wanted}.>"),
|
|
]);
|
|
// Publishing this hive's agents into the per-agent status bucket, and
|
|
// **only its own agents**.
|
|
//
|
|
// `.*` matches exactly one token, so this is every agent of one hive
|
|
// and nothing deeper — `.>` would also admit `<hive>.<agent>.<more>`,
|
|
// which `agent_status::split_key` rejects on read anyway; the grant has
|
|
// no reason to be wider than the format.
|
|
//
|
|
// The scoping is only expressible because the key is `.`-joined: a KV
|
|
// entry publishes to `$KV.<bucket>.<key>`, and a NATS wildcard matches
|
|
// whole tokens, so a key joined by anything else is one token and
|
|
// leaves only "one exact subject per agent" (needs a roster in here,
|
|
// which `hive_name`'s doc argues against) or a bucket-wide wildcard
|
|
// (any hive overwrites any other hive's agents).
|
|
//
|
|
// `STREAM.INFO` + `CREATE` come along for the same reason they do
|
|
// above: `open_or_create` resolves the bucket before it writes, and
|
|
// either end may be first on a fresh swarm. Without them the publish
|
|
// grant is unreachable — the client times out at bucket open.
|
|
let agent_status = Self::agent_status_stream();
|
|
subjects.extend([
|
|
format!("$JS.API.STREAM.INFO.{agent_status}"),
|
|
format!("$JS.API.STREAM.CREATE.{agent_status}"),
|
|
format!("$KV.{}.{hive}.*", swarm_queue_client::agent_status::BUCKET),
|
|
]);
|
|
// Reading the caps the swarm sets for the agents this hive runs
|
|
// (`swarm_queue_client::agent_limits`): a direct get, so read-only, and
|
|
// no `STREAM.INFO` because the reader skips the lookup.
|
|
//
|
|
// Every agent's key, not just this hive's: the bucket is keyed by agent
|
|
// alone, and naming one hive's agents here needs the roster
|
|
// `hive_name`'s doc argues against.
|
|
subjects.push(format!(
|
|
"$JS.API.DIRECT.GET.{}.>",
|
|
swarm_queue_client::agent_limits::STREAM
|
|
));
|
|
// The permissions the swarm grants those agents
|
|
// (`swarm_queue_client::agent_permissions`), read the same way and
|
|
// every key for the same reason.
|
|
subjects.push(format!(
|
|
"$JS.API.DIRECT.GET.{}.>",
|
|
swarm_queue_client::agent_permissions::STREAM
|
|
));
|
|
// The one-shot import of the grants this hive's meta repo held, sent
|
|
// as a request on this hive's own subject: the controller takes the
|
|
// hive from the subject, so one hive cannot import for another.
|
|
subjects.push(swarm_queue_client::agent_permissions::import_subject(hive));
|
|
subjects.extend(
|
|
self.extra_hive_subjects
|
|
.iter()
|
|
.map(|s| s.replace(HIVE_PLACEHOLDER, hive)),
|
|
);
|
|
subjects
|
|
}
|
|
|
|
fn reader_subjects(&self) -> Vec<String> {
|
|
let stream = self.stream();
|
|
let mut subjects = Self::jetstream_minimum().to_vec();
|
|
subjects.extend([
|
|
format!("$JS.API.STREAM.INFO.{stream}"),
|
|
self.create(),
|
|
// The `.>` form specifically: the bare `$JS.API.DIRECT.GET.<stream>`
|
|
// is not the subject the client uses, and granting it was measured
|
|
// to make no difference.
|
|
format!("$JS.API.DIRECT.GET.{stream}.>"),
|
|
// `store.keys()` — the controller lists before it fetches, so a
|
|
// reader without this can get a key it already knows and discover
|
|
// nothing.
|
|
//
|
|
// BOTH forms, and the bare one is the one that matters. `keys()`
|
|
// creates an **ephemeral** ordered consumer, whose create subject
|
|
// carries no consumer name — and `>` matches one or more tokens,
|
|
// never zero, so the `.>` form alone does not cover it. Granting
|
|
// only that produced `Permissions Violation for Publish to
|
|
// "$JS.API.CONSUMER.CREATE.KV_hive-status"`, which reaches the
|
|
// client as a **timeout** and the operator as a 503. The `.>` form
|
|
// stays for a named/durable consumer.
|
|
format!("$JS.API.CONSUMER.CREATE.{stream}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{stream}.>"),
|
|
// The knowledge event. One writer, many readers: the controller is
|
|
// the only publisher and every hive subscribes, so this is one
|
|
// literal subject rather than a per-hive family — named from the
|
|
// crate the publisher and the subscriber also name, so a rename
|
|
// cannot leave the grant pointing at a subject nobody uses.
|
|
//
|
|
// This is the reader's only non-JetStream subject, and without it
|
|
// the controller cannot emit the event at all. Worth stating because
|
|
// the symptom is unhelpful: a refused publish reaches the client as
|
|
// a **timeout**, so the visible failure is a hive that never hears
|
|
// about a change, with nothing in the controller's log to say a
|
|
// permission was the reason.
|
|
swarm_queue_client::KNOWLEDGE_SUBJECT.to_owned(),
|
|
// The deploy events: one subject per hive, so a hive is not woken
|
|
// by a deploy meant for another. Granted as a wildcard because
|
|
// this responder has no roster — it cannot enumerate hives, and a
|
|
// grant that had to track one would be a second place to get the
|
|
// list wrong (see `hive_name`'s doc for the same argument about
|
|
// admission). Same failure mode as the knowledge event above: a
|
|
// refused publish reaches the client as a timeout.
|
|
swarm_queue_client::DEPLOY_SUBJECT_WILDCARD.to_owned(),
|
|
// Operator input to an agent, one subject per agent, wildcard for
|
|
// the same no-roster reason. The harness takes the message's
|
|
// `from` as given, which holds only while this client is the
|
|
// family's sole publisher: no hive or agent grant may name it.
|
|
swarm_queue_client::AGENT_INPUT_SUBJECT_WILDCARD.to_owned(),
|
|
// Operator control commands to an agent (cancel its turn). A
|
|
// family of its own so the text-input grant never implies the
|
|
// power to stop a turn; same sole-publisher property, so a
|
|
// command needs no sender field to be trusted.
|
|
swarm_queue_client::AGENT_CONTROL_SUBJECT_WILDCARD.to_owned(),
|
|
// A review classifier try-out, sent as a request: the reply comes
|
|
// back on this client's inbox, which it already subscribes to.
|
|
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
|
|
// A build of an agent config, sent as a request the same way.
|
|
swarm_queue_client::builder::SUBJECT.to_owned(),
|
|
// An archetype evaluation, sent as a request the same way.
|
|
swarm_queue_client::builder::ARCHETYPES_SUBJECT.to_owned(),
|
|
// An operator's claude login step, sent as a request the same way.
|
|
swarm_queue_client::claude_login::SUBJECT.to_owned(),
|
|
// The wanted-state buckets — one per hive, all created and written
|
|
// by this single client.
|
|
//
|
|
// Wildcards rather than a name per hive: a bucket name is one
|
|
// subject token and subjects have no prefix matching, so
|
|
// `KV_hive-wanted-*` is not expressible. The choice is an exact
|
|
// name per hive — which needs a hive roster in this responder,
|
|
// which `hive_name`'s doc argues against — or `*`.
|
|
//
|
|
// ⚠️ `*` therefore reaches every stream in the account, including
|
|
// the buckets hives publish themselves, so this client *can*
|
|
// overwrite a hive's self-reported status. That is an accepted
|
|
// operator decision, not an oversight: the controller is the
|
|
// swarm's writer of record, and a swarm that cannot trust it has a
|
|
// larger problem than this grant.
|
|
"$JS.API.STREAM.INFO.*".to_owned(),
|
|
"$JS.API.STREAM.CREATE.*".to_owned(),
|
|
"$JS.API.DIRECT.GET.*.>".to_owned(),
|
|
// The newest row of one subagent in `term-sub-<agent>`, which the
|
|
// subagent list reads by subject. Those streams do not allow
|
|
// direct get, so `DIRECT.GET` above does not reach them. `*` is a
|
|
// whole token: `term-sub-*` would name one stream literally, and
|
|
// the agent is not known here. Also reaches every other stream's
|
|
// `MSG.GET`, the same width as `STREAM.INFO.*` above. A refused
|
|
// publish reaches the client as a timeout.
|
|
"$JS.API.STREAM.MSG.GET.*".to_owned(),
|
|
// Replaying an agent's terminal from `term-main-<agent>` to
|
|
// swarm-ui. Consumer create on every stream, but only in the
|
|
// extended form whose last tokens are the consumer's filter,
|
|
// which the server checks against the payload. So a consumer
|
|
// delivers only rows below `$SWARM.term`, every agent's and every
|
|
// subagent's included (the `*` also matches a literal `*` or `>`),
|
|
// which this client already subscribes to and reads by
|
|
// `MSG.GET` above. No bare `CONSUMER.CREATE.<stream>`, whose
|
|
// filter is unchecked.
|
|
swarm_queue_client::agent_term::consumer_create_grant(),
|
|
"$KV.*.>".to_owned(),
|
|
]);
|
|
// The per-agent status bucket, read-side only. Same five subjects as
|
|
// the hive-status block above and for the same measured reasons —
|
|
// including BOTH `CONSUMER.CREATE` forms, since `keys()` builds an
|
|
// ephemeral consumer whose subject carries no name.
|
|
//
|
|
// Deliberately **no `$KV.<bucket>.…` subject**: that is the write
|
|
// side, and the controller only reads this bucket. The hive gets a
|
|
// write grant scoped to its own agents (see `hive_subjects`); a
|
|
// reader holding one could forge any agent's status on any hive.
|
|
// Reading needs none of it — a KV read is a `DIRECT.GET`.
|
|
let agent_status = Self::agent_status_stream();
|
|
subjects.extend([
|
|
format!("$JS.API.STREAM.INFO.{agent_status}"),
|
|
format!("$JS.API.STREAM.CREATE.{agent_status}"),
|
|
format!("$JS.API.DIRECT.GET.{agent_status}.>"),
|
|
format!("$JS.API.CONSUMER.CREATE.{agent_status}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{agent_status}.>"),
|
|
]);
|
|
subjects
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn policy() -> Policy {
|
|
Policy::new(
|
|
"hive-".to_owned(),
|
|
"-agent".to_owned(),
|
|
"hive-status".to_owned(),
|
|
vec!["swarm-controller".to_owned()],
|
|
Vec::new(),
|
|
)
|
|
.expect("the default policy is valid")
|
|
}
|
|
|
|
/// An agent grant's publish list without the operator-profile read every
|
|
/// agent holds, so a test can compare the templated part exactly.
|
|
fn templated(grant: &Permissions) -> Vec<String> {
|
|
let shared = swarm_queue_client::operator_profiles::direct_get_subject();
|
|
grant
|
|
.publish
|
|
.iter()
|
|
.filter(|s| **s != shared)
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn the_reader_can_open_and_list_the_agent_status_bucket() {
|
|
let subjects = policy().reader_subjects();
|
|
let s = Policy::agent_status_stream();
|
|
for want in [
|
|
format!("$JS.API.STREAM.INFO.{s}"),
|
|
format!("$JS.API.STREAM.CREATE.{s}"),
|
|
format!("$JS.API.DIRECT.GET.{s}.>"),
|
|
// The bare form is the one `keys()` needs: its ephemeral consumer
|
|
// has no name, and `>` never matches zero tokens.
|
|
format!("$JS.API.CONSUMER.CREATE.{s}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{s}.>"),
|
|
] {
|
|
assert!(subjects.contains(&want), "reader is missing {want}");
|
|
}
|
|
// Control: the same reader really can be missing a subject, so the
|
|
// assertions above are not vacuously true of any string.
|
|
assert!(!subjects.contains(&format!("$JS.API.STREAM.DELETE.{s}")));
|
|
}
|
|
|
|
#[test]
|
|
fn no_hive_may_write_another_role_s_agent_status() {
|
|
// A hive publishes its *own* agents' status under
|
|
// `$KV.agent-status.<hive>.*` and must reach no further. This is the
|
|
// arm of the old reader-side guard that still holds.
|
|
let bucket = swarm_queue_client::agent_status::BUCKET;
|
|
let subjects = policy().hive_subjects("alpha");
|
|
for forbidden in [
|
|
format!("$KV.{bucket}.>"),
|
|
format!("$KV.{bucket}.*"),
|
|
format!("$KV.{bucket}.beta.*"),
|
|
] {
|
|
assert!(!subjects.contains(&forbidden), "hive alpha got {forbidden}");
|
|
}
|
|
// Control: the containment being asserted is not vacuous — the hive
|
|
// does hold its own slice.
|
|
assert!(subjects.contains(&format!("$KV.{bucket}.alpha.*")));
|
|
}
|
|
|
|
#[test]
|
|
fn the_readers_grant_is_deliberately_account_wide() {
|
|
// ⚠️ This pins a DECISION, not a safety property, and it replaced a
|
|
// guard that asserted the opposite — the operator's call was that the
|
|
// controller being able to override a hive is acceptable.
|
|
//
|
|
// A bucket name is one subject token and subjects have no prefix
|
|
// matching, so per-hive wanted-state buckets cannot be covered by any
|
|
// wildcard narrower than `*`. Scoping the controller would mean a hive
|
|
// roster inside this responder. If that trade is ever revisited, this
|
|
// test is the thing to change — it exists so the width reads as chosen.
|
|
let subjects = policy().reader_subjects();
|
|
assert!(subjects.contains(&"$KV.*.>".to_owned()));
|
|
assert!(subjects.contains(&"$JS.API.STREAM.INFO.*".to_owned()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_write_only_its_own_agents_status_keys() {
|
|
let bucket = swarm_queue_client::agent_status::BUCKET;
|
|
let subjects = policy().hive_subjects("alpha");
|
|
let s = Policy::agent_status_stream();
|
|
for want in [
|
|
// Without these the write subject is unreachable: `open_or_create`
|
|
// resolves the bucket first and times out before it publishes.
|
|
format!("$JS.API.STREAM.INFO.{s}"),
|
|
format!("$JS.API.STREAM.CREATE.{s}"),
|
|
format!("$KV.{bucket}.alpha.*"),
|
|
] {
|
|
assert!(subjects.contains(&want), "hive alpha is missing {want}");
|
|
}
|
|
// The scoping is the point, so pin what must NOT be there: a
|
|
// bucket-wide wildcard in any of its spellings, and another hive's
|
|
// slice. `.*` matches one token; `.>` would match deeper than the key
|
|
// format ever produces.
|
|
for forbidden in [
|
|
format!("$KV.{bucket}.>"),
|
|
format!("$KV.{bucket}.*"),
|
|
format!("$KV.{bucket}.alpha.>"),
|
|
format!("$KV.{bucket}.beta.*"),
|
|
] {
|
|
assert!(
|
|
!subjects.contains(&forbidden),
|
|
"hive alpha was granted {forbidden}, which reaches past its own agents"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A hive reads any agent's caps and can change none of them, and the
|
|
/// grant reaches no other bucket.
|
|
#[test]
|
|
fn a_hive_may_read_every_agents_caps_and_write_none() {
|
|
use swarm_queue_client::agent_limits::{STREAM, direct_get_subject, key_subject};
|
|
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
let may = |subject: &str| p.publish.iter().any(|g| covers(g, subject));
|
|
|
|
// Its own agents and agents running elsewhere alike.
|
|
for agent in ["atlas", "iris"] {
|
|
assert!(may(&direct_get_subject(agent)), "cannot read {agent}");
|
|
}
|
|
// A KV put or delete is a publish to the key's subject; the rest
|
|
// reshape or empty the stream.
|
|
for forbidden in [
|
|
key_subject("atlas"),
|
|
format!("$JS.API.STREAM.CREATE.{STREAM}"),
|
|
format!("$JS.API.STREAM.UPDATE.{STREAM}"),
|
|
format!("$JS.API.STREAM.DELETE.{STREAM}"),
|
|
format!("$JS.API.STREAM.PURGE.{STREAM}"),
|
|
format!("$JS.API.STREAM.MSG.DELETE.{STREAM}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
|
|
// A named consumer, bare and with a key filter, which a grant of
|
|
// `CONSUMER.CREATE.<stream>.>` would cover.
|
|
format!("$JS.API.CONSUMER.CREATE.{STREAM}.c1"),
|
|
format!(
|
|
"$JS.API.CONSUMER.CREATE.{STREAM}.c1.{}",
|
|
key_subject("atlas")
|
|
),
|
|
] {
|
|
assert!(!may(&forbidden), "hive alpha may publish {forbidden}");
|
|
}
|
|
// The read reaches only this bucket.
|
|
for other in [
|
|
swarm_queue_client::agent_inference::direct_get_subject("atlas"),
|
|
swarm_queue_client::agent_schedules::direct_get_subject("atlas"),
|
|
"$JS.API.DIRECT.GET.KV_hive-wanted-beta.$KV.hive-wanted-beta.beta".to_owned(),
|
|
] {
|
|
assert!(!may(&other), "hive alpha may publish {other}");
|
|
}
|
|
}
|
|
|
|
/// A hive reads any agent's permissions and can change none of them; an
|
|
/// agent, with the template `swarm-nats.nix` passes, reads its own key and
|
|
/// no other, and writes none.
|
|
#[test]
|
|
fn a_hive_reads_every_agents_permissions_and_an_agent_only_its_own() {
|
|
use swarm_queue_client::agent_permissions::{STREAM, key_subject};
|
|
|
|
let direct_get_subject = |agent: &str| {
|
|
format!("$JS.API.DIRECT.GET.KV_agent-permissions.$KV.agent-permissions.{agent}")
|
|
};
|
|
let p = policy()
|
|
.with_agent_token_subjects(vec![
|
|
"$JS.API.DIRECT.GET.KV_agent-permissions.$KV.agent-permissions.{agent}".to_owned(),
|
|
])
|
|
.expect("a per-agent template is valid");
|
|
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
|
|
let hive_may = |subject: &str| hive.publish.iter().any(|g| covers(g, subject));
|
|
for agent in ["atlas", "ruth"] {
|
|
assert!(hive_may(&direct_get_subject(agent)), "cannot read {agent}");
|
|
}
|
|
for forbidden in [
|
|
key_subject("atlas"),
|
|
format!("$JS.API.STREAM.CREATE.{STREAM}"),
|
|
format!("$JS.API.STREAM.DELETE.{STREAM}"),
|
|
format!("$JS.API.STREAM.PURGE.{STREAM}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
|
|
] {
|
|
assert!(!hive_may(&forbidden), "hive alpha may publish {forbidden}");
|
|
}
|
|
|
|
let agent = p.agent_token_permissions("atlas").expect("configured");
|
|
let agent_may = |subject: &str| agent.publish.iter().any(|g| covers(g, subject));
|
|
assert!(agent_may(&direct_get_subject("atlas")));
|
|
for forbidden in [direct_get_subject("ruth"), key_subject("atlas")] {
|
|
assert!(!agent_may(&forbidden), "atlas may publish {forbidden}");
|
|
}
|
|
}
|
|
|
|
/// A hive sends its import on its own subject only; the reader hears every
|
|
/// hive's and may answer, and no other role may send one.
|
|
#[test]
|
|
fn a_hive_imports_only_under_its_own_name_and_the_reader_answers() {
|
|
use swarm_queue_client::agent_permissions::{
|
|
IMPORT_REPLY_DEADLINE, import_subject, import_subject_wildcard,
|
|
};
|
|
|
|
let p = policy();
|
|
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
|
|
let hive_may = |subject: &str| hive.publish.iter().any(|g| covers(g, subject));
|
|
assert!(hive_may(&import_subject("alpha")));
|
|
assert!(!hive_may(&import_subject("beta")));
|
|
|
|
let reader = p.permissions("swarm-controller").expect("the reader");
|
|
assert!(
|
|
reader
|
|
.subscribe
|
|
.as_ref()
|
|
.expect("scoped")
|
|
.contains(&import_subject_wildcard())
|
|
);
|
|
assert_eq!(reader.responses, Some(IMPORT_REPLY_DEADLINE));
|
|
assert!(
|
|
!reader
|
|
.publish
|
|
.iter()
|
|
.any(|g| covers(g, &import_subject("alpha"))),
|
|
"the reader may send an import as a hive"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_extra_subject_without_the_placeholder_is_refused() {
|
|
// 🩸 The option exists to put a second stream inside ONE hive's
|
|
// namespace. A template with no `{hive}` expands to the same subject
|
|
// for all of them, so the flag whose purpose is scoping becomes the
|
|
// way to remove it - silently, and only in the deployment that set it.
|
|
let err = Policy::new(
|
|
"hive-".to_owned(),
|
|
"-agent".to_owned(),
|
|
"hive-status".to_owned(),
|
|
Vec::new(),
|
|
vec!["$SWARM.events.all".to_owned()],
|
|
)
|
|
.expect_err("a subject shared by every hive must not be accepted");
|
|
let msg = format!("{err}");
|
|
assert!(
|
|
msg.contains("$SWARM.events.all"),
|
|
"the error must name the offending template, got: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_extra_subject_with_the_placeholder_is_accepted() {
|
|
// The other half: a check that only ever rejects would be indistinguishable
|
|
// from the option being broken.
|
|
Policy::new(
|
|
"hive-".to_owned(),
|
|
"-agent".to_owned(),
|
|
"hive-status".to_owned(),
|
|
Vec::new(),
|
|
vec!["$SWARM.events.{hive}.>".to_owned()],
|
|
)
|
|
.expect("a per-hive template is the shape this option is for");
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_publish_its_own_key_and_nothing_elses() {
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
assert!(p.publish.contains(&"$KV.hive-status.alpha".to_owned()));
|
|
assert!(!p.publish.iter().any(|s| s.contains("beta")));
|
|
}
|
|
|
|
#[test]
|
|
fn a_reader_may_publish_the_knowledge_event() {
|
|
let p = policy().permissions("swarm-controller").expect("a reader");
|
|
assert!(
|
|
p.publish
|
|
.contains(&swarm_queue_client::KNOWLEDGE_SUBJECT.to_owned()),
|
|
"the controller is the only publisher of this event; without the \
|
|
grant its publish is refused, and a refusal arrives as a timeout"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_not_publish_the_knowledge_event_to_anyone_including_itself() {
|
|
// The controller *interprets* what a delivery means; a hive receives
|
|
// that verdict. A hive able to publish here could tell every other hive
|
|
// in the swarm — or itself — that the knowledge repo changed when it did
|
|
// not, which is an unauthenticated write into someone else's control
|
|
// path wearing an event's shape.
|
|
//
|
|
// One writer and many readers makes this arm matter MORE, not less: with
|
|
// a single shared subject a forged event reaches the whole swarm, where
|
|
// a per-hive subject would have reached one.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
assert!(
|
|
!p.publish
|
|
.iter()
|
|
.any(|s| s == swarm_queue_client::KNOWLEDGE_SUBJECT),
|
|
"a hive must not publish the knowledge event: {:?}",
|
|
p.publish
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_reader_may_publish_a_deploy_event_to_any_hive() {
|
|
let p = policy().permissions("swarm-controller").expect("a reader");
|
|
assert!(
|
|
p.publish
|
|
.contains(&swarm_queue_client::DEPLOY_SUBJECT_WILDCARD.to_owned()),
|
|
"the controller is the only publisher of these events; without the \
|
|
grant its publish is refused, and a refusal arrives as a timeout"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_not_publish_a_deploy_event_to_anyone_including_itself() {
|
|
// Same arm as the knowledge event's, and it matters more here: a forged
|
|
// knowledge event makes a hive re-read a repo, while a forged deploy
|
|
// event makes it rebuild and restart a named agent.
|
|
//
|
|
// Both directions asserted, because splitting the subject per hive
|
|
// makes them separate strings for the first time: a hive must reach
|
|
// neither another hive's deploy subject nor its own. Nothing in the
|
|
// grant should mention this family at all.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
for forbidden in [
|
|
swarm_queue_client::deploy_subject("beta"),
|
|
swarm_queue_client::deploy_subject("alpha"),
|
|
swarm_queue_client::DEPLOY_SUBJECT_WILDCARD.to_owned(),
|
|
] {
|
|
assert!(
|
|
!p.publish.contains(&forbidden),
|
|
"a hive must not publish {forbidden}: {:?}",
|
|
p.publish
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_reader_may_publish_input_to_any_agent() {
|
|
let p = policy().permissions("swarm-controller").expect("a reader");
|
|
assert!(
|
|
p.publish
|
|
.contains(&swarm_queue_client::AGENT_INPUT_SUBJECT_WILDCARD.to_owned()),
|
|
"the controller is the only publisher of operator input; without \
|
|
the grant its request is refused, and a refusal arrives as a timeout"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_reader_may_publish_control_to_any_agent() {
|
|
let p = policy().permissions("swarm-controller").expect("a reader");
|
|
assert!(
|
|
p.publish
|
|
.contains(&swarm_queue_client::AGENT_CONTROL_SUBJECT_WILDCARD.to_owned()),
|
|
"the controller is the only publisher of control commands; without \
|
|
the grant its request is refused, and a refusal arrives as a timeout"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_but_the_controller_may_publish_control() {
|
|
// A command carries no sender: the harness acts on it because only
|
|
// the controller can publish it. A hive or an agent able to publish
|
|
// here could cancel any agent's turn.
|
|
let hive = policy().permissions("hive-alpha").expect("admitted");
|
|
let agent = policy()
|
|
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
|
|
.expect("a per-agent template is valid")
|
|
.agent_token_permissions("alpha")
|
|
.expect("configured");
|
|
for (who, p) in [("a hive", &hive), ("an agent", &agent)] {
|
|
assert!(
|
|
!p.publish.iter().any(|s| s.starts_with("$SWARM.control")),
|
|
"{who} must not publish control: {:?}",
|
|
p.publish
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_may_answer_the_command_it_was_sent() {
|
|
let p = policy()
|
|
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
|
|
.expect("a per-agent template is valid");
|
|
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
|
assert_eq!(
|
|
atlas.responses,
|
|
Some(swarm_queue_client::AGENT_CONTROL_REPLY_DEADLINE)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_not_publish_input_to_an_agent() {
|
|
// The harness turns this message into a turn from the operator named in
|
|
// it. A hive able to publish here could speak as the operator to any
|
|
// agent in the swarm.
|
|
let p = policy().permissions("hive-alpha").expect("admitted");
|
|
for forbidden in [
|
|
swarm_queue_client::agent_input_subject("alpha"),
|
|
swarm_queue_client::AGENT_INPUT_SUBJECT_WILDCARD.to_owned(),
|
|
] {
|
|
assert!(
|
|
!p.publish.contains(&forbidden),
|
|
"a hive must not publish {forbidden}: {:?}",
|
|
p.publish
|
|
);
|
|
}
|
|
assert!(
|
|
!p.publish.iter().any(|s| s.starts_with("$SWARM.input")),
|
|
"{:?}",
|
|
p.publish
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_grant_never_includes_the_jetstream_wildcard() {
|
|
// `$JS.API.>` also covers `$JS.API.STREAM.DELETE.KV_hive-status`, with
|
|
// which a hive refused on a neighbour's key can delete the entire
|
|
// bucket. Measured, not theorised - this test is the guard against it
|
|
// coming back as a convenience.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
assert!(!p.publish.iter().any(|s| s.contains("$JS.API.>")));
|
|
assert!(!p.publish.iter().any(|s| s.contains("STREAM.DELETE")));
|
|
assert!(!p.publish.iter().any(|s| s.contains("STREAM.PURGE")));
|
|
}
|
|
|
|
#[test]
|
|
fn the_bucket_lookup_is_part_of_a_publishers_minimum() {
|
|
// Without it the put is refused: the client resolves the bucket before
|
|
// it writes. Dropping this line looks like tightening and is breaking.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
assert!(
|
|
p.publish
|
|
.contains(&"$JS.API.STREAM.INFO.KV_hive-status".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_grant_carries_the_jetstream_minimum() {
|
|
// 🩸 Found by the shipping gate, not by any unit test: a grant with
|
|
// every bucket-specific subject and neither of these cannot create the
|
|
// bucket at all. The client times out on `$JS.API.INFO` before it
|
|
// reaches anything that was granted, and a NATS denial looks like a
|
|
// hang from the client side — the server log is what named them.
|
|
for client in ["hive-alpha", "swarm-controller"] {
|
|
let p = policy().permissions(client).expect("admitted");
|
|
for required in ["$JS.API.INFO", "$JS.API.STREAM.NAMES"] {
|
|
assert!(
|
|
p.publish.iter().any(|s| s == required),
|
|
"{client} is missing {required}, so it cannot use JetStream at all"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn both_ends_may_create_the_bucket_but_not_reshape_it() {
|
|
// 🩸 The bug the subject measurements could not see: they ran against
|
|
// a bucket the *setup* had already created while unscoped, so
|
|
// "minimal" meant minimal-given-a-bucket-that-exists. `open_or_create`
|
|
// is called by both ends by design — whichever arrives first on a
|
|
// fresh swarm makes the bucket — so without CREATE a new swarm never
|
|
// gets one, and every other test still passes.
|
|
for client in ["hive-alpha", "swarm-controller"] {
|
|
let p = policy().permissions(client).expect("admitted");
|
|
assert!(
|
|
p.publish
|
|
.contains(&"$JS.API.STREAM.CREATE.KV_hive-status".to_owned()),
|
|
"{client} cannot create the bucket on a fresh swarm"
|
|
);
|
|
// CREATE is not UPDATE: a second arrival must not be able to
|
|
// reshape the bucket the first one made.
|
|
assert!(!p.publish.iter().any(|s| s.contains("STREAM.UPDATE")));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_reader_may_list_and_fetch_but_not_write() {
|
|
let p = policy()
|
|
.permissions("swarm-controller")
|
|
.expect("the reader is admitted");
|
|
// The BARE subject, asserted separately and first: `keys()` creates an
|
|
// ephemeral consumer, so its create subject ends at the stream name,
|
|
// and `>` matches one or more tokens rather than zero. This assertion
|
|
// used to name only the `.>` form below — which reads as covering the
|
|
// bare one and does not, so the suite was green while every list timed
|
|
// out in production.
|
|
assert!(
|
|
p.publish
|
|
.contains(&"$JS.API.CONSUMER.CREATE.KV_hive-status".to_owned()),
|
|
"an ephemeral consumer create carries no name token"
|
|
);
|
|
assert!(
|
|
p.publish
|
|
.contains(&"$JS.API.CONSUMER.CREATE.KV_hive-status.>".to_owned())
|
|
);
|
|
assert!(
|
|
p.publish
|
|
.contains(&"$JS.API.DIRECT.GET.KV_hive-status.>".to_owned())
|
|
);
|
|
// Scoped to the status bucket, not to `$KV.` as a whole: the hives
|
|
// write that one and a controller able to write it could forge a
|
|
// hive's own report. The wanted bucket runs the other way and the
|
|
// controller is its writer, so a blanket assertion here would forbid
|
|
// the grant that bucket exists for.
|
|
assert!(!p.publish.iter().any(|s| s.starts_with("$KV.hive-status")));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unmatched_client_is_denied_not_granted_everything() {
|
|
// The whole policy is advisory if this returns a grant.
|
|
assert_eq!(policy().permissions("some-other-service"), None);
|
|
assert_eq!(policy().permissions(""), None);
|
|
}
|
|
|
|
/// Whether the grant `grant` covers the concrete subject `subject`, by
|
|
/// NATS' token rules: `*` is one token, `>` one or more.
|
|
fn covers(grant: &str, subject: &str) -> bool {
|
|
let g: Vec<&str> = grant.split('.').collect();
|
|
let s: Vec<&str> = subject.split('.').collect();
|
|
for (i, t) in g.iter().enumerate() {
|
|
match *t {
|
|
">" => return s.len() > i,
|
|
"*" if i < s.len() => {}
|
|
t if s.get(i) == Some(&t) => {}
|
|
_ => return false,
|
|
}
|
|
}
|
|
g.len() == s.len()
|
|
}
|
|
|
|
fn may_subscribe(p: &Permissions, subject: &str) -> bool {
|
|
let grants = p
|
|
.subscribe
|
|
.as_ref()
|
|
.expect("a grant's subscribe is an allow-list, never unrestricted");
|
|
assert!(!grants.is_empty(), "an empty allow-list is unrestricted");
|
|
grants.iter().any(|g| covers(g, subject))
|
|
}
|
|
|
|
/// The reply inbox each client connects with, as a subject a
|
|
/// request under it would receive on.
|
|
fn reply(prefix: &str) -> String {
|
|
format!("{prefix}.nuid123.token456")
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_token_may_subscribe_to_its_own_inbox_input_and_control_only() {
|
|
use swarm_queue_client::{agent_control_subject, agent_inbox_prefix, agent_input_subject};
|
|
|
|
let p = policy()
|
|
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
|
|
.expect("a per-agent template is valid");
|
|
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
|
assert!(may_subscribe(&atlas, &reply(&agent_inbox_prefix("atlas"))));
|
|
assert!(may_subscribe(&atlas, &agent_input_subject("atlas")));
|
|
assert!(may_subscribe(&atlas, &agent_control_subject("atlas")));
|
|
for refused in [
|
|
">".to_owned(),
|
|
"$SWARM.term.>".to_owned(),
|
|
"$SWARM.term.argus".to_owned(),
|
|
"$SWARM.term.atlas".to_owned(),
|
|
"$SWARM.input.*".to_owned(),
|
|
"$SWARM.control.*".to_owned(),
|
|
agent_input_subject("argus"),
|
|
agent_control_subject("argus"),
|
|
reply(&agent_inbox_prefix("argus")),
|
|
"_INBOX.nuid123.token456".to_owned(),
|
|
] {
|
|
assert!(
|
|
!may_subscribe(&atlas, &refused),
|
|
"atlas may subscribe to {refused}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The subscribe the reply multiplexer makes under `prefix`: a wildcard,
|
|
/// so the grant has to cover it as a subset, not just one reply.
|
|
fn multiplexer(prefix: &str) -> String {
|
|
format!("{prefix}.nuid123.*")
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_subscribe_to_its_own_events_and_inbox_only() {
|
|
use swarm_queue_client::{
|
|
KNOWLEDGE_SUBJECT, agent_inbox_prefix, agent_input_subject, client_inbox_prefix,
|
|
deploy_subject,
|
|
};
|
|
|
|
let alpha = policy().permissions("hive-alpha").expect("a hive");
|
|
let inbox = client_inbox_prefix("hive-alpha");
|
|
for allowed in [
|
|
KNOWLEDGE_SUBJECT.to_owned(),
|
|
deploy_subject("alpha"),
|
|
reply(&inbox),
|
|
multiplexer(&inbox),
|
|
] {
|
|
assert!(
|
|
may_subscribe(&alpha, &allowed),
|
|
"alpha may not subscribe to {allowed}"
|
|
);
|
|
}
|
|
for refused in [
|
|
">".to_owned(),
|
|
"$SWARM.>".to_owned(),
|
|
deploy_subject("beta"),
|
|
swarm_queue_client::DEPLOY_SUBJECT_WILDCARD.to_owned(),
|
|
"$SWARM.term.>".to_owned(),
|
|
"$SWARM.term.atlas".to_owned(),
|
|
"$SWARM.agent-state.atlas".to_owned(),
|
|
agent_input_subject("atlas"),
|
|
reply(&agent_inbox_prefix("atlas")),
|
|
reply(&client_inbox_prefix("hive-beta")),
|
|
reply(&client_inbox_prefix("swarm-controller")),
|
|
"_INBOX.nuid123.token456".to_owned(),
|
|
] {
|
|
assert!(
|
|
!may_subscribe(&alpha, &refused),
|
|
"alpha may subscribe to {refused}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_reader_may_subscribe_to_agent_streams_and_its_inbox_only() {
|
|
use swarm_queue_client::{
|
|
KNOWLEDGE_SUBJECT, agent_inbox_prefix, agent_input_subject, client_inbox_prefix,
|
|
deploy_subject,
|
|
};
|
|
|
|
let reader = policy()
|
|
.permissions("swarm-controller")
|
|
.expect("the reader");
|
|
let inbox = client_inbox_prefix("swarm-controller");
|
|
for allowed in [
|
|
"$SWARM.term.atlas".to_owned(),
|
|
swarm_queue_client::subagent_term::subject("atlas", "scout"),
|
|
"$SWARM.agent-state.atlas".to_owned(),
|
|
reply(&inbox),
|
|
multiplexer(&inbox),
|
|
] {
|
|
assert!(
|
|
may_subscribe(&reader, &allowed),
|
|
"the reader may not subscribe to {allowed}"
|
|
);
|
|
}
|
|
for refused in [
|
|
">".to_owned(),
|
|
"$SWARM.>".to_owned(),
|
|
agent_input_subject("atlas"),
|
|
deploy_subject("alpha"),
|
|
KNOWLEDGE_SUBJECT.to_owned(),
|
|
reply(&agent_inbox_prefix("atlas")),
|
|
reply(&client_inbox_prefix("hive-alpha")),
|
|
"_INBOX.nuid123.token456".to_owned(),
|
|
] {
|
|
assert!(
|
|
!may_subscribe(&reader, &refused),
|
|
"the reader may subscribe to {refused}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The subjects the publishers and relays build from the shared helpers
|
|
/// are the ones the reader's grant covers, whatever those helpers spell.
|
|
#[test]
|
|
fn the_reader_may_subscribe_to_every_subject_the_shared_helpers_build() {
|
|
let reader = policy()
|
|
.permissions("swarm-controller")
|
|
.expect("the reader");
|
|
for built in [
|
|
swarm_queue_client::term_subject("atlas"),
|
|
swarm_queue_client::subagent_term::subject("atlas", "scout"),
|
|
swarm_queue_client::agent_state_subject("atlas"),
|
|
] {
|
|
assert!(
|
|
may_subscribe(&reader, &built),
|
|
"the reader may not subscribe to {built}"
|
|
);
|
|
}
|
|
}
|
|
|
|
const CLASSIFIER: &str = "swarm-review-classifier";
|
|
|
|
fn with_classifier() -> Policy {
|
|
policy().with_review_classifiers(vec![CLASSIFIER.to_owned()])
|
|
}
|
|
|
|
#[test]
|
|
fn the_review_classifier_hears_its_subject_and_inbox_only_and_answers_by_reply() {
|
|
use swarm_queue_client::review_classifier::{RUN_DEADLINE, SUBJECT};
|
|
use swarm_queue_client::{agent_input_subject, client_inbox_prefix};
|
|
|
|
let p = with_classifier()
|
|
.permissions(CLASSIFIER)
|
|
.expect("a configured classifier is admitted");
|
|
let inbox = client_inbox_prefix(CLASSIFIER);
|
|
assert!(may_subscribe(&p, SUBJECT));
|
|
assert!(may_subscribe(&p, &reply(&inbox)));
|
|
for refused in [
|
|
">".to_owned(),
|
|
"$SWARM.>".to_owned(),
|
|
"$SWARM.review-classifier.other".to_owned(),
|
|
"$SWARM.term.atlas".to_owned(),
|
|
agent_input_subject("atlas"),
|
|
reply(&client_inbox_prefix("swarm-controller")),
|
|
"_INBOX.nuid123.token456".to_owned(),
|
|
] {
|
|
assert!(
|
|
!may_subscribe(&p, &refused),
|
|
"the classifier may subscribe to {refused}"
|
|
);
|
|
}
|
|
// It publishes nothing but replies: its own inbox keeps the list
|
|
// non-empty, and reaches no other client.
|
|
assert_eq!(p.publish, vec![format!("{inbox}.>")]);
|
|
assert_eq!(p.responses, Some(RUN_DEADLINE));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_client_is_denied_with_a_review_classifier_configured() {
|
|
let p = with_classifier();
|
|
assert_eq!(p.permissions("some-other-service"), None);
|
|
assert_eq!(p.permissions("swarm-review-classifier-2"), None);
|
|
// The classifier's id is nothing until it is configured.
|
|
assert_eq!(policy().permissions(CLASSIFIER), None);
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_reader_may_send_a_try_out() {
|
|
use swarm_queue_client::review_classifier::SUBJECT;
|
|
|
|
let p = with_classifier()
|
|
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
|
|
.expect("a per-agent template is valid");
|
|
let reader = p.permissions("swarm-controller").expect("the reader");
|
|
assert!(reader.publish.iter().any(|g| covers(g, SUBJECT)));
|
|
for (who, grant) in [
|
|
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
|
(
|
|
"the classifier",
|
|
p.permissions(CLASSIFIER).expect("admitted"),
|
|
),
|
|
(
|
|
"an agent",
|
|
p.agent_token_permissions("atlas").expect("configured"),
|
|
),
|
|
] {
|
|
assert!(
|
|
!grant.publish.iter().any(|g| covers(g, SUBJECT)),
|
|
"{who} may send a try-out: {:?}",
|
|
grant.publish
|
|
);
|
|
}
|
|
}
|
|
|
|
const BUILDER: &str = "swarm-builder";
|
|
|
|
#[test]
|
|
fn the_builder_hears_its_subject_and_inbox_only_and_answers_by_reply() {
|
|
use swarm_queue_client::builder::{ARCHETYPES_SUBJECT, REPLY_DEADLINE, SUBJECT};
|
|
use swarm_queue_client::client_inbox_prefix;
|
|
|
|
let p = policy()
|
|
.with_builders(vec![BUILDER.to_owned()])
|
|
.permissions(BUILDER)
|
|
.expect("a configured builder is admitted");
|
|
let inbox = client_inbox_prefix(BUILDER);
|
|
assert!(may_subscribe(&p, SUBJECT));
|
|
assert!(may_subscribe(&p, ARCHETYPES_SUBJECT));
|
|
assert!(may_subscribe(&p, &reply(&inbox)));
|
|
for refused in [
|
|
">".to_owned(),
|
|
"$SWARM.>".to_owned(),
|
|
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
|
|
reply(&client_inbox_prefix("swarm-controller")),
|
|
] {
|
|
assert!(
|
|
!may_subscribe(&p, &refused),
|
|
"the builder may subscribe to {refused}"
|
|
);
|
|
}
|
|
assert_eq!(p.publish, vec![format!("{inbox}.>")]);
|
|
assert_eq!(p.responses, Some(REPLY_DEADLINE));
|
|
// The builder's id is nothing until it is configured.
|
|
assert_eq!(policy().permissions(BUILDER), None);
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_reader_may_send_a_build() {
|
|
use swarm_queue_client::builder::{ARCHETYPES_SUBJECT, SUBJECT};
|
|
|
|
let p = with_classifier().with_builders(vec![BUILDER.to_owned()]);
|
|
let reader = p.permissions("swarm-controller").expect("the reader");
|
|
for subject in [SUBJECT, ARCHETYPES_SUBJECT] {
|
|
assert!(reader.publish.iter().any(|g| covers(g, subject)));
|
|
for (who, grant) in [
|
|
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
|
("the builder", p.permissions(BUILDER).expect("admitted")),
|
|
(
|
|
"the classifier",
|
|
p.permissions(CLASSIFIER).expect("admitted"),
|
|
),
|
|
] {
|
|
assert!(
|
|
!grant.publish.iter().any(|g| covers(g, subject)),
|
|
"{who} may send to {subject}: {:?}",
|
|
grant.publish
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const CLAUDE_LOGIN: &str = "swarm-claude-login";
|
|
|
|
#[test]
|
|
fn the_claude_login_minter_hears_its_subject_and_inbox_only_and_answers_by_reply() {
|
|
use swarm_queue_client::claude_login::{REPLY_DEADLINE, SUBJECT};
|
|
use swarm_queue_client::client_inbox_prefix;
|
|
|
|
let p = policy()
|
|
.with_claude_logins(vec![CLAUDE_LOGIN.to_owned()])
|
|
.permissions(CLAUDE_LOGIN)
|
|
.expect("a configured minter is admitted");
|
|
let inbox = client_inbox_prefix(CLAUDE_LOGIN);
|
|
assert!(may_subscribe(&p, SUBJECT));
|
|
assert!(may_subscribe(&p, &reply(&inbox)));
|
|
for refused in [
|
|
">".to_owned(),
|
|
"$SWARM.>".to_owned(),
|
|
swarm_queue_client::builder::SUBJECT.to_owned(),
|
|
reply(&client_inbox_prefix("swarm-controller")),
|
|
] {
|
|
assert!(
|
|
!may_subscribe(&p, &refused),
|
|
"the minter may subscribe to {refused}"
|
|
);
|
|
}
|
|
assert_eq!(p.publish, vec![format!("{inbox}.>")]);
|
|
assert_eq!(p.responses, Some(REPLY_DEADLINE));
|
|
// The minter's id is nothing until it is configured.
|
|
assert_eq!(policy().permissions(CLAUDE_LOGIN), None);
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_reader_may_send_a_login_step() {
|
|
use swarm_queue_client::claude_login::SUBJECT;
|
|
|
|
let p = with_classifier()
|
|
.with_builders(vec![BUILDER.to_owned()])
|
|
.with_claude_logins(vec![CLAUDE_LOGIN.to_owned()]);
|
|
let reader = p.permissions("swarm-controller").expect("the reader");
|
|
assert!(reader.publish.iter().any(|g| covers(g, SUBJECT)));
|
|
for (who, grant) in [
|
|
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
|
("the minter", p.permissions(CLAUDE_LOGIN).expect("admitted")),
|
|
("the builder", p.permissions(BUILDER).expect("admitted")),
|
|
(
|
|
"the classifier",
|
|
p.permissions(CLASSIFIER).expect("admitted"),
|
|
),
|
|
] {
|
|
assert!(
|
|
!grant.publish.iter().any(|g| covers(g, SUBJECT)),
|
|
"{who} may send a login step: {:?}",
|
|
grant.publish
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_prefix_alone_names_no_hive() {
|
|
// `hive-` would otherwise produce `$KV.hive-status.`, a subject nobody
|
|
// reviewed and that no hive owns.
|
|
assert_eq!(policy().permissions("hive-"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_open_the_shared_notices_stream() {
|
|
// 🩸 The actual defect this closes: `open_or_create`'s `get_stream`
|
|
// call needs these two before a hive ever reaches its own
|
|
// `hive-notices.<hive>` publish subject, and neither has a
|
|
// `{hive}` to go through `extra_hive_subjects`.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
assert!(
|
|
p.publish
|
|
.contains(&"$JS.API.STREAM.INFO.hive-notices".to_owned())
|
|
);
|
|
assert!(
|
|
p.publish
|
|
.contains(&"$JS.API.STREAM.CREATE.hive-notices".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_publish_to_its_own_notices_subject() {
|
|
// Opening the stream and writing into it are separate grants.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
assert!(p.publish.contains(&"hive-notices.alpha".to_owned()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_not_publish_to_another_hives_notices_subject() {
|
|
// The stream-level subjects are identical for every hive; this one
|
|
// must not be, or reaching the stream would carry writing to every
|
|
// other hive's slice of it.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
assert!(!p.publish.contains(&"hive-notices.beta".to_owned()));
|
|
assert!(!p.publish.iter().any(|s| s == "hive-notices.>"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_read_its_own_wanted_key() {
|
|
// A KV read is a publish, and the direct-get subject carries the key.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
assert!(
|
|
p.publish.contains(
|
|
&"$JS.API.DIRECT.GET.KV_hive-wanted-alpha.$KV.hive-wanted-alpha.alpha".to_owned()
|
|
),
|
|
"got: {:?}",
|
|
p.publish
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_watch_its_own_wanted_bucket() {
|
|
// A watch is a consumer, so `DIRECT.GET` covers the boot-time read and
|
|
// nothing else. Both spellings, for the reason the status buckets grant
|
|
// both: an ephemeral consumer's subject carries no name, and `>` never
|
|
// matches zero tokens.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
for want in [
|
|
"$JS.API.CONSUMER.CREATE.KV_hive-wanted-alpha",
|
|
"$JS.API.CONSUMER.CREATE.KV_hive-wanted-alpha.>",
|
|
] {
|
|
assert!(p.publish.contains(&want.to_owned()), "got: {:?}", p.publish);
|
|
}
|
|
// Control: the grant's width is one stream, not the account — this is
|
|
// the property the per-hive bucket split bought.
|
|
assert!(
|
|
!p.publish
|
|
.iter()
|
|
.any(|s| s.starts_with("$JS.API.CONSUMER.CREATE.KV_hive-wanted-beta"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_not_read_another_hives_wanted_key() {
|
|
// The whole reason the read is scoped by key rather than by bucket.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
// ⚠️ Matched against the CURRENT bucket name. The per-hive split moved
|
|
// this from `hive-wanted.beta` to `hive-wanted-beta`, and the old
|
|
// spelling kept passing against a subject set that no longer contains
|
|
// it — a pass that had stopped meaning anything.
|
|
assert!(!p.publish.iter().any(|s| s.contains("hive-wanted-beta")));
|
|
assert!(!p.publish.iter().any(|s| s.contains("beta")));
|
|
assert!(
|
|
!p.publish
|
|
.iter()
|
|
.any(|s| s.starts_with("$JS.API.DIRECT.GET.KV_hive-wanted") && s.ends_with('>'))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_may_not_write_its_own_wanted_key() {
|
|
// The controller declares wanted state; a hive that could write its own
|
|
// key could declare its own.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
// Current names, for the reason the sibling test above records: the
|
|
// pre-split strings `$KV.hive-wanted.{alpha,>}` still "passed" after
|
|
// the rename because nothing produces them any more.
|
|
assert!(!p.publish.iter().any(|s| s == "$KV.hive-wanted-alpha.alpha"));
|
|
assert!(!p.publish.iter().any(|s| s == "$KV.hive-wanted-alpha.>"));
|
|
assert!(!p.publish.iter().any(|s| s.starts_with("$KV.hive-wanted")));
|
|
}
|
|
|
|
#[test]
|
|
fn the_controller_may_create_and_write_the_wanted_bucket() {
|
|
// Presence control for the three above: the subjects exist, on the one
|
|
// identity that is meant to hold them.
|
|
let p = policy()
|
|
.permissions("swarm-controller")
|
|
.expect("a reader is admitted");
|
|
// Account-wide since the per-hive split: a bucket name is one subject
|
|
// token, so no wildcard narrower than `*` covers N per-hive buckets.
|
|
// See `the_readers_grant_is_deliberately_account_wide`.
|
|
assert!(p.publish.contains(&"$KV.*.>".to_owned()));
|
|
assert!(p.publish.contains(&"$JS.API.STREAM.CREATE.*".to_owned()));
|
|
}
|
|
|
|
#[test]
|
|
fn the_controller_may_replay_terminal_rows_and_no_other_subject() {
|
|
use swarm_queue_client::agent_term::stream_name;
|
|
use swarm_queue_client::{subagent_term, term_subject};
|
|
|
|
let p = policy()
|
|
.permissions("swarm-controller")
|
|
.expect("a reader is admitted");
|
|
let may_publish = |s: &str| p.publish.iter().any(|g| covers(g, s));
|
|
let create = |stream: &str, filter: &str| {
|
|
format!("$JS.API.CONSUMER.CREATE.{stream}.term-replay-n1.{filter}")
|
|
};
|
|
assert!(may_publish(&create(
|
|
&stream_name("atlas"),
|
|
&term_subject("atlas")
|
|
)));
|
|
for refused in [
|
|
// The bare form, whose filter rides in the payload alone.
|
|
format!("$JS.API.CONSUMER.CREATE.{}", stream_name("atlas")),
|
|
create(
|
|
&subagent_term::stream_name("atlas"),
|
|
&subagent_term::subject("atlas", "scout"),
|
|
),
|
|
create("hive-notices", "hive-notices.alpha"),
|
|
create(&stream_name("atlas"), "$SWARM.agent-state.atlas"),
|
|
create(
|
|
&stream_name("atlas"),
|
|
&format!("{}.x", term_subject("atlas")),
|
|
),
|
|
] {
|
|
assert!(!may_publish(&refused), "the reader may publish {refused}");
|
|
}
|
|
// Control: no hive holds the grant.
|
|
assert!(
|
|
!policy()
|
|
.hive_subjects("alpha")
|
|
.iter()
|
|
.any(|s| covers(s, &create(&stream_name("atlas"), &term_subject("atlas"))))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_controller_may_read_a_subagents_newest_row() {
|
|
let p = policy()
|
|
.permissions("swarm-controller")
|
|
.expect("a reader is admitted");
|
|
assert!(p.publish.contains(&"$JS.API.STREAM.MSG.GET.*".to_owned()));
|
|
// `*` only matches a whole token, so a prefix spelling grants one
|
|
// stream literally named that and reads nothing.
|
|
assert!(!p.publish.iter().any(|s| s.contains("term-sub-*")));
|
|
// Control: a hive's grant holds no `MSG.GET`, so the subject above is
|
|
// the controller's alone.
|
|
assert!(
|
|
!policy()
|
|
.hive_subjects("alpha")
|
|
.iter()
|
|
.any(|s| s.starts_with("$JS.API.STREAM.MSG.GET"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_notices_stream_grant_is_identical_across_hives() {
|
|
// Unlike `$KV.<bucket>.<hive>` or an `extra_hive_subjects`
|
|
// template, these two subjects name the stream itself, not a
|
|
// per-hive slice of it — every hive gets the exact same two
|
|
// strings, and that is the correct shape, not an oversight.
|
|
let alpha = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
let beta = policy()
|
|
.permissions("hive-beta")
|
|
.expect("a hive is admitted");
|
|
for subject in [
|
|
"$JS.API.STREAM.INFO.hive-notices",
|
|
"$JS.API.STREAM.CREATE.hive-notices",
|
|
] {
|
|
assert!(alpha.publish.contains(&subject.to_owned()));
|
|
assert!(beta.publish.contains(&subject.to_owned()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_notices_grant_never_includes_stream_update_or_delete() {
|
|
// Same invariant `a_hive_grant_never_includes_the_jetstream_wildcard`
|
|
// holds for the status bucket, restated for the stream this fold
|
|
// grants CREATE/INFO on: create-if-absent must not become
|
|
// reshape-or-destroy.
|
|
let p = policy()
|
|
.permissions("hive-alpha")
|
|
.expect("a hive is admitted");
|
|
assert!(
|
|
!p.publish
|
|
.iter()
|
|
.any(|s| s.contains("hive-notices") && s.contains("STREAM.UPDATE"))
|
|
);
|
|
assert!(
|
|
!p.publish
|
|
.iter()
|
|
.any(|s| s.contains("hive-notices") && s.contains("STREAM.DELETE"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extra_subjects_are_scoped_to_the_hive_that_publishes_them() {
|
|
// The extension point: a second stream (lifecycle notices, say) is
|
|
// published by the same `hive-<name>` identity on a different subject.
|
|
// It has to land inside that hive's namespace, or one hive can write
|
|
// another's events even though its status key is scoped.
|
|
let p = Policy::new(
|
|
"hive-".to_owned(),
|
|
"-agent".to_owned(),
|
|
"hive-status".to_owned(),
|
|
Vec::new(),
|
|
vec!["$SWARM.events.{hive}.>".to_owned()],
|
|
)
|
|
.expect("a per-hive template is valid");
|
|
let g = p.permissions("hive-alpha").expect("a hive is admitted");
|
|
assert!(g.publish.contains(&"$SWARM.events.alpha.>".to_owned()));
|
|
assert!(!g.publish.iter().any(|s| s.contains("{hive}")));
|
|
}
|
|
|
|
#[test]
|
|
fn the_hives_shared_agent_client_is_refused() {
|
|
// Agents connect with their own credential. The shared client names
|
|
// the hive and never the agent, so any grant to it would let one agent
|
|
// publish as its siblings.
|
|
//
|
|
// 🩸 The agent id satisfies the HIVE parse too (`hive-alpha-agent`
|
|
// strips to a hive named `alpha-agent`), so a hive-first arm order
|
|
// would hand this client its hive's grant, status key included, and
|
|
// nothing would report it. `is_none` here is what holds the order.
|
|
assert!(policy().permissions("hive-alpha-agent").is_none());
|
|
// Control: the hive itself is admitted, so the refusal above is the
|
|
// agent arm answering rather than the prefix failing to match.
|
|
assert!(policy().permissions("hive-alpha").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_agent_suffix_is_refused() {
|
|
// 🩸 An agent id is a hive id plus the suffix, so an empty suffix makes
|
|
// `strip_suffix` succeed on every hive id: the two principals become
|
|
// one string, and the agent arm, which runs first and refuses, would
|
|
// refuse every hive. That fails at construction rather than at connect.
|
|
Policy::new(
|
|
"hive-".to_owned(),
|
|
String::new(),
|
|
"hive-status".to_owned(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
)
|
|
.expect_err("an empty agent suffix must not construct");
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_token_grant_is_the_agents_own_subjects_and_nothing_else() {
|
|
let p = policy()
|
|
.with_agent_token_subjects(vec![
|
|
"$SWARM.term.{agent}".to_owned(),
|
|
"$SWARM.agent-state.{agent}".to_owned(),
|
|
])
|
|
.expect("per-agent templates are valid");
|
|
let g = p.agent_token_permissions("atlas").expect("configured");
|
|
assert_eq!(
|
|
g.publish,
|
|
vec![
|
|
"$SWARM.term.atlas".to_owned(),
|
|
"$SWARM.agent-state.atlas".to_owned(),
|
|
"$JS.API.DIRECT.GET.KV_operator-profiles.$KV.operator-profiles.operators"
|
|
.to_owned(),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// A verified agent may write its own icon key and no other agent's.
|
|
#[test]
|
|
fn an_agent_may_write_only_its_own_icon_key() {
|
|
use swarm_queue_client::agent_icon::subject;
|
|
|
|
let p = policy()
|
|
.with_agent_token_subjects(vec!["$KV.agent-icons.{agent}".to_owned()])
|
|
.expect("a per-agent template is valid");
|
|
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
|
assert_eq!(templated(&atlas), vec![subject("atlas")]);
|
|
assert!(!atlas.publish.contains(&subject("argus")));
|
|
// Control: the same template does grant argus its own key, so atlas
|
|
// lacking it is atlas's grant being scoped.
|
|
let argus = p.agent_token_permissions("argus").expect("configured");
|
|
assert_eq!(templated(&argus), vec![subject("argus")]);
|
|
}
|
|
|
|
/// The subagent template as `swarm-nats.nix` spells it expands to the
|
|
/// subjects the subagent daemon publishes on, publish only. The stream is
|
|
/// the controller's to create, which its own grant covers.
|
|
#[test]
|
|
fn an_agents_subagent_grant_is_publish_on_its_own_family_only() {
|
|
use swarm_queue_client::subagent_term::{stream_name, stream_subjects, subject};
|
|
|
|
let p = policy()
|
|
.with_agent_token_subjects(vec!["$SWARM.term.{agent}.sub.>".to_owned()])
|
|
.expect("a per-agent template is valid");
|
|
let g = p.agent_token_permissions("atlas").expect("configured");
|
|
assert_eq!(templated(&g), vec![stream_subjects("atlas")]);
|
|
assert!(subject("atlas", "scout").starts_with(g.publish[0].trim_end_matches('>')));
|
|
let argus = p.agent_token_permissions("argus").expect("configured");
|
|
assert_eq!(templated(&argus), vec![stream_subjects("argus")]);
|
|
assert_ne!(g.publish, argus.publish);
|
|
|
|
let stream = stream_name("atlas");
|
|
assert!(
|
|
!stream.contains('.'),
|
|
"{stream} is one token, so `*` covers it"
|
|
);
|
|
assert!(
|
|
policy()
|
|
.reader_subjects()
|
|
.contains(&"$JS.API.STREAM.CREATE.*".to_owned())
|
|
);
|
|
}
|
|
|
|
/// The schedule template as `swarm-nats.nix` spells it reads one key: the
|
|
/// agent's own. It covers no other agent's key, no other stream or bucket,
|
|
/// and no way to open, watch or write the bucket.
|
|
#[test]
|
|
fn an_agent_may_read_only_its_own_schedule_key() {
|
|
use swarm_queue_client::agent_schedules::{STREAM, direct_get_subject, key_subject};
|
|
|
|
let p = policy()
|
|
.with_agent_token_subjects(vec![
|
|
"$JS.API.DIRECT.GET.KV_agent-schedules.$KV.agent-schedules.{agent}".to_owned(),
|
|
])
|
|
.expect("a per-agent template is valid");
|
|
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
|
assert_eq!(templated(&atlas), vec![direct_get_subject("atlas")]);
|
|
// Control: argus's own expansion is a different subject, so atlas
|
|
// lacking it is the grant being scoped, not the template being empty.
|
|
let argus = p.agent_token_permissions("argus").expect("configured");
|
|
assert_eq!(templated(&argus), vec![direct_get_subject("argus")]);
|
|
assert!(!atlas.publish.contains(&direct_get_subject("argus")));
|
|
|
|
for refused in [
|
|
direct_get_subject("argus"),
|
|
format!("$JS.API.DIRECT.GET.{STREAM}"),
|
|
format!("$JS.API.STREAM.INFO.{STREAM}"),
|
|
format!("$JS.API.STREAM.CREATE.{STREAM}"),
|
|
format!("$JS.API.STREAM.DELETE.{STREAM}"),
|
|
format!("$JS.API.STREAM.MSG.GET.{STREAM}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
|
|
format!(
|
|
"$JS.API.CONSUMER.CREATE.{STREAM}.w.{}",
|
|
key_subject("atlas")
|
|
),
|
|
key_subject("atlas"),
|
|
key_subject("argus"),
|
|
"$JS.API.DIRECT.GET.KV_agent-icons.$KV.agent-icons.atlas".to_owned(),
|
|
"$JS.API.DIRECT.GET.KV_hive-wanted-alpha.$KV.hive-wanted-alpha.alpha".to_owned(),
|
|
"$JS.API.INFO".to_owned(),
|
|
] {
|
|
assert!(
|
|
!atlas.publish.iter().any(|g| covers(g, &refused)),
|
|
"atlas's grant covers {refused}"
|
|
);
|
|
}
|
|
// Control: the matcher does match, so the refusals above are not
|
|
// vacuous.
|
|
assert!(covers(&atlas.publish[0], &direct_get_subject("atlas")));
|
|
assert!(covers("$KV.*.>", &key_subject("argus")));
|
|
}
|
|
|
|
/// The inference template as `swarm-nats.nix` spells it reads one key, the
|
|
/// agent's own, and nothing that writes, watches or opens the bucket.
|
|
#[test]
|
|
fn an_agent_may_read_only_its_own_inference_key() {
|
|
use swarm_queue_client::agent_inference::{STREAM, direct_get_subject, key_subject};
|
|
|
|
let p = policy()
|
|
.with_agent_token_subjects(vec![
|
|
"$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.{agent}".to_owned(),
|
|
])
|
|
.expect("a per-agent template is valid");
|
|
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
|
assert_eq!(templated(&atlas), vec![direct_get_subject("atlas")]);
|
|
let argus = p.agent_token_permissions("argus").expect("configured");
|
|
assert_eq!(templated(&argus), vec![direct_get_subject("argus")]);
|
|
|
|
for refused in [
|
|
direct_get_subject("argus"),
|
|
format!("$JS.API.DIRECT.GET.{STREAM}"),
|
|
format!("$JS.API.STREAM.INFO.{STREAM}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
|
|
key_subject("atlas"),
|
|
swarm_queue_client::agent_schedules::direct_get_subject("atlas"),
|
|
] {
|
|
assert!(
|
|
!atlas.publish.iter().any(|g| covers(g, &refused)),
|
|
"atlas's grant covers {refused}"
|
|
);
|
|
}
|
|
// Control: the matcher matches the one subject granted.
|
|
assert!(covers(&atlas.publish[0], &direct_get_subject("atlas")));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_token_subject_without_the_placeholder_is_refused() {
|
|
let err = policy()
|
|
.with_agent_token_subjects(vec!["$SWARM.term.all".to_owned()])
|
|
.expect_err("a subject shared by every agent is not the agent's own");
|
|
assert!(format!("{err}").contains("$SWARM.term.all"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn with_no_agent_token_subject_configured_an_agent_token_is_refused() {
|
|
assert!(policy().agent_token_permissions("atlas").is_none());
|
|
}
|
|
|
|
/// Every agent reads the operator list with one direct get on its one key,
|
|
/// and nothing else of the bucket: no lookup, no listing, no other key and
|
|
/// no write.
|
|
#[test]
|
|
fn an_agent_may_read_only_the_operator_list_key() {
|
|
use swarm_queue_client::operator_profiles::{
|
|
BUCKET, STREAM, direct_get_subject, key_subject,
|
|
};
|
|
|
|
let p = policy()
|
|
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
|
|
.expect("a per-agent template is valid");
|
|
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
|
assert!(atlas.publish.contains(&direct_get_subject()));
|
|
let may = |subject: &str| atlas.publish.iter().any(|g| covers(g, subject));
|
|
for refused in [
|
|
key_subject(),
|
|
format!("$JS.API.DIRECT.GET.{STREAM}"),
|
|
format!("$JS.API.DIRECT.GET.{STREAM}.$KV.{BUCKET}.mara"),
|
|
format!("$JS.API.STREAM.INFO.{STREAM}"),
|
|
format!("$JS.API.STREAM.CREATE.{STREAM}"),
|
|
format!("$JS.API.STREAM.UPDATE.{STREAM}"),
|
|
format!("$JS.API.STREAM.DELETE.{STREAM}"),
|
|
format!("$JS.API.STREAM.PURGE.{STREAM}"),
|
|
format!("$JS.API.STREAM.MSG.GET.{STREAM}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{STREAM}.c1.$KV.{BUCKET}.>"),
|
|
] {
|
|
assert!(!may(&refused), "atlas may publish {refused}");
|
|
}
|
|
// Control: the matcher does match, so the refusals above are not
|
|
// vacuous.
|
|
assert!(may(&direct_get_subject()));
|
|
// The hive's own client is not the reader and gets none of it.
|
|
let hive = p.permissions("hive-alpha").expect("admitted");
|
|
assert!(
|
|
!hive
|
|
.publish
|
|
.iter()
|
|
.any(|g| covers(g, &direct_get_subject()))
|
|
);
|
|
}
|
|
}
|