Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
5 changed files with 128 additions and 18 deletions

View file

@ -355,8 +355,8 @@ principal and create what's missing.
A unit creates each secret only while the store holds none, so a second start
mints nothing. Both tokens last ten years and nothing renews them. To replace one,
write the new token to its path in the store: nothing mints over a value that
exists. A new signing key invalidates both tokens.
write the new token to its path in the store, in its UI under the OIDC login:
nothing mints over a value that exists. A new signing key invalidates both tokens.
The unit creates the address, name and key once and never updates them. After
you change `swarm.attic.domain`, or after the server's database goes and the
@ -387,8 +387,8 @@ the swarm at an existing tenant:
`services.hyperhive.swarm.attic.cache` to the cache's name on it. Set
both options on every host that reads them.
2. Seed the push token in the swarm secret store at
`secret/swarm/services/attic/push-token`, field `value`. Use the store's UI,
or `bao kv put` with `value=-` and the token on stdin, so the token is in no
`secret/swarm/services/attic/push-token`, field `value`. Use the store's UI under
the OIDC login, or `bao kv put` with `value=-` and the token on stdin, so the token is in no
command line. The token needs push rights on that cache. Until it
exists, every push fails and builds stay green with `cached` false.
Seed the hives' pull token the same way at

View file

@ -556,11 +556,24 @@ The OIDC tab logs in through authelia as the client `swarm-bao-ui`
(`swarm.bao.ui.oidc.clientId`). An `admins` member gets a token under
`swarm-operator-viewer`: `list` and `read` on `secret/metadata/*`, and
`create` and `update` on
`secret/data/swarm/services/review-classifier/anthropic-api-key` and
`secret/data/swarm/services/review-classifier/acp-provider`. That shows the key
tree and each key's versions and timestamps, and no value: the store refuses
every `secret/data/` read. The two writes are the review classifier's keys,
which the operator fills in by hand. Anything more needs a token.
`secret/data/swarm/services/review-classifier/anthropic-api-key`,
`secret/data/swarm/services/review-classifier/acp-provider` and the cache's five
leaves under `secret/data/swarm/services/attic/` (`push-token`, `pull-token`,
`url`, `cache` and `public-key`). That shows the key tree and each key's
versions and timestamps, and no value: the store refuses every `secret/data/`
read. The writes are the leaves the operator fills in by hand: the review
classifier's keys, and the cache's tokens and facts, which configure an external
cache or replace the in-swarm cache's own. The cache's host units create those
leaves only while the store holds none, so a saved value survives their next run
and every deploy. The cache's signing key, `attic/jwt-secret`, isn't writable here.
Anything more needs a token.
To replace one of the cache's leaves in the UI, open `secret` > `swarm` >
`services` > `attic`, pick the leaf and choose **Create new version**. The
form starts empty because the login can't read the current value: type the
key `value` and the new value, and save. A new version replaces the whole
secret, so `value` is the only key to enter. `bao kv put` with the same login
works too.
| piece | written by |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |

View file

@ -777,18 +777,28 @@ let
# `read`. The UI needs no `sys/` grant on top:
# `sys/internal/ui/mounts` lists any mount the token holds a capability
# under, and the rest it calls is in the `default` policy.
#
# The cache's five leaves are where an external cache is configured, and
# where the operator replaces the in-swarm cache's tokens; its host units
# create them only while none is stored. `atticJwtLeaf` is left out: a new
# signing key invalidates both tokens.
operatorWritableLeaves = [
classifierApiKeyLeaf
classifierAcpKeyLeaf
atticPushTokenLeaf
]
++ atticFactLeaves;
operatorViewerPolicyName = "swarm-operator-viewer";
operatorViewerPolicyText = ''
path "${credentialMountPath}/metadata/*" {
capabilities = ["list", "read"]
}
path "${credentialMountPath}/data/${classifierApiKeyLeaf}" {
''
+ lib.concatMapStrings (leaf: ''
path "${credentialMountPath}/data/${leaf}" {
capabilities = ["create", "update"]
}
path "${credentialMountPath}/data/${classifierAcpKeyLeaf}" {
capabilities = ["create", "update"]
}
'';
'') operatorWritableLeaves;
# authelia's operator group (`operatorGroup` in ./swarm-authelia.nix, the
# group its rule for the UI's vhost admits) → the viewer policy. authelia

View file

@ -316,6 +316,15 @@ let
best: g: if best == null || lib.stringLength g.path > lib.stringLength best.path then g else best
) null globs;
# Whether the policy text in `text` lets its holder read `secret/data/swarm/<leaf>`,
# by `grantFor`'s rule, so a glob that reaches the leaf counts and a write does not.
grantReads =
leaf: text:
let
g = grantFor (grantsIn text) "secret/data/swarm/${leaf}";
in
g != null && lib.elem "read" g.caps;
ungranted =
grants: units:
lib.concatLists (
@ -2139,20 +2148,42 @@ let
{
# The signing key is the cache's alone; the push token is also the
# builder's, and is read there. The pull token is also the controller's,
# which only reads it (pinned above).
name = "no granting unit but the cache's names its signing key, and only it and the controller name its pull token";
# which only reads it (pinned above). The pull token's value is what
# the roster guards, so it counts the units that may read it: the
# operator's write-only grant on it, which replaces the token and
# exposes nothing, stays outside.
name = "no granting unit but the cache's names its signing key, and only it and the controller read its pull token";
ok =
lib.filter (
u: lib.hasInfix "services/attic/jwt-secret" baoGrantHere.systemd.services.${u}.script
) grantingUnitNames == [ "swarm-bao-attic-policy" ]
&&
lib.filter (
u: lib.hasInfix "services/attic/pull-token" baoGrantHere.systemd.services.${u}.script
u: grantReads "services/attic/pull-token" baoGrantHere.systemd.services.${u}.script
) grantingUnitNames == [
"swarm-bao-controller-policy"
"swarm-bao-attic-policy"
];
}
{
# Control for the property above: `grantReads` tells a read from a write
# on the same path, and the operator's policy does name the pull token.
name = "a grant that only writes the pull token is not a read of it, and one that reads it is";
ok =
let
stanza = caps: ''
path "secret/data/swarm/services/attic/pull-token" {
capabilities = [${caps}]
}
'';
viewer = baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script;
in
!(grantReads "services/attic/pull-token" (stanza ''"create", "update"''))
&& grantReads "services/attic/pull-token" (stanza ''"read"'')
&& grantReads "services/attic/pull-token" (stanza ''"create", "read"'')
&& lib.hasInfix "services/attic/pull-token" viewer
&& !(grantReads "services/attic/pull-token" viewer);
}
{
name = "the PKI unit signs the cache's leaf under its own subject";
ok =
@ -2634,7 +2665,7 @@ let
# metadata, and a write on the leaves the operator fills by hand.
# A `read` under `data/` would hand a browser session a secret's
# value, and any `sys/` path more than the UI needs.
name = "the operator viewer policy is list and read on KV metadata, a write on the classifier's two keys, and no data/ read or sys/";
name = "the operator viewer policy is list and read on KV metadata, a write on the classifier's two keys and the cache's five, and no data/ read, attic signing key or sys/";
ok =
let
viewer = grantsIn baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script;
@ -2661,7 +2692,43 @@ let
"update"
];
}
{
path = "secret/data/swarm/services/attic/push-token";
caps = [
"create"
"update"
];
}
{
path = "secret/data/swarm/services/attic/pull-token";
caps = [
"create"
"update"
];
}
{
path = "secret/data/swarm/services/attic/url";
caps = [
"create"
"update"
];
}
{
path = "secret/data/swarm/services/attic/cache";
caps = [
"create"
"update"
];
}
{
path = "secret/data/swarm/services/attic/public-key";
caps = [
"create"
"update"
];
}
]
&& grantFor viewer "secret/data/swarm/services/attic/jwt-secret" == null
&& grantFor viewer "secret/data/swarm/agents/x/queue" == null
&& grantFor viewer "secret/metadata/swarm/agents/x/queue" != null
&& !(lib.any (g: lib.hasPrefix "secret/data" g.path && lib.elem "read" g.caps) viewer)

View file

@ -207,6 +207,26 @@ expect "pull token unchanged" "$(stored $PULL)" "$pull"
expect "push token unchanged" "$(stored $PUSH)" "$push"
expect "address, name and key unchanged" "$(stored $URL)$(stored $CACHE)$(stored $KEY)" "http://cache.examplegridgrid:FAKEKEY"
case="values the operator stored over the minted ones are kept"
for leaf in $PULL $PUSH $URL $CACHE $KEY; do
printf '%s' "operator-$leaf" >"$dir/store/$leaf"
done
: >"$calls"
run "$PROVISION"
expect "exit status" $? 0
expect "no token minted" "$(n '^atticadm')" 0
expect "nothing written" "$(n '^bao put')" 0
expect "the operator's push token is still stored" "$(stored $PUSH)" "operator-$PUSH"
expect "the operator's pull token is still stored" "$(stored $PULL)" "operator-$PULL"
expect "the operator's address, name and key are still stored" \
"$(stored $URL)$(stored $CACHE)$(stored $KEY)" "operator-${URL}operator-${CACHE}operator-${KEY}"
expect "the cache lookup used the operator's pull token" "$(n "^attic cache info local:grid | token=operator-$PULL")" 1
printf '%s' "$pull" >"$dir/store/$PULL"
printf '%s' "$push" >"$dir/store/$PUSH"
printf '%s' http://cache.example >"$dir/store/$URL"
printf '%s' grid >"$dir/store/$CACHE"
printf '%s' grid:FAKEKEY >"$dir/store/$KEY"
case="an empty server gets its cache back with the stored tokens"
rm -f "$dir/cache-exists"
: >"$calls"