swarm: guard hive names where the roster is declared, and reserve the cert subjects
The two hive-name guards lived in swarm-otel.nix, inside its `config = lib.mkIf (… && deployCfg.swarm-otel.enable)`. A swarm running the secret store and the controller but no collector therefore had no hive-name check at all, while the names were still composed into OIDC client ids, bao policies and cert-auth roles exactly the same way. They move to swarm.nix, which declares `swarm.hives` and is unconditional. swarm-otel keeps the assertion that its own entry is still in the shared list — that one is about this module's stake in a file it no longer controls. The equality guard also takes the store's cert-auth subjects now. Cert auth trusts the CA, so `allowed_common_names` is the whole of what narrows a role to one identity, and the same CA signs every hive's leaf with the hive's name as its CN. A hive named after a role's subject presents a certificate that role accepts, which for the controller is write access to every hive's credentials and policies. A list rather than the one string, because the next role added beside it widens what a hive name must not collide with, and because the subject is an option an operator sets — a literal deny entry covers the default and nothing else. Four module-eval cases, two of them controls. The fixture overrides the subject to `ctl` on purpose: the default contains `swarm`, which the substring guard catches whatever the new arm does, so a fixture using it could not tell the two apart. The controls are that a legal roster trips neither guard, and that all three fixtures really do have the collector disabled — without the second, every case would pass while testing the arrangement they exist to rule out.
This commit is contained in:
parent
613ca541e1
commit
bb62bf1aa9
3 changed files with 129 additions and 31 deletions
|
|
@ -57,15 +57,13 @@ let
|
||||||
# is asserted to CONTAIN `swarmTierName`, so a rename that dropped it from
|
# is asserted to CONTAIN `swarmTierName`, so a rename that dropped it from
|
||||||
# the file (or a `producerName` override the file was never updated for)
|
# the file (or a `producerName` override the file was never updated for)
|
||||||
# would be an eval error rather than a silently missing guard.
|
# would be an eval error rather than a silently missing guard.
|
||||||
|
# ⚠️ The guards themselves live in ./swarm.nix, which declares
|
||||||
|
# `swarm.hives` and is unconditional. They were here and gated on this
|
||||||
|
# module's own `enable`, so a swarm without the collector had no hive-name
|
||||||
|
# check at all. What stays here is the assertion below: this module's own
|
||||||
|
# entry must still be in that shared list.
|
||||||
reservedOwners = import ../reserved-names.nix;
|
reservedOwners = import ../reserved-names.nix;
|
||||||
|
|
||||||
# Words no hive name may CONTAIN, because identifiers are composed from a
|
|
||||||
# hive name. That file explains why it is not merged into the list above.
|
|
||||||
reservedFragments = import ../reserved-hive-fragments.nix;
|
|
||||||
|
|
||||||
nameGuards = import ./lib/name-guards.nix { inherit lib; };
|
|
||||||
hiveNames = lib.attrNames hyperhiveCfg.swarm.hives;
|
|
||||||
|
|
||||||
# A published target is declared as ONE url, because that url is also the
|
# A published target is declared as ONE url, because that url is also the
|
||||||
# audience its token is minted for — but prometheus wants the same fact in
|
# audience its token is minted for — but prometheus wants the same fact in
|
||||||
# three fields. Split it here rather than asking a service to state it
|
# three fields. Split it here rather than asking a service to state it
|
||||||
|
|
@ -775,30 +773,6 @@ in
|
||||||
Put it back, or give this module a different swarmTierName.
|
Put it back, or give this module a different swarmTierName.
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
(nameGuards.mustNotEqual {
|
|
||||||
option = "services.hyperhive.swarm.hives";
|
|
||||||
names = hiveNames;
|
|
||||||
reserved = reservedOwners;
|
|
||||||
why = ''
|
|
||||||
The collector names components `<kind>/<owner>` with the hive name
|
|
||||||
as owner, and `//` resolves the clash silently: the swarm tier's
|
|
||||||
parts win, that hive's pipeline and `hive=` stamp disappear, and it
|
|
||||||
keeps pushing into a route that goes nowhere. Rename the hive.
|
|
||||||
'';
|
|
||||||
})
|
|
||||||
(nameGuards.mustNotContain {
|
|
||||||
option = "services.hyperhive.swarm.hives";
|
|
||||||
names = hiveNames;
|
|
||||||
fragments = reservedFragments;
|
|
||||||
why = ''
|
|
||||||
Hive-scoped identifiers are composed from a hive name —
|
|
||||||
`hive-<name>`, `hive-<name>-agent` — so a name containing one of
|
|
||||||
these produces an identifier that is also somebody else's. The
|
|
||||||
failure is a wrong grant rather than an error: the client
|
|
||||||
authenticates and receives another principal's permissions, and a
|
|
||||||
NATS denial arrives as a timeout. Rename the hive.
|
|
||||||
'';
|
|
||||||
})
|
|
||||||
{
|
{
|
||||||
# A published target that is not an `https://host/path` url. Without
|
# A published target that is not an `https://host/path` url. Without
|
||||||
# this the split returns null and the failure surfaces as
|
# this the split returns null and the failure surfaces as
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,26 @@ let
|
||||||
swarmCfg = cfg.swarm;
|
swarmCfg = cfg.swarm;
|
||||||
deployCfg = cfg.deploy;
|
deployCfg = cfg.deploy;
|
||||||
|
|
||||||
|
hiveNames = lib.attrNames swarmCfg.hives;
|
||||||
|
|
||||||
|
nameGuards = import ./lib/name-guards.nix { inherit lib; };
|
||||||
|
|
||||||
|
# Names no hive may BE, and words no hive name may CONTAIN. Both files
|
||||||
|
# explain their own admission rules; the second says why they are not one
|
||||||
|
# list. They are applied HERE rather than in the module that happens to
|
||||||
|
# consume them, because a hive name composes identifiers whatever else is
|
||||||
|
# enabled — ./swarm-otel.nix used to hold these guards and gated them on its
|
||||||
|
# own `enable`, so a swarm without the collector had no check at all.
|
||||||
|
reservedNames = import ../reserved-names.nix;
|
||||||
|
reservedFragments = import ../reserved-hive-fragments.nix;
|
||||||
|
|
||||||
|
# Subjects the store's cert-auth roles accept. A LIST, not one string: cert
|
||||||
|
# auth trusts the CA and `allowed_common_names` is the whole narrowing, so
|
||||||
|
# every role added beside these widens what a hive name must not collide
|
||||||
|
# with. A hive's own leaf carries its name as the CN, so a hive named after
|
||||||
|
# one of these presents a certificate that role accepts.
|
||||||
|
certAuthCns = [ deployCfg.bao.controllerCommonName ];
|
||||||
|
|
||||||
# Public hostnames of the swarm's own services, in declaration order.
|
# Public hostnames of the swarm's own services, in declaration order.
|
||||||
# `serviceDomains` below is this set sorted + deduplicated.
|
# `serviceDomains` below is this set sorted + deduplicated.
|
||||||
#
|
#
|
||||||
|
|
@ -387,6 +407,42 @@ in
|
||||||
swarm, or set all three to null to turn publishing off.
|
swarm, or set all three to null to turn publishing off.
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
(nameGuards.mustNotEqual {
|
||||||
|
option = "services.hyperhive.swarm.hives";
|
||||||
|
names = hiveNames;
|
||||||
|
reserved = reservedNames ++ certAuthCns;
|
||||||
|
why = ''
|
||||||
|
A hive's name is what other components address it by, and two
|
||||||
|
of those uses resolve the clash silently rather than erroring.
|
||||||
|
|
||||||
|
The collector names components `<kind>/<owner>` with the hive
|
||||||
|
name as owner, and `//` merges them: the swarm tier's parts
|
||||||
|
win, that hive's pipeline and `hive=` stamp disappear, and it
|
||||||
|
keeps pushing into a route that goes nowhere.
|
||||||
|
|
||||||
|
The secret store's cert-auth roles match on a certificate's
|
||||||
|
common name, and a hive's own leaf carries its name. A hive
|
||||||
|
named after a role's subject presents a certificate that role
|
||||||
|
accepts — so it receives that principal's grants, which for
|
||||||
|
the controller means write access to every hive's credentials
|
||||||
|
and policies.
|
||||||
|
|
||||||
|
Rename the hive.
|
||||||
|
'';
|
||||||
|
})
|
||||||
|
(nameGuards.mustNotContain {
|
||||||
|
option = "services.hyperhive.swarm.hives";
|
||||||
|
names = hiveNames;
|
||||||
|
fragments = reservedFragments;
|
||||||
|
why = ''
|
||||||
|
Hive-scoped identifiers are composed from a hive name —
|
||||||
|
`hive-<name>`, `hive-<name>-agent` — so a name containing one of
|
||||||
|
these produces an identifier that is also somebody else's. The
|
||||||
|
failure is a wrong grant rather than an error: the client
|
||||||
|
authenticates and receives another principal's permissions, and a
|
||||||
|
NATS denial arrives as a timeout. Rename the hive.
|
||||||
|
'';
|
||||||
|
})
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -507,8 +507,76 @@ let
|
||||||
bao = h.deploy.bao.enable;
|
bao = h.deploy.bao.enable;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The hive-name guards, with the collector explicitly OFF. That is the whole
|
||||||
|
# property: the guards live where `swarm.hives` is declared, so they run in a
|
||||||
|
# deployment that has a secret store and no collector — which used to skip
|
||||||
|
# them entirely, because they were assertions inside swarm-otel's own `mkIf`.
|
||||||
|
#
|
||||||
|
# ⚠️ `controllerCommonName` is overridden to a name containing NO reserved
|
||||||
|
# fragment. Its default (`swarm-controller`) contains `swarm` and is caught
|
||||||
|
# by the substring guard whatever the cert-auth arm does — so a fixture using
|
||||||
|
# the default could not tell the two apart, and the arm under test would pass
|
||||||
|
# on the neighbour's work.
|
||||||
|
hiveNamedAfterCertSubject = hive {
|
||||||
|
deploy.swarm-otel.enable = false;
|
||||||
|
deploy.bao.controllerCommonName = "ctl";
|
||||||
|
swarm.hives.ctl.domain = "ctl.t.local";
|
||||||
|
};
|
||||||
|
|
||||||
|
# The control for both arms below: same shape, a roster nothing objects to.
|
||||||
|
hiveNamesAllLegal = hive {
|
||||||
|
deploy.swarm-otel.enable = false;
|
||||||
|
deploy.bao.controllerCommonName = "ctl";
|
||||||
|
};
|
||||||
|
|
||||||
|
hiveNameWithComposedWord = hive {
|
||||||
|
deploy.swarm-otel.enable = false;
|
||||||
|
swarm.hives."h1-agent".domain = "a.t.local";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Markers from `lib/name-guards.nix`'s two `problem` strings. Matching the
|
||||||
|
# problem rather than the `why` prose keeps the messages rewordable.
|
||||||
|
equalityGuardFired =
|
||||||
|
h: lib.any (a: !a.assertion && lib.hasInfix "has reserved name(s)" a.message) h.assertions;
|
||||||
|
fragmentGuardFired =
|
||||||
|
h:
|
||||||
|
lib.any (
|
||||||
|
a: !a.assertion && lib.hasInfix "has name(s) containing a reserved word" a.message
|
||||||
|
) h.assertions;
|
||||||
|
|
||||||
# Each case: a name stating the property, and `ok`.
|
# Each case: a name stating the property, and `ok`.
|
||||||
cases = [
|
cases = [
|
||||||
|
{
|
||||||
|
# `ctl` is in no deny list — it is reserved *because it is the subject a
|
||||||
|
# cert-auth role accepts*, which is a value an operator sets, so a
|
||||||
|
# literal deny entry could never have covered it.
|
||||||
|
name = "a hive named after a cert-auth subject is refused, with the collector off";
|
||||||
|
ok =
|
||||||
|
equalityGuardFired hiveNamedAfterCertSubject
|
||||||
|
&& lib.any (a: !a.assertion && lib.hasInfix "'ctl'" a.message) hiveNamedAfterCertSubject.assertions;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Without this the case above proves nothing: an arm that fires for every
|
||||||
|
# roster is not a guard, and `hives` is non-empty in both fixtures.
|
||||||
|
name = "a legal hive roster trips neither name guard";
|
||||||
|
ok = !(equalityGuardFired hiveNamesAllLegal) && !(fragmentGuardFired hiveNamesAllLegal);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The substring guard came along in the move and has to still work.
|
||||||
|
# `h1-agent` mints exactly the client id hive `h1`'s agents present.
|
||||||
|
name = "a hive name containing a composed-identifier word is refused, with the collector off";
|
||||||
|
ok = fragmentGuardFired hiveNameWithComposedWord;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# ⚠️ The control that makes "with the collector off" mean anything. If a
|
||||||
|
# fixture silently had swarm-otel enabled, all three cases above would
|
||||||
|
# pass while testing the arrangement they exist to rule out.
|
||||||
|
name = "the guard fixtures really do have the collector disabled";
|
||||||
|
ok =
|
||||||
|
!hiveNamedAfterCertSubject.services.hyperhive.deploy.swarm-otel.enable
|
||||||
|
&& !hiveNamesAllLegal.services.hyperhive.deploy.swarm-otel.enable
|
||||||
|
&& !hiveNameWithComposedWord.services.hyperhive.deploy.swarm-otel.enable;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# `lib.all` over an empty set holds vacuously, so the roster is counted
|
# `lib.all` over an empty set holds vacuously, so the roster is counted
|
||||||
# before it is read: a helper that lost a member would otherwise turn
|
# before it is read: a helper that lost a member would otherwise turn
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue