2497 lines
110 KiB
Rust
2497 lines
110 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,
|
|
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>,
|
|
notify_routers: 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. `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).
|
|
///
|
|
/// # 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.
|
|
pub fn new(
|
|
hive_prefix: 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"
|
|
);
|
|
}
|
|
Ok(Self {
|
|
hive_prefix,
|
|
bucket,
|
|
readers,
|
|
extra_hive_subjects,
|
|
agent_token_subjects: Vec::new(),
|
|
review_classifiers: Vec::new(),
|
|
builders: Vec::new(),
|
|
claude_logins: Vec::new(),
|
|
notify_routers: 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
|
|
}
|
|
|
|
/// Set the client ids that route forge events to agents' todo queues.
|
|
#[must_use]
|
|
pub fn with_notify_routers(mut self, clients: Vec<String>) -> Self {
|
|
self.notify_routers = 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> {
|
|
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_wire::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));
|
|
}
|
|
if self.notify_routers.iter().any(|c| c == client_id) {
|
|
return Some(Self::notify_router_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_wire::review_classifier::SUBJECT.to_owned(),
|
|
Self::inbox(client_id),
|
|
]),
|
|
responses: Some(swarm_wire::review_classifier::RUN_DEADLINE),
|
|
}
|
|
}
|
|
|
|
/// What the builder may do: hear build, archetype evaluation and meta-flake
|
|
/// lock 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_wire::builder::SUBJECT.to_owned(),
|
|
swarm_wire::builder::ARCHETYPES_SUBJECT.to_owned(),
|
|
swarm_wire::builder::LOCK_SUBJECT.to_owned(),
|
|
Self::inbox(client_id),
|
|
]),
|
|
responses: Some(swarm_wire::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_wire::claude_login::SUBJECT.to_owned(),
|
|
Self::inbox(client_id),
|
|
]),
|
|
responses: Some(swarm_wire::claude_login::REPLY_DEADLINE),
|
|
}
|
|
}
|
|
|
|
/// What the notification router may do: drain its own consumer on the
|
|
/// forge events stream, queue todo ops for any agent, keep its own state
|
|
/// bucket, read the flows document, and list which agents exist.
|
|
///
|
|
/// - **Forge events.** Open or create the stream, create its one durable
|
|
/// in the extended form whose last tokens are the filter (the server
|
|
/// checks them against the payload), pull from it and ack. No bare
|
|
/// `CONSUMER.CREATE`, no `CONSUMER.INFO`, no publish on the events: the
|
|
/// controller is their one publisher.
|
|
/// - **Todo queue.** Open or create `ROUTER_TODOS` and publish on every
|
|
/// agent's subject: one per agent, so a wildcard, for the no-roster
|
|
/// reason the reader's input grant gives. No other grant names these
|
|
/// subjects, which is what makes the router their sole writer.
|
|
/// - **Its state bucket.** Create, look up, read and write
|
|
/// `notify-router-state`, and no other bucket.
|
|
/// - **The flows document.** `STREAM.INFO` and `DIRECT.GET` on
|
|
/// `notify-flows`, which it reads and never creates or writes: the
|
|
/// controller creates the bucket and is its one writer.
|
|
/// - **The roster.** `STREAM.INFO` on the agent-status bucket, which lists
|
|
/// its keys (`<hive>.<agent>`) and writes none.
|
|
/// - **Review requests.** Publish on the one sink subject
|
|
/// `swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT` and hear the answer
|
|
/// on its own inbox. The controller is the subject's one subscriber and
|
|
/// holds the forge token; no wildcard over `$SWARM.router.sink`.
|
|
/// - **Deliveries log.** Open or create `NOTIFY_ROUTER_LOG`, publish its one
|
|
/// subject, and read rows and the newest events by `STREAM.MSG.GET`.
|
|
/// - **Api.** Hear the two `swarm_wire::router_api` requests and answer each
|
|
/// once, under `allow_responses`. The controller is their one publisher.
|
|
fn notify_router_permissions(client_id: &str) -> Permissions {
|
|
use swarm_wire::{forge_events, notify_flows, notify_router, router_api, router_todo};
|
|
|
|
let events = forge_events::STREAM;
|
|
let log_stream = router_api::LOG_STREAM;
|
|
let consumer = forge_events::ROUTER_CONSUMER;
|
|
let state = format!("KV_{}", notify_router::STATE_BUCKET);
|
|
let flows = format!("KV_{}", notify_flows::BUCKET);
|
|
let mut publish = Self::jetstream_minimum().to_vec();
|
|
publish.extend([
|
|
format!("$JS.API.STREAM.INFO.{events}"),
|
|
format!("$JS.API.STREAM.CREATE.{events}"),
|
|
format!(
|
|
"$JS.API.CONSUMER.CREATE.{events}.{consumer}.{}",
|
|
forge_events::subject_wildcard()
|
|
),
|
|
format!("$JS.API.CONSUMER.MSG.NEXT.{events}.{consumer}"),
|
|
format!("$JS.ACK.{events}.{consumer}.>"),
|
|
format!("$JS.API.STREAM.INFO.{}", router_todo::STREAM),
|
|
format!("$JS.API.STREAM.CREATE.{}", router_todo::STREAM),
|
|
router_todo::subject_wildcard(),
|
|
format!("$JS.API.STREAM.INFO.{state}"),
|
|
format!("$JS.API.STREAM.CREATE.{state}"),
|
|
format!("$JS.API.DIRECT.GET.{state}.>"),
|
|
format!("$KV.{}.>", notify_router::STATE_BUCKET),
|
|
format!("$JS.API.STREAM.INFO.{flows}"),
|
|
format!("$JS.API.DIRECT.GET.{flows}.>"),
|
|
format!("$JS.API.STREAM.INFO.{}", Self::agent_status_stream()),
|
|
swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT.to_owned(),
|
|
format!("$JS.API.STREAM.MSG.GET.{events}"),
|
|
format!("$JS.API.STREAM.INFO.{log_stream}"),
|
|
format!("$JS.API.STREAM.CREATE.{log_stream}"),
|
|
format!("$JS.API.STREAM.MSG.GET.{log_stream}"),
|
|
router_api::LOG_SUBJECT.to_owned(),
|
|
Self::inbox(client_id),
|
|
]);
|
|
Permissions {
|
|
publish,
|
|
subscribe: Some(vec![
|
|
router_api::DELIVERIES_SUBJECT.to_owned(),
|
|
router_api::DRY_RUN_SUBJECT.to_owned(),
|
|
Self::inbox(client_id),
|
|
]),
|
|
responses: Some(router_api::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, the notification router's
|
|
/// review requests, and its own reply inbox.
|
|
///
|
|
/// The three families are the publishers' (`hive-agent`'s `swarm_term`
|
|
/// and `swarm_agent_state`, `swarm_wire::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_wire::term_subject_wildcard(),
|
|
swarm_wire::subagent_term::subject_wildcard(),
|
|
swarm_queue_client::agent_state_subject_wildcard(),
|
|
swarm_wire::agent_permissions::import_subject_wildcard(),
|
|
swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT.to_owned(),
|
|
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())
|
|
}
|
|
|
|
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_wire::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_wire::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_wire::notices::STREAM`
|
|
// verbatim — the `KV_` prefix is `create_key_value`'s own
|
|
// convention, not something every stream carries.
|
|
let stream = swarm_wire::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_wire::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_wire::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_wire::agent_status::BUCKET),
|
|
]);
|
|
// Reading the caps the swarm sets for the agents this hive runs
|
|
// (`swarm_wire::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_wire::agent_limits::STREAM
|
|
));
|
|
// The permissions the swarm grants those agents
|
|
// (`swarm_wire::agent_permissions`), read the same way and
|
|
// every key for the same reason.
|
|
subjects.push(format!(
|
|
"$JS.API.DIRECT.GET.{}.>",
|
|
swarm_wire::agent_permissions::STREAM
|
|
));
|
|
// The archetype an agent was created from
|
|
// (`swarm_queue_client::agent_archetype`), read the same way and every
|
|
// key for the same reason.
|
|
subjects.push(format!(
|
|
"$JS.API.DIRECT.GET.{}.>",
|
|
swarm_queue_client::agent_archetype::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_wire::agent_permissions::import_subject(hive));
|
|
subjects.extend(
|
|
self.extra_hive_subjects
|
|
.iter()
|
|
.map(|s| s.replace(HIVE_PLACEHOLDER, hive)),
|
|
);
|
|
subjects
|
|
}
|
|
|
|
/// The review requests the controller queues and takes
|
|
/// (`swarm_wire::review_classifier::REQUEST_STREAM`): publishing one on
|
|
/// its PR's subject, creating the controller's durable consumer, pulling
|
|
/// from it and acking what it pulled. `STREAM.INFO.*` and
|
|
/// `STREAM.CREATE.*` open the stream. The controller is the stream's one
|
|
/// publisher, so no other grant may name its subjects. A refused publish
|
|
/// reaches the client as a timeout.
|
|
fn review_request_subjects() -> [String; 4] {
|
|
use swarm_wire::review_classifier::{
|
|
REQUEST_CONSUMER, REQUEST_STREAM, REQUEST_SUBJECT_PREFIX,
|
|
};
|
|
[
|
|
format!("{REQUEST_SUBJECT_PREFIX}.>"),
|
|
format!("$JS.API.CONSUMER.CREATE.{REQUEST_STREAM}.{REQUEST_CONSUMER}"),
|
|
format!("$JS.API.CONSUMER.MSG.NEXT.{REQUEST_STREAM}.{REQUEST_CONSUMER}"),
|
|
format!("$JS.ACK.{REQUEST_STREAM}.{REQUEST_CONSUMER}.>"),
|
|
]
|
|
}
|
|
|
|
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_wire::review_classifier::SUBJECT.to_owned(),
|
|
// A build of an agent config, sent as a request the same way.
|
|
swarm_wire::builder::SUBJECT.to_owned(),
|
|
// An archetype evaluation, sent as a request the same way.
|
|
swarm_wire::builder::ARCHETYPES_SUBJECT.to_owned(),
|
|
// A swarm meta-flake lock, sent as a request the same way.
|
|
swarm_wire::builder::LOCK_SUBJECT.to_owned(),
|
|
// An operator's claude login step, sent as a request the same way.
|
|
swarm_wire::claude_login::SUBJECT.to_owned(),
|
|
// The notification router's deliveries log and a dry run of a flows
|
|
// document, sent as requests: the reply comes back on this client's
|
|
// inbox. The router is their one subscriber; no other grant names
|
|
// them.
|
|
swarm_wire::router_api::DELIVERIES_SUBJECT.to_owned(),
|
|
swarm_wire::router_api::DRY_RUN_SUBJECT.to_owned(),
|
|
// Verified forge deliveries, kept in the forge events stream for
|
|
// the notification router. This client is their one publisher: the
|
|
// router reads them and no hive or agent grant names them.
|
|
swarm_wire::forge_events::subject_wildcard(),
|
|
// 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_wire::agent_term::consumer_create_grant(),
|
|
// The same for one subagent's rows in `term-sub-<agent>`: the
|
|
// filter is `$SWARM.term.<agent>.sub.<subagent>`, two tokens
|
|
// deeper than the grant above reaches.
|
|
swarm_wire::subagent_term::consumer_create_grant(),
|
|
"$KV.*.>".to_owned(),
|
|
]);
|
|
subjects.extend(Self::review_request_subjects());
|
|
// 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(),
|
|
"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_wire::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_wire::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_wire::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_wire::agent_inference::direct_get_subject("atlas"),
|
|
swarm_wire::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 archetype and can change none of them, and the
|
|
/// grant reaches no other bucket.
|
|
#[test]
|
|
fn a_hive_may_read_every_agents_archetype_and_write_none() {
|
|
use swarm_queue_client::agent_archetype::{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));
|
|
|
|
for agent in ["atlas", "iris"] {
|
|
assert!(may(&direct_get_subject(agent)), "cannot read {agent}");
|
|
}
|
|
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}"),
|
|
] {
|
|
assert!(!may(&forbidden), "hive alpha may publish {forbidden}");
|
|
}
|
|
// Not the per-source archetype list, which only the controller reads.
|
|
assert!(!may(&format!(
|
|
"$JS.API.DIRECT.GET.KV_{0}.$KV.{0}.hyperhive",
|
|
swarm_queue_client::archetypes::BUCKET
|
|
)));
|
|
}
|
|
|
|
/// 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_wire::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_wire::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(),
|
|
"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(),
|
|
"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_wire::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_wire::term_subject("atlas"),
|
|
swarm_wire::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::{agent_input_subject, client_inbox_prefix};
|
|
use swarm_wire::review_classifier::{RUN_DEADLINE, SUBJECT};
|
|
|
|
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_wire::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
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Queueing a review request, taking it through the controller's durable
|
|
/// consumer and acking it are the reader's, and nobody else's.
|
|
#[test]
|
|
fn only_the_reader_may_queue_and_take_review_requests() {
|
|
use swarm_wire::review_classifier::{
|
|
REQUEST_CONSUMER, REQUEST_STREAM, REQUEST_SUBJECT_PREFIX,
|
|
};
|
|
|
|
let subjects = [
|
|
format!("{REQUEST_SUBJECT_PREFIX}.hyperhive.hyperhive.5219"),
|
|
format!("$JS.API.STREAM.INFO.{REQUEST_STREAM}"),
|
|
format!("$JS.API.STREAM.CREATE.{REQUEST_STREAM}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{REQUEST_STREAM}.{REQUEST_CONSUMER}"),
|
|
format!("$JS.API.CONSUMER.MSG.NEXT.{REQUEST_STREAM}.{REQUEST_CONSUMER}"),
|
|
format!("$JS.ACK.{REQUEST_STREAM}.{REQUEST_CONSUMER}.1.4.4.1761000000000000000.0"),
|
|
];
|
|
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");
|
|
for subject in &subjects {
|
|
assert!(
|
|
reader.publish.iter().any(|g| covers(g, subject)),
|
|
"the reader may not publish {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"),
|
|
),
|
|
] {
|
|
for subject in &subjects {
|
|
assert!(
|
|
!grant.publish.iter().any(|g| covers(g, subject)),
|
|
"{who} may publish {subject}: {:?}",
|
|
grant.publish
|
|
);
|
|
}
|
|
}
|
|
// The consumer grants name the one consumer.
|
|
let other = format!("$JS.API.CONSUMER.MSG.NEXT.{REQUEST_STREAM}.another");
|
|
assert!(!reader.publish.iter().any(|g| covers(g, &other)));
|
|
}
|
|
|
|
const BUILDER: &str = "swarm-builder";
|
|
|
|
#[test]
|
|
fn the_builder_hears_its_subject_and_inbox_only_and_answers_by_reply() {
|
|
use swarm_queue_client::client_inbox_prefix;
|
|
use swarm_wire::builder::{ARCHETYPES_SUBJECT, LOCK_SUBJECT, REPLY_DEADLINE, SUBJECT};
|
|
|
|
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, LOCK_SUBJECT));
|
|
assert!(may_subscribe(&p, &reply(&inbox)));
|
|
for refused in [
|
|
">".to_owned(),
|
|
"$SWARM.>".to_owned(),
|
|
swarm_wire::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_wire::builder::{ARCHETYPES_SUBJECT, LOCK_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, LOCK_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::client_inbox_prefix;
|
|
use swarm_wire::claude_login::{REPLY_DEADLINE, SUBJECT};
|
|
|
|
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_wire::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_wire::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
|
|
);
|
|
}
|
|
}
|
|
|
|
const NOTIFY_ROUTER: &str = "swarm-notify-router";
|
|
|
|
/// The three router-todo templates as `swarm-nats.nix` spells them.
|
|
const ROUTER_TODO_TEMPLATES: [&str; 3] = [
|
|
"$JS.API.CONSUMER.CREATE.ROUTER_TODOS.todo-{agent}.$SWARM.router.todo.{agent}",
|
|
"$JS.API.CONSUMER.MSG.NEXT.ROUTER_TODOS.todo-{agent}",
|
|
"$JS.ACK.ROUTER_TODOS.todo-{agent}.>",
|
|
];
|
|
|
|
fn with_router() -> Policy {
|
|
with_classifier()
|
|
.with_builders(vec![BUILDER.to_owned()])
|
|
.with_claude_logins(vec![CLAUDE_LOGIN.to_owned()])
|
|
.with_notify_routers(vec![NOTIFY_ROUTER.to_owned()])
|
|
.with_agent_token_subjects(ROUTER_TODO_TEMPLATES.map(str::to_owned).to_vec())
|
|
.expect("per-agent templates are valid")
|
|
}
|
|
|
|
#[test]
|
|
fn the_notify_router_drains_its_own_consumer_and_writes_only_its_own_state() {
|
|
use swarm_queue_client::client_inbox_prefix;
|
|
use swarm_wire::{
|
|
forge_events, notify_flows, notify_router, router_api, router_sink, router_todo,
|
|
};
|
|
|
|
let p = with_router()
|
|
.permissions(NOTIFY_ROUTER)
|
|
.expect("a configured router is admitted");
|
|
let may = |subject: &str| p.publish.iter().any(|g| covers(g, subject));
|
|
let events = forge_events::STREAM;
|
|
let state = notify_router::STATE_BUCKET;
|
|
let flows = notify_flows::BUCKET;
|
|
for allowed in [
|
|
format!(
|
|
"$JS.API.CONSUMER.CREATE.{events}.notify-router.{}",
|
|
forge_events::subject_wildcard()
|
|
),
|
|
format!("$JS.API.CONSUMER.MSG.NEXT.{events}.notify-router"),
|
|
format!("$JS.ACK.{events}.notify-router.1.5.5.1700000000.0"),
|
|
format!("$JS.API.STREAM.INFO.{events}"),
|
|
format!("$JS.API.STREAM.CREATE.{}", router_todo::STREAM),
|
|
router_todo::subject("atlas"),
|
|
format!("$KV.{state}.42.5571"),
|
|
format!("$JS.API.DIRECT.GET.KV_{state}.$KV.{state}.42.5571"),
|
|
router_sink::REQUEST_REVIEWER_SUBJECT.to_owned(),
|
|
"$JS.API.STREAM.INFO.KV_agent-status".to_owned(),
|
|
// The deliveries log: make it, append to it, read it by sequence.
|
|
format!("$JS.API.STREAM.CREATE.{}", router_api::LOG_STREAM),
|
|
format!("$JS.API.STREAM.INFO.{}", router_api::LOG_STREAM),
|
|
format!("$JS.API.STREAM.MSG.GET.{}", router_api::LOG_STREAM),
|
|
router_api::LOG_SUBJECT.to_owned(),
|
|
// The newest forge events, for a dry run.
|
|
format!("$JS.API.STREAM.MSG.GET.{events}"),
|
|
// The flows document: read it.
|
|
format!("$JS.API.STREAM.INFO.KV_{flows}"),
|
|
format!(
|
|
"$JS.API.DIRECT.GET.KV_{flows}.$KV.{flows}.{}",
|
|
notify_flows::KEY
|
|
),
|
|
] {
|
|
assert!(may(&allowed), "the router may not reach {allowed}");
|
|
}
|
|
for refused in [
|
|
// The bare form, whose filter the server does not check.
|
|
format!("$JS.API.CONSUMER.CREATE.{events}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{events}.other.$SWARM.events.forge.*"),
|
|
format!("$JS.API.CONSUMER.DELETE.{events}.notify-router"),
|
|
format!("$JS.API.STREAM.DELETE.{}", router_todo::STREAM),
|
|
format!("$JS.API.STREAM.UPDATE.{}", router_todo::STREAM),
|
|
format!(
|
|
"$JS.API.CONSUMER.CREATE.{}.todo-atlas.{}",
|
|
router_todo::STREAM,
|
|
router_todo::subject("atlas")
|
|
),
|
|
forge_events::subject("issues"),
|
|
"$KV.agent-status.alpha.atlas".to_owned(),
|
|
"$KV.agent-schedules.atlas".to_owned(),
|
|
"$SWARM.router.sink.other".to_owned(),
|
|
"$SWARM.router.sink.>".to_owned(),
|
|
// The log: no delete, no purge, no message delete; no read of the todo
|
|
// stream; no other subject below the router's two prefixes; and no
|
|
// sending of the requests it answers.
|
|
format!("$JS.API.STREAM.DELETE.{}", router_api::LOG_STREAM),
|
|
format!("$JS.API.STREAM.PURGE.{}", router_api::LOG_STREAM),
|
|
format!("$JS.API.STREAM.MSG.DELETE.{}", router_api::LOG_STREAM),
|
|
format!("$JS.API.STREAM.MSG.GET.{}", router_todo::STREAM),
|
|
"$SWARM.router.log.other".to_owned(),
|
|
"$SWARM.router.api.other".to_owned(),
|
|
router_api::DRY_RUN_SUBJECT.to_owned(),
|
|
router_api::DELIVERIES_SUBJECT.to_owned(),
|
|
// The flows document: not write it, nor make the bucket.
|
|
format!("$KV.{flows}.{}", notify_flows::KEY),
|
|
format!("$JS.API.STREAM.CREATE.KV_{flows}"),
|
|
format!("$JS.API.STREAM.DELETE.KV_{flows}"),
|
|
swarm_queue_client::agent_input_subject("atlas"),
|
|
"$JS.API.STREAM.CREATE.KV_agent-status".to_owned(),
|
|
] {
|
|
assert!(!may(&refused), "the router may reach {refused}");
|
|
}
|
|
let inbox = client_inbox_prefix(NOTIFY_ROUTER);
|
|
assert_eq!(
|
|
p.subscribe,
|
|
Some(vec![
|
|
router_api::DELIVERIES_SUBJECT.to_owned(),
|
|
router_api::DRY_RUN_SUBJECT.to_owned(),
|
|
format!("{inbox}.>"),
|
|
])
|
|
);
|
|
assert_eq!(p.responses, Some(router_api::REPLY_DEADLINE));
|
|
assert_eq!(policy().permissions(NOTIFY_ROUTER), None);
|
|
}
|
|
|
|
/// The router asks and the controller answers: nobody else may send the
|
|
/// request, and nobody else may hear it, since the answer acts under the
|
|
/// controller's forge token.
|
|
#[test]
|
|
fn only_the_router_may_send_a_review_request_and_only_the_reader_may_hear_it() {
|
|
use swarm_wire::router_sink::{REPLY_DEADLINE, REQUEST_REVIEWER_SUBJECT};
|
|
|
|
let p = with_router();
|
|
let grants = [
|
|
(
|
|
"the reader",
|
|
p.permissions("swarm-controller").expect("reader"),
|
|
),
|
|
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
|
("the router", p.permissions(NOTIFY_ROUTER).expect("router")),
|
|
("the minter", p.permissions(CLAUDE_LOGIN).expect("minter")),
|
|
("the builder", p.permissions(BUILDER).expect("builder")),
|
|
(
|
|
"the classifier",
|
|
p.permissions(CLASSIFIER).expect("classifier"),
|
|
),
|
|
("atlas", p.agent_token_permissions("atlas").expect("atlas")),
|
|
];
|
|
for (who, grant) in &grants {
|
|
let sends = grant
|
|
.publish
|
|
.iter()
|
|
.any(|g| covers(g, REQUEST_REVIEWER_SUBJECT));
|
|
let hears = may_subscribe(grant, REQUEST_REVIEWER_SUBJECT);
|
|
assert_eq!(sends, *who == "the router", "{who} sends: {sends}");
|
|
assert_eq!(hears, *who == "the reader", "{who} hears: {hears}");
|
|
}
|
|
// The controller answers on the reply subject for as long as the
|
|
// router waits.
|
|
let reader = &grants[0].1;
|
|
assert!(
|
|
reader.responses >= Some(REPLY_DEADLINE),
|
|
"{:?}",
|
|
reader.responses
|
|
);
|
|
}
|
|
|
|
/// The controller asks and the router answers: nobody else may send the
|
|
/// two requests, and nobody else may hear them.
|
|
#[test]
|
|
fn only_the_reader_may_ask_the_router_and_only_the_router_may_hear_it() {
|
|
use swarm_wire::router_api::{DELIVERIES_SUBJECT, DRY_RUN_SUBJECT, LOG_SUBJECT};
|
|
|
|
let p = with_router();
|
|
let grants = [
|
|
(
|
|
"the reader",
|
|
p.permissions("swarm-controller").expect("reader"),
|
|
),
|
|
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
|
("the router", p.permissions(NOTIFY_ROUTER).expect("router")),
|
|
("the minter", p.permissions(CLAUDE_LOGIN).expect("minter")),
|
|
("the builder", p.permissions(BUILDER).expect("builder")),
|
|
(
|
|
"the classifier",
|
|
p.permissions(CLASSIFIER).expect("classifier"),
|
|
),
|
|
("atlas", p.agent_token_permissions("atlas").expect("atlas")),
|
|
];
|
|
for (who, grant) in &grants {
|
|
for subject in [DELIVERIES_SUBJECT, DRY_RUN_SUBJECT] {
|
|
let sends = grant.publish.iter().any(|g| covers(g, subject));
|
|
let hears = may_subscribe(grant, subject);
|
|
assert_eq!(sends, *who == "the reader", "{who} sends {subject}");
|
|
assert_eq!(hears, *who == "the router", "{who} hears {subject}");
|
|
}
|
|
// The log has one writer.
|
|
let appends = grant.publish.iter().any(|g| covers(g, LOG_SUBJECT));
|
|
assert_eq!(appends, *who == "the router", "{who} appends to the log");
|
|
}
|
|
// The router answers for as long as the controller waits.
|
|
let router = &grants[2].1;
|
|
assert!(
|
|
router.responses >= Some(swarm_wire::router_api::REPLY_DEADLINE),
|
|
"{:?}",
|
|
router.responses
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_router_may_queue_an_agents_todo_and_only_the_reader_may_keep_a_forge_event() {
|
|
use swarm_wire::{forge_events, router_todo};
|
|
|
|
let p = with_router();
|
|
let todo = router_todo::subject("atlas");
|
|
let event = forge_events::subject("issue_assign");
|
|
let grants = [
|
|
(
|
|
"the reader",
|
|
p.permissions("swarm-controller").expect("reader"),
|
|
),
|
|
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
|
("the router", p.permissions(NOTIFY_ROUTER).expect("router")),
|
|
("the minter", p.permissions(CLAUDE_LOGIN).expect("minter")),
|
|
("the builder", p.permissions(BUILDER).expect("builder")),
|
|
(
|
|
"the classifier",
|
|
p.permissions(CLASSIFIER).expect("classifier"),
|
|
),
|
|
("atlas", p.agent_token_permissions("atlas").expect("atlas")),
|
|
];
|
|
for (who, grant) in &grants {
|
|
let queues = grant.publish.iter().any(|g| covers(g, &todo));
|
|
let keeps = grant.publish.iter().any(|g| covers(g, &event));
|
|
assert_eq!(queues, *who == "the router", "{who} queues: {queues}");
|
|
assert_eq!(keeps, *who == "the reader", "{who} keeps events: {keeps}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_may_drain_only_its_own_router_todo_consumer() {
|
|
use swarm_wire::router_todo::{STREAM, consumer_name, subject};
|
|
|
|
let p = with_router();
|
|
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
|
let own_create = format!(
|
|
"$JS.API.CONSUMER.CREATE.{STREAM}.{}.{}",
|
|
consumer_name("atlas"),
|
|
subject("atlas")
|
|
);
|
|
assert_eq!(
|
|
templated(&atlas),
|
|
vec![
|
|
own_create.clone(),
|
|
"$JS.API.CONSUMER.MSG.NEXT.ROUTER_TODOS.todo-atlas".to_owned(),
|
|
"$JS.ACK.ROUTER_TODOS.todo-atlas.>".to_owned(),
|
|
]
|
|
);
|
|
// Control: argus's expansion names argus.
|
|
let argus = p.agent_token_permissions("argus").expect("configured");
|
|
assert!(
|
|
argus
|
|
.publish
|
|
.contains(&"$JS.API.CONSUMER.MSG.NEXT.ROUTER_TODOS.todo-argus".to_owned())
|
|
);
|
|
let may = |s: &str| atlas.publish.iter().any(|g| covers(g, s));
|
|
assert!(may(&own_create));
|
|
assert!(may("$JS.ACK.ROUTER_TODOS.todo-atlas.1.3.3.1700000000.0"));
|
|
for refused in [
|
|
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
|
|
format!("$JS.API.CONSUMER.CREATE.{STREAM}.todo-atlas"),
|
|
format!(
|
|
"$JS.API.CONSUMER.CREATE.{STREAM}.todo-atlas.{}",
|
|
subject("argus")
|
|
),
|
|
format!("$JS.API.CONSUMER.CREATE.{STREAM}.todo-atlas.$SWARM.router.todo.*"),
|
|
format!(
|
|
"$JS.API.CONSUMER.CREATE.{STREAM}.todo-argus.{}",
|
|
subject("argus")
|
|
),
|
|
format!("$JS.API.CONSUMER.MSG.NEXT.{STREAM}.todo-argus"),
|
|
format!("$JS.API.CONSUMER.DELETE.{STREAM}.todo-atlas"),
|
|
format!("$JS.API.CONSUMER.INFO.{STREAM}.todo-atlas"),
|
|
format!("$JS.ACK.{STREAM}.todo-argus.1.3.3.1700000000.0"),
|
|
format!("$JS.API.STREAM.INFO.{STREAM}"),
|
|
subject("atlas"),
|
|
] {
|
|
assert!(!may(&refused), "atlas's grant covers {refused}");
|
|
}
|
|
}
|
|
|
|
#[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_wire::agent_term::stream_name;
|
|
use swarm_wire::{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")
|
|
)));
|
|
assert!(may_publish(&create(
|
|
&subagent_term::stream_name("atlas"),
|
|
&subagent_term::subject("atlas", "scout"),
|
|
)));
|
|
for refused in [
|
|
// The bare form, whose filter rides in the payload alone.
|
|
format!("$JS.API.CONSUMER.CREATE.{}", stream_name("atlas")),
|
|
format!(
|
|
"$JS.API.CONSUMER.CREATE.{}",
|
|
subagent_term::stream_name("atlas")
|
|
),
|
|
create(
|
|
&subagent_term::stream_name("atlas"),
|
|
&format!("{}.x", subagent_term::subject("atlas", "scout")),
|
|
),
|
|
create(
|
|
&subagent_term::stream_name("atlas"),
|
|
"$SWARM.agent-state.atlas.sub.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(),
|
|
"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 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_wire::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_wire::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_wire::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_wire::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_wire::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()))
|
|
);
|
|
}
|
|
}
|