The limit was a constant of ten in swarm-wire, so a repo with eleven rules could not be reviewed and nothing short of a rebuild changed it. It is now `max_classifications` in the classifier settings, beside `max_concurrent_reviews`: 1 to 1000, ten until set, and a document stored before the field decodes with the default. The classifier settings form in the swarm UI gets a "rules per review" field, and the try-out's add button stops at the saved limit instead of a hard-coded ten. The controller checks a try-out, and a requested review's rules file, against the setting where the run starts. The rules file itself may hold up to the 1000 the setting allows, so raising the limit needs no change to a repo. A review whose rules exceed the limit posts a no-verdict comment saying so. A run's reply deadline was a constant that covered ten classifications, a minute plus three each. It is now computed from the run's own size, so a bigger run waits as long as it needs and a default-sized one still waits 31 minutes. The classifier's answer window in the queue permissions covers the biggest run the setting allows.
2678 lines
118 KiB
Rust
2678 lines
118 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::max_run_deadline()),
|
|
}
|
|
}
|
|
|
|
/// What the builder may do: hear build, archetype evaluation, meta-flake
|
|
/// lock and meta build 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(),
|
|
swarm_wire::builder::META_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, and read its
|
|
/// own consumer's counts with `CONSUMER.INFO` for the lag gauges. No bare
|
|
/// `CONSUMER.CREATE`, no `CONSUMER.INFO` on any other consumer, 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.API.CONSUMER.INFO.{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 egress allow-list (`swarm_wire::egress_allow`): one key, read with
|
|
// one direct get, so exactly that subject and no other key of the bucket.
|
|
subjects.push(swarm_wire::egress_allow::direct_get_subject());
|
|
// The deny list is a second key of that bucket, read the same way; the
|
|
// allow-list grant does not cover it, since it names one key.
|
|
subjects.push(swarm_wire::egress_allow::deny_direct_get_subject());
|
|
// 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 latest closure the swarm built for each agent
|
|
// (`swarm_wire::agent_closures`), read the same way and every key for
|
|
// the same reason.
|
|
subjects.push(format!(
|
|
"$JS.API.DIRECT.GET.{}.>",
|
|
swarm_wire::agent_closures::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 agent build from the meta flake, sent as a request the same way.
|
|
swarm_wire::builder::META_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}.>"),
|
|
]);
|
|
// The permissions bucket, listed only: the controller's five-minute
|
|
// re-mirror calls `keys()`, whose ephemeral ordered consumer has no
|
|
// name, so the bare create subject is the one subject it needs. No
|
|
// `.>` form: that is for named consumers, and none is created here.
|
|
// Open, read and write of the bucket are already covered by
|
|
// `STREAM.INFO.*`, `DIRECT.GET.*.>` and `$KV.*.>` above.
|
|
subjects.push(format!(
|
|
"$JS.API.CONSUMER.CREATE.{}",
|
|
swarm_wire::agent_permissions::STREAM
|
|
));
|
|
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 the_reader_can_list_the_agent_permissions_bucket_and_no_other_stream() {
|
|
let subjects = policy().reader_subjects();
|
|
let s = swarm_wire::agent_permissions::STREAM;
|
|
// The bare form is what `keys()` needs: its ephemeral consumer has
|
|
// no name.
|
|
assert!(subjects.contains(&format!("$JS.API.CONSUMER.CREATE.{s}")));
|
|
// Negative controls: the grant names this one stream, and holds no
|
|
// wildcard, `.>` form or delete/info subject for it.
|
|
assert!(!subjects.contains(&"$JS.API.CONSUMER.CREATE.*".to_owned()));
|
|
assert!(!subjects.contains(&format!("$JS.API.CONSUMER.CREATE.{s}.>")));
|
|
assert!(!subjects.contains(&format!("$JS.API.CONSUMER.CREATE.{s}.*")));
|
|
assert!(
|
|
!subjects
|
|
.iter()
|
|
.any(|x| x.starts_with("$JS.API.CONSUMER.DELETE"))
|
|
);
|
|
assert!(
|
|
!subjects.contains(&"$JS.API.CONSUMER.CREATE.KV_unrelated".to_owned()),
|
|
"an unrelated stream must not be creatable"
|
|
);
|
|
}
|
|
|
|
#[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 the swarm's egress allow-list and can change none of it,
|
|
/// and no other key of its bucket.
|
|
#[test]
|
|
fn a_hive_may_read_the_egress_allow_list_and_write_none() {
|
|
use swarm_wire::egress_allow::{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));
|
|
|
|
assert!(may(&direct_get_subject()), "cannot read the list");
|
|
for forbidden in [
|
|
key_subject(),
|
|
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.CONSUMER.CREATE.{STREAM}"),
|
|
// Another key of the same bucket.
|
|
format!("$JS.API.DIRECT.GET.{STREAM}.$KV.egress-allow.other"),
|
|
] {
|
|
assert!(!may(&forbidden), "hive alpha may publish {forbidden}");
|
|
}
|
|
}
|
|
|
|
/// A hive reads the deny list with its own grant, not the allow-list's, and
|
|
/// can change none of it. An agent's token reaches neither key, so nothing an
|
|
/// agent holds can write the list a hive enforces.
|
|
#[test]
|
|
fn a_hive_reads_the_deny_list_and_no_agent_reaches_it() {
|
|
use swarm_wire::egress_allow::{
|
|
STREAM, deny_direct_get_subject, deny_key_subject, 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));
|
|
assert!(may(&deny_direct_get_subject()), "cannot read the deny list");
|
|
for forbidden in [
|
|
deny_key_subject(),
|
|
format!("$JS.API.STREAM.PURGE.{STREAM}"),
|
|
format!("$JS.API.STREAM.MSG.DELETE.{STREAM}"),
|
|
] {
|
|
assert!(!may(&forbidden), "hive alpha may publish {forbidden}");
|
|
}
|
|
|
|
let agent = policy()
|
|
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
|
|
.expect("a per-agent template is valid")
|
|
.agent_token_permissions("atlas")
|
|
.expect("configured");
|
|
let agent_may = |subject: &str| agent.publish.iter().any(|g| covers(g, subject));
|
|
for reached in [
|
|
deny_key_subject(),
|
|
deny_direct_get_subject(),
|
|
key_subject(),
|
|
direct_get_subject(),
|
|
] {
|
|
assert!(!agent_may(&reached), "an agent may publish {reached}");
|
|
}
|
|
// Control: the lookup finds a subject the agent is granted.
|
|
assert!(agent_may(
|
|
&swarm_queue_client::operator_profiles::direct_get_subject()
|
|
));
|
|
}
|
|
|
|
/// 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 closure record and can change none, and the
|
|
/// grant reaches no other bucket.
|
|
#[test]
|
|
fn a_hive_may_read_every_agents_closure_and_write_none() {
|
|
use swarm_wire::agent_closures::{STREAM, 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"] {
|
|
let read = format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent));
|
|
assert!(may(&read), "cannot read {agent}");
|
|
}
|
|
// A neighbouring bucket stays closed.
|
|
assert!(!may(&swarm_wire::agent_schedules::direct_get_subject(
|
|
"atlas"
|
|
)));
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// 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::{SUBJECT, max_run_deadline};
|
|
|
|
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(max_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, META_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, META_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, META_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, META_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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The controller writes the closure bucket; the builder, which evaluates
|
|
/// unreviewed PR configs, cannot.
|
|
#[test]
|
|
fn only_the_controller_may_write_the_closure_bucket() {
|
|
use swarm_wire::agent_closures::key_subject;
|
|
|
|
let p = with_classifier().with_builders(vec![BUILDER.to_owned()]);
|
|
let key = key_subject("atlas");
|
|
let controller = p.permissions("swarm-controller").expect("the reader");
|
|
assert!(controller.publish.iter().any(|g| covers(g, &key)));
|
|
for (who, grant) in [
|
|
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
|
("the builder", p.permissions(BUILDER).expect("admitted")),
|
|
] {
|
|
assert!(
|
|
!grant.publish.iter().any(|g| covers(g, &key)),
|
|
"{who} may write {key}: {:?}",
|
|
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"),
|
|
// Its own consumer's counts, for the lag gauges.
|
|
format!("$JS.API.CONSUMER.INFO.{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"),
|
|
// Only its own consumer's counts, not another's.
|
|
format!("$JS.API.CONSUMER.INFO.{events}.other"),
|
|
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()))
|
|
);
|
|
}
|
|
}
|