stop writing and attaching per-agent and per-hive bao policies
This commit is contained in:
parent
eac7f2a7c1
commit
139007e656
17 changed files with 253 additions and 419 deletions
|
|
@ -369,35 +369,38 @@ that mount's one role for a `hive-agent-<agent>` client certificate at agent
|
||||||
creation. Host roles never pin that CA and agent roles pin only it, so an
|
creation. Host roles never pin that CA and agent roles pin only it, so an
|
||||||
agent's certificate opens that agent's own `swarm/agents/<agent>/*` and
|
agent's certificate opens that agent's own `swarm/agents/<agent>/*` and
|
||||||
nothing else: `read` on the values there and `list` on their names.
|
nothing else: `read` on the values there and `list` on their names.
|
||||||
`swarm-controller` writes that policy at agent creation and rewrites every
|
|
||||||
agent's at its own start, so a change to it reaches existing agents with the
|
|
||||||
next controller restart.
|
|
||||||
|
|
||||||
The agent's cert-auth role attaches a second policy, `hive-agent`, shared by
|
All agents share one policy, `hive-agent`, which the agent's cert-auth role
|
||||||
all agents. It states the same rule with `{{identity.entity.name}}` where the
|
attaches and no other. It states the rule with `{{identity.entity.name}}`
|
||||||
per-agent policy has the agent's name, so the rule is one document. For that
|
where an agent's name would go, so the rule is one document and a change to it
|
||||||
the controller names each agent's identity entity after the agent: at agent
|
is one write. `swarm-controller` writes it at agent creation and at its own
|
||||||
creation, and for every agent at its own start, it reads the cert mount's
|
start.
|
||||||
|
|
||||||
|
For that the controller names each agent's identity entity after the agent: at
|
||||||
|
agent creation, and for every agent at its own start, it reads the cert mount's
|
||||||
accessor and creates the entity `<agent>` with the alias `hive-agent-<agent>`,
|
accessor and creates the entity `<agent>` with the alias `hive-agent-<agent>`,
|
||||||
or renames the entity a first login created under a generated name. When the alias
|
or renames the entity a first login created under a generated name. When the alias
|
||||||
sits on any other entity, the controller logs an error and changes nothing. It needs
|
sits on any other entity, the controller logs an error and changes nothing, and
|
||||||
the `identity/` and `sys/auth/cert` grants in `swarm-bao.nix`'s
|
that agent reads nothing until an operator resolves it. It needs the `identity/`
|
||||||
`controllerPolicyText` for that. Without them it logs a warning and the agent
|
and `sys/auth/cert` grants in `swarm-bao.nix`'s `controllerPolicyText` for that.
|
||||||
keeps working through its own policy, which stays attached until every agent
|
|
||||||
has its entity. An agent's role gains `hive-agent` at its next certificate
|
|
||||||
mint.
|
|
||||||
|
|
||||||
A hive's cert-auth role `hive-<hive>` likewise attaches a second policy,
|
A cert-auth role that predates the shared policy may still name a policy
|
||||||
`hive-hives`, shared by all hives. A hive's certificate carries its bare name as
|
`hive-agent-<agent>`. The controller no longer writes or attaches these. The
|
||||||
|
objects stay in the store and keep granting what they did, to a token that
|
||||||
|
already carries them and to a role not yet rewritten. A live agent's role is
|
||||||
|
rewritten at its next certificate renewal.
|
||||||
|
|
||||||
|
A hive's cert-auth role `hive-<hive>` attaches one policy, `hive-hives`, shared
|
||||||
|
by all hives. A hive's certificate carries its bare name as
|
||||||
the common name, which is the login's alias name, so hives need no entity:
|
the common name, which is the login's alias name, so hives need no entity:
|
||||||
the policy states the hive's own path as
|
the policy states the hive's own path as
|
||||||
`swarm/hives/{{identity.entity.aliases.<accessor>.name}}/*`, where `<accessor>`
|
`swarm/hives/{{identity.entity.aliases.<accessor>.name}}/*`, where `<accessor>`
|
||||||
is the cert mount's accessor. The controller reads the accessor at its own start,
|
is the cert mount's accessor. The controller reads the accessor at its own start,
|
||||||
writes the policy, and attaches it when it rewrites the roles. No hive name can
|
writes the policy, and rewrites the roles to attach it. No hive name can
|
||||||
spell `hive-hives`: it would be a hive called `hives`, and
|
spell `hive-hives`: it would be a hive called `hives`, and
|
||||||
`reserved-hive-fragments.nix` refuses the substring `hive`. When the controller
|
`reserved-hive-fragments.nix` refuses the substring `hive`. When the controller
|
||||||
can't read the accessor or write the policy it logs a warning and the role
|
can't read the accessor or write the policy it logs a warning and leaves the
|
||||||
attaches the hive's own policy alone, which stays attached.
|
roles untouched, so a role that names a `hive-<hive>` policy keeps it.
|
||||||
|
|
||||||
### Per-principal identities
|
### Per-principal identities
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
//!
|
//!
|
||||||
//! Which accounts exist is read the same way: [`linked_accounts`] lists this
|
//! Which accounts exist is read the same way: [`linked_accounts`] lists this
|
||||||
//! agent's `matrix/` directory, which its policy grants `list` on
|
//! agent's `matrix/` directory, which its policy grants `list` on
|
||||||
//! (`swarm_secret_client::policy::render_agent`).
|
//! (`swarm_secret_client::policy::render_shared_agent`).
|
||||||
//!
|
//!
|
||||||
//! The file arm is a `tokenFile` an operator declared in `matrixAccounts`, or
|
//! The file arm is a `tokenFile` an operator declared in `matrixAccounts`, or
|
||||||
//! the `main` token a hive minted before the swarm did.
|
//! the `main` token a hive minted before the swarm did.
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ let
|
||||||
|
|
||||||
# The hive's own store identity. The collector authenticates to the swarm
|
# The hive's own store identity. The collector authenticates to the swarm
|
||||||
# as the hive, so it reads its secret as the hive: the hive leaf's policy
|
# as the hive, so it reads its secret as the hive: the hive leaf's policy
|
||||||
# (`swarm-secret-client`'s `policy::render`) grants `swarm/hives/<hive>/*`.
|
# (`swarm-secret-client`'s `policy::render_shared_hives`) grants `swarm/hives/<hive>/*`.
|
||||||
runsHive = config.services.hyperhive.otel.collector.enable;
|
runsHive = config.services.hyperhive.otel.collector.enable;
|
||||||
|
|
||||||
hiveLeaf =
|
hiveLeaf =
|
||||||
|
|
|
||||||
|
|
@ -872,8 +872,8 @@ let
|
||||||
agentPkiRoleName = baoDeploy.agentPkiRoleName;
|
agentPkiRoleName = baoDeploy.agentPkiRoleName;
|
||||||
|
|
||||||
# Every common name the agent role issues for: `swarm_secret_client`'s
|
# Every common name the agent role issues for: `swarm_secret_client`'s
|
||||||
# `policy::AGENT_PREFIX`, which names each agent's cert-auth role and
|
# `policy::AGENT_PREFIX`, which names each agent's cert-auth role too.
|
||||||
# policy too. Outside it the role refuses, so the controller cannot obtain
|
# Outside it the role refuses, so the controller cannot obtain
|
||||||
# a certificate for any other name.
|
# a certificate for any other name.
|
||||||
agentCnGlob = "hive-agent-*";
|
agentCnGlob = "hive-agent-*";
|
||||||
|
|
||||||
|
|
@ -938,7 +938,7 @@ let
|
||||||
#
|
#
|
||||||
# 🩸 Each of these reads exactly ONE path in the store, and does it holding
|
# 🩸 Each of these reads exactly ONE path in the store, and does it holding
|
||||||
# its own leaf rather than `deploy.bao.clientCertFile` — the hive's own leaf,
|
# its own leaf rather than `deploy.bao.clientCertFile` — the hive's own leaf,
|
||||||
# whose policy (`swarm-secret-client`'s `policy::render`) grants read on
|
# whose policy (`swarm-secret-client`'s `policy::render_shared_hives`) grants read on
|
||||||
# `swarm/agents/*` AND `swarm/hives/<hive>/*`, and nothing under
|
# `swarm/agents/*` AND `swarm/hives/<hive>/*`, and nothing under
|
||||||
# `swarm/services/`.
|
# `swarm/services/`.
|
||||||
# Principals presenting one certificate are ONE principal to bao, so sharing
|
# Principals presenting one certificate are ONE principal to bao, so sharing
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,12 @@
|
||||||
//! is ever moved. Any other arrangement is a [`Plan::Conflict`]: nothing is
|
//! is ever moved. Any other arrangement is a [`Plan::Conflict`]: nothing is
|
||||||
//! written and an operator decides.
|
//! written and an operator decides.
|
||||||
//!
|
//!
|
||||||
//! Everything else here is best effort. An agent's own policy still grants it
|
//! Everything else here is best effort: a failure is logged at `warn` and the
|
||||||
//! its paths, so a store that refuses these calls costs the shared policy and
|
//! caller goes on. An agent whose entity is not named after it reads nothing,
|
||||||
//! nothing else: the failure is logged at `warn` and the caller goes on. The
|
//! because the shared policy is the only one its role attaches; at mint the
|
||||||
//! grants are the `identity/` stanzas of `swarm-bao.nix`'s
|
//! read-back fails, and on a start pass its next reads are refused.
|
||||||
//! `controllerPolicyText`, and a controller can start before they deploy.
|
//! The grants are the `identity/` stanzas of `swarm-bao.nix`'s
|
||||||
|
//! `controllerPolicyText`.
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use swarm_secret_client::{
|
use swarm_secret_client::{
|
||||||
|
|
@ -97,7 +98,7 @@ pub async fn ensure_agents(store: &SecretStore, agents: &[String]) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
"agent entities are not managed: the cert auth mount's accessor could not be read; agents keep their own policy"
|
"agent entities are not managed: the cert auth mount's accessor could not be read; an agent whose entity is not named after it cannot read its secrets"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +174,7 @@ async fn ensure_one(store: &SecretStore, accessor: &str, agent: &str) -> Result<
|
||||||
agent,
|
agent,
|
||||||
alias,
|
alias,
|
||||||
entity_name,
|
entity_name,
|
||||||
"the agent's alias is on an entity that is not the agent's; nothing was changed and the agent keeps its own policy"
|
"the agent's alias is on an entity that is not the agent's; nothing was changed and the agent cannot read its secrets until an operator resolves it"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@
|
||||||
//! not derived from the first, so either renews without reference to the other.
|
//! not derived from the first, so either renews without reference to the other.
|
||||||
//!
|
//!
|
||||||
//! Four separate strings have to agree before an agent can authenticate: the
|
//! Four separate strings have to agree before an agent can authenticate: the
|
||||||
//! policy's name, the cert-auth role's name, the certificate's common name,
|
//! identity entity's name, the cert-auth role's name, the certificate's common
|
||||||
//! and the authority the role pins. That is the kind of agreement that holds
|
//! name, and the authority the role pins. That is the kind of agreement that
|
||||||
//! in review and fails in production — a mismatch is a 403 naming none of the
|
//! holds in review and fails in production — a mismatch is a 403 naming none
|
||||||
//! four — so [`mint_and_verify`] does not finish on a write. See
|
//! of the four — so [`mint_and_verify`] does not finish on a write. See
|
||||||
//! [`read_back_as_agent`].
|
//! [`read_back_as_agent`].
|
||||||
//!
|
//!
|
||||||
//! The authority is the store's own agent CA, generated inside its agent PKI
|
//! The authority is the store's own agent CA, generated inside its agent PKI
|
||||||
|
|
@ -62,15 +62,12 @@ fn agent_pki(get: impl Fn(&str) -> Option<String>) -> Result<(String, String)> {
|
||||||
|
|
||||||
/// What the cert-auth role for `agent` is written from.
|
/// What the cert-auth role for `agent` is written from.
|
||||||
struct RoleInputs<'a> {
|
struct RoleInputs<'a> {
|
||||||
/// Role name, policy name and the common name the role matches: one string.
|
/// Role name and the common name the role matches: one string.
|
||||||
name: String,
|
name: String,
|
||||||
/// The authority the role pins: the one that signed this very leaf.
|
/// The authority the role pins: the one that signed this very leaf.
|
||||||
ca: &'a str,
|
ca: &'a str,
|
||||||
/// The policy document the role attaches.
|
/// The policies the role attaches by name: the one every agent shares.
|
||||||
policy: String,
|
attached: Vec<String>,
|
||||||
/// The policies the role attaches by name: the agent's own, and the one every
|
|
||||||
/// agent shares.
|
|
||||||
attached: [String; 2],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result<RoleInputs<'a>> {
|
fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result<RoleInputs<'a>> {
|
||||||
|
|
@ -78,8 +75,7 @@ fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result<Role
|
||||||
Ok(RoleInputs {
|
Ok(RoleInputs {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
ca: &credential.ca,
|
ca: &credential.ca,
|
||||||
policy: policy::render_agent(agent)?,
|
attached: vec![policy::SHARED_AGENT_POLICY.to_owned()],
|
||||||
attached: [name, policy::SHARED_AGENT_POLICY.to_owned()],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,7 +111,7 @@ pub(crate) fn generate_queue_secret() -> Result<String> {
|
||||||
|
|
||||||
/// Give `agent` an identity at the store, and prove it works.
|
/// Give `agent` an identity at the store, and prove it works.
|
||||||
///
|
///
|
||||||
/// Five store calls' worth of agreement, then the login that checks it:
|
/// Six store calls' worth of agreement, then the login that checks it:
|
||||||
///
|
///
|
||||||
/// 1. have the agent PKI role issue a leaf whose common name is
|
/// 1. have the agent PKI role issue a leaf whose common name is
|
||||||
/// [`policy::agent_object_name`]; the store generates its key;
|
/// [`policy::agent_object_name`]; the store generates its key;
|
||||||
|
|
@ -124,14 +120,13 @@ pub(crate) fn generate_queue_secret() -> Result<String> {
|
||||||
/// 3. publish a queue secret at [`queue::agent_queue_path`] — the agent's own
|
/// 3. publish a queue secret at [`queue::agent_queue_path`] — the agent's own
|
||||||
/// identity at the swarm queue, minted here so that the credential an agent
|
/// identity at the swarm queue, minted here so that the credential an agent
|
||||||
/// presents names *it* rather than its hive;
|
/// presents names *it* rather than its hive;
|
||||||
/// 4. write the ACL document [`policy::render_agent`] renders — read and list on
|
/// 4. write the shared ACL document [`policy::render_shared_agent`] renders;
|
||||||
/// this one agent's paths — and the shared [`policy::render_shared_agent`];
|
/// 5. write the cert-auth role that ties the three together, attaching that
|
||||||
/// 5. write the cert-auth role that ties the three together, attaching both
|
/// policy and pinning the CA the store named as this leaf's issuer;
|
||||||
/// policies and pinning the CA the store named as this leaf's issuer.
|
/// 6. name the agent's identity entity after the agent ([`crate::agent_entity`]),
|
||||||
|
/// which the policy reads.
|
||||||
///
|
///
|
||||||
/// Policy before role, for the reason `read_policy::provision` gives: the role
|
/// Policy before role, for the reason `read_policy::provision` gives.
|
||||||
/// names the policy, so the other order leaves a window in which it points at
|
|
||||||
/// nothing.
|
|
||||||
///
|
///
|
||||||
/// ⚠️ **Step 3 is idempotent and steps 1–2 are not.** Re-running issues a
|
/// ⚠️ **Step 3 is idempotent and steps 1–2 are not.** Re-running issues a
|
||||||
/// fresh leaf, picked up on the agent's next boot, but leaves an existing
|
/// fresh leaf, picked up on the agent's next boot, but leaves an existing
|
||||||
|
|
@ -140,9 +135,10 @@ pub(crate) fn generate_queue_secret() -> Result<String> {
|
||||||
/// replacing one by age is [`crate::agent_renewal`]'s.
|
/// replacing one by age is [`crate::agent_renewal`]'s.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Anything that stops one of those five steps, with the step named. A
|
/// Anything that stops steps 1–5, with the step named, or the read-back login,
|
||||||
/// failure here fails the job node and nothing else — the agent is still
|
/// which is where a step 6 that did not take shows up. A failure here fails the
|
||||||
/// created, without a store identity.
|
/// job node and nothing else — the agent is still created, without a store
|
||||||
|
/// identity.
|
||||||
pub async fn mint_and_verify(agent: &str) -> Result<()> {
|
pub async fn mint_and_verify(agent: &str) -> Result<()> {
|
||||||
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
|
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
|
||||||
let name = policy::agent_object_name(agent)?;
|
let name = policy::agent_object_name(agent)?;
|
||||||
|
|
@ -201,10 +197,6 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> {
|
||||||
let queue_credential = wanted;
|
let queue_credential = wanted;
|
||||||
|
|
||||||
let inputs = role_inputs(agent, &credential)?;
|
let inputs = role_inputs(agent, &credential)?;
|
||||||
store
|
|
||||||
.write_policy(&inputs.name, &inputs.policy)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("writing the read policy {}", inputs.name))?;
|
|
||||||
store
|
store
|
||||||
.write_policy(policy::SHARED_AGENT_POLICY, &policy::render_shared_agent())
|
.write_policy(policy::SHARED_AGENT_POLICY, &policy::render_shared_agent())
|
||||||
.await
|
.await
|
||||||
|
|
@ -220,7 +212,11 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> {
|
||||||
&inputs.name,
|
&inputs.name,
|
||||||
inputs.ca,
|
inputs.ca,
|
||||||
&inputs.name,
|
&inputs.name,
|
||||||
&inputs.attached.each_ref().map(String::as_str),
|
&inputs
|
||||||
|
.attached
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("writing the cert-auth role {}", inputs.name))?;
|
.with_context(|| format!("writing the cert-auth role {}", inputs.name))?;
|
||||||
|
|
@ -297,7 +293,7 @@ pub async fn revoke_queue_credential(agent: &str) -> Result<()> {
|
||||||
///
|
///
|
||||||
/// Both paths, not just the certificate's, for the reason this function
|
/// Both paths, not just the certificate's, for the reason this function
|
||||||
/// exists at all: the queue credential is readable only because it sits inside
|
/// exists at all: the queue credential is readable only because it sits inside
|
||||||
/// the stanza [`policy::render_agent`] already grants, and a policy that
|
/// the stanza [`policy::render_shared_agent`] grants, and a policy that
|
||||||
/// drifted from that path would otherwise fail at the agent's first connection
|
/// drifted from that path would otherwise fail at the agent's first connection
|
||||||
/// — far from here, as a denial naming neither the policy nor the path.
|
/// — far from here, as a denial naming neither the policy nor the path.
|
||||||
///
|
///
|
||||||
|
|
@ -450,9 +446,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Three of the four strings that must agree, checked where this process
|
/// Three of the four strings that must agree, checked where this process
|
||||||
/// sets them: role, policy and matched CN are one name; the pinned CA is
|
/// sets them: role and matched CN are one name; the pinned CA is the issuer
|
||||||
/// the issuer the store reported for this leaf; the policy is the agent's
|
/// the store reported for this leaf; the role attaches the shared policy
|
||||||
/// own single stanza.
|
/// and no policy of the agent's own.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_role_pins_the_leafs_own_issuer_under_the_agents_name() {
|
fn the_role_pins_the_leafs_own_issuer_under_the_agents_name() {
|
||||||
let credential = mtls::Credential {
|
let credential = mtls::Credential {
|
||||||
|
|
@ -468,17 +464,21 @@ mod tests {
|
||||||
"the CN the leaf is issued for"
|
"the CN the leaf is issued for"
|
||||||
);
|
);
|
||||||
assert_eq!(inputs.ca, "AGENT-CA");
|
assert_eq!(inputs.ca, "AGENT-CA");
|
||||||
|
// Only the shared policy: a policy named for the agent is not attached.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
inputs.attached,
|
inputs.attached,
|
||||||
["hive-agent-atlas", policy::SHARED_AGENT_POLICY],
|
[policy::SHARED_AGENT_POLICY],
|
||||||
"the role attaches the agent's own policy and the shared one"
|
"the role attaches the shared policy and nothing else"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!inputs.attached.contains(&inputs.name),
|
||||||
|
"the role must not attach a policy named for the agent"
|
||||||
);
|
);
|
||||||
assert_eq!(inputs.policy, policy::render_agent("atlas").expect("legal"));
|
|
||||||
assert!(!inputs.policy.contains("swarm/hives/"), "{}", inputs.policy);
|
|
||||||
|
|
||||||
// The control: another agent's inputs differ in both name and grant.
|
// The control: another agent's inputs differ in name, and attach the
|
||||||
|
// same shared policy, so the check above can see a policy that is there.
|
||||||
let other = role_inputs("argus", &credential).expect("legal");
|
let other = role_inputs("argus", &credential).expect("legal");
|
||||||
assert_ne!(other.name, inputs.name);
|
assert_ne!(other.name, inputs.name);
|
||||||
assert!(!other.policy.contains("agents/atlas/"), "{}", other.policy);
|
assert_eq!(other.attached, inputs.attached);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,8 +217,8 @@ async fn main() -> Result<()> {
|
||||||
// abandoned, since the two of them boot together.
|
// abandoned, since the two of them boot together.
|
||||||
let hive_names: Vec<String> = hives.iter().map(|h| h.name.clone()).collect();
|
let hive_names: Vec<String> = hives.iter().map(|h| h.name.clone()).collect();
|
||||||
read_policy::ensure_hive_access(hive_names).await;
|
read_policy::ensure_hive_access(hive_names).await;
|
||||||
// Agents keep the policy they were minted with until this rewrites it, so
|
// The one policy every agent's role attaches, and the entity each agent's
|
||||||
// what `policy::render_agent` grants today reaches them only from here.
|
// login is read under, are checked here at every start.
|
||||||
read_policy::ensure_agent_policies().await;
|
read_policy::ensure_agent_policies().await;
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,16 @@
|
||||||
//! take down a daemon that serves everything else, and a store that answers is
|
//! take down a daemon that serves everything else, and a store that answers is
|
||||||
//! answering the same way next time, so the next start is the retry for those.
|
//! answering the same way next time, so the next start is the retry for those.
|
||||||
//!
|
//!
|
||||||
//! The document names the hive it is written for: one stanza is the same
|
//! The rule is written once for all hives, [`policy::SHARED_HIVE_POLICY`]: one
|
||||||
//! everywhere, the other is scoped to the reader's own name
|
//! stanza is the same everywhere, the other is scoped to the reader's own name
|
||||||
//! ([`swarm_secret_client::policy`] explains the asymmetry). The same rule is
|
//! ([`swarm_secret_client::policy`] explains the asymmetry), taken from its
|
||||||
//! also written once for all hives, [`policy::SHARED_HIVE_POLICY`], with the
|
//! login's alias on the cert mount instead of written into the text. A hive's
|
||||||
//! reader's own name taken from its login's alias on the cert mount instead of
|
//! role attaches that policy and no other. The hive list is loaded once because
|
||||||
//! written into the text; a hive's role attaches both. The hive list is loaded
|
//! a config change means a redeploy.
|
||||||
//! once because a config change means a redeploy.
|
|
||||||
//!
|
//!
|
||||||
//! Every agent's policy is rewritten by the same kind of pass
|
//! The shared agent policy is written by the same kind of pass
|
||||||
//! ([`ensure_agent_policies`]), so a change to `policy::render_agent` reaches
|
//! ([`ensure_agent_policies`]), together with each agent's identity entity.
|
||||||
//! agents minted before it. Its roster is the store's `hive-agent-*` cert-auth
|
//! Its roster is the store's `hive-agent-*` cert-auth roles.
|
||||||
//! roles.
|
|
||||||
|
|
||||||
use std::{future::Future, time::Duration};
|
use std::{future::Future, time::Duration};
|
||||||
|
|
||||||
|
|
@ -128,10 +126,12 @@ where
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One pass: log in once, write every hive's policy, then every hive's role.
|
/// One pass: log in once, write the shared hive policy, then every hive's role.
|
||||||
///
|
///
|
||||||
/// Policy first, because the role names the policy — the other order leaves a
|
/// Policy first, because the role names the policy — the other order leaves a
|
||||||
/// window in which the role points at nothing.
|
/// window in which the role points at nothing. Without the policy the roles are
|
||||||
|
/// left as they are: a role written to name a policy that does not exist logs
|
||||||
|
/// its hive in able to read nothing.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`Unreachable`] for the states a later attempt can resolve: a store that is
|
/// [`Unreachable`] for the states a later attempt can resolve: a store that is
|
||||||
|
|
@ -154,12 +154,9 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> {
|
||||||
}
|
}
|
||||||
Err(e) => return Err(Unreachable(e.to_string())),
|
Err(e) => return Err(Unreachable(e.to_string())),
|
||||||
};
|
};
|
||||||
for hive in hives {
|
if !write_shared_policy(&store).await {
|
||||||
if let Err(e) = write_policy_for(&store, hive).await {
|
return Ok(());
|
||||||
tracing::warn!(hive, error = %format!("{e:#}"), "writing this hive's read policy failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let shared = write_shared_policy(&store).await;
|
|
||||||
|
|
||||||
let Some(path) = std::env::var_os(ENV_HIVE_CLIENT_CA) else {
|
let Some(path) = std::env::var_os(ENV_HIVE_CLIENT_CA) else {
|
||||||
tracing::info!("no hive certificate authority configured; hive roles are not managed here");
|
tracing::info!("no hive certificate authority configured; hive roles are not managed here");
|
||||||
|
|
@ -171,38 +168,25 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> {
|
||||||
let ca = std::fs::read_to_string(&path)
|
let ca = std::fs::read_to_string(&path)
|
||||||
.map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?;
|
.map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?;
|
||||||
for hive in hives {
|
for hive in hives {
|
||||||
if let Err(e) = write_role(&store, hive, &ca, shared).await {
|
if let Err(e) = write_role(&store, hive, &ca).await {
|
||||||
tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed");
|
tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One hive's policy: its own name, its own document.
|
|
||||||
async fn write_policy_for(store: &SecretStore, hive: &str) -> Result<()> {
|
|
||||||
let name = policy::hive_object_name(hive)?;
|
|
||||||
store
|
|
||||||
.write_policy(&name, &policy::render(hive)?)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("writing the read policy {name}"))?;
|
|
||||||
tracing::info!(hive, policy = %name, "hive read policy in place");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write [`policy::SHARED_HIVE_POLICY`], rendered with the cert mount's
|
/// Write [`policy::SHARED_HIVE_POLICY`], rendered with the cert mount's
|
||||||
/// accessor. Answers whether it is in place: a hive's role attaches it only
|
/// accessor. Answers whether it is in place: hive roles are written only then.
|
||||||
/// then.
|
|
||||||
///
|
///
|
||||||
/// Best effort. A hive's own policy grants it the same paths, so a store that
|
/// A store that refuses the accessor read (the controller's policy may not
|
||||||
/// refuses the accessor read (the controller's policy may not carry it yet) or
|
/// carry it yet) or the write leaves the roles already in the store as they are.
|
||||||
/// the write costs the shared policy and nothing else.
|
|
||||||
async fn write_shared_policy(store: &SecretStore) -> bool {
|
async fn write_shared_policy(store: &SecretStore) -> bool {
|
||||||
let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await {
|
let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await {
|
||||||
Ok(accessor) => accessor,
|
Ok(accessor) => accessor,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
"the shared hive policy is not managed: the cert auth mount's accessor could not be read; hives keep their own policy"
|
"the shared hive policy is not managed: the cert auth mount's accessor could not be read; hive roles are left as they are"
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +194,7 @@ async fn write_shared_policy(store: &SecretStore) -> bool {
|
||||||
let document = match policy::render_shared_hives(&accessor) {
|
let document = match policy::render_shared_hives(&accessor) {
|
||||||
Ok(document) => document,
|
Ok(document) => document,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "the shared hive policy is not managed: the cert auth mount's accessor is not usable in a policy; hives keep their own policy");
|
tracing::warn!(error = %e, "the shared hive policy is not managed: the cert auth mount's accessor is not usable in a policy; hive roles are left as they are");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -229,28 +213,23 @@ async fn write_shared_policy(store: &SecretStore) -> bool {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
policy = policy::SHARED_HIVE_POLICY,
|
policy = policy::SHARED_HIVE_POLICY,
|
||||||
error = %e,
|
error = %e,
|
||||||
"writing the shared hive policy failed; hives keep their own policy"
|
"writing the shared hive policy failed; hive roles are left as they are"
|
||||||
);
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The policies `hive`'s role attaches: its own, and the shared one when that
|
/// The policies a hive's role attaches: the shared one, and no policy named for
|
||||||
/// is in place.
|
/// the hive.
|
||||||
fn attached_policies(hive: &str, shared: bool) -> Result<Vec<String>, Error> {
|
fn attached_policies() -> Vec<String> {
|
||||||
let mut attached = vec![policy::hive_object_name(hive)?];
|
vec![policy::SHARED_HIVE_POLICY.to_owned()]
|
||||||
if shared {
|
|
||||||
attached.push(policy::SHARED_HIVE_POLICY.to_owned());
|
|
||||||
}
|
|
||||||
Ok(attached)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The role for one hive: its own certificate's subject, its own policy, and
|
/// The role for one hive: its own certificate's subject and the shared policy.
|
||||||
/// the shared one when `shared`.
|
async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> {
|
||||||
async fn write_role(store: &SecretStore, hive: &str, ca: &str, shared: bool) -> Result<()> {
|
|
||||||
let name = policy::hive_object_name(hive)?;
|
let name = policy::hive_object_name(hive)?;
|
||||||
let attached = attached_policies(hive, shared)?;
|
let attached = attached_policies();
|
||||||
store
|
store
|
||||||
// The subject is the hive's name: `glue-bao-tls.nix` mints a hive's
|
// The subject is the hive's name: `glue-bao-tls.nix` mints a hive's
|
||||||
// client leaf with its name as the CN, and cert auth matches on that.
|
// client leaf with its name as the CN, and cert auth matches on that.
|
||||||
|
|
@ -267,8 +246,8 @@ async fn write_role(store: &SecretStore, hive: &str, ca: &str, shared: bool) ->
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite every agent's policy from [`policy::render_agent`], with the retry
|
/// Write the shared agent policy and give every agent its identity entity, with
|
||||||
/// [`ensure_hive_access`] has and for the same reason.
|
/// the retry [`ensure_hive_access`] has and for the same reason.
|
||||||
///
|
///
|
||||||
/// Returns once the first pass is done. When that pass cannot reach the store,
|
/// Returns once the first pass is done. When that pass cannot reach the store,
|
||||||
/// it repeats in the background for up to [`RETRY_WINDOW`].
|
/// it repeats in the background for up to [`RETRY_WINDOW`].
|
||||||
|
|
@ -294,7 +273,7 @@ pub async fn ensure_agent_policies() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
attempts = RETRY_ATTEMPTS,
|
attempts = RETRY_ATTEMPTS,
|
||||||
window = ?RETRY_WINDOW,
|
window = ?RETRY_WINDOW,
|
||||||
"giving up on the agent policy rewrite; agents keep the policy they were minted with until this daemon is restarted"
|
"giving up on the agent policy rewrite; the shared agent policy and the agents' identity entities are not checked until this daemon is restarted"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -302,9 +281,9 @@ pub async fn ensure_agent_policies() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One pass: log in once, list the agents, write the shared agent policy and
|
/// One pass: log in once, list the agents, write the shared agent policy and
|
||||||
/// each one's own, and give each its identity entity.
|
/// give each agent its identity entity.
|
||||||
///
|
///
|
||||||
/// Policies and entities only. An agent's cert-auth role and certificate are
|
/// The policy and entities only. An agent's cert-auth role and certificate are
|
||||||
/// `agent_identity::mint_and_verify`'s, and that reissues the certificate, so
|
/// `agent_identity::mint_and_verify`'s, and that reissues the certificate, so
|
||||||
/// it is not what a pass on every start may call.
|
/// it is not what a pass on every start may call.
|
||||||
///
|
///
|
||||||
|
|
@ -343,46 +322,17 @@ async fn rewrite_agent_policies() -> Result<(), Unreachable> {
|
||||||
"writing the shared agent policy failed"
|
"writing the shared agent policy failed"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
let policies = agent_policies(&roles);
|
|
||||||
let mut written = 0_usize;
|
|
||||||
for (name, document) in &policies {
|
|
||||||
match store.write_policy(name, document).await {
|
|
||||||
Ok(()) => written += 1,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(policy = %name, error = %e, "rewriting this agent's policy failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::info!(written, agents = policies.len(), "agent policies rewritten");
|
|
||||||
crate::agent_entity::ensure_agents(&store, &policy::agents_from_role_names(&roles)).await;
|
crate::agent_entity::ensure_agents(&store, &policy::agents_from_role_names(&roles)).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The policy to write for each agent among `roles`, a listing of cert-auth
|
|
||||||
/// roles: its name, which is the role's own, and its document.
|
|
||||||
///
|
|
||||||
/// A role that is not an agent's — a hive's `hive-<hive>`, a host's — gets
|
|
||||||
/// nothing: the same `write_policy` call with an agent's document would
|
|
||||||
/// narrow that principal to one agent's subtree.
|
|
||||||
fn agent_policies(roles: &[String]) -> Vec<(String, String)> {
|
|
||||||
policy::agents_from_role_names(roles)
|
|
||||||
.iter()
|
|
||||||
.filter_map(|agent| {
|
|
||||||
Some((
|
|
||||||
policy::agent_object_name(agent).ok()?,
|
|
||||||
policy::render_agent(agent).ok()?,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{cell::Cell, time::Duration};
|
use std::{cell::Cell, time::Duration};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies,
|
RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, attached_policies, policy,
|
||||||
attached_policies, policy, retry_until_ok,
|
retry_until_ok,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -424,54 +374,15 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_pass_writes_agents_policies_under_their_role_names_and_no_one_elses() {
|
fn a_hives_role_attaches_the_shared_policy_and_none_named_for_the_hive() {
|
||||||
let roles: Vec<String> = [
|
let attached = attached_policies();
|
||||||
"hive-agent-atlas",
|
assert_eq!(attached, [policy::SHARED_HIVE_POLICY]);
|
||||||
"hive-pr1ma",
|
let own = policy::hive_object_name("pr1ma").expect("legal");
|
||||||
"swarm-controller",
|
assert!(
|
||||||
"hive-agent-argus",
|
!attached.contains(&own),
|
||||||
]
|
"the role must not attach a policy named for the hive"
|
||||||
.map(str::to_owned)
|
|
||||||
.to_vec();
|
|
||||||
let written = agent_policies(&roles);
|
|
||||||
let names: Vec<&str> = written.iter().map(|(name, _)| name.as_str()).collect();
|
|
||||||
// The role names an agent's policy by its own name, so a policy written
|
|
||||||
// under any other name is one the role does not attach.
|
|
||||||
assert_eq!(names, ["hive-agent-atlas", "hive-agent-argus"]);
|
|
||||||
assert_eq!(
|
|
||||||
written[0].1,
|
|
||||||
policy::render_agent("atlas").expect("legal"),
|
|
||||||
"the prefix is stripped before rendering, or the document names `agent-atlas`"
|
|
||||||
);
|
);
|
||||||
assert_eq!(written[1].1, policy::render_agent("argus").expect("legal"));
|
// The control: the same lookup finds a policy that is attached.
|
||||||
}
|
assert!(attached.contains(&policy::SHARED_HIVE_POLICY.to_owned()));
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_hives_role_attaches_its_own_policy_and_the_shared_one() {
|
|
||||||
assert_eq!(
|
|
||||||
attached_policies("pr1ma", true).expect("legal"),
|
|
||||||
["hive-pr1ma", policy::SHARED_HIVE_POLICY]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn without_the_shared_policy_the_role_keeps_the_hives_own() {
|
|
||||||
assert_eq!(
|
|
||||||
attached_policies("pr1ma", false).expect("legal"),
|
|
||||||
["hive-pr1ma"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_shared_text_serves_every_hive_what_its_own_policy_does() {
|
|
||||||
let shared = policy::render_shared_hives("auth_cert_0a1b2c3d").expect("legal accessor");
|
|
||||||
let template = "{{identity.entity.aliases.auth_cert_0a1b2c3d.name}}";
|
|
||||||
for hive in ["pr1ma", "beta", "gamma-2", "delta_3"] {
|
|
||||||
assert_eq!(
|
|
||||||
shared.replace(template, hive),
|
|
||||||
policy::render(hive).expect("legal"),
|
|
||||||
"{hive}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use crate::{
|
||||||
/// The path holding `agent`'s ACP provider API key.
|
/// The path holding `agent`'s ACP provider API key.
|
||||||
///
|
///
|
||||||
/// A flat leaf under the agent's prefix, like [`crate::forge::agent_token_path`],
|
/// A flat leaf under the agent's prefix, like [`crate::forge::agent_token_path`],
|
||||||
/// so the agent's own read stanza ([`crate::policy::render_agent`]) already
|
/// so the agent's own read stanza ([`crate::policy::render_shared_agent`]) already
|
||||||
/// covers it.
|
/// covers it.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use crate::{
|
||||||
///
|
///
|
||||||
/// A flat leaf under the agent's prefix, like
|
/// A flat leaf under the agent's prefix, like
|
||||||
/// [`crate::github::account_path`], so the agent's own read stanza
|
/// [`crate::github::account_path`], so the agent's own read stanza
|
||||||
/// ([`crate::policy::render_agent`]) already covers it.
|
/// ([`crate::policy::render_shared_agent`]) already covers it.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
||||||
|
|
@ -88,12 +88,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_agents_own_read_grant_covers_the_path() {
|
fn the_agents_own_read_grant_covers_the_path() {
|
||||||
let path = credentials_path("atlas").expect("legal");
|
let path = credentials_path("atlas").expect("legal");
|
||||||
let own = crate::policy::render_agent("atlas").expect("legal");
|
let own = crate::policy::effective_agent("atlas");
|
||||||
assert!(
|
assert!(
|
||||||
covered_by(&own, &path),
|
covered_by(&own, &path),
|
||||||
"no stanza in\n{own}\ncovers {path}"
|
"no stanza in\n{own}\ncovers {path}"
|
||||||
);
|
);
|
||||||
let other = crate::policy::render_agent("argus").expect("legal");
|
let other = crate::policy::effective_agent("argus");
|
||||||
assert!(!covered_by(&other, &path), "argus's policy reaches {path}");
|
assert!(!covered_by(&other, &path), "argus's policy reaches {path}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -780,7 +780,7 @@ mod tests {
|
||||||
|
|
||||||
let request = WriteAclPolicy {
|
let request = WriteAclPolicy {
|
||||||
name: "hive-pr1ma".to_owned(),
|
name: "hive-pr1ma".to_owned(),
|
||||||
policy: crate::policy::render("pr1ma").expect("a plain name is legal"),
|
policy: crate::policy::effective_hive("pr1ma"),
|
||||||
};
|
};
|
||||||
assert_eq!(request.path(), "sys/policies/acl/hive-pr1ma");
|
assert_eq!(request.path(), "sys/policies/acl/hive-pr1ma");
|
||||||
}
|
}
|
||||||
|
|
@ -791,7 +791,7 @@ mod tests {
|
||||||
|
|
||||||
let request = WriteAclPolicy {
|
let request = WriteAclPolicy {
|
||||||
name: "hive-pr1ma".to_owned(),
|
name: "hive-pr1ma".to_owned(),
|
||||||
policy: crate::policy::render("pr1ma").expect("a plain name is legal"),
|
policy: crate::policy::effective_hive("pr1ma"),
|
||||||
};
|
};
|
||||||
let body = request
|
let body = request
|
||||||
.body()
|
.body()
|
||||||
|
|
@ -799,10 +799,7 @@ mod tests {
|
||||||
.expect("a policy write sends one");
|
.expect("a policy write sends one");
|
||||||
let sent: serde_json::Value =
|
let sent: serde_json::Value =
|
||||||
serde_json::from_slice(&body).expect("the body is the JSON the store parses");
|
serde_json::from_slice(&body).expect("the body is the JSON the store parses");
|
||||||
assert_eq!(
|
assert_eq!(sent["policy"], crate::policy::effective_hive("pr1ma"));
|
||||||
sent["policy"],
|
|
||||||
crate::policy::render("pr1ma").expect("legal")
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
sent.get("name").is_none(),
|
sent.get("name").is_none(),
|
||||||
"`name` addresses the policy in the path; sending it too would make \
|
"`name` addresses the policy in the path; sending it too would make \
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ use crate::{
|
||||||
///
|
///
|
||||||
/// A flat leaf under the agent's prefix, like its controller-minted siblings
|
/// A flat leaf under the agent's prefix, like its controller-minted siblings
|
||||||
/// [`crate::queue::agent_queue_path`] and [`crate::mtls::identity_path`], so
|
/// [`crate::queue::agent_queue_path`] and [`crate::mtls::identity_path`], so
|
||||||
/// the agent's own read stanza ([`crate::policy::render_agent`])
|
/// the agent's own read stanza ([`crate::policy::render_shared_agent`])
|
||||||
/// already covers it.
|
/// already covers it.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|
@ -190,7 +190,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_hive_policy_reaches_the_controller_token() {
|
fn no_hive_policy_reaches_the_controller_token() {
|
||||||
let policy = crate::policy::render("alpha").expect("legal");
|
let policy = crate::policy::effective_hive("alpha");
|
||||||
let covers = |path: &str| {
|
let covers = |path: &str| {
|
||||||
policy.lines().any(|line| {
|
policy.lines().any(|line| {
|
||||||
line.strip_prefix("path \"")
|
line.strip_prefix("path \"")
|
||||||
|
|
@ -232,7 +232,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_agents_own_grants_list_its_accounts_and_read_each() {
|
fn the_agents_own_grants_list_its_accounts_and_read_each() {
|
||||||
let policy = crate::policy::render_agent("atlas").expect("legal");
|
let policy = crate::policy::effective_agent("atlas");
|
||||||
let covers = |path: &str| {
|
let covers = |path: &str| {
|
||||||
policy.lines().any(|line| {
|
policy.lines().any(|line| {
|
||||||
line.strip_prefix("path \"")
|
line.strip_prefix("path \"")
|
||||||
|
|
@ -309,7 +309,7 @@ mod tests {
|
||||||
// policy is rendered elsewhere. If the path ever moved out from under
|
// policy is rendered elsewhere. If the path ever moved out from under
|
||||||
// it, the agent's fetch would 403 at boot, naming neither.
|
// it, the agent's fetch would 403 at boot, naming neither.
|
||||||
let path = agent_token_path("atlas").expect("legal");
|
let path = agent_token_path("atlas").expect("legal");
|
||||||
let policy = crate::policy::render_agent("atlas").expect("legal");
|
let policy = crate::policy::effective_agent("atlas");
|
||||||
let covered = policy.lines().any(|line| {
|
let covered = policy.lines().any(|line| {
|
||||||
line.strip_prefix("path \"")
|
line.strip_prefix("path \"")
|
||||||
.and_then(|rest| rest.split_once("\" {"))
|
.and_then(|rest| rest.split_once("\" {"))
|
||||||
|
|
@ -324,7 +324,7 @@ mod tests {
|
||||||
// Control for the test above: the prefix match must actually be
|
// Control for the test above: the prefix match must actually be
|
||||||
// discriminating, or it proves nothing.
|
// discriminating, or it proves nothing.
|
||||||
let path = agent_token_path("atlas").expect("legal");
|
let path = agent_token_path("atlas").expect("legal");
|
||||||
let policy = crate::policy::render_agent("argus").expect("legal");
|
let policy = crate::policy::effective_agent("argus");
|
||||||
let covered = policy.lines().any(|line| {
|
let covered = policy.lines().any(|line| {
|
||||||
line.strip_prefix("path \"")
|
line.strip_prefix("path \"")
|
||||||
.and_then(|rest| rest.split_once("\" {"))
|
.and_then(|rest| rest.split_once("\" {"))
|
||||||
|
|
@ -357,15 +357,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn a_hive_reaches_its_own_forge_token_and_no_other_hives() {
|
fn a_hive_reaches_its_own_forge_token_and_no_other_hives() {
|
||||||
let covered_by = |hive: &str, path: &str| {
|
let covered_by = |hive: &str, path: &str| {
|
||||||
crate::policy::render(hive)
|
crate::policy::effective_hive(hive).lines().any(|line| {
|
||||||
.expect("legal")
|
line.strip_prefix("path \"")
|
||||||
.lines()
|
.and_then(|rest| rest.split_once("\" {"))
|
||||||
.any(|line| {
|
.and_then(|(p, _)| p.strip_suffix('*'))
|
||||||
line.strip_prefix("path \"")
|
.is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
|
||||||
.and_then(|rest| rest.split_once("\" {"))
|
})
|
||||||
.and_then(|(p, _)| p.strip_suffix('*'))
|
|
||||||
.is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
let alpha = hive_token_path("alpha").expect("legal");
|
let alpha = hive_token_path("alpha").expect("legal");
|
||||||
assert!(covered_by("alpha", &alpha), "alpha cannot read {alpha}");
|
assert!(covered_by("alpha", &alpha), "alpha cannot read {alpha}");
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use crate::{
|
||||||
///
|
///
|
||||||
/// A flat leaf under the agent's prefix, like
|
/// A flat leaf under the agent's prefix, like
|
||||||
/// [`crate::forge::agent_token_path`], so the agent's own read stanza
|
/// [`crate::forge::agent_token_path`], so the agent's own read stanza
|
||||||
/// ([`crate::policy::render_agent`]) already covers it.
|
/// ([`crate::policy::render_shared_agent`]) already covers it.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
||||||
|
|
@ -84,13 +84,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_agents_own_read_grant_covers_the_path() {
|
fn the_agents_own_read_grant_covers_the_path() {
|
||||||
let path = account_path("atlas").expect("legal");
|
let path = account_path("atlas").expect("legal");
|
||||||
let own = crate::policy::render_agent("atlas").expect("legal");
|
let own = crate::policy::effective_agent("atlas");
|
||||||
assert!(
|
assert!(
|
||||||
covered_by(&own, &path),
|
covered_by(&own, &path),
|
||||||
"no stanza in\n{own}\ncovers {path}"
|
"no stanza in\n{own}\ncovers {path}"
|
||||||
);
|
);
|
||||||
// The control: another agent's grant does not reach it.
|
// The control: another agent's grant does not reach it.
|
||||||
let other = crate::policy::render_agent("argus").expect("legal");
|
let other = crate::policy::effective_agent("argus");
|
||||||
assert!(!covered_by(&other, &path), "argus's policy reaches {path}");
|
assert!(!covered_by(&other, &path), "argus's policy reaches {path}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ pub(crate) const CONTROLLER: &str = "swarm-controller";
|
||||||
///
|
///
|
||||||
/// The matrix container mints it and publishes it here; `swarm-controller`
|
/// The matrix container mints it and publishes it here; `swarm-controller`
|
||||||
/// reads it to create agents' accounts. Under [`Kind::Controller`] because
|
/// reads it to create agents' accounts. Under [`Kind::Controller`] because
|
||||||
/// [`crate::policy::render`] grants no hive that kind: every hive reads
|
/// [`crate::policy::render_shared_hives`] grants no hive that kind: every hive reads
|
||||||
/// `agents/*` and its own `hives/<hive>/*`, and nothing else.
|
/// `agents/*` and its own `hives/<hive>/*`, and nothing else.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|
@ -186,7 +186,7 @@ mod tests {
|
||||||
fn the_agent_reads_its_own_recovery_passphrase_and_no_other() {
|
fn the_agent_reads_its_own_recovery_passphrase_and_no_other() {
|
||||||
// The daemon reads this under the agent's own policy, so the rendered
|
// The daemon reads this under the agent's own policy, so the rendered
|
||||||
// document is what has to cover it.
|
// document is what has to cover it.
|
||||||
let doc = crate::policy::render_agent("atlas").expect("a plain name is legal");
|
let doc = crate::policy::effective_agent("atlas");
|
||||||
let granted: Vec<&str> = doc
|
let granted: Vec<&str> = doc
|
||||||
.lines()
|
.lines()
|
||||||
.filter_map(|l| l.strip_prefix("path \"secret/data/"))
|
.filter_map(|l| l.strip_prefix("path \"secret/data/"))
|
||||||
|
|
@ -272,7 +272,7 @@ mod tests {
|
||||||
// be under any stanza a hive's policy renders. Checked against the
|
// be under any stanza a hive's policy renders. Checked against the
|
||||||
// rendered document rather than against the prefix, because the
|
// rendered document rather than against the prefix, because the
|
||||||
// document is what the store enforces.
|
// document is what the store enforces.
|
||||||
let doc = crate::policy::render("pr1ma").expect("a plain name is legal");
|
let doc = crate::policy::effective_hive("pr1ma");
|
||||||
// Every stanza is `path "secret/data/<prefix>*" { … }`.
|
// Every stanza is `path "secret/data/<prefix>*" { … }`.
|
||||||
let granted: Vec<&str> = doc
|
let granted: Vec<&str> = doc
|
||||||
.lines()
|
.lines()
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,12 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The path sits under the same prefix an agent's policy grants
|
/// The path sits under the same prefix an agent's policy grants
|
||||||
/// (`policy::render_agent`'s stanza is `swarm/agents/<agent>/*`), which is
|
/// (`policy::render_shared_agent`'s stanza is `swarm/agents/<entity name>/*`), which is
|
||||||
/// what makes an agent able to read its own identity back.
|
/// what makes an agent able to read its own identity back.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_agents_identity_is_inside_its_own_policy_stanza() {
|
fn an_agents_identity_is_inside_its_own_policy_stanza() {
|
||||||
let path = identity_path("atlas").expect("legal");
|
let path = identity_path("atlas").expect("legal");
|
||||||
let document = crate::policy::render_agent("atlas").expect("legal");
|
let document = crate::policy::effective_agent("atlas");
|
||||||
let prefix = format!("{}/data/swarm/agents/atlas/", crate::path::MOUNT);
|
let prefix = format!("{}/data/swarm/agents/atlas/", crate::path::MOUNT);
|
||||||
assert!(
|
assert!(
|
||||||
path.starts_with("swarm/agents/atlas/"),
|
path.starts_with("swarm/agents/atlas/"),
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
//! two have to agree on the same path or a delivery fails with a 403 that names
|
//! two have to agree on the same path or a delivery fails with a 403 that names
|
||||||
//! nothing.
|
//! nothing.
|
||||||
//!
|
//!
|
||||||
//! The document has one stanza per kind a hive reads, and they are not all
|
//! The hive document ([`render_shared_hives`]) has one stanza per kind a hive
|
||||||
//! scoped alike — which is the point rather than an inconsistency:
|
//! reads, and they are not all scoped alike — which is the point rather than
|
||||||
|
//! an inconsistency:
|
||||||
//!
|
//!
|
||||||
//! ⚠️ The **agent** stanza grants read on *every* agent's credentials rather
|
//! ⚠️ The **agent** stanza grants read on *every* agent's credentials rather
|
||||||
//! than on the ones that hive hosts. That is a decision, not an oversight: an
|
//! than on the ones that hive hosts. That is a decision, not an oversight: an
|
||||||
|
|
@ -33,15 +34,14 @@ use crate::{
|
||||||
path::{Kind, MOUNT, ROOT, checked_segment},
|
path::{Kind, MOUNT, ROOT, checked_segment},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Namespace for a hive's own policy and cert-auth role.
|
/// Namespace for a hive's cert-auth role and the policies the controller writes.
|
||||||
///
|
///
|
||||||
/// The controller's own grant is scoped to `hive-*` for both, so this prefix is
|
/// The controller's own grant is scoped to `hive-*` for both, so this prefix is
|
||||||
/// the difference between a hive the controller may provision and a policy it
|
/// the difference between a hive the controller may provision and a policy it
|
||||||
/// must not be able to rewrite — including its own.
|
/// must not be able to rewrite — including its own.
|
||||||
pub const HIVE_PREFIX: &str = "hive-";
|
pub const HIVE_PREFIX: &str = "hive-";
|
||||||
|
|
||||||
/// The policy and cert-auth role name for `hive`. One name, both objects: the
|
/// The cert-auth role name for `hive`.
|
||||||
/// role attaches the policy by spelling it identically.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`.
|
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`.
|
||||||
|
|
@ -50,7 +50,7 @@ pub fn hive_object_name(hive: &str) -> Result<String, Error> {
|
||||||
Ok(format!("{HIVE_PREFIX}{hive}"))
|
Ok(format!("{HIVE_PREFIX}{hive}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Namespace for an agent's own policy and cert-auth role.
|
/// Namespace for an agent's cert-auth role and certificate common name.
|
||||||
///
|
///
|
||||||
/// Deliberately *inside* [`HIVE_PREFIX`] rather than beside it.
|
/// Deliberately *inside* [`HIVE_PREFIX`] rather than beside it.
|
||||||
/// `sys/policies/acl/hive-*` and `auth/cert/certs/hive-*` are the whole of what
|
/// `sys/policies/acl/hive-*` and `auth/cert/certs/hive-*` are the whole of what
|
||||||
|
|
@ -59,7 +59,7 @@ pub fn hive_object_name(hive: &str) -> Result<String, Error> {
|
||||||
/// learns an agent exists — a name outside that prefix is one nothing can
|
/// learns an agent exists — a name outside that prefix is one nothing can
|
||||||
/// write. It costs no authority: the controller already holds `create`/`update`
|
/// write. It costs no authority: the controller already holds `create`/`update`
|
||||||
/// on `secret/data/swarm/agents/*`, so it can already replace every credential
|
/// on `secret/data/swarm/agents/*`, so it can already replace every credential
|
||||||
/// this policy grants read on.
|
/// the shared policies grant read on.
|
||||||
///
|
///
|
||||||
/// It still cannot collide with a hive's. [`hive_object_name`] renders
|
/// It still cannot collide with a hive's. [`hive_object_name`] renders
|
||||||
/// `hive-<hive>`, so a collision needs a hive named `agent-<agent>` — and
|
/// `hive-<hive>`, so a collision needs a hive named `agent-<agent>` — and
|
||||||
|
|
@ -75,9 +75,9 @@ pub fn hive_object_name(hive: &str) -> Result<String, Error> {
|
||||||
/// agent leaf. A CN match alone logs nobody in.
|
/// agent leaf. A CN match alone logs nobody in.
|
||||||
pub const AGENT_PREFIX: &str = "hive-agent-";
|
pub const AGENT_PREFIX: &str = "hive-agent-";
|
||||||
|
|
||||||
/// The policy and cert-auth role name for `agent`, and the common name of the
|
/// The cert-auth role name for `agent`, and the common name of the certificate
|
||||||
/// certificate that authenticates it. One string, three objects: the role
|
/// that authenticates it. One string, two objects: the role matches the subject
|
||||||
/// attaches the policy and matches the subject by spelling all three the same.
|
/// by spelling both the same.
|
||||||
///
|
///
|
||||||
/// Injective in `agent` — the prefix is fixed and [`checked_segment`] has
|
/// Injective in `agent` — the prefix is fixed and [`checked_segment`] has
|
||||||
/// already refused anything that could re-punctuate the suffix. Agent names
|
/// already refused anything that could re-punctuate the suffix. Agent names
|
||||||
|
|
@ -124,39 +124,43 @@ fn list_stanza(path: &str) -> String {
|
||||||
format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n")
|
format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render `hive`'s policy document: read on every agent's credentials and on
|
/// The one policy every hive's cert-auth role attaches.
|
||||||
/// this hive's own.
|
|
||||||
///
|
///
|
||||||
/// The name is the only input, and it is deploy-time — so the document is
|
/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object
|
||||||
/// still a deploy-time object rather than derived state with a re-emission to
|
/// name of a hive called `hives`, and `nix/reserved-hive-fragments.nix` forbids
|
||||||
/// get wrong. Nothing about which agents exist changes the text.
|
/// the substring `hive` in every hive name, so no hive spells it.
|
||||||
|
pub const SHARED_HIVE_POLICY: &str = "hive-hives";
|
||||||
|
|
||||||
|
/// Render the policy [`SHARED_HIVE_POLICY`] holds: read on every agent's
|
||||||
|
/// credentials and on this hive's own, with the hive's name taken from the alias
|
||||||
|
/// of its certificate login on the cert auth mount `mount_accessor`.
|
||||||
|
///
|
||||||
|
/// A hive's certificate carries its bare name as the common name, and that is
|
||||||
|
/// the alias name, so the own-path stanza reads `swarm/hives/<hive>/` for
|
||||||
|
/// whichever hive presents the token. A login whose alias is not a hive's name
|
||||||
|
/// reads a path where nothing is stored. The `agents/` and `hives/` segments
|
||||||
|
/// are fixed text, never taken from the identity.
|
||||||
|
///
|
||||||
|
/// The controller kind and the `swarm/services/` tree are deliberately absent:
|
||||||
|
/// a hive has no business reading the credentials of the thing that provisions
|
||||||
|
/// it, and a swarm service's OIDC client secret is read under a certificate that
|
||||||
|
/// is not a hive's. Adding either is a boundary decision, not a consequence of
|
||||||
|
/// the namespace growing.
|
||||||
|
///
|
||||||
|
/// The hive's *own* kind is granted, and only that one: a hive holds the queue
|
||||||
|
/// credential its own agents authenticate with, so it reads the one principal
|
||||||
|
/// named after itself, which is why the path takes the name from the alias
|
||||||
|
/// instead of widening to the whole kind.
|
||||||
///
|
///
|
||||||
/// Read-only: the controller mints these and never reads one back.
|
/// Read-only: the controller mints these and never reads one back.
|
||||||
///
|
///
|
||||||
/// ⚠️ The controller kind and the `swarm/services/` tree are deliberately
|
|
||||||
/// absent: a hive has no business reading the credentials of the thing that
|
|
||||||
/// provisions it, and a swarm service's OIDC client secret is read under a
|
|
||||||
/// certificate that is not a hive's. Adding either is a boundary decision,
|
|
||||||
/// not a consequence of the namespace growing.
|
|
||||||
///
|
|
||||||
/// The hive's *own* kind is granted, and that is the decision the agent-only
|
|
||||||
/// version of this grant said had to be made rather than assumed: a hive holds
|
|
||||||
/// the queue credential its own agents authenticate with, so it has to read the
|
|
||||||
/// one principal named after itself — and only that one, which is why the path
|
|
||||||
/// interpolates the name instead of widening to the whole kind.
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]` — the
|
/// [`Error::PathSegment`] when `mount_accessor` holds anything but
|
||||||
/// name is interpolated into a policy path, so a name that could close the
|
/// `[A-Za-z0-9_-]`: it is interpolated into the policy text, where it could
|
||||||
/// stanza could grant itself anything.
|
/// close the stanza.
|
||||||
pub fn render(hive: &str) -> Result<String, Error> {
|
pub fn render_shared_hives(mount_accessor: &str) -> Result<String, Error> {
|
||||||
checked_segment("hive", hive)?;
|
checked_segment("mount accessor", mount_accessor)?;
|
||||||
Ok(hive_document(hive))
|
let own = format!("{{{{identity.entity.aliases.{mount_accessor}.name}}}}");
|
||||||
}
|
|
||||||
|
|
||||||
/// The text of [`render`] with `own` as the hive segment: a checked hive name,
|
|
||||||
/// or the template [`render_shared_hives`] puts there.
|
|
||||||
fn hive_document(own: &str) -> String {
|
|
||||||
let agents = read_stanza(&format!(
|
let agents = read_stanza(&format!(
|
||||||
"{MOUNT}/data/{ROOT}/{}/*",
|
"{MOUNT}/data/{ROOT}/{}/*",
|
||||||
<&str>::from(Kind::Agent)
|
<&str>::from(Kind::Agent)
|
||||||
|
|
@ -165,76 +169,10 @@ fn hive_document(own: &str) -> String {
|
||||||
"{MOUNT}/data/{ROOT}/{}/{own}/*",
|
"{MOUNT}/data/{ROOT}/{}/{own}/*",
|
||||||
<&str>::from(Kind::Hive)
|
<&str>::from(Kind::Hive)
|
||||||
));
|
));
|
||||||
format!("{agents}{own}")
|
Ok(format!("{agents}{own}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one policy every hive's cert-auth role attaches next to its own.
|
/// The one policy every agent's cert-auth role attaches.
|
||||||
///
|
|
||||||
/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object
|
|
||||||
/// name of a hive called `hives`, and `nix/reserved-hive-fragments.nix` forbids
|
|
||||||
/// the substring `hive` in every hive name, so no hive spells it.
|
|
||||||
pub const SHARED_HIVE_POLICY: &str = "hive-hives";
|
|
||||||
|
|
||||||
/// Render the policy [`SHARED_HIVE_POLICY`] holds: the text of [`render`], with
|
|
||||||
/// the hive's name taken from the alias of its certificate login on the cert
|
|
||||||
/// auth mount `mount_accessor`.
|
|
||||||
///
|
|
||||||
/// A hive's certificate carries its bare name as the common name, and that is
|
|
||||||
/// the alias name, so the own-path stanza reads `swarm/hives/<hive>/` for
|
|
||||||
/// whichever hive presents the token. A login whose alias is not a hive's name
|
|
||||||
/// reads a path where nothing is stored. The `agents/` and `hives/` segments
|
|
||||||
/// are fixed text, never taken from the identity.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// [`Error::PathSegment`] when `mount_accessor` holds anything but
|
|
||||||
/// `[A-Za-z0-9_-]`: it is interpolated into the policy text, where it could
|
|
||||||
/// close the stanza.
|
|
||||||
pub fn render_shared_hives(mount_accessor: &str) -> Result<String, Error> {
|
|
||||||
checked_segment("mount accessor", mount_accessor)?;
|
|
||||||
Ok(hive_document(&format!(
|
|
||||||
"{{{{identity.entity.aliases.{mount_accessor}.name}}}}"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render `agent`'s policy document: read on that one agent's credentials, list
|
|
||||||
/// on their names (how `hive-matrix-daemon` finds its linked accounts), and
|
|
||||||
/// nothing else at all.
|
|
||||||
///
|
|
||||||
/// Two stanzas over one subtree, and the single interpolated name is the whole
|
|
||||||
/// document: an agent authenticating with its own certificate gets a token that
|
|
||||||
/// fetches `swarm/agents/<agent>/…`, lists the keys under it, and is refused
|
|
||||||
/// every other path in the store. That is the point — the reason to give an
|
|
||||||
/// agent an identity is that it then depends on its hive for one file (the
|
|
||||||
/// certificate) rather than for every credential it uses.
|
|
||||||
///
|
|
||||||
/// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants
|
|
||||||
/// read on `swarm/agents/*` — *every* agent's credentials, not the ones that
|
|
||||||
/// hive hosts; the module header says why that is a decision rather than an
|
|
||||||
/// oversight. The reason does not transfer: an agent's path names the agent,
|
|
||||||
/// so scoping costs nothing and drifts nowhere (the argument that keeps
|
|
||||||
/// [`render`]'s hive stanza narrow), and an agent has no business with another
|
|
||||||
/// agent's credentials, a hive's, a service's, or the controller's. Narrow
|
|
||||||
/// here does **not** narrow the hive's: a hive still reads this agent's
|
|
||||||
/// secrets, and closing that is its own decision.
|
|
||||||
/// Nothing here writes, for the reason the hive's does not: an agent that could
|
|
||||||
/// write its own credentials could hand itself an identity it was never issued.
|
|
||||||
///
|
|
||||||
/// ⚠️ Not deploy-time: hive-c0re creates agents at runtime (`hive_c0re::meta`).
|
|
||||||
/// swarm-controller rewrites every agent's document at its own start, so an
|
|
||||||
/// edit here reaches existing agents with the next controller restart.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]`: the
|
|
||||||
/// name is interpolated into the policy text, where it could close the stanza.
|
|
||||||
pub fn render_agent(agent: &str) -> Result<String, Error> {
|
|
||||||
checked_segment("agent", agent)?;
|
|
||||||
let kind = <&str>::from(Kind::Agent);
|
|
||||||
let read = read_stanza(&format!("{MOUNT}/data/{ROOT}/{kind}/{agent}/*"));
|
|
||||||
let list = list_stanza(&format!("{MOUNT}/metadata/{ROOT}/{kind}/{agent}/*"));
|
|
||||||
Ok(format!("{read}{list}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The one policy every agent's cert-auth role attaches next to its own.
|
|
||||||
///
|
///
|
||||||
/// Inside [`HIVE_PREFIX`] so that the controller may write it, and without an
|
/// Inside [`HIVE_PREFIX`] so that the controller may write it, and without an
|
||||||
/// agent's suffix so that it is never an agent's object name:
|
/// agent's suffix so that it is never an agent's object name:
|
||||||
|
|
@ -248,14 +186,21 @@ pub const SHARED_AGENT_POLICY: &str = "hive-agent";
|
||||||
/// agent's name.
|
/// agent's name.
|
||||||
const ENTITY_NAME_TEMPLATE: &str = "{{identity.entity.name}}";
|
const ENTITY_NAME_TEMPLATE: &str = "{{identity.entity.name}}";
|
||||||
|
|
||||||
/// Render the policy [`SHARED_AGENT_POLICY`] holds: the stanzas of
|
/// Render the policy [`SHARED_AGENT_POLICY`] holds: read on one agent's
|
||||||
/// [`render_agent`], with the agent's name taken from the identity entity of
|
/// credentials and list on their names (how `hive-matrix-daemon` finds its
|
||||||
/// whoever presents the token instead of written into the text.
|
/// linked accounts), with the agent's name taken from the identity entity of
|
||||||
|
/// whoever presents the token.
|
||||||
///
|
///
|
||||||
/// The text is the same for every agent, so a change to the rule is one write.
|
/// The text is the same for every agent, so a change to the rule is one write.
|
||||||
/// An entity whose name is not an agent's grants nothing: the controller names
|
/// An entity whose name is not an agent's grants nothing: the controller names
|
||||||
/// each agent's entity after the agent, and a login that no entity of that
|
/// each agent's entity after the agent, and a login that no entity of that
|
||||||
/// name owns reads `swarm/agents/<generated name>/`, where nothing is stored.
|
/// name owns reads `swarm/agents/<generated name>/`, where nothing is stored.
|
||||||
|
///
|
||||||
|
/// Nothing here writes: an agent that could write its own credentials could
|
||||||
|
/// hand itself an identity it was never issued. An agent has no business with
|
||||||
|
/// another agent's credentials, a hive's, a service's, or the controller's, so
|
||||||
|
/// none of [`render_shared_hives`]'s breadth applies. A hive still reads this
|
||||||
|
/// agent's secrets, and closing that is its own decision.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_shared_agent() -> String {
|
pub fn render_shared_agent() -> String {
|
||||||
let kind = <&str>::from(Kind::Agent);
|
let kind = <&str>::from(Kind::Agent);
|
||||||
|
|
@ -268,23 +213,36 @@ pub fn render_shared_agent() -> String {
|
||||||
format!("{read}{list}")
|
format!("{read}{list}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What an agent's token grants under [`SHARED_AGENT_POLICY`]: the document
|
||||||
|
/// with the store's template filled in for `agent`. The oracle tests use to ask
|
||||||
|
/// whether the policy covers a path.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn effective_agent(agent: &str) -> String {
|
||||||
|
checked_segment("agent", agent).expect("a legal agent name");
|
||||||
|
render_shared_agent().replace(ENTITY_NAME_TEMPLATE, agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a hive's token grants under [`SHARED_HIVE_POLICY`]: the document with
|
||||||
|
/// the store's template filled in for `hive`.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn effective_hive(hive: &str) -> String {
|
||||||
|
checked_segment("hive", hive).expect("a legal hive name");
|
||||||
|
render_shared_hives("auth_cert_test")
|
||||||
|
.expect("a legal accessor")
|
||||||
|
.replace("{{identity.entity.aliases.auth_cert_test.name}}", hive)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_shared_document_is_the_per_agent_one_with_the_entity_name_for_the_agent() {
|
fn the_shared_agent_document_reads_the_entity_name_where_the_agent_goes() {
|
||||||
let shared = render_shared_agent();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shared,
|
render_shared_agent(),
|
||||||
"path \"secret/data/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"read\"]\n}\n\
|
"path \"secret/data/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"read\"]\n}\n\
|
||||||
path \"secret/metadata/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"list\"]\n}\n"
|
path \"secret/metadata/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"list\"]\n}\n"
|
||||||
);
|
);
|
||||||
// Same shape as the per-agent document, so the rule is one rule.
|
|
||||||
assert_eq!(
|
|
||||||
shared.replace("{{identity.entity.name}}", "atlas"),
|
|
||||||
render_agent("atlas").expect("legal")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -314,28 +272,26 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_document_grants_every_agent_and_this_hive_alone() {
|
fn the_document_grants_every_agent_and_this_hive_alone() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
render("pr1ma").expect("a plain name is legal"),
|
effective_hive("pr1ma"),
|
||||||
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
|
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
|
||||||
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n"
|
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_shared_hive_document_is_each_hives_own_with_the_alias_name_for_the_hive() {
|
fn the_shared_hive_document_takes_the_hive_from_the_alias_name() {
|
||||||
let shared = render_shared_hives("auth_cert_1a2b3c4d").expect("legal accessor");
|
let shared = render_shared_hives("auth_cert_1a2b3c4d").expect("legal accessor");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shared,
|
shared,
|
||||||
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
|
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
|
||||||
path \"secret/data/swarm/hives/{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}/*\" {\n capabilities = [\"read\"]\n}\n"
|
path \"secret/data/swarm/hives/{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}/*\" {\n capabilities = [\"read\"]\n}\n"
|
||||||
);
|
);
|
||||||
// Same text as every hive's own document once the template is filled
|
// The template fills in for hives that differ in length, case and
|
||||||
// in, for hives that differ in length, case and punctuation.
|
// punctuation, and each then reads exactly its own path.
|
||||||
for hive in ["pr1ma", "a", "Mixed_Case-1", "x".repeat(63).as_str()] {
|
for hive in ["pr1ma", "a", "Mixed_Case-1", "x".repeat(63).as_str()] {
|
||||||
assert_eq!(
|
let own = effective_hive(hive);
|
||||||
shared.replace("{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}", hive),
|
assert!(own.contains(&format!("swarm/hives/{hive}/*")), "{own}");
|
||||||
render(hive).expect("legal"),
|
assert!(!own.contains("{{"), "{own}");
|
||||||
"{hive}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,7 +343,7 @@ mod tests {
|
||||||
// A swarm service's OIDC client secret is read under a certificate that
|
// A swarm service's OIDC client secret is read under a certificate that
|
||||||
// is not a hive's. Checked by walking the stanzas, so a wider grant
|
// is not a hive's. Checked by walking the stanzas, so a wider grant
|
||||||
// that happened to cover the tree (`swarm/*`, say) fails too.
|
// that happened to cover the tree (`swarm/*`, say) fails too.
|
||||||
let p = render("pr1ma").expect("legal");
|
let p = effective_hive("pr1ma");
|
||||||
assert!(!p.contains("swarm/services"), "{p}");
|
assert!(!p.contains("swarm/services"), "{p}");
|
||||||
let service = "swarm/services/swarm-grafana/oidc/client";
|
let service = "swarm/services/swarm-grafana/oidc/client";
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -408,7 +364,7 @@ mod tests {
|
||||||
/// means something if the stanza that reaches it is too.
|
/// means something if the stanza that reaches it is too.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
|
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
|
||||||
let alpha = render("alpha").expect("legal");
|
let alpha = effective_hive("alpha");
|
||||||
let own = crate::matrix::sender_token_path("alpha").expect("legal");
|
let own = crate::matrix::sender_token_path("alpha").expect("legal");
|
||||||
let other = crate::matrix::sender_token_path("beta").expect("legal");
|
let other = crate::matrix::sender_token_path("beta").expect("legal");
|
||||||
|
|
||||||
|
|
@ -456,7 +412,7 @@ mod tests {
|
||||||
fn the_grant_is_read_only() {
|
fn the_grant_is_read_only() {
|
||||||
// A hive reads credentials; a hive that could write one could hand
|
// A hive reads credentials; a hive that could write one could hand
|
||||||
// itself an agent's identity.
|
// itself an agent's identity.
|
||||||
let p = render("pr1ma").expect("legal");
|
let p = effective_hive("pr1ma");
|
||||||
assert!(!p.contains("create"));
|
assert!(!p.contains("create"));
|
||||||
assert!(!p.contains("update"));
|
assert!(!p.contains("update"));
|
||||||
assert!(!p.contains("delete"));
|
assert!(!p.contains("delete"));
|
||||||
|
|
@ -465,32 +421,16 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn one_hives_document_does_not_reach_another_hives_path() {
|
fn one_hives_document_does_not_reach_another_hives_path() {
|
||||||
// Replaces `every_hive_gets_a_byte_identical_document`: the hive stanza
|
let a = effective_hive("alpha");
|
||||||
// is per-reader now, so identical text is no longer the property. This
|
|
||||||
// is what that test was protecting — that a document says only what the
|
|
||||||
// deploy-time name puts in it.
|
|
||||||
let a = render("alpha").expect("legal");
|
|
||||||
assert!(a.contains("swarm/hives/alpha/*"));
|
assert!(a.contains("swarm/hives/alpha/*"));
|
||||||
assert!(!a.contains("beta"));
|
assert!(!a.contains("beta"));
|
||||||
assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow");
|
assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_same_name_still_renders_byte_identically() {
|
|
||||||
// The half of the old property that survives: the text is a function of
|
|
||||||
// the deploy-time name alone, so a re-emission cannot drift.
|
|
||||||
assert_eq!(
|
|
||||||
render("pr1ma").expect("legal"),
|
|
||||||
render("pr1ma").expect("legal")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_name_that_could_close_the_stanza_is_refused() {
|
fn a_name_that_could_close_the_stanza_is_refused() {
|
||||||
// The name reaches the document now, which it did not before — so the
|
// The name reaches a role name and a certificate subject, so a name that
|
||||||
// injection case is live again in the policy TEXT, not just in the
|
// could re-punctuate either is refused.
|
||||||
// policy's identifier.
|
|
||||||
assert!(render("alpha/*\" { capabilities = [\"root\"] }").is_err());
|
|
||||||
assert!(
|
assert!(
|
||||||
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
|
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
|
||||||
"the object NAME is a place a name can do damage too"
|
"the object NAME is a place a name can do damage too"
|
||||||
|
|
@ -502,7 +442,6 @@ mod tests {
|
||||||
// The control for the case above: if every name were refused, that
|
// The control for the case above: if every name were refused, that
|
||||||
// assertion would pass while proving nothing.
|
// assertion would pass while proving nothing.
|
||||||
assert!(hive_object_name("a-b_C9").is_ok());
|
assert!(hive_object_name("a-b_C9").is_ok());
|
||||||
assert!(render("a-b_C9").is_ok());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -515,16 +454,13 @@ mod tests {
|
||||||
assert!(n.starts_with(HIVE_PREFIX));
|
assert!(n.starts_with(HIVE_PREFIX));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `render_agent("atlas")` must render, byte for byte.
|
/// What `effective_agent("atlas")` must be, byte for byte.
|
||||||
const ATLAS_DOCUMENT: &str = "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n\
|
const ATLAS_DOCUMENT: &str = "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n\
|
||||||
path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n";
|
path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n";
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_agents_document_is_two_stanzas_naming_that_agent() {
|
fn an_agents_document_is_two_stanzas_naming_that_agent() {
|
||||||
assert_eq!(
|
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
|
||||||
render_agent("atlas").expect("a plain name is legal"),
|
|
||||||
ATLAS_DOCUMENT
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -532,7 +468,7 @@ mod tests {
|
||||||
// `render`'s agent stanza is `agents/*` on purpose, and inheriting one
|
// `render`'s agent stanza is `agents/*` on purpose, and inheriting one
|
||||||
// character of that here would give every agent every other agent's
|
// character of that here would give every agent every other agent's
|
||||||
// credentials while the document still read as per-agent.
|
// credentials while the document still read as per-agent.
|
||||||
let p = render_agent("atlas").expect("legal");
|
let p = effective_agent("atlas");
|
||||||
assert!(
|
assert!(
|
||||||
!p.contains("swarm/agents/*"),
|
!p.contains("swarm/agents/*"),
|
||||||
"the agent stanza must not widen to the kind: {p}"
|
"the agent stanza must not widen to the kind: {p}"
|
||||||
|
|
@ -546,7 +482,7 @@ mod tests {
|
||||||
// Stated as an exhaustive check over the kinds rather than as a list of
|
// Stated as an exhaustive check over the kinds rather than as a list of
|
||||||
// paths, so a kind added to `path::Kind` cannot be granted here by a
|
// paths, so a kind added to `path::Kind` cannot be granted here by a
|
||||||
// renderer nobody re-read.
|
// renderer nobody re-read.
|
||||||
let p = render_agent("atlas").expect("legal");
|
let p = effective_agent("atlas");
|
||||||
for kind in Kind::ALL {
|
for kind in Kind::ALL {
|
||||||
let segment = <&str>::from(kind);
|
let segment = <&str>::from(kind);
|
||||||
let expected = kind == Kind::Agent;
|
let expected = kind == Kind::Agent;
|
||||||
|
|
@ -568,13 +504,9 @@ mod tests {
|
||||||
fn an_agents_document_is_exactly_its_own_two_stanzas() {
|
fn an_agents_document_is_exactly_its_own_two_stanzas() {
|
||||||
// Pinned byte for byte: an added stanza (a hive's queue credential, a
|
// Pinned byte for byte: an added stanza (a hive's queue credential, a
|
||||||
// second agent) is exactly what a presence check misses.
|
// second agent) is exactly what a presence check misses.
|
||||||
assert_eq!(render_agent("atlas").expect("legal"), ATLAS_DOCUMENT);
|
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
|
||||||
// The control: the pin discriminates between agents.
|
// The control: the pin discriminates between agents.
|
||||||
assert!(
|
assert!(!effective_agent("other").contains("agents/atlas/"));
|
||||||
!render_agent("other")
|
|
||||||
.expect("legal")
|
|
||||||
.contains("agents/atlas/")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -582,7 +514,7 @@ mod tests {
|
||||||
// An agent that could write its own credentials could hand itself an
|
// An agent that could write its own credentials could hand itself an
|
||||||
// identity it was never issued — and `create`/`update` on that path is
|
// identity it was never issued — and `create`/`update` on that path is
|
||||||
// exactly what the controller holds, so the wall is the capability.
|
// exactly what the controller holds, so the wall is the capability.
|
||||||
let p = render_agent("atlas").expect("legal");
|
let p = effective_agent("atlas");
|
||||||
for capability in ["create", "update", "delete", "sudo", "patch"] {
|
for capability in ["create", "update", "delete", "sudo", "patch"] {
|
||||||
assert!(
|
assert!(
|
||||||
!p.contains(capability),
|
!p.contains(capability),
|
||||||
|
|
@ -596,7 +528,7 @@ mod tests {
|
||||||
// `read` on `data/` is the values; `list` on `metadata/` is the key
|
// `read` on `data/` is the values; `list` on `metadata/` is the key
|
||||||
// names. `read` on `metadata/` (version history, deletion times) is not
|
// names. `read` on `metadata/` (version history, deletion times) is not
|
||||||
// granted, nor is `list` anywhere but `metadata/`.
|
// granted, nor is `list` anywhere but `metadata/`.
|
||||||
let p = render_agent("atlas").expect("legal");
|
let p = effective_agent("atlas");
|
||||||
let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
|
let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
|
||||||
assert_eq!(stanzas.len(), 2, "{p}");
|
assert_eq!(stanzas.len(), 2, "{p}");
|
||||||
for stanza in stanzas {
|
for stanza in stanzas {
|
||||||
|
|
@ -618,22 +550,19 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn one_agents_document_does_not_reach_another_agents_path() {
|
fn one_agents_document_does_not_reach_another_agents_path() {
|
||||||
let a = render_agent("atlas").expect("legal");
|
let a = effective_agent("atlas");
|
||||||
assert!(a.contains("swarm/agents/atlas/*"));
|
assert!(a.contains("swarm/agents/atlas/*"));
|
||||||
assert!(!a.contains("argus"));
|
assert!(!a.contains("argus"));
|
||||||
assert_eq!(a, render_agent("atlas").expect("legal"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_agent_name_that_could_close_the_stanza_is_refused() {
|
fn an_agent_name_that_could_close_the_stanza_is_refused() {
|
||||||
// Same live injection surface as the hive renderer's: the name reaches
|
// The name reaches a role name and a certificate subject, so a name that
|
||||||
// the document text, not just an identifier.
|
// could re-punctuate either is refused.
|
||||||
assert!(render_agent("atlas/*\" { capabilities = [\"root\"] }").is_err());
|
|
||||||
assert!(render_agent("").is_err());
|
|
||||||
assert!(agent_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err());
|
assert!(agent_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err());
|
||||||
|
assert!(agent_object_name("").is_err());
|
||||||
// The control: if everything were refused the arms above would pass for
|
// The control: if everything were refused the arms above would pass for
|
||||||
// the wrong reason.
|
// the wrong reason.
|
||||||
assert!(render_agent("a-b_C9").is_ok());
|
|
||||||
assert!(agent_object_name("a-b_C9").is_ok());
|
assert!(agent_object_name("a-b_C9").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -673,18 +602,14 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_agents_document_is_not_the_hives() {
|
fn an_agents_document_is_not_the_hives() {
|
||||||
// The pin the two renderers need against each other: a later edit that
|
// A later edit that made one shared document delegate to the other
|
||||||
// made `render_agent` delegate to `render`, or vice versa, would hand
|
// would hand an agent the hive's `agents/*` stanza.
|
||||||
// an agent the hive's `agents/*` stanza.
|
let agent = effective_agent("atlas");
|
||||||
let agent = render_agent("atlas").expect("legal");
|
let hive = effective_hive("atlas");
|
||||||
let hive = render("atlas").expect("legal");
|
|
||||||
assert_ne!(agent, hive);
|
assert_ne!(agent, hive);
|
||||||
assert!(!agent.contains("swarm/services/"));
|
assert!(!agent.contains("swarm/services/"));
|
||||||
assert!(!agent.contains("swarm/hives/"));
|
assert!(!agent.contains("swarm/hives/"));
|
||||||
// And the hive's is untouched by this module's growth: still the two
|
// The control: the hive's document does carry that stanza.
|
||||||
// stanzas `the_document_grants_every_agent_and_this_hive_alone` pins
|
|
||||||
// byte for byte, neither narrowed to make the agent's look consistent
|
|
||||||
// with it.
|
|
||||||
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
|
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ use crate::{
|
||||||
/// Under the agent's own principal prefix, and that is the whole reason for
|
/// Under the agent's own principal prefix, and that is the whole reason for
|
||||||
/// this spelling rather than a new top-level one: every agent's ACL document
|
/// this spelling rather than a new top-level one: every agent's ACL document
|
||||||
/// already grants read on `swarm/agents/<agent>/*`
|
/// already grants read on `swarm/agents/<agent>/*`
|
||||||
/// ([`crate::policy::render_agent`]), so a credential here needs no new grant,
|
/// ([`crate::policy::render_shared_agent`]), so a credential here needs no new grant
|
||||||
/// no policy re-render, and no rewrite of any existing agent's document.
|
/// and no policy rewrite.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
||||||
|
|
@ -68,7 +68,7 @@ pub struct ControllerCredential {
|
||||||
/// identity hive-c0re publishes the hive's status to the swarm queue with.
|
/// identity hive-c0re publishes the hive's status to the swarm queue with.
|
||||||
///
|
///
|
||||||
/// Under the hive's own prefix, which the hive's policy reads
|
/// Under the hive's own prefix, which the hive's policy reads
|
||||||
/// ([`crate::policy::render`]).
|
/// ([`crate::policy::render_shared_hives`]).
|
||||||
///
|
///
|
||||||
/// The nix half is `swarm-secret-publisher.nix`, which writes it.
|
/// The nix half is `swarm-secret-publisher.nix`, which writes it.
|
||||||
///
|
///
|
||||||
|
|
@ -129,7 +129,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_hive_policy_reaches_the_controller_client() {
|
fn no_hive_policy_reaches_the_controller_client() {
|
||||||
let doc = crate::policy::render("alpha").expect("a plain name is legal");
|
let doc = crate::policy::effective_hive("alpha");
|
||||||
// Every stanza is `path "secret/data/<prefix>*" { … }`.
|
// Every stanza is `path "secret/data/<prefix>*" { … }`.
|
||||||
let granted: Vec<&str> = doc
|
let granted: Vec<&str> = doc
|
||||||
.lines()
|
.lines()
|
||||||
|
|
@ -157,7 +157,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_hives_own_policy_reads_its_client_and_no_other_hives() {
|
fn a_hives_own_policy_reads_its_client_and_no_other_hives() {
|
||||||
let doc = crate::policy::render("alpha").expect("a plain name is legal");
|
let doc = crate::policy::effective_hive("alpha");
|
||||||
let granted: Vec<&str> = doc
|
let granted: Vec<&str> = doc
|
||||||
.lines()
|
.lines()
|
||||||
.filter_map(|l| l.strip_prefix("path \"secret/data/"))
|
.filter_map(|l| l.strip_prefix("path \"secret/data/"))
|
||||||
|
|
@ -216,7 +216,7 @@ mod tests {
|
||||||
/// it belongs to.
|
/// it belongs to.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_agents_existing_policy_already_covers_its_queue_path() {
|
fn the_agents_existing_policy_already_covers_its_queue_path() {
|
||||||
let document = crate::policy::render_agent("atlas").expect("legal");
|
let document = crate::policy::effective_agent("atlas");
|
||||||
let path = agent_queue_path("atlas").expect("legal");
|
let path = agent_queue_path("atlas").expect("legal");
|
||||||
let stanza = path
|
let stanza = path
|
||||||
.rsplit_once('/')
|
.rsplit_once('/')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue