swarm-secret-client: hives no longer read swarm/services/*
The hive policy rendered by policy::render granted every hive cert read on secret/data/swarm/services/*, which holds each swarm service's OIDC client secret. No reader of that tree authenticates with a hive cert: grafana, the otel collector and the store forwarder each read with their own leaf, and the bao UI's secret is read by the store's granter. render() now emits two stanzas: swarm/agents/* and the hive's own swarm/hives/<hive>/*. Kind::Service had no other caller and is removed. Docs and comments that described the grant as a decision are rewritten to the current state. Closes #4960
This commit is contained in:
parent
3bfdf0dfe0
commit
2505008b83
13 changed files with 82 additions and 136 deletions
|
|
@ -69,7 +69,7 @@ of the cell says how.
|
||||||
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
|
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
|
||||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
||||||
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
||||||
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | must be stated |
|
| `swarm/services/<clientId>/oidc/client` | authelia | that service's own reader, under its own certificate; the bao UI's by the store's granter | must be stated | must be stated |
|
||||||
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
|
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
|
||||||
|
|
||||||
<!-- vale write-good.Passive = YES -->
|
<!-- vale write-good.Passive = YES -->
|
||||||
|
|
|
||||||
|
|
@ -72,37 +72,24 @@ advertises a boundary it doesn't actually hold. A wide grant that says what it
|
||||||
is beats a narrow one that only looks narrow.
|
is beats a narrow one that only looks narrow.
|
||||||
|
|
||||||
What still holds: the grant is **read-only** (a hive can't write an agent's
|
What still holds: the grant is **read-only** (a hive can't write an agent's
|
||||||
credential, so it can't hand itself an agent's identity), and it reaches three
|
credential, so it can't hand itself an agent's identity), and it reaches two
|
||||||
prefixes and nothing else in the store — every agent's credentials, the reader's
|
prefixes and nothing else in the store — every agent's credentials, and the
|
||||||
_own_ entry under the hive namespace, which names the hive asking and so widens
|
reader's _own_ entry under the hive namespace, which names the hive asking and
|
||||||
nothing between them, and every **swarm service's** OIDC client secret.
|
so widens nothing between them.
|
||||||
|
|
||||||
That third prefix has the same shape of reason as the first, and the same honest
|
A hive reads no **swarm service's** OIDC client secret. Every reader of
|
||||||
cost. A swarm service (Grafana and the swarm collector are the two there today)
|
`swarm/services/` presents a certificate that isn't a hive's: Grafana, the swarm
|
||||||
registers **one** client for the whole swarm, so its credential's path names
|
collector and the store forwarder each fetch theirs with a leaf of their own,
|
||||||
the service and never the host — and which hive runs a given service is a
|
and the store's granter fetches the bao UI's.
|
||||||
`deploy.*` fact, per-host by definition, so nothing swarm-wide exists to scope
|
|
||||||
the grant to. The host running such a service has no store identity of its own
|
|
||||||
either; it reads with the certificate of the hive it is. **Any hive can
|
|
||||||
therefore read any swarm service's client secret**, which is worth what it
|
|
||||||
buys: every swarm service gets its credential the same way from anywhere,
|
|
||||||
instead of only where the identity provider happens to sit. Giving such a
|
|
||||||
service its own store identity is what would remove this rather than re-scope
|
|
||||||
it.
|
|
||||||
|
|
||||||
**A tracked follow-up** narrows this, with the two candidate directions: scope
|
### Per-hive credentials live under the hive kind
|
||||||
the grant per hive (and pay for the re-emission), or give each agent container
|
|
||||||
its own store identity so credentials never pass through a hive at all.
|
|
||||||
|
|
||||||
### Per-hive credentials live under the hive kind, not beside a service
|
|
||||||
|
|
||||||
`swarm_secret_client::policy` scopes the **hive** stanza narrowly (unlike the
|
`swarm_secret_client::policy` scopes the **hive** stanza narrowly (unlike the
|
||||||
agent and service stanzas above) because a hive's own path names it, so
|
agent stanza above) because a hive's own path names it, so scoping to the
|
||||||
scoping to the reader's own name costs nothing and drifts nowhere. That only
|
reader's own name costs nothing and drifts nowhere. That only holds if a
|
||||||
holds if a credential that must be one-per-hive is actually stored under the
|
credential that must be one-per-hive is actually stored under the hive kind.
|
||||||
hive kind.
|
|
||||||
**A credential that must be one-per-hive goes under `Kind::Hive`**; putting it
|
**A credential that must be one-per-hive goes under `Kind::Hive`**; putting it
|
||||||
under `Kind::Service` and expecting the grant to scope it — that's the mistake
|
under the agent prefix and expecting the grant to scope it — that's the mistake
|
||||||
this note exists to stop.
|
this note exists to stop.
|
||||||
|
|
||||||
### Threat model: prompt injection → confused deputy
|
### Threat model: prompt injection → confused deputy
|
||||||
|
|
|
||||||
|
|
@ -218,8 +218,7 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
|
||||||
/// this process's environment, so the store read costs no plumbing through
|
/// this process's environment, so the store read costs no plumbing through
|
||||||
/// [`ensure_all`]. That name is also the path's own hive segment, which is
|
/// [`ensure_all`]. That name is also the path's own hive segment, which is
|
||||||
/// what makes this read reach **this** hive's token and 403 on any other's:
|
/// what makes this read reach **this** hive's token and 403 on any other's:
|
||||||
/// the grant behind it is the hive's own `swarm/hives/<name>/*` stanza, not
|
/// the grant behind it is the hive's own `swarm/hives/<name>/*` stanza.
|
||||||
/// the swarm-wide `swarm/services/*` one the shared token used to sit under.
|
|
||||||
///
|
///
|
||||||
/// `None`, never an error, for every way this can come up empty — no hive
|
/// `None`, never an error, for every way this can come up empty — no hive
|
||||||
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
|
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
|
||||||
|
|
|
||||||
|
|
@ -759,7 +759,7 @@ let
|
||||||
#
|
#
|
||||||
# 🩸 Each of the four reads exactly ONE path in the store, and until this
|
# 🩸 Each of the four reads exactly ONE path in the store, and until this
|
||||||
# split each did it holding `deploy.bao.clientCertFile` — the hive's own leaf,
|
# split each did it holding `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`) granted read on
|
||||||
# `swarm/agents/*`, `swarm/hives/<hive>/*` AND `swarm/services/*`. Four
|
# `swarm/agents/*`, `swarm/hives/<hive>/*` AND `swarm/services/*`. Four
|
||||||
# principals presenting one certificate are ONE principal to bao, so the union
|
# principals presenting one certificate are ONE principal to bao, so the union
|
||||||
# of what the four need was the only grant expressible: the unit that fetches
|
# of what the four need was the only grant expressible: the unit that fetches
|
||||||
|
|
@ -1142,8 +1142,7 @@ let
|
||||||
# own and lands it in a collector's container — because that route is
|
# own and lands it in a collector's container — because that route is
|
||||||
# proven and there is no second one worth inventing.
|
# proven and there is no second one worth inventing.
|
||||||
#
|
#
|
||||||
# Where the publisher on authelia's host leaves it. The `services` segment
|
# Where the publisher on authelia's host leaves it. A swarm service's client
|
||||||
# is `swarm-secret-client`'s `path::Kind::Service`: a swarm service's client
|
|
||||||
# is registered once for the whole swarm, so its secret is one value. The
|
# is registered once for the whole swarm, so its secret is one value. The
|
||||||
# `swarm-forwarder-oidc` role's grant is this one path, so any other answers
|
# `swarm-forwarder-oidc` role's grant is this one path, so any other answers
|
||||||
# 403 however correct it looks.
|
# 403 however correct it looks.
|
||||||
|
|
@ -2288,8 +2287,8 @@ in
|
||||||
environment = {
|
environment = {
|
||||||
BAO_ADDR = "https://${cfg.domain}:${toString cfg.port}";
|
BAO_ADDR = "https://${cfg.domain}:${toString cfg.port}";
|
||||||
# 🩸 Its OWN leaf, not `clientCertFile`: the hive's grant reads every
|
# 🩸 Its OWN leaf, not `clientCertFile`: the hive's grant reads every
|
||||||
# agent's credential and every service's OIDC secret, and this unit
|
# agent's credential, and this unit needs one path. The pair is
|
||||||
# needs one path. The pair is asserted set above.
|
# asserted set above.
|
||||||
BAO_CLIENT_CERT = baoDeploy.forwarderOidcClientCertFile;
|
BAO_CLIENT_CERT = baoDeploy.forwarderOidcClientCertFile;
|
||||||
BAO_CLIENT_KEY = baoDeploy.forwarderOidcClientKeyFile;
|
BAO_CLIENT_KEY = baoDeploy.forwarderOidcClientKeyFile;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -238,8 +238,7 @@ let
|
||||||
hostSecretPath = "/var/lib/nixos-containers/${cfg.machine}${secretPath}";
|
hostSecretPath = "/var/lib/nixos-containers/${cfg.machine}${secretPath}";
|
||||||
hostSecretDir = builtins.dirOf hostSecretPath;
|
hostSecretDir = builtins.dirOf hostSecretPath;
|
||||||
|
|
||||||
# Where the publisher on authelia's host leaves this client's secret. The
|
# Where the publisher on authelia's host leaves this client's secret. A
|
||||||
# `services` segment is `swarm-secret-client`'s `path::Kind::Service` — a
|
|
||||||
# swarm service's client is registered once for the whole swarm, so its
|
# swarm service's client is registered once for the whole swarm, so its
|
||||||
# secret is one value and not one per hive. Both ends compose this from the
|
# secret is one value and not one per hive. Both ends compose this from the
|
||||||
# same swarm-wide client id, so a rename cannot leave one of them behind.
|
# same swarm-wide client id, so a rename cannot leave one of them behind.
|
||||||
|
|
|
||||||
|
|
@ -272,10 +272,8 @@ let
|
||||||
# Where the publisher on authelia's host leaves this client's secret —
|
# Where the publisher on authelia's host leaves this client's secret —
|
||||||
# composed from the same swarm-wide `clientId` the registration in
|
# composed from the same swarm-wide `clientId` the registration in
|
||||||
# ./glue-swarm-otel-oidc-client.nix uses, so a rename cannot leave one of
|
# ./glue-swarm-otel-oidc-client.nix uses, so a rename cannot leave one of
|
||||||
# them behind. The `services` segment is `swarm-secret-client`'s
|
# them behind. The same prefix ./swarm-grafana.nix reads its own client
|
||||||
# `path::Kind::Service`, the same prefix ./swarm-grafana.nix reads its own
|
# secret under; the `swarm-otel-oidc` role in ./swarm-bao.nix grants the read.
|
||||||
# client secret under — one write grant in ./swarm-bao.nix and one hive read
|
|
||||||
# grant in `policy::render` already cover it.
|
|
||||||
storeSecretPath = "secret/swarm/services/${cfg.clientId}/oidc/client";
|
storeSecretPath = "secret/swarm/services/${cfg.clientId}/oidc/client";
|
||||||
|
|
||||||
# The operator-configured upstream, named once: the same exporter carries
|
# The operator-configured upstream, named once: the same exporter carries
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,7 @@ let
|
||||||
|
|
||||||
# The swarm's own services, as opposed to its hives. One client for the whole
|
# The swarm's own services, as opposed to its hives. One client for the whole
|
||||||
# swarm rather than one per hive, so one value in the store rather than a copy
|
# swarm rather than one per hive, so one value in the store rather than a copy
|
||||||
# each: `swarm/services/<id>/oidc/client`, under the `services` kind
|
# each: `swarm/services/<id>/oidc/client`.
|
||||||
# `swarm-secret-client`'s `path::Kind` declares.
|
|
||||||
#
|
#
|
||||||
# The ids come from `swarm.*`, which is identical on every host — that is what
|
# The ids come from `swarm.*`, which is identical on every host — that is what
|
||||||
# lets this host name a service's client while running none of them, and it is
|
# lets this host name a service's client while running none of them, and it is
|
||||||
|
|
@ -267,9 +266,8 @@ in
|
||||||
fi
|
fi
|
||||||
'') serviceClientIds}
|
'') serviceClientIds}
|
||||||
|
|
||||||
# The controller's own client, under `controller/` rather than
|
# The controller's own client, under `controller/`, which no hive
|
||||||
# `services/`: every hive reads `services/*`, and this client may read
|
# reads: this client may read every hive's status. The nix half of
|
||||||
# every hive's status. The nix half of
|
|
||||||
# `swarm_secret_client::queue::controller_client_path`.
|
# `swarm_secret_client::queue::controller_client_path`.
|
||||||
src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${controllerClientId}.secret"}
|
src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${controllerClientId}.secret"}
|
||||||
if [ -s "$src" ]; then
|
if [ -s "$src" ]; then
|
||||||
|
|
|
||||||
|
|
@ -482,7 +482,7 @@ let
|
||||||
# ── the four readers that used to share the hive's own leaf ──────────────
|
# ── the four readers that used to share the hive's own leaf ──────────────
|
||||||
#
|
#
|
||||||
# 🩸 Until this split all four presented `deploy.bao.clientCertFile`, whose
|
# 🩸 Until this split all four presented `deploy.bao.clientCertFile`, whose
|
||||||
# policy grants read on `swarm/agents/*`, `swarm/hives/<hive>/*` AND
|
# policy granted read on `swarm/agents/*`, `swarm/hives/<hive>/*` AND
|
||||||
# `swarm/services/*`. Four principals behind one certificate are one
|
# `swarm/services/*`. Four principals behind one certificate are one
|
||||||
# principal to bao, so the only expressible grant was the union: the unit
|
# principal to bao, so the only expressible grant was the union: the unit
|
||||||
# fetching Grafana's OIDC secret could fetch every agent credential in the
|
# fetching Grafana's OIDC secret could fetch every agent credential in the
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ let
|
||||||
{
|
{
|
||||||
# The controller reads this exact path
|
# The controller reads this exact path
|
||||||
# (`swarm_secret_client::queue::controller_client_path`, pinned by that
|
# (`swarm_secret_client::queue::controller_client_path`, pinned by that
|
||||||
# crate's own test). Not under `services/`, which every hive reads: this
|
# crate's own test). Under `controller/`, which no hive reads: this
|
||||||
# client may read every hive's status.
|
# client may read every hive's status.
|
||||||
name = "the publisher writes the controller's client under controller/, never services/";
|
name = "the publisher writes the controller's client under controller/, never services/";
|
||||||
ok =
|
ok =
|
||||||
|
|
|
||||||
|
|
@ -60,18 +60,10 @@ pub fn hive_localpart(hive: &str) -> String {
|
||||||
|
|
||||||
/// The path holding `hive`'s matrix sender account's homeserver access token.
|
/// The path holding `hive`'s matrix sender account's homeserver access token.
|
||||||
///
|
///
|
||||||
/// Keyed per **hive**, like [`appservice_token_path`] below and unlike the
|
/// Keyed per **hive**, like [`appservice_token_path`] below. Under
|
||||||
/// per-homeserver constant this replaced. The old path
|
/// [`Kind::Hive`] the grant that reaches it is the hive's **own** stanza,
|
||||||
/// (`swarm/services/matrix/sender-token`) held **one value for the whole
|
/// `secret/data/swarm/hives/<this hive>/*`, which interpolates the name: hive
|
||||||
/// swarm** and sat under the [`Kind::Service`] tree that
|
/// `alpha` reads its own token and gets a 403 on `beta`'s.
|
||||||
/// [`crate::policy::render`] grants *every* hive read on — so every hive could
|
|
||||||
/// read, and act as, the one shared account.
|
|
||||||
///
|
|
||||||
/// Under [`Kind::Hive`] the grant that reaches it is the hive's **own**
|
|
||||||
/// stanza, `secret/data/swarm/hives/<this hive>/*`, which interpolates the
|
|
||||||
/// name: hive `alpha` reads its own token and gets a 403 on `beta`'s. Nothing
|
|
||||||
/// was added to the policy to do that — the path moved out from under the
|
|
||||||
/// broad grant, which is the half that makes it real.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
|
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
|
||||||
|
|
@ -112,9 +104,8 @@ 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
|
||||||
/// that is the one kind [`crate::policy::render`] grants no hive: every hive
|
/// [`crate::policy::render`] grants no hive that kind: every hive reads
|
||||||
/// reads `agents/*`, its own `hives/<hive>/*` and `services/*`, so under any
|
/// `agents/*` and its own `hives/<hive>/*`, and nothing else.
|
||||||
/// of those this admin credential would be readable by every hive.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Never in practice: the name segment is a constant. The `Result` is
|
/// Never in practice: the name segment is a constant. The `Result` is
|
||||||
|
|
@ -174,18 +165,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_sender_token_left_the_tree_every_hive_can_read() {
|
|
||||||
// 🩸 The whole point of the move. `policy::render` grants every hive
|
|
||||||
// read on `secret/data/swarm/services/*` — a deliberate grant that
|
|
||||||
// stays, because a service's OIDC secret is read with the certificate
|
|
||||||
// of whatever hive hosts it. What must not stay is this credential
|
|
||||||
// sitting inside it: under `services/` one shared token was readable
|
|
||||||
// by every hive, which is one matrix identity for the whole swarm.
|
|
||||||
let p = sender_token_path("pr1ma").expect("legal");
|
|
||||||
assert!(!p.starts_with("swarm/services/"), "{p}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn one_hives_sender_token_is_not_another_hives() {
|
fn one_hives_sender_token_is_not_another_hives() {
|
||||||
// The property the per-hive path exists for: two hives never name the
|
// The property the per-hive path exists for: two hives never name the
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,6 @@ pub enum Kind {
|
||||||
/// is what uses it.
|
/// is what uses it.
|
||||||
#[strum(serialize = "hives")]
|
#[strum(serialize = "hives")]
|
||||||
Hive,
|
Hive,
|
||||||
/// One swarm service — the things a swarm runs beside the controller.
|
|
||||||
#[strum(serialize = "services")]
|
|
||||||
Service,
|
|
||||||
/// The controller itself. There is one per swarm, so the name segment
|
/// The controller itself. There is one per swarm, so the name segment
|
||||||
/// below it does not vary; the shape stays uniform anyway, because one
|
/// below it does not vary; the shape stays uniform anyway, because one
|
||||||
/// grant pattern over `<kind>/<name>` is cheaper than a special case.
|
/// grant pattern over `<kind>/<name>` is cheaper than a special case.
|
||||||
|
|
@ -53,7 +50,7 @@ pub enum Kind {
|
||||||
impl Kind {
|
impl Kind {
|
||||||
/// Every kind, so callers that must cover the whole set can iterate rather
|
/// Every kind, so callers that must cover the whole set can iterate rather
|
||||||
/// than restate it — a second list is a list that drifts.
|
/// than restate it — a second list is a list that drifts.
|
||||||
pub const ALL: [Kind; 4] = [Kind::Agent, Kind::Hive, Kind::Service, Kind::Controller];
|
pub const ALL: [Kind; 3] = [Kind::Agent, Kind::Hive, Kind::Controller];
|
||||||
|
|
||||||
/// What to call the name in an error — singular, because the message reads
|
/// What to call the name in an error — singular, because the message reads
|
||||||
/// "hive name ... is not a single path segment".
|
/// "hive name ... is not a single path segment".
|
||||||
|
|
@ -62,7 +59,6 @@ impl Kind {
|
||||||
match self {
|
match self {
|
||||||
Kind::Agent => "agent",
|
Kind::Agent => "agent",
|
||||||
Kind::Hive => "hive",
|
Kind::Hive => "hive",
|
||||||
Kind::Service => "service",
|
|
||||||
Kind::Controller => "controller",
|
Kind::Controller => "controller",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -175,7 +171,7 @@ mod tests {
|
||||||
// decision. Spelling each one out is what makes adding a kind a
|
// decision. Spelling each one out is what makes adding a kind a
|
||||||
// deliberate edit.
|
// deliberate edit.
|
||||||
let segments: Vec<&str> = Kind::ALL.iter().map(|k| (*k).into()).collect();
|
let segments: Vec<&str> = Kind::ALL.iter().map(|k| (*k).into()).collect();
|
||||||
assert_eq!(segments, ["agents", "hives", "services", "controller"]);
|
assert_eq!(segments, ["agents", "hives", "controller"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,15 @@
|
||||||
//! narrow one that only looks narrow. The narrower shapes, and what they would
|
//! narrow one that only looks narrow. The narrower shapes, and what they would
|
||||||
//! cost, are in `docs/trust-boundary/security.md`.
|
//! cost, are in `docs/trust-boundary/security.md`.
|
||||||
//!
|
//!
|
||||||
//! ⚠️ The **service** stanza is wide for the same shape of reason: a swarm
|
|
||||||
//! service's client is registered once per swarm, so its path names the service
|
|
||||||
//! and never the host, and which hive runs a service is a `deploy.*` fact with
|
|
||||||
//! no swarm-wide spelling to scope against. Cost: the same doc.
|
|
||||||
//!
|
|
||||||
//! The **hive** stanza has no such problem and is therefore narrow: that path
|
//! The **hive** stanza has no such problem and is therefore narrow: that path
|
||||||
//! names its principal, so scoping it to the reader's own name costs nothing
|
//! names its principal, so scoping it to the reader's own name costs nothing
|
||||||
//! and drifts nowhere. Do not widen it to match its neighbours — the asymmetry
|
//! and drifts nowhere. Do not widen it to match the agent stanza — the
|
||||||
//! is the point, and only holds if a one-per-hive credential is stored under
|
//! asymmetry is the point, and only holds if a one-per-hive credential is
|
||||||
//! [`Kind::Hive`], not [`Kind::Service`] — see docs/trust-boundary/security.md.
|
//! stored under [`Kind::Hive`] — see docs/trust-boundary/security.md.
|
||||||
|
//!
|
||||||
|
//! A hive reads nothing under `swarm/services/` or `swarm/controller/`. A swarm
|
||||||
|
//! service's OIDC client secret is read under a certificate that is not a
|
||||||
|
//! hive's: `swarm-bao.nix`'s per-service readers, and its granter for the UI's.
|
||||||
//!
|
//!
|
||||||
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
|
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
|
||||||
|
|
||||||
|
|
@ -127,8 +126,8 @@ 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, on this
|
/// Render `hive`'s policy document: read on every agent's credentials and on
|
||||||
/// hive's own, and on the swarm services'.
|
/// this hive's own.
|
||||||
///
|
///
|
||||||
/// The name is the only input, and it is deploy-time — so the document is
|
/// The name is the only input, and it is deploy-time — so the document is
|
||||||
/// still a deploy-time object rather than derived state with a re-emission to
|
/// still a deploy-time object rather than derived state with a re-emission to
|
||||||
|
|
@ -136,15 +135,11 @@ fn list_stanza(path: &str) -> String {
|
||||||
///
|
///
|
||||||
/// 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 is deliberately absent: a hive has no business
|
/// ⚠️ The controller kind and the `swarm/services/` tree are deliberately
|
||||||
/// reading the credentials of the thing that provisions it. Adding it is a
|
/// absent: a hive has no business reading the credentials of the thing that
|
||||||
/// boundary decision, not a consequence of the namespace growing.
|
/// 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,
|
||||||
/// The **service** kind is granted, and that was such a decision rather than a
|
/// not a consequence of the namespace growing.
|
||||||
/// consequence: a service whose identity provider is on another host reads its
|
|
||||||
/// own OIDC client secret with the certificate of the hive it runs on, that
|
|
||||||
/// being the only identity such a host has — so every hive can read every
|
|
||||||
/// service's. Bought and paid for in `docs/trust-boundary/security.md`.
|
|
||||||
///
|
///
|
||||||
/// The hive's *own* kind is granted, and that is the decision the agent-only
|
/// 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
|
/// version of this grant said had to be made rather than assumed: a hive holds
|
||||||
|
|
@ -166,11 +161,7 @@ pub fn render(hive: &str) -> Result<String, Error> {
|
||||||
"{MOUNT}/data/{ROOT}/{}/{hive}/*",
|
"{MOUNT}/data/{ROOT}/{}/{hive}/*",
|
||||||
<&str>::from(Kind::Hive)
|
<&str>::from(Kind::Hive)
|
||||||
));
|
));
|
||||||
let services = read_stanza(&format!(
|
Ok(format!("{agents}{own}"))
|
||||||
"{MOUNT}/data/{ROOT}/{}/*",
|
|
||||||
<&str>::from(Kind::Service)
|
|
||||||
));
|
|
||||||
Ok(format!("{agents}{own}{services}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render `agent`'s policy document: read on that one agent's credentials, list
|
/// Render `agent`'s policy document: read on that one agent's credentials, list
|
||||||
|
|
@ -186,13 +177,13 @@ pub fn render(hive: &str) -> Result<String, Error> {
|
||||||
///
|
///
|
||||||
/// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants
|
/// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants
|
||||||
/// read on `swarm/agents/*` — *every* agent's credentials, not the ones that
|
/// read on `swarm/agents/*` — *every* agent's credentials, not the ones that
|
||||||
/// hive hosts — and on `swarm/services/*` the same way; the module header says
|
/// hive hosts; the module header says why that is a decision rather than an
|
||||||
/// why each is a decision rather than an oversight. Neither reason transfers:
|
/// oversight. The reason does not transfer: an agent's path names the agent,
|
||||||
/// an agent's path names the agent, so scoping costs nothing and drifts nowhere
|
/// so scoping costs nothing and drifts nowhere (the argument that keeps
|
||||||
/// (the argument that keeps [`render`]'s hive stanza narrow), and an agent has
|
/// [`render`]'s hive stanza narrow), and an agent has no business with another
|
||||||
/// no business with a service's OIDC secret, another agent's credentials, a
|
/// agent's credentials, a hive's, a service's, or the controller's. Narrow
|
||||||
/// hive's, or the controller's. Narrow here does **not** narrow the hive's: a
|
/// here does **not** narrow the hive's: a hive still reads this agent's
|
||||||
/// hive still reads this agent's secrets, and closing that is its own decision.
|
/// secrets, and closing that is its own decision.
|
||||||
/// Nothing here writes, for the reason the hive's does not: an agent that could
|
/// 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.
|
/// write its own credentials could hand itself an identity it was never issued.
|
||||||
///
|
///
|
||||||
|
|
@ -216,26 +207,31 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_document_grants_two_whole_prefixes_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"),
|
render("pr1ma").expect("a plain name is legal"),
|
||||||
"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"
|
||||||
path \"secret/data/swarm/services/*\" {\n capabilities = [\"read\"]\n}\n"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_service_stanza_covers_a_service_this_hive_was_never_named_beside() {
|
fn a_hive_reaches_no_swarm_services_secret() {
|
||||||
// The property the swarm-grafana delivery depends on: the host running a
|
// A swarm service's OIDC client secret is read under a certificate that
|
||||||
// swarm service reads that service's client secret with its own hive
|
// is not a hive's. Checked by walking the stanzas, so a wider grant
|
||||||
// certificate, and the path names the service rather than the host. A
|
// that happened to cover the tree (`swarm/*`, say) fails too.
|
||||||
// stanza narrowed to the reader's name would 403 every such read.
|
|
||||||
let p = render("pr1ma").expect("legal");
|
let p = render("pr1ma").expect("legal");
|
||||||
assert!(p.contains("path \"secret/data/swarm/services/*\""));
|
assert!(!p.contains("swarm/services"), "{p}");
|
||||||
|
let service = "swarm/services/swarm-grafana/oidc/client";
|
||||||
assert!(
|
assert!(
|
||||||
!p.contains("services/pr1ma"),
|
!stanza_paths(&p).iter().any(|g| covers(g, service)),
|
||||||
"the service stanza is not scoped to the reader"
|
"a hive's document reaches {service}:\n{p}"
|
||||||
|
);
|
||||||
|
// The control: the walk does find a path the document grants.
|
||||||
|
assert!(
|
||||||
|
stanza_paths(&p)
|
||||||
|
.iter()
|
||||||
|
.any(|g| covers(g, "swarm/hives/pr1ma/queue/agent"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,10 +239,6 @@ mod tests {
|
||||||
/// document rather than on the path function alone: it is the policy text
|
/// document rather than on the path function alone: it is the policy text
|
||||||
/// that decides what a hive may fetch, so "the path is per-hive" only
|
/// that decides what a hive may fetch, so "the path is per-hive" only
|
||||||
/// means something if the stanza that reaches it is too.
|
/// means something if the stanza that reaches it is too.
|
||||||
///
|
|
||||||
/// The `services/*` stanza above is deliberate and stays — it is how a
|
|
||||||
/// host reads the OIDC secret of a service it runs. The property here is
|
|
||||||
/// that the matrix sender token is no longer *inside* it.
|
|
||||||
#[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 = render("alpha").expect("legal");
|
||||||
|
|
@ -516,16 +508,16 @@ mod tests {
|
||||||
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
|
// The pin the two renderers need against each other: a later edit that
|
||||||
// made `render_agent` delegate to `render`, or vice versa, would hand
|
// made `render_agent` delegate to `render`, or vice versa, would hand
|
||||||
// an agent the hive's two wide stanzas.
|
// an agent the hive's `agents/*` stanza.
|
||||||
let agent = render_agent("atlas").expect("legal");
|
let agent = render_agent("atlas").expect("legal");
|
||||||
let hive = render("atlas").expect("legal");
|
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 three
|
// And the hive's is untouched by this module's growth: still the two
|
||||||
// stanzas `the_document_grants_two_whole_prefixes_and_this_hive_alone`
|
// stanzas `the_document_grants_every_agent_and_this_hive_alone` pins
|
||||||
// pins byte for byte, none of them narrowed to make the agent's look
|
// byte for byte, neither narrowed to make the agent's look consistent
|
||||||
// consistent with it.
|
// with it.
|
||||||
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
|
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,8 @@ pub fn agent_queue_path(agent: &str) -> Result<String, Error> {
|
||||||
/// The path holding `swarm-controller`'s own OIDC client secret: the identity
|
/// The path holding `swarm-controller`'s own OIDC client secret: the identity
|
||||||
/// it connects to the queue with, and mints its other bearer tokens from.
|
/// it connects to the queue with, and mints its other bearer tokens from.
|
||||||
///
|
///
|
||||||
/// Under [`Kind::Controller`] because no hive's policy reads that kind. The
|
/// Under [`Kind::Controller`] because no hive's policy reads that kind, and
|
||||||
/// client may read every hive's status, so a copy under `services/`, which
|
/// this client may read every hive's status.
|
||||||
/// every hive reads, would hand that reach to every hive.
|
|
||||||
///
|
///
|
||||||
/// The nix half is `swarm-secret-publisher.nix`, which writes it, and
|
/// The nix half is `swarm-secret-publisher.nix`, which writes it, and
|
||||||
/// `swarm-bao.nix`'s `controllerPolicyText`, which grants the read.
|
/// `swarm-bao.nix`'s `controllerPolicyText`, which grants the read.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue