Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
780df10d9d | ||
|
|
fe9417ae52 |
5 changed files with 399 additions and 13 deletions
|
|
@ -181,9 +181,10 @@ namespace.
|
||||||
|
|
||||||
By default agents can only reach the host on 80/443 (+53 DNS), so a
|
By default agents can only reach the host on 80/443 (+53 DNS), so a
|
||||||
host-side service on another port — for example a dev OTLP collector you want
|
host-side service on another port — for example a dev OTLP collector you want
|
||||||
agents to reach directly — is unreachable. (hyperhive's own telemetry
|
agents to reach directly — is unreachable. (hyperhive's own services
|
||||||
needs none of this: `otel.enable` opens its collector's port itself, and
|
need none of this: a module that means to be reachable from agents opens
|
||||||
`otel.endpoint` is the _upstream_, which no agent ever dials. See
|
its own port here, which `otel.enable` and `nats.enable` both do. Note
|
||||||
|
that `otel.endpoint` is the _upstream_, which no agent ever dials. See
|
||||||
`docs/scheduler/observability.md`.)
|
`docs/scheduler/observability.md`.)
|
||||||
|
|
||||||
`services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens each
|
`services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens each
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,40 @@ let
|
||||||
accessTokenSignedResponseAlg = "RS256";
|
accessTokenSignedResponseAlg = "RS256";
|
||||||
}) hyperhiveCfg.swarm.hives;
|
}) hyperhiveCfg.swarm.hives;
|
||||||
|
|
||||||
|
# The identity an agent container presents to the swarm's queue. One per
|
||||||
|
# HIVE, not per agent, and that is the load-bearing choice rather than a
|
||||||
|
# shortcut: agents are created at **runtime**, so anything minted per agent
|
||||||
|
# would make creating one a config change plus an authelia reload. Keyed on
|
||||||
|
# the hive, this list's length tracks the roster above it — deploy-time, like
|
||||||
|
# every other entry here.
|
||||||
|
#
|
||||||
|
# ⚠️ The cost, stated rather than discovered later: every agent on a hive
|
||||||
|
# presents the SAME client id, so the broker can tell *hives* apart and not
|
||||||
|
# *agents*. Deliberate and deferred — closing it requires the client list to
|
||||||
|
# stop being static, which is the same problem the users-database writer
|
||||||
|
# already solves for identities.
|
||||||
|
#
|
||||||
|
# 🏷️ The id EXTENDS the hive's own (`hive-<name>-agent`) rather than taking a
|
||||||
|
# prefix of its own (`agent-<name>`), because that reads as *the agent called
|
||||||
|
# `<name>`* — which is the one thing this identity does not carry. The cost is
|
||||||
|
# that the two ids are no longer distinguishable by prefix, so the responder's
|
||||||
|
# agent rule must be tried BEFORE its hive rule; `policy.rs` says so at the
|
||||||
|
# match site, and the assertion below covers the case that ordering cannot.
|
||||||
|
#
|
||||||
|
# Mirrors `hiveClients` field for field so the two stay comparable. The
|
||||||
|
# signing algorithm is not load-bearing here — this client's only consumer is
|
||||||
|
# the queue's auth-callout responder, which *introspects* rather than
|
||||||
|
# verifying offline — but it matches its sibling rather than inventing a
|
||||||
|
# second answer to a question nobody asked.
|
||||||
|
agentClients = lib.mapAttrsToList (name: _: {
|
||||||
|
id = "${cfg.hiveClientPrefix}${name}${cfg.agentClientSuffix}";
|
||||||
|
description = "HyperHive agents on hive ${name}";
|
||||||
|
kind = "machine";
|
||||||
|
redirectUris = [ ];
|
||||||
|
audience = [ "${cfg.hiveClientPrefix}${name}${cfg.agentClientSuffix}" ];
|
||||||
|
accessTokenSignedResponseAlg = "RS256";
|
||||||
|
}) hyperhiveCfg.swarm.hives;
|
||||||
|
|
||||||
# `swarm-authelia-bridge`'s own identity — distinct from
|
# `swarm-authelia-bridge`'s own identity — distinct from
|
||||||
# `swarm-controller`'s (`swarm-controller.nix`'s `queueClientId`). A
|
# `swarm-controller`'s (`swarm-controller.nix`'s `queueClientId`). A
|
||||||
# resource server introspecting a token proves its OWN identity to the
|
# resource server introspecting a token proves its OWN identity to the
|
||||||
|
|
@ -416,9 +450,21 @@ in
|
||||||
default = deployCfg.nats.enable;
|
default = deployCfg.nats.enable;
|
||||||
defaultText = lib.literalExpression "services.hyperhive.deploy.nats.enable";
|
defaultText = lib.literalExpression "services.hyperhive.deploy.nats.enable";
|
||||||
description = ''
|
description = ''
|
||||||
Mint one machine client per hive in
|
Mint machine clients per hive in
|
||||||
{option}`services.hyperhive.swarm.hives`, so each hive can
|
{option}`services.hyperhive.swarm.hives`, so each hive can
|
||||||
authenticate to swarm services as itself.
|
authenticate to swarm services as itself: `hive-<name>` for the
|
||||||
|
hive's own daemons, and `hive-<name>-agent` for the agent containers
|
||||||
|
running on it.
|
||||||
|
|
||||||
|
Two clients rather than one because they are not the same
|
||||||
|
principal — a hive's daemons run on the host and an agent runs
|
||||||
|
in a container the host hands a credential to, so a swarm
|
||||||
|
service has to be able to grant them different things. It is
|
||||||
|
one client per *hive* on the agent side, not per agent: agents
|
||||||
|
are created at runtime, and a per-agent client would make
|
||||||
|
creating one a config change plus an authelia reload. The cost
|
||||||
|
is that agents on a hive are indistinguishable from each other,
|
||||||
|
tracked as a follow-up rather than papered over.
|
||||||
|
|
||||||
Defaults to whether the swarm message queue is enabled, because
|
Defaults to whether the swarm message queue is enabled, because
|
||||||
that is the first service that needs a hive to prove who it is.
|
that is the first service that needs a hive to prove who it is.
|
||||||
|
|
@ -667,6 +713,30 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
agentClientSuffix = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
readOnly = true;
|
||||||
|
default = "-agent";
|
||||||
|
description = ''
|
||||||
|
Suffix appended to a hive's own client id to name the client its
|
||||||
|
*agent containers* present — agents on hive `alpha` all present
|
||||||
|
`${config.services.hyperhive.swarm.authelia.hiveClientPrefix}alpha${config.services.hyperhive.swarm.authelia.agentClientSuffix}`.
|
||||||
|
Read-only for the same reason as `hiveClientPrefix`, and read by the
|
||||||
|
same consumer with the same failure mode: a split spelling denies
|
||||||
|
every agent as a timeout.
|
||||||
|
|
||||||
|
A suffix on the hive's id rather than a prefix of its own, because
|
||||||
|
`agent-alpha` reads as *the agent named alpha* — which is precisely
|
||||||
|
what this identity does not say.
|
||||||
|
|
||||||
|
One id per hive rather than per agent, because agents are created at
|
||||||
|
runtime and a per-agent client would make creating one a config
|
||||||
|
change plus a reload. The consequence is that this identity says
|
||||||
|
*which hive* an agent belongs to and never *which agent* — the
|
||||||
|
broker cannot tell two agents on one hive apart.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
machine = lib.mkOption {
|
machine = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
|
|
@ -828,7 +898,7 @@ in
|
||||||
# wherever this module is, so `oidc.clients` is never actually empty
|
# wherever this module is, so `oidc.clients` is never actually empty
|
||||||
# — see `oidcEnabled`'s comment above.
|
# — see `oidcEnabled`'s comment above.
|
||||||
services.hyperhive.swarm.authelia.oidc.clients =
|
services.hyperhive.swarm.authelia.oidc.clients =
|
||||||
lib.optionals cfg.oidc.hiveIdentities hiveClients
|
lib.optionals cfg.oidc.hiveIdentities (hiveClients ++ agentClients)
|
||||||
++ [ bridgeClient ];
|
++ [ bridgeClient ];
|
||||||
|
|
||||||
# A redirect URI on a machine client is not harmless-but-unused: it
|
# A redirect URI on a machine client is not harmless-but-unused: it
|
||||||
|
|
@ -877,6 +947,38 @@ in
|
||||||
+ "services.hyperhive.swarm.hives; rename the hive or the "
|
+ "services.hyperhive.swarm.hives; rename the hive or the "
|
||||||
+ "colliding client.";
|
+ "colliding client.";
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# A hive whose name ENDS with the agent suffix mints an id the
|
||||||
|
# queue's responder reads as somebody else's agents:
|
||||||
|
# `hive-foo-agent` parses as *the agents of hive foo* before it
|
||||||
|
# parses as *the hive foo-agent*, because the agent rule is the
|
||||||
|
# more specific one and therefore runs first.
|
||||||
|
#
|
||||||
|
# ⚠️ NOT covered by the duplicate-id assertion above, and the gap is
|
||||||
|
# the interesting half. That one fires only when BOTH `foo` and
|
||||||
|
# `foo-agent` are on the roster, because only then are two clients
|
||||||
|
# actually named the same string. With `foo-agent` alone there is no
|
||||||
|
# duplicate and nothing to see — the hive simply receives its
|
||||||
|
# agents' grant instead of its own, and a NATS denial arrives as a
|
||||||
|
# timeout, so the symptom names nothing.
|
||||||
|
#
|
||||||
|
# Checkable here and nowhere downstream: the responder runs in a
|
||||||
|
# container with no view of the roster (`policy.rs`'s `hive_name`
|
||||||
|
# says why that is deliberate), so this is the last place that knows
|
||||||
|
# both the naming scheme and the set of names.
|
||||||
|
assertion =
|
||||||
|
!cfg.oidc.hiveIdentities
|
||||||
|
|| !lib.any (h: lib.hasSuffix cfg.agentClientSuffix h) (lib.attrNames hyperhiveCfg.swarm.hives);
|
||||||
|
message =
|
||||||
|
"services.hyperhive.swarm.hives contains "
|
||||||
|
+ lib.concatMapStringsSep ", " (h: "'${h}'") (
|
||||||
|
lib.filter (h: lib.hasSuffix cfg.agentClientSuffix h) (lib.attrNames hyperhiveCfg.swarm.hives)
|
||||||
|
)
|
||||||
|
+ " — names ending with '${cfg.agentClientSuffix}', the suffix that "
|
||||||
|
+ "marks a hive's agent containers. Such a hive's own client id is "
|
||||||
|
+ "indistinguishable from another hive's agent client, and the "
|
||||||
|
+ "queue resolves it as the agents. Rename the hive.";
|
||||||
|
}
|
||||||
# The two obligations `authelia.bearer.authz` carries. Authelia
|
# The two obligations `authelia.bearer.authz` carries. Authelia
|
||||||
# enforces both itself — but in its `preStart` validator, so a
|
# enforces both itself — but in its `preStart` validator, so a
|
||||||
# violation produces a green `nixos-rebuild switch` and an authelia
|
# violation produces a green `nixos-rebuild switch` and an authelia
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,17 @@ in
|
||||||
TCP port the queue listens on. 4222 is upstream's default and
|
TCP port the queue listens on. 4222 is upstream's default and
|
||||||
sits outside hyperhive's claimed ranges (dashboard 7000, forge
|
sits outside hyperhive's claimed ranges (dashboard 7000, forge
|
||||||
3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash).
|
3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash).
|
||||||
|
|
||||||
|
Contributed to
|
||||||
|
`services.hyperhive.network.exposeHostPorts`, which opens it on
|
||||||
|
the bridge interface only — so it is reachable from agent
|
||||||
|
containers and not from the outside world. An agent connects to
|
||||||
|
`nats://<bridgeIp>:<port>`; loopback inside a container is the
|
||||||
|
agent itself, not this host.
|
||||||
|
|
||||||
|
Unlike {option}`monitorPort` and {option}`metricsPort`, the
|
||||||
|
address is not what bounds who may use this port: the queue's
|
||||||
|
`auth_callout` refuses every client it cannot identify.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -564,6 +575,26 @@ in
|
||||||
nats = "127.0.0.1:${toString cfg.metricsPort}";
|
nats = "127.0.0.1:${toString cfg.metricsPort}";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Open the client port on the bridge, so agent containers can reach
|
||||||
|
# the queue. `privateNetwork = false` below means the server binds in
|
||||||
|
# the host netns, which is the precondition this option names — but
|
||||||
|
# sharing a netns is not reachability: the bridge interface is
|
||||||
|
# default-deny, so without this an agent's connect attempt is dropped
|
||||||
|
# by the firewall and looks exactly like every other NATS failure,
|
||||||
|
# a timeout.
|
||||||
|
#
|
||||||
|
# ⚠️ Bridge interface only — never the world. What makes it safe to
|
||||||
|
# open at all is that the queue is fail-closed: `auth_callout` admits
|
||||||
|
# nobody until the responder above answers for them, so an agent that
|
||||||
|
# reaches this port still has to present a token authelia vouches for.
|
||||||
|
#
|
||||||
|
# An agent connects to `nats://<bridgeIp>:<port>`, NOT to
|
||||||
|
# `nats://127.0.0.1:<port>` — inside a container loopback is the
|
||||||
|
# *agent*. Every url in this repo today is the loopback one and each
|
||||||
|
# is correct for its reader, because those readers share the host
|
||||||
|
# netns; an agent does not.
|
||||||
|
services.hyperhive.network.exposeHostPorts = [ cfg.port ];
|
||||||
|
|
||||||
containers.swarm-nats = {
|
containers.swarm-nats = {
|
||||||
autoStart = true;
|
autoStart = true;
|
||||||
ephemeral = false;
|
ephemeral = false;
|
||||||
|
|
@ -730,14 +761,15 @@ in
|
||||||
# be the same string — which is why both come from one let.
|
# be the same string — which is why both come from one let.
|
||||||
"--account ${lib.escapeShellArg clientAccount}"
|
"--account ${lib.escapeShellArg clientAccount}"
|
||||||
"--introspection-url ${lib.escapeShellArg introspectionUrl}"
|
"--introspection-url ${lib.escapeShellArg introspectionUrl}"
|
||||||
# Both of these name a principal some OTHER module mints,
|
# Each of these names a principal some OTHER module mints,
|
||||||
# so both are read out of that module rather than spelled
|
# so each is read out of that module rather than spelled
|
||||||
# again here — same argument as `--account` above, one
|
# again here — same argument as `--account` above, one
|
||||||
# level wider. The responder denies a client id it does
|
# level wider. The responder denies a client id it does
|
||||||
# not recognise, and a NATS denial arrives as a timeout,
|
# not recognise, and a NATS denial arrives as a timeout,
|
||||||
# so a drift here is silent at the point of change and
|
# so a drift here is silent at the point of change and
|
||||||
# misattributed at the point of failure.
|
# misattributed at the point of failure.
|
||||||
"--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}"
|
"--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}"
|
||||||
|
"--agent-client-suffix ${lib.escapeShellArg autheliaCfg.agentClientSuffix}"
|
||||||
"--reader-client ${lib.escapeShellArg controllerCfg.queueClientId}"
|
"--reader-client ${lib.escapeShellArg controllerCfg.queueClientId}"
|
||||||
];
|
];
|
||||||
# Every credential arrives by `LoadCredential` and is named
|
# Every credential arrives by `LoadCredential` and is named
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,21 @@ struct Args {
|
||||||
#[arg(long, default_value = "hive-")]
|
#[arg(long, default_value = "hive-")]
|
||||||
hive_client_prefix: String,
|
hive_client_prefix: String,
|
||||||
|
|
||||||
|
/// Suffix that marks an agent container, appended to the hive's own client
|
||||||
|
/// id: `swarm-authelia.nix` mints one machine client per roster entry as
|
||||||
|
/// `hive-<name>-agent` — per **hive**, not per agent, because agents are
|
||||||
|
/// created at runtime and a per-agent client would make creating one a
|
||||||
|
/// config change plus a reload.
|
||||||
|
///
|
||||||
|
/// So this identity says which hive an agent belongs to and never which
|
||||||
|
/// agent: two agents on one hive are indistinguishable to this responder.
|
||||||
|
///
|
||||||
|
/// A suffix on the hive's id rather than a prefix of its own, because
|
||||||
|
/// `agent-<name>` reads as *the agent called `<name>`* — the one thing
|
||||||
|
/// this identity does not carry.
|
||||||
|
#[arg(long, default_value = "-agent")]
|
||||||
|
agent_client_suffix: String,
|
||||||
|
|
||||||
/// Client ids allowed to read every hive's status. Repeatable. The
|
/// Client ids allowed to read every hive's status. Repeatable. The
|
||||||
/// default is the swarm controller, which is the only reader that exists.
|
/// default is the swarm controller, which is the only reader that exists.
|
||||||
#[arg(long = "reader-client", default_values_t = [String::from("swarm-controller")])]
|
#[arg(long = "reader-client", default_values_t = [String::from("swarm-controller")])]
|
||||||
|
|
@ -96,6 +111,18 @@ struct Args {
|
||||||
/// subject still lands inside that hive's own namespace.
|
/// subject still lands inside that hive's own namespace.
|
||||||
#[arg(long = "hive-publish-subject")]
|
#[arg(long = "hive-publish-subject")]
|
||||||
hive_publish_subjects: Vec<String>,
|
hive_publish_subjects: Vec<String>,
|
||||||
|
|
||||||
|
/// Subjects an agent may publish to, with `{hive}` standing for the hive
|
||||||
|
/// its identity names — `hive-alpha-agent` expands it to `alpha`.
|
||||||
|
/// Repeatable, empty by default.
|
||||||
|
///
|
||||||
|
/// Empty means agents get **no grant at all** rather than a grant that can
|
||||||
|
/// do nothing — the identity exists, and what it may say is a deployment's
|
||||||
|
/// decision, not this responder's. The first consumer is the agent
|
||||||
|
/// terminal-event stream; until one is configured, an agent that connects
|
||||||
|
/// is refused, which is loud rather than silently over-broad.
|
||||||
|
#[arg(long = "agent-publish-subject")]
|
||||||
|
agent_publish_subjects: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a secret file and strip surrounding whitespace.
|
/// Read a secret file and strip surrounding whitespace.
|
||||||
|
|
@ -140,9 +167,11 @@ async fn main() -> anyhow::Result<()> {
|
||||||
// silently over-broad grant is not.
|
// silently over-broad grant is not.
|
||||||
let policy = policy::Policy::new(
|
let policy = policy::Policy::new(
|
||||||
args.hive_client_prefix.clone(),
|
args.hive_client_prefix.clone(),
|
||||||
|
args.agent_client_suffix.clone(),
|
||||||
swarm_queue_client::status::BUCKET.to_owned(),
|
swarm_queue_client::status::BUCKET.to_owned(),
|
||||||
args.reader_clients.clone(),
|
args.reader_clients.clone(),
|
||||||
args.hive_publish_subjects.clone(),
|
args.hive_publish_subjects.clone(),
|
||||||
|
args.agent_publish_subjects.clone(),
|
||||||
)?;
|
)?;
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?)
|
let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?)
|
||||||
|
|
|
||||||
|
|
@ -40,19 +40,29 @@ pub struct Permissions {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Policy {
|
pub struct Policy {
|
||||||
hive_prefix: String,
|
hive_prefix: String,
|
||||||
|
agent_suffix: String,
|
||||||
bucket: String,
|
bucket: String,
|
||||||
readers: Vec<String>,
|
readers: Vec<String>,
|
||||||
extra_hive_subjects: Vec<String>,
|
extra_hive_subjects: Vec<String>,
|
||||||
|
extra_agent_subjects: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
|
/// Placeholder replaced with the hive's own name in `extra_hive_subjects` and
|
||||||
|
/// `extra_agent_subjects`.
|
||||||
const HIVE_PLACEHOLDER: &str = "{hive}";
|
const HIVE_PLACEHOLDER: &str = "{hive}";
|
||||||
|
|
||||||
impl Policy {
|
impl Policy {
|
||||||
/// `hive_prefix` is the client-id prefix that marks a hive, `bucket` the
|
/// `hive_prefix` is the client-id prefix that marks a hive and
|
||||||
/// KV bucket hives report status in, `readers` the client ids allowed to
|
/// `agent_suffix` what a hive's agent containers carry **on top of** it —
|
||||||
/// read every hive's key, and `extra_hive_subjects` additional subjects a
|
/// `hive-<name>` and `hive-<name>-agent`. `bucket` is the KV bucket hives
|
||||||
/// hive may publish to (with `{hive}` standing for its own name).
|
/// report status in, `readers` the client ids allowed to read every hive's
|
||||||
|
/// key, and `extra_hive_subjects` / `extra_agent_subjects` additional
|
||||||
|
/// subjects each may publish to (with `{hive}` standing for the hive's own
|
||||||
|
/// name).
|
||||||
|
///
|
||||||
|
/// The agent id is deliberately the hive's id extended, not a second
|
||||||
|
/// top-level prefix: `agent-<name>` reads as *the agent called `<name>`*,
|
||||||
|
/// which is the one thing this identity does not carry.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
|
|
@ -62,11 +72,18 @@ impl Policy {
|
||||||
/// whose only purpose is to provide one. Returning a `Result` rather than
|
/// whose only purpose is to provide one. Returning a `Result` rather than
|
||||||
/// checking at the call site is deliberate: it makes an unscoped policy
|
/// checking at the call site is deliberate: it makes an unscoped policy
|
||||||
/// unconstructible instead of merely unlikely.
|
/// unconstructible instead of merely unlikely.
|
||||||
|
///
|
||||||
|
/// An empty `agent_suffix` is refused for the same reason: every hive id
|
||||||
|
/// would also parse as its own agent id, and the arms are tried in order,
|
||||||
|
/// so the overlap does not error at match time — it silently hands one
|
||||||
|
/// principal the other's grant.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
hive_prefix: String,
|
hive_prefix: String,
|
||||||
|
agent_suffix: String,
|
||||||
bucket: String,
|
bucket: String,
|
||||||
readers: Vec<String>,
|
readers: Vec<String>,
|
||||||
extra_hive_subjects: Vec<String>,
|
extra_hive_subjects: Vec<String>,
|
||||||
|
extra_agent_subjects: Vec<String>,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
if let Some(bad) = extra_hive_subjects
|
if let Some(bad) = extra_hive_subjects
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -78,11 +95,41 @@ impl Policy {
|
||||||
a per-hive namespace"
|
a per-hive namespace"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Same rule, same reason: an agent's identity is its *hive's*, so a
|
||||||
|
// template with no placeholder is one subject shared by every agent in
|
||||||
|
// the swarm rather than a per-hive one.
|
||||||
|
if let Some(bad) = extra_agent_subjects
|
||||||
|
.iter()
|
||||||
|
.find(|s| !s.contains(HIVE_PLACEHOLDER))
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"--agent-publish-subject {bad:?} contains no {HIVE_PLACEHOLDER}: every agent \
|
||||||
|
in the swarm would be granted that exact subject, so it is a shared channel \
|
||||||
|
rather than a per-hive namespace"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Checked here rather than trusted from nix, because these are two
|
||||||
|
// flags and nothing downstream compares them. An agent id is a hive id
|
||||||
|
// plus this suffix, so an empty one makes `strip_suffix` succeed on
|
||||||
|
// every hive id and the two principals become the same string. What
|
||||||
|
// that costs is not a parse error but a **grant**: whichever arm runs
|
||||||
|
// first answers for both, and a client that authenticates fine is
|
||||||
|
// handed more than it should have — the one failure this module must
|
||||||
|
// not have.
|
||||||
|
if agent_suffix.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"--agent-client-suffix is empty: an agent id is a hive id plus this suffix, \
|
||||||
|
so every hive would also parse as its own agents and one of the two would \
|
||||||
|
silently receive the other's grant"
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
hive_prefix,
|
hive_prefix,
|
||||||
|
agent_suffix,
|
||||||
bucket,
|
bucket,
|
||||||
readers,
|
readers,
|
||||||
extra_hive_subjects,
|
extra_hive_subjects,
|
||||||
|
extra_agent_subjects,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +139,24 @@ impl Policy {
|
||||||
/// a connected client with no permissions still holds a slot and still
|
/// a connected client with no permissions still holds a slot and still
|
||||||
/// looks admitted in the logs, which is a worse answer than a refusal.
|
/// looks admitted in the logs, which is a worse answer than a refusal.
|
||||||
pub fn permissions(&self, client_id: &str) -> Option<Permissions> {
|
pub fn permissions(&self, client_id: &str) -> Option<Permissions> {
|
||||||
|
// ⚠️ THE AGENT ARM RUNS FIRST, AND THE ORDER IS LOAD-BEARING. An agent
|
||||||
|
// id is a hive id with a suffix, so `hive-foo-agent` satisfies the hive
|
||||||
|
// arm too — it strips to the hive `foo-agent`. Hive-first would
|
||||||
|
// therefore hand every agent its hive's grant, including writing that
|
||||||
|
// hive's status key, and nothing would report it: the client
|
||||||
|
// authenticates, connects, and is simply able to do more than it
|
||||||
|
// should. Most specific wins, so the more specific test is the one
|
||||||
|
// that has to be asked first.
|
||||||
|
//
|
||||||
|
// An agent's identity names its hive, never the agent — see
|
||||||
|
// `Self::agent_hive`. The grant is whatever the deployment configured
|
||||||
|
// for agents, expanded for that hive, and **nothing when it configured
|
||||||
|
// none**: an empty publish list would be a denial wearing a grant's
|
||||||
|
// shape, so this returns `None` and the client is refused outright.
|
||||||
|
if let Some(hive) = self.agent_hive(client_id) {
|
||||||
|
let publish = self.agent_subjects(hive);
|
||||||
|
return (!publish.is_empty()).then_some(Permissions { publish });
|
||||||
|
}
|
||||||
if let Some(hive) = self.hive_name(client_id) {
|
if let Some(hive) = self.hive_name(client_id) {
|
||||||
return Some(Permissions {
|
return Some(Permissions {
|
||||||
publish: self.hive_subjects(hive),
|
publish: self.hive_subjects(hive),
|
||||||
|
|
@ -134,6 +199,29 @@ impl Policy {
|
||||||
.filter(|name| !name.is_empty())
|
.filter(|name| !name.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The hive whose agents a client id names, when it names one.
|
||||||
|
///
|
||||||
|
/// [`Self::hive_name`]'s caveats all apply — this is that parse with the
|
||||||
|
/// suffix stripped as well, so `hive-foo-agent` answers `foo`. Two more on
|
||||||
|
/// top:
|
||||||
|
///
|
||||||
|
/// - The agent client is minted per **hive**, so this answers *whose
|
||||||
|
/// agents*, never *which agent*. Two agents on one hive are
|
||||||
|
/// indistinguishable here — not a gap in the parsing but the identity
|
||||||
|
/// itself, which does not carry the agent.
|
||||||
|
/// - A hive genuinely named `foo-agent` mints `hive-foo-agent` and is
|
||||||
|
/// therefore **indistinguishable from hive `foo`'s agents** right here.
|
||||||
|
/// Unguardable from inside this responder, which has no roster (see
|
||||||
|
/// [`Self::hive_name`]) — `swarm-authelia.nix` asserts it at eval, where
|
||||||
|
/// the roster is known, so the deployment fails to build rather than
|
||||||
|
/// minting two principals with one id.
|
||||||
|
fn agent_hive<'a>(&self, client_id: &'a str) -> Option<&'a str> {
|
||||||
|
client_id
|
||||||
|
.strip_prefix(&self.hive_prefix)?
|
||||||
|
.strip_suffix(&self.agent_suffix)
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
fn stream(&self) -> String {
|
fn stream(&self) -> String {
|
||||||
format!("KV_{}", self.bucket)
|
format!("KV_{}", self.bucket)
|
||||||
}
|
}
|
||||||
|
|
@ -314,6 +402,19 @@ impl Policy {
|
||||||
subjects
|
subjects
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subjects an agent of `hive` may publish to.
|
||||||
|
///
|
||||||
|
/// No `JetStream` minimum, unlike [`Self::hive_subjects`]: a hive needs it
|
||||||
|
/// because it writes the status bucket, and an agent publishes to plain
|
||||||
|
/// subjects. Granting it anyway would be reasoning by analogy about a
|
||||||
|
/// permission set the module documents as minimal by removal.
|
||||||
|
fn agent_subjects(&self, hive: &str) -> Vec<String> {
|
||||||
|
self.extra_agent_subjects
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.replace(HIVE_PLACEHOLDER, hive))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn reader_subjects(&self) -> Vec<String> {
|
fn reader_subjects(&self) -> Vec<String> {
|
||||||
let stream = self.stream();
|
let stream = self.stream();
|
||||||
let mut subjects = Self::jetstream_minimum().to_vec();
|
let mut subjects = Self::jetstream_minimum().to_vec();
|
||||||
|
|
@ -419,13 +520,30 @@ mod tests {
|
||||||
fn policy() -> Policy {
|
fn policy() -> Policy {
|
||||||
Policy::new(
|
Policy::new(
|
||||||
"hive-".to_owned(),
|
"hive-".to_owned(),
|
||||||
|
"-agent".to_owned(),
|
||||||
"hive-status".to_owned(),
|
"hive-status".to_owned(),
|
||||||
vec!["swarm-controller".to_owned()],
|
vec!["swarm-controller".to_owned()],
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
)
|
)
|
||||||
.expect("the default policy is valid")
|
.expect("the default policy is valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same policy with agents given something to publish, since the
|
||||||
|
/// default deployment configures no agent subject and an agent is then
|
||||||
|
/// refused outright.
|
||||||
|
fn policy_with_agent_subject() -> Policy {
|
||||||
|
Policy::new(
|
||||||
|
"hive-".to_owned(),
|
||||||
|
"-agent".to_owned(),
|
||||||
|
"hive-status".to_owned(),
|
||||||
|
vec!["swarm-controller".to_owned()],
|
||||||
|
Vec::new(),
|
||||||
|
vec!["$SWARM.term.{hive}.>".to_owned()],
|
||||||
|
)
|
||||||
|
.expect("a per-hive agent template is valid")
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_reader_can_open_and_list_the_agent_status_bucket() {
|
fn the_reader_can_open_and_list_the_agent_status_bucket() {
|
||||||
let subjects = policy().reader_subjects();
|
let subjects = policy().reader_subjects();
|
||||||
|
|
@ -520,9 +638,11 @@ mod tests {
|
||||||
// way to remove it - silently, and only in the deployment that set it.
|
// way to remove it - silently, and only in the deployment that set it.
|
||||||
let err = Policy::new(
|
let err = Policy::new(
|
||||||
"hive-".to_owned(),
|
"hive-".to_owned(),
|
||||||
|
"-agent".to_owned(),
|
||||||
"hive-status".to_owned(),
|
"hive-status".to_owned(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec!["$SWARM.events.all".to_owned()],
|
vec!["$SWARM.events.all".to_owned()],
|
||||||
|
Vec::new(),
|
||||||
)
|
)
|
||||||
.expect_err("a subject shared by every hive must not be accepted");
|
.expect_err("a subject shared by every hive must not be accepted");
|
||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
|
|
@ -538,9 +658,11 @@ mod tests {
|
||||||
// from the option being broken.
|
// from the option being broken.
|
||||||
Policy::new(
|
Policy::new(
|
||||||
"hive-".to_owned(),
|
"hive-".to_owned(),
|
||||||
|
"-agent".to_owned(),
|
||||||
"hive-status".to_owned(),
|
"hive-status".to_owned(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec!["$SWARM.events.{hive}.>".to_owned()],
|
vec!["$SWARM.events.{hive}.>".to_owned()],
|
||||||
|
Vec::new(),
|
||||||
)
|
)
|
||||||
.expect("a per-hive template is the shape this option is for");
|
.expect("a per-hive template is the shape this option is for");
|
||||||
}
|
}
|
||||||
|
|
@ -944,13 +1066,113 @@ mod tests {
|
||||||
// another's events even though its status key is scoped.
|
// another's events even though its status key is scoped.
|
||||||
let p = Policy::new(
|
let p = Policy::new(
|
||||||
"hive-".to_owned(),
|
"hive-".to_owned(),
|
||||||
|
"-agent".to_owned(),
|
||||||
"hive-status".to_owned(),
|
"hive-status".to_owned(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec!["$SWARM.events.{hive}.>".to_owned()],
|
vec!["$SWARM.events.{hive}.>".to_owned()],
|
||||||
|
Vec::new(),
|
||||||
)
|
)
|
||||||
.expect("a per-hive template is valid");
|
.expect("a per-hive template is valid");
|
||||||
let g = p.permissions("hive-alpha").expect("a hive is admitted");
|
let g = p.permissions("hive-alpha").expect("a hive is admitted");
|
||||||
assert!(g.publish.contains(&"$SWARM.events.alpha.>".to_owned()));
|
assert!(g.publish.contains(&"$SWARM.events.alpha.>".to_owned()));
|
||||||
assert!(!g.publish.iter().any(|s| s.contains("{hive}")));
|
assert!(!g.publish.iter().any(|s| s.contains("{hive}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_agent_with_no_configured_subject_is_refused() {
|
||||||
|
// The shipped default: the identity exists, what it may say does not.
|
||||||
|
// A grant with an empty publish list would let it connect and then
|
||||||
|
// fail on every publish, which reads as a broken queue rather than as
|
||||||
|
// a deployment that configured nothing.
|
||||||
|
assert!(policy().permissions("hive-alpha-agent").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_agent_publishes_inside_its_own_hives_namespace() {
|
||||||
|
// The presence control for the test above - without it, "refused"
|
||||||
|
// would also be the answer if the agent arm never matched at all.
|
||||||
|
let g = policy_with_agent_subject()
|
||||||
|
.permissions("hive-alpha-agent")
|
||||||
|
.expect("an agent with a configured subject is admitted");
|
||||||
|
assert_eq!(g.publish, vec!["$SWARM.term.alpha.>".to_owned()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_agent_does_not_get_its_hives_grant() {
|
||||||
|
// An agent holds a credential that sits in a container; a hive's grant
|
||||||
|
// includes writing that hive's status key, which an agent must not be
|
||||||
|
// able to forge.
|
||||||
|
//
|
||||||
|
// 🩸 This became a REGRESSION test when the id moved from `agent-<hive>`
|
||||||
|
// to `hive-<hive>-agent`: the agent id now satisfies the HIVE parse too
|
||||||
|
// (`hive-alpha-agent` strips to a hive named `alpha-agent`), so a
|
||||||
|
// hive-first arm order hands every agent its hive's grant, and nothing
|
||||||
|
// reports it — the client authenticates and is merely able to do more
|
||||||
|
// than it should. Together with the `assert_eq` in
|
||||||
|
// `an_agent_publishes_inside_its_own_hives_namespace` (which pins the
|
||||||
|
// subject to `alpha`, not `alpha-agent`) this is what holds the
|
||||||
|
// ordering in place.
|
||||||
|
let g = policy_with_agent_subject()
|
||||||
|
.permissions("hive-alpha-agent")
|
||||||
|
.expect("an agent is admitted");
|
||||||
|
assert!(!g.publish.iter().any(|s| s.starts_with("$KV.")));
|
||||||
|
assert!(!g.publish.iter().any(|s| s.starts_with("$JS.API.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_agent_subject_without_the_placeholder_is_refused() {
|
||||||
|
let err = Policy::new(
|
||||||
|
"hive-".to_owned(),
|
||||||
|
"-agent".to_owned(),
|
||||||
|
"hive-status".to_owned(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
vec!["$SWARM.term.all".to_owned()],
|
||||||
|
)
|
||||||
|
.expect_err("a subject shared by every agent in the swarm is not per-hive");
|
||||||
|
assert!(
|
||||||
|
format!("{err}").contains("$SWARM.term.all"),
|
||||||
|
"the error must name the offending template"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_agent_suffix_is_refused() {
|
||||||
|
// 🩸 An agent id is a hive id plus the suffix, so an empty suffix makes
|
||||||
|
// `strip_suffix` succeed on every hive id: the two principals become
|
||||||
|
// one string, and whichever arm runs first answers for both. That is a
|
||||||
|
// GRANT, not a parse error, which is why it has to fail at
|
||||||
|
// construction.
|
||||||
|
Policy::new(
|
||||||
|
"hive-".to_owned(),
|
||||||
|
String::new(),
|
||||||
|
"hive-status".to_owned(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.expect_err("an empty agent suffix must not construct");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_agent_suffix_alone_names_no_hive() {
|
||||||
|
// `hive--agent` is the prefix and the suffix with nothing between them.
|
||||||
|
// The agent arm must refuse it rather than expand `{hive}` to the empty
|
||||||
|
// string and hand out `$SWARM.term..>`.
|
||||||
|
//
|
||||||
|
// 🩸 Written first as `is_none()`, by analogy with
|
||||||
|
// `the_prefix_alone_names_no_hive`, and it FAILED — correctly. The
|
||||||
|
// analogy does not hold: under the hive parse the same id is a hive
|
||||||
|
// named `-agent`, which is a non-empty name this responder cannot rule
|
||||||
|
// out, because it has no roster. `swarm-authelia.nix` refuses such a
|
||||||
|
// name at eval, where the roster is known. So the assertion here is
|
||||||
|
// narrowed to the only part this module owns.
|
||||||
|
let g = policy_with_agent_subject().permissions("hive--agent");
|
||||||
|
assert!(
|
||||||
|
!g.iter()
|
||||||
|
.flat_map(|p| &p.publish)
|
||||||
|
.any(|s| s.contains("$SWARM.term..")),
|
||||||
|
"an empty hive name must never be expanded into a subject: {g:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue