diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 25157091..13597a34 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -377,12 +377,15 @@ is one write. `swarm-controller` writes it at agent creation and at its own 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 `` with the alias `hive-agent-`, -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, and -that agent reads nothing until an operator resolves it. It needs the `identity/` -and `sys/auth/cert` grants in `swarm-bao.nix`'s `controllerPolicyText` for that. +each certificate mint or renewal, before it writes the agent's cert-auth role, +and for every agent at its own start. It reads the cert mount's accessor and +creates the entity `` with the alias `hive-agent-`, or renames the +entity a first login created under a generated name. At a mint or renewal this is +a precondition: when the store refuses a call, or the alias sits on any other +entity, the mint fails and the role stays as it was, so a live agent keeps +working until an operator resolves it. The start pass only logs the same +failures, because it writes no role. The controller needs the `identity/` and +`sys/auth/cert` grants in `swarm-bao.nix`'s `controllerPolicyText` for that. A cert-auth role that predates the shared policy may still name a policy `hive-agent-`. The controller no longer writes or attaches these. The diff --git a/swarm-controller/src/agent_entity.rs b/swarm-controller/src/agent_entity.rs index a6f0b03b..5c698630 100644 --- a/swarm-controller/src/agent_entity.rs +++ b/swarm-controller/src/agent_entity.rs @@ -13,20 +13,22 @@ //! is ever moved. Any other arrangement is a [`Plan::Conflict`]: nothing is //! written and an operator decides. //! -//! Everything else here is best effort: a failure is logged at `warn` and the -//! caller goes on. An agent whose entity is not named after it reads nothing, -//! because the shared policy is the only one its role attaches; at mint the -//! read-back fails, and on a start pass its next reads are refused. +//! An agent whose entity is not named after it reads nothing under the shared +//! policy, so what a failure costs depends on the caller. [`ensure_agent`], +//! which runs before a cert-auth role is narrowed to that policy, returns the +//! error and leaves the role as it was. [`ensure_agents`], the pass at every +//! start, writes no role and so narrows nothing: it logs at `warn` and goes on. //! The grants are the `identity/` stanzas of `swarm-bao.nix`'s //! `controllerPolicyText`. use anyhow::{Context, Result, bail}; use swarm_secret_client::{ - SecretStore, client::{DEFAULT_CERT_MOUNT, EntityRef}, policy, }; +use crate::store::AccessStore; + /// What the store holds for one agent's entity. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Seen { @@ -92,13 +94,13 @@ pub fn plan(agent: &str, seen: &Seen) -> Plan { /// Make sure every agent in `agents` has its entity, logging what could not be /// done. Never fails: see the module header. -pub async fn ensure_agents(store: &SecretStore, agents: &[String]) { +pub async fn ensure_agents(store: &impl AccessStore, agents: &[String]) { let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await { Ok(accessor) => accessor, Err(e) => { tracing::warn!( error = %e, - "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" + "agent entities are not managed: the cert auth mount's accessor could not be read; an agent whose role attaches the shared policy alone reads nothing until its entity is named after it" ); return; } @@ -120,13 +122,30 @@ pub async fn ensure_agents(store: &SecretStore, agents: &[String]) { ); } -/// [`ensure_agents`] for one agent. -pub async fn ensure_agent(store: &SecretStore, agent: &str) { - ensure_agents(store, &[agent.to_owned()]).await; +/// Make sure `agent` has its entity, or say why not. +/// +/// For a caller that must not go on without it, such as the one that narrows a +/// cert-auth role to the shared policy: an agent whose entity is not named after +/// it reads nothing under that policy. +/// +/// # Errors +/// When the cert mount's accessor cannot be read, when a store call fails, and +/// on a [`Plan::Conflict`], which [`ensure_agents`] only logs. +pub async fn ensure_agent(store: &impl AccessStore, agent: &str) -> Result<()> { + let accessor = store + .auth_mount_accessor(DEFAULT_CERT_MOUNT) + .await + .context("reading the cert auth mount's accessor")?; + match ensure_one(store, &accessor, agent).await? { + Plan::Conflict { alias, entity_name } => bail!( + "the alias {alias} is on the entity {entity_name}, which is not {agent}; nothing was changed" + ), + _ => Ok(()), + } } /// Observe, plan and apply for one agent. -async fn ensure_one(store: &SecretStore, accessor: &str, agent: &str) -> Result { +async fn ensure_one(store: &impl AccessStore, accessor: &str, agent: &str) -> Result { let alias = policy::agent_object_name(agent)?; let seen = Seen { by_alias: store @@ -174,7 +193,7 @@ async fn ensure_one(store: &SecretStore, accessor: &str, agent: &str) -> Result< agent, alias, entity_name, - "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" + "the agent's alias is on an entity that is not the agent's; nothing was changed; under the shared policy alone the agent reads nothing until an operator resolves it" ); } } diff --git a/swarm-controller/src/agent_identity.rs b/swarm-controller/src/agent_identity.rs index f51a0b6b..13eec673 100644 --- a/swarm-controller/src/agent_identity.rs +++ b/swarm-controller/src/agent_identity.rs @@ -29,6 +29,7 @@ //! [`crate::agent_renewal`] re-issues a live agent's leaf once it is past half //! its validity (the role's `ttl`), by queueing the same node as creation. +use crate::store::AccessStore; use anyhow::{Context, Result, bail}; use swarm_secret_client::{ SecretStore, @@ -79,6 +80,48 @@ fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result, +) -> Result<()> { + store + .write_policy(policy::SHARED_AGENT_POLICY, &policy::render_shared_agent()) + .await + .with_context(|| { + format!( + "writing the shared agent policy {}", + policy::SHARED_AGENT_POLICY + ) + })?; + crate::agent_entity::ensure_agent(store, agent) + .await + .with_context(|| format!("naming {agent}'s identity entity; the role is not changed"))?; + store + .write_cert_role( + DEFAULT_CERT_MOUNT, + &inputs.name, + inputs.ca, + &inputs.name, + &inputs + .attached + .iter() + .map(String::as_str) + .collect::>(), + ) + .await + .with_context(|| format!("writing the cert-auth role {}", inputs.name)) +} + /// How many bytes of kernel randomness a queue secret is before encoding. /// /// Thirty-two because the secret is a bearer token compared for equality and @@ -121,12 +164,12 @@ pub(crate) fn generate_queue_secret() -> Result { /// identity at the swarm queue, minted here so that the credential an agent /// presents names *it* rather than its hive; /// 4. write the shared ACL document [`policy::render_shared_agent`] renders; -/// 5. write the cert-auth role that ties the three together, attaching that -/// policy 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. +/// 5. name the agent's identity entity after the agent ([`crate::agent_entity`]), +/// which the policy reads; +/// 6. write the cert-auth role that ties the three together, attaching that +/// policy and pinning the CA the store named as this leaf's issuer. /// -/// Policy before role, for the reason `read_policy::provision` gives. +/// Policy and entity before role: see [`publish_access`]. /// /// ⚠️ **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 @@ -135,10 +178,10 @@ pub(crate) fn generate_queue_secret() -> Result { /// replacing one by age is [`crate::agent_renewal`]'s. /// /// # Errors -/// Anything that stops steps 1–5, with the step named, or the read-back login, -/// which is where a step 6 that did not take shows up. A failure here fails the -/// job node and nothing else — the agent is still created, without a store -/// identity. +/// Anything that stops one of those six steps, with the step named, or the +/// read-back login. A failure here fails the job node and nothing else — a new +/// agent is still created, without a store identity, and a live agent keeps the +/// role it had. pub async fn mint_and_verify(agent: &str) -> Result<()> { let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?; let name = policy::agent_object_name(agent)?; @@ -197,33 +240,8 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> { let queue_credential = wanted; let inputs = role_inputs(agent, &credential)?; - store - .write_policy(policy::SHARED_AGENT_POLICY, &policy::render_shared_agent()) - .await - .with_context(|| { - format!( - "writing the shared agent policy {}", - policy::SHARED_AGENT_POLICY - ) - })?; - store - .write_cert_role( - DEFAULT_CERT_MOUNT, - &inputs.name, - inputs.ca, - &inputs.name, - &inputs - .attached - .iter() - .map(String::as_str) - .collect::>(), - ) - .await - .with_context(|| format!("writing the cert-auth role {}", inputs.name))?; + publish_access(&store, agent, &inputs).await?; tracing::info!(agent, %path, role = %name, "agent store identity published"); - // Before the read-back below, whose login would otherwise create the - // entity under a generated name. - crate::agent_entity::ensure_agent(&store, agent).await; read_back_as_agent(&credential, &name, &path, &queue_path, &queue_credential).await?; tracing::info!( @@ -352,8 +370,9 @@ async fn read_back_as_agent( mod tests { use super::{ ENV_AGENT_PKI_MOUNT, ENV_AGENT_PKI_ROLE, QUEUE_SECRET_BYTES, agent_pki, - generate_queue_secret, role_inputs, + generate_queue_secret, publish_access, role_inputs, }; + use crate::store::fake::FakeAccess; use swarm_secret_client::{mtls, policy}; /// The alphabet claim the token format rests on: the secret is carried in @@ -481,4 +500,73 @@ mod tests { assert_ne!(other.name, inputs.name); assert_eq!(other.attached, inputs.attached); } + + fn credential() -> mtls::Credential { + mtls::Credential { + cert: "LEAF".to_owned(), + key: "KEY".to_owned(), + ca: "AGENT-CA".to_owned(), + } + } + + /// The control: with a working entity step the role is narrowed, after the + /// entity is named, so the absence checks below can see a role write. + #[tokio::test] + async fn the_role_is_narrowed_after_the_entity_is_named() { + let store = FakeAccess::default(); + let credential = credential(); + let inputs = role_inputs("atlas", &credential).expect("legal"); + publish_access(&store, "atlas", &inputs) + .await + .expect("a store that accepts everything"); + assert_eq!( + store.roles_written(), + ["write_cert_role hive-agent-atlas hive-agent"] + ); + let calls = store.calls(); + let at = |call: &str| calls.iter().position(|c| c == call).expect(call); + assert!( + at("create_entity_alias hive-agent-atlas") + < at("write_cert_role hive-agent-atlas hive-agent"), + "the entity is named before the role is written: {calls:?}" + ); + } + + #[tokio::test] + async fn a_renewal_whose_entity_step_fails_leaves_the_role_alone() { + let store = FakeAccess::without_accessor(); + let credential = credential(); + let inputs = role_inputs("atlas", &credential).expect("legal"); + let err = publish_access(&store, "atlas", &inputs) + .await + .expect_err("the entity step failed"); + assert!( + format!("{err:#}").contains("the role is not changed"), + "{err:#}" + ); + assert!(store.roles_written().is_empty(), "{:?}", store.calls()); + // The control: the call log sees writes, and the shared policy was one. + assert!( + store + .calls() + .contains(&"write_policy hive-agent".to_owned()) + ); + } + + #[tokio::test] + async fn a_renewal_whose_alias_is_on_another_entity_leaves_the_role_alone() { + let store = FakeAccess::with_alias_on("id-1", "somebody"); + let credential = credential(); + let inputs = role_inputs("atlas", &credential).expect("legal"); + let err = publish_access(&store, "atlas", &inputs) + .await + .expect_err("a conflict is an error"); + assert!(format!("{err:#}").contains("somebody"), "{err:#}"); + assert!(store.roles_written().is_empty(), "{:?}", store.calls()); + assert!( + store + .calls() + .contains(&"entity_by_alias hive-agent-atlas".to_owned()) + ); + } } diff --git a/swarm-controller/src/read_policy.rs b/swarm-controller/src/read_policy.rs index 80a0849a..c651d189 100644 --- a/swarm-controller/src/read_policy.rs +++ b/swarm-controller/src/read_policy.rs @@ -29,7 +29,9 @@ use std::{future::Future, time::Duration}; use anyhow::{Context, Result}; use backon::{BackoffBuilder as _, ConstantBuilder}; -use swarm_secret_client::{Error, SecretStore, client::DEFAULT_CERT_MOUNT, policy}; +use swarm_secret_client::{Error, client::DEFAULT_CERT_MOUNT, policy}; + +use crate::store::AccessStore; /// File holding the authority hives are issued from, as /// `swarm-controller.nix` names it. @@ -154,21 +156,38 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> { } Err(e) => return Err(Unreachable(e.to_string())), }; - if !write_shared_policy(&store).await { - return Ok(()); - } + provision_with(&store, hives, read_hive_authority).await +} +/// The hive authority from the file `ENV_HIVE_CLIENT_CA` names, or `None` when +/// none is configured. +fn read_hive_authority() -> Result, Unreachable> { 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"); - return Ok(()); + return Ok(None); }; let path = path.to_string_lossy().into_owned(); // Retryable rather than fatal: the unit that places this file and this // daemon start together, so "not there yet" is a moment, not a verdict. - let ca = std::fs::read_to_string(&path) - .map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?; + std::fs::read_to_string(&path) + .map(Some) + .map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}"))) +} + +/// [`provision`] against `store`, with the authority read by `read_authority`. +async fn provision_with( + store: &impl AccessStore, + hives: &[String], + read_authority: impl FnOnce() -> Result, Unreachable>, +) -> Result<(), Unreachable> { + if !write_shared_policy(store).await { + return Ok(()); + } + let Some(ca) = read_authority()? else { + tracing::info!("no hive certificate authority configured; hive roles are not managed here"); + return Ok(()); + }; for hive in hives { - if let Err(e) = write_role(&store, hive, &ca).await { + if let Err(e) = write_role(store, hive, &ca).await { tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed"); } } @@ -180,7 +199,7 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> { /// /// A store that refuses the accessor read (the controller's policy may not /// carry it yet) or the write leaves the roles already in the store as they are. -async fn write_shared_policy(store: &SecretStore) -> bool { +async fn write_shared_policy(store: &impl AccessStore) -> bool { let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await { Ok(accessor) => accessor, Err(e) => { @@ -227,7 +246,7 @@ fn attached_policies() -> Vec { } /// The role for one hive: its own certificate's subject and the shared policy. -async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> { +async fn write_role(store: &impl AccessStore, hive: &str, ca: &str) -> Result<()> { let name = policy::hive_object_name(hive)?; let attached = attached_policies(); store @@ -330,9 +349,11 @@ async fn rewrite_agent_policies() -> Result<(), Unreachable> { mod tests { use std::{cell::Cell, time::Duration}; + use crate::store::fake::FakeAccess; + use super::{ RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, attached_policies, policy, - retry_until_ok, + provision_with, retry_until_ok, }; #[test] @@ -385,4 +406,84 @@ mod tests { // The control: the same lookup finds a policy that is attached. assert!(attached.contains(&policy::SHARED_HIVE_POLICY.to_owned())); } + + fn hives() -> Vec { + ["pr1ma", "beta"].map(str::to_owned).to_vec() + } + + #[tokio::test] + async fn every_hives_role_is_written_after_the_shared_policy_and_attaches_it_alone() { + let store = FakeAccess::default(); + provision_with(&store, &hives(), || Ok(Some("HIVE-CA".to_owned()))) + .await + .unwrap_or_else(|_| panic!("a store that accepts everything")); + assert_eq!( + store.roles_written(), + [ + "write_cert_role hive-pr1ma hive-hives", + "write_cert_role hive-beta hive-hives" + ] + ); + let calls = store.calls(); + let policy_at = calls + .iter() + .position(|c| c == "write_policy hive-hives") + .expect("the shared policy was written"); + let first_role_at = calls + .iter() + .position(|c| c.starts_with("write_cert_role ")) + .expect("a role was written"); + assert!(policy_at < first_role_at, "{calls:?}"); + } + + #[tokio::test] + async fn an_unreadable_accessor_leaves_every_hive_role_as_it_was() { + let store = FakeAccess::without_accessor(); + provision_with(&store, &hives(), || Ok(Some("HIVE-CA".to_owned()))) + .await + .unwrap_or_else(|_| panic!("a policy that cannot be written is not a retry")); + assert!(store.roles_written().is_empty(), "{:?}", store.calls()); + // The control: the accessor read was attempted, and the same fake + // writes roles when it works (the test above). + assert_eq!(store.calls(), ["auth_mount_accessor"]); + } + + #[tokio::test] + async fn a_refused_shared_policy_leaves_every_hive_role_as_it_was() { + let store = FakeAccess::refusing_policies(); + provision_with(&store, &hives(), || Ok(Some("HIVE-CA".to_owned()))) + .await + .unwrap_or_else(|_| panic!("a refused write is not a retry")); + assert!(store.roles_written().is_empty(), "{:?}", store.calls()); + assert!( + store + .calls() + .contains(&"write_policy hive-hives".to_owned()) + ); + } + + #[tokio::test] + async fn without_an_authority_the_policy_is_written_and_no_role_is() { + let store = FakeAccess::default(); + provision_with(&store, &hives(), || Ok(None)) + .await + .unwrap_or_else(|_| panic!("no authority configured is not a retry")); + assert!(store.roles_written().is_empty(), "{:?}", store.calls()); + assert!( + store + .calls() + .contains(&"write_policy hive-hives".to_owned()) + ); + } + + #[tokio::test] + async fn an_authority_that_cannot_be_read_is_retried_and_writes_no_role() { + let store = FakeAccess::default(); + let outcome = provision_with(&store, &hives(), || { + Err(Unreachable("not placed yet".to_owned())) + }) + .await; + assert!(outcome.is_err()); + assert!(store.roles_written().is_empty(), "{:?}", store.calls()); + } } diff --git a/swarm-controller/src/store.rs b/swarm-controller/src/store.rs index 6d94a400..eff037d2 100644 --- a/swarm-controller/src/store.rs +++ b/swarm-controller/src/store.rs @@ -3,8 +3,12 @@ //! Two paths log in: writing an agent's credential, and writing the read //! grant that lets a hive fetch one back. Both present the same certificate //! under the same role, so the role is named here rather than at each caller. +//! [`AccessStore`] is the subset of calls that give a principal its access, so +//! a test can stand in for the store. -use swarm_secret_client::{Error, SecretStore}; +use std::future::Future; + +use swarm_secret_client::{Error, SecretStore, client::EntityRef}; /// The cert-auth role the controller logs in under. /// @@ -23,3 +27,256 @@ pub const CERT_ROLE: &str = "swarm-controller"; pub async fn connect() -> Result { SecretStore::from_env(CERT_ROLE).await } + +/// The store calls that give a principal its access: policies, cert-auth roles +/// and identity entities. A test stands in for the store through this trait. +pub(crate) trait AccessStore { + /// As [`SecretStore::write_policy`]. + fn write_policy( + &self, + name: &str, + policy: &str, + ) -> impl Future> + Send; + + /// As [`SecretStore::write_cert_role`]. + fn write_cert_role( + &self, + mount: &str, + name: &str, + certificate: &str, + common_name: &str, + policies: &[&str], + ) -> impl Future> + Send; + + /// As [`SecretStore::auth_mount_accessor`]. + fn auth_mount_accessor( + &self, + mount: &str, + ) -> impl Future> + Send; + + /// As [`SecretStore::entity_by_alias`]. + fn entity_by_alias( + &self, + alias_name: &str, + mount_accessor: &str, + ) -> impl Future, Error>> + Send; + + /// As [`SecretStore::entity_id_by_name`]. + fn entity_id_by_name( + &self, + name: &str, + ) -> impl Future, Error>> + Send; + + /// As [`SecretStore::create_entity`]. + fn create_entity(&self, name: &str) -> impl Future> + Send; + + /// As [`SecretStore::rename_entity`]. + fn rename_entity(&self, id: &str, name: &str) + -> impl Future> + Send; + + /// As [`SecretStore::create_entity_alias`]. + fn create_entity_alias( + &self, + alias_name: &str, + mount_accessor: &str, + entity_id: &str, + ) -> impl Future> + Send; +} + +impl AccessStore for SecretStore { + async fn write_policy(&self, name: &str, policy: &str) -> Result<(), Error> { + SecretStore::write_policy(self, name, policy).await + } + + async fn write_cert_role( + &self, + mount: &str, + name: &str, + certificate: &str, + common_name: &str, + policies: &[&str], + ) -> Result<(), Error> { + SecretStore::write_cert_role(self, mount, name, certificate, common_name, policies).await + } + + async fn auth_mount_accessor(&self, mount: &str) -> Result { + SecretStore::auth_mount_accessor(self, mount).await + } + + async fn entity_by_alias( + &self, + alias_name: &str, + mount_accessor: &str, + ) -> Result, Error> { + SecretStore::entity_by_alias(self, alias_name, mount_accessor).await + } + + async fn entity_id_by_name(&self, name: &str) -> Result, Error> { + SecretStore::entity_id_by_name(self, name).await + } + + async fn create_entity(&self, name: &str) -> Result<(), Error> { + SecretStore::create_entity(self, name).await + } + + async fn rename_entity(&self, id: &str, name: &str) -> Result<(), Error> { + SecretStore::rename_entity(self, id, name).await + } + + async fn create_entity_alias( + &self, + alias_name: &str, + mount_accessor: &str, + entity_id: &str, + ) -> Result<(), Error> { + SecretStore::create_entity_alias(self, alias_name, mount_accessor, entity_id).await + } +} + +/// A store that records what it is asked to do, for tests of the order and the +/// outcome of the access calls. +#[cfg(test)] +pub(crate) mod fake { + use std::sync::Mutex; + + use swarm_secret_client::{Error, client::EntityRef}; + + use super::AccessStore; + + /// What the fake answers; the default is a store with a readable cert mount + /// and no entities, which accepts every write. + #[derive(Default)] + pub(crate) struct FakeAccess { + /// The accessor read fails. + no_accessor: bool, + /// Every policy write is refused. + refuse_policy: bool, + /// The entity that owns each alias, as `(id, name)`. + alias_owner: Option<(String, String)>, + entities: Mutex>, + calls: Mutex>, + } + + impl FakeAccess { + /// A store whose cert mount accessor cannot be read. + pub(crate) fn without_accessor() -> Self { + Self { + no_accessor: true, + ..Self::default() + } + } + + /// A store that refuses every policy write. + pub(crate) fn refusing_policies() -> Self { + Self { + refuse_policy: true, + ..Self::default() + } + } + + /// A store where the agent alias is already on the entity `name`. + pub(crate) fn with_alias_on(id: &str, name: &str) -> Self { + Self { + alias_owner: Some((id.to_owned(), name.to_owned())), + ..Self::default() + } + } + + /// The calls so far, as `verb name` strings, in order. + pub(crate) fn calls(&self) -> Vec { + self.calls.lock().expect("not poisoned").clone() + } + + /// The `write_cert_role` calls so far, as `write_cert_role `. + pub(crate) fn roles_written(&self) -> Vec { + self.calls() + .into_iter() + .filter(|c| c.starts_with("write_cert_role ")) + .collect() + } + + fn log(&self, call: String) { + self.calls.lock().expect("not poisoned").push(call); + } + + fn denied() -> Error { + Error::Settings("fake store: refused".to_owned()) + } + } + + impl AccessStore for FakeAccess { + async fn write_policy(&self, name: &str, _policy: &str) -> Result<(), Error> { + self.log(format!("write_policy {name}")); + if self.refuse_policy { + return Err(Self::denied()); + } + Ok(()) + } + + async fn write_cert_role( + &self, + _mount: &str, + name: &str, + _certificate: &str, + _common_name: &str, + policies: &[&str], + ) -> Result<(), Error> { + self.log(format!("write_cert_role {name} {}", policies.join(","))); + Ok(()) + } + + async fn auth_mount_accessor(&self, _mount: &str) -> Result { + self.log("auth_mount_accessor".to_owned()); + if self.no_accessor { + return Err(Self::denied()); + } + Ok("auth_cert_test".to_owned()) + } + + async fn entity_by_alias( + &self, + alias_name: &str, + _mount_accessor: &str, + ) -> Result, Error> { + self.log(format!("entity_by_alias {alias_name}")); + Ok(self.alias_owner.as_ref().map(|(id, name)| EntityRef { + id: id.clone(), + name: name.clone(), + })) + } + + async fn entity_id_by_name(&self, name: &str) -> Result, Error> { + self.log(format!("entity_id_by_name {name}")); + let named = self + .entities + .lock() + .expect("not poisoned") + .contains(&name.to_owned()); + Ok(named.then(|| format!("id-{name}"))) + } + + async fn create_entity(&self, name: &str) -> Result<(), Error> { + self.log(format!("create_entity {name}")); + self.entities + .lock() + .expect("not poisoned") + .push(name.to_owned()); + Ok(()) + } + + async fn rename_entity(&self, id: &str, name: &str) -> Result<(), Error> { + self.log(format!("rename_entity {id} {name}")); + Ok(()) + } + + async fn create_entity_alias( + &self, + alias_name: &str, + _mount_accessor: &str, + _entity_id: &str, + ) -> Result<(), Error> { + self.log(format!("create_entity_alias {alias_name}")); + Ok(()) + } + } +}