Watch
0
0
Fork
You've already forked hyperhive
0

bao: OIDC login to the browser UI via authelia, as a metadata-only viewer
Some checks were skipped
public bin cache / build + push to preem:grid (push) Has been skipped

The bao UI at bao-ui.<swarm> took a raw store token and nothing else.
It now offers an OIDC tab: authelia's `admins` group logs in and lands
on `swarm-operator-viewer`, which is list+read on `secret/metadata/*`
and nothing under `secret/data/` or `sys/`.

- authelia registers an interactive client `swarm-bao-ui`
  (glue-bao-ui-oidc-client.nix) with redirect
  `https://bao-ui.<swarm>/ui/vault/auth/oidc/oidc/callback`; the secret
  publisher carries its secret to
  `secret/swarm/services/swarm-bao-ui/oidc/client`.
- `swarm-bao-granter-role` (bootstrap token) enables the `oidc` auth
  mount with listing visibility `unauth`, asked before attempted like
  cert/approle; `bao-bootstrap-policy.hcl` gains `sys/auth/oidc`.
- The granter's policy gains `auth/oidc/config`, `auth/oidc/role/swarm-*`
  and read on that one secret leaf. It still holds no `sys/auth`.
- New granting unit `swarm-bao-operator-viewer-policy` writes the viewer
  policy, and once the granter may configure `auth/oidc/config` (checked
  through `sys/capabilities-self`), writes the mount's config from the
  published secret and the role binding `groups=admins` to the viewer.
  Before the bootstrap step re-runs it writes the policy, logs the step
  and exits 0.

Route (a) per mara on #4775: enabling the auth method stays a
bootstrap-token step, re-run once on the live store.

module-eval pins the viewer policy's single metadata stanza, that the
granter's policy has no sys/auth path, the oidc enable in the bootstrap
unit, the exit-0 path, the config/role contents, and the client
registration + publish.
This commit is contained in:
atlas 2026-09-28 19:56:38 +02:00
commit b14ff2796c
9 changed files with 413 additions and 18 deletions

View file

@ -112,9 +112,10 @@ The token file is `services.hyperhive.deploy.bao.bootstrapTokenFile`, which
all-local names for you. On a store host that isn't all-local, set it and
rebuild first.
`swarm-bao-granter-role` runs **on the host**. It enables the cert auth
method, writes the `bao-granter` policy, and creates the `bao-granter` role,
which accepts the leaf `/var/lib/swarm-bao-pki/granter.pem`. Every
`swarm-bao-granter-role` runs **on the host**. It enables the cert, approle
and oidc auth methods, writes the `bao-granter` policy, and creates the
`bao-granter` role, which accepts the leaf
`/var/lib/swarm-bao-pki/granter.pem`. Every
`swarm-bao-*-policy` unit then logs in with that leaf. The controller's unit
mounts the KV and pki engines and writes the `swarm-controller` role, and each
sibling unit writes its own principal's policy and role. Every one runs on the

View file

@ -432,8 +432,9 @@ whichever certificate the reader presents, unchanged.
OpenBao's built-in web UI is at `https://bao-ui.<swarm domain>/`
(`swarm.bao.ui.domain`), for members of authelia's `admins` group only. Log in
with a bao token. The gateway vhost checks the session with authelia and
proxies to an nginx inside the store's container on
with the **OIDC** tab, or with a bao token under **Other**. The gateway vhost
checks the session with authelia and proxies to an nginx inside the store's
container on
`127.0.0.1:<deploy.bao.uiProxyPort>`. That nginx forwards `/ui/` and `/v1/` to
a second openbao listener on `127.0.0.1:<deploy.bao.uiPort>`, answers 403 on
the unseal, seal, step-down, rekey and generate-root endpoints, and 404 on
@ -447,6 +448,29 @@ doesn't wait for the store: it serves the hive certificate on the UI's name (a
browser warning) until the unsealed store issues the services leaf, so the
stream passthrough readers use on that host comes up with the store sealed.
### OIDC login
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/*`. That shows
the key tree and each key's versions and timestamps, and no value: the store
refuses every `secret/data/` read. Anything more needs a token.
| piece | written by |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| authelia client, with redirect `swarm.bao.ui.oidc.redirectUri` | `glue-bao-ui-oidc-client.nix`, wherever authelia runs |
| its secret at `swarm/services/swarm-bao-ui/oidc/client` | `swarm-secret-publish`, like every other service's |
| the `oidc` auth mount | `swarm-bao-granter-role`, with the bootstrap token |
| `swarm-operator-viewer` policy, the mount's config and role `swarm-operator-viewer` | `swarm-bao-operator-viewer-policy`, as the granter, which reads the secret above into the config |
The granter holds no `sys/auth`, so enabling the mount stays a bootstrap-token
step. A store set up before the mount existed needs the
[one-time granter step](../getting-started/setup.md) again: it re-writes
`bao-bootstrap`, which covers `sys/auth/oidc`, and the granter's own
policy, which covers `auth/oidc/`. Until then
`swarm-bao-operator-viewer-policy` writes the viewer policy, logs the step, and
exits 0, and the UI offers token login only.
## The constraint that decides where the root lives
A hive CA carries `nameConstraints=permitted;DNS:<hive domain>`, and **a swarm

View file

@ -27,6 +27,10 @@ path "sys/auth/approle" {
capabilities = ["create", "update", "sudo"]
}
path "sys/auth/oidc" {
capabilities = ["create", "update", "sudo"]
}
# The granter's own policy and role, and nothing it may write.
path "sys/policies/acl/bao-granter" {
capabilities = ["create", "update"]

View file

@ -27,6 +27,7 @@
./otel.nix
./glue-bao-readers-policy-order.nix
./glue-bao-tls.nix
./glue-bao-ui-oidc-client.nix
./glue-controller-bao-identity.nix
./glue-forge-oidc-client.nix
./glue-grafana-oidc-client.nix

View file

@ -0,0 +1,35 @@
# Glue: register the secret store's browser UI as an OIDC client wherever
# authelia runs.
#
# ONE PAIRING PER FILE — the store's `oidc` auth method ← authelia, and nothing
# else. Deleting this leaves a UI whose OIDC button sends the browser to a
# client authelia has never heard of, and nothing mints the secret
# `swarm-bao-operator-viewer-policy` waits for.
#
# ⚠️ Gated on authelia being HERE, and deliberately NOT on this host running
# the store, for the reason ./glue-grafana-oidc-client.nix gives: a client is a
# row in THIS host's provider config.
{
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
deployCfg = hyperhiveCfg.deploy;
uiCfg = hyperhiveCfg.swarm.bao.ui;
in
{
config = lib.mkIf deployCfg.authelia.enable {
# `kind` is left at its `interactive` default: a person logs in here, and
# that kind is what permits the `profile` and `groups` scopes the store's
# role asks for.
services.hyperhive.swarm.authelia.oidc.clients = [
{
id = uiCfg.oidc.clientId;
description = "HyperHive secret store UI";
redirectUris = [ uiCfg.oidc.redirectUri ];
}
];
};
}

View file

@ -188,11 +188,13 @@ let
#
# The first three are the per-principal grants. The next eight are what
# `swarm-bao-controller-policy` does besides grants: the KV and pki mounts and
# the services root. The last six set up the agent PKI mount: the mount, its
# the services root. The next six set up the agent PKI mount: the mount, its
# root and the `swarm-*` role agent certificates are issued through. No
# `root` delete there: every agent's cert-auth role pins that root by value,
# so replacing it would lock every agent out. No `sys/auth`: the auth mounts
# are created with the bootstrap token by `swarm-bao-granter-role`.
# so replacing it would lock every agent out. The last three configure the
# `oidc` auth method: its config, its `swarm-*` roles, and a read on the one
# client secret that config carries. No `sys/auth`: the auth mounts are
# created with the bootstrap token by `swarm-bao-granter-role`.
#
# Piped as a shell-quoted argument like `controllerPolicyText`, so
# ../module-eval/bao-grants.nix can read it out of the unit script.
@ -264,6 +266,18 @@ let
path "${agentPkiMountPath}/roles/swarm-*" {
capabilities = ["create", "update"]
}
path "auth/oidc/config" {
capabilities = ["create", "update"]
}
path "auth/oidc/role/swarm-*" {
capabilities = ["create", "update"]
}
path "${credentialMountPath}/data/${baoUiClientLeaf}" {
capabilities = ["read"]
}
'';
# What a granting unit prints when the store refuses the granter: the
@ -504,6 +518,54 @@ let
# it; the controller reads it and holds no other copy.
controllerClientLeaf = "swarm/controller/swarm-controller/oidc/client";
# The UI's OIDC client secret. The publisher writes it; the granter reads it
# into the `oidc` auth method's config, which is the only copy bao uses.
baoUiClientLeaf = "swarm/services/${cfg.ui.oidc.clientId}/oidc/client";
# What an OIDC login through the UI gets: the key tree and each key's KV
# metadata, never a value — KV v2 serves values under `data/`, which no
# stanza here names. 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.
operatorViewerPolicyName = "swarm-operator-viewer";
operatorViewerPolicyText = ''
path "${credentialMountPath}/metadata/*" {
capabilities = ["list", "read"]
}
'';
# authelia's operator group (`operatorGroup` in ./swarm-authelia.nix, the
# group its rule for the UI's vhost admits) → the viewer policy. authelia
# sends `groups` and `preferred_username` from its userinfo endpoint, and
# bao merges those into the ID token's claims before it checks
# `bound_claims`.
operatorViewerRole = builtins.toJSON {
role_type = "oidc";
user_claim = "preferred_username";
groups_claim = "groups";
oidc_scopes = [
"profile"
"groups"
];
bound_claims.groups = [ "admins" ];
allowed_redirect_uris = [ cfg.ui.oidc.redirectUri ];
token_policies = [ operatorViewerPolicyName ];
token_ttl = "1h";
token_max_ttl = "8h";
};
# The `oidc` auth method's config, as `bao write` arguments. The client
# secret is `-`, read from stdin. On a self-signed gateway authelia's
# certificate chains to the host's anchor bundle; bao takes every
# certificate in `oidc_discovery_ca_pem`, not only the first.
operatorViewerOidcConfig = [
"oidc_discovery_url=${toString autheliaCfg.url}"
"oidc_client_id=${cfg.ui.oidc.clientId}"
"oidc_client_secret=-"
"default_role=${operatorViewerPolicyName}"
]
++ lib.optional caTrust.useSelfSigned "oidc_discovery_ca_pem=@${deployCfg.hive-controller.tls.stateDir}/trust-bundle.pem";
# The KV v2 engine the controller writes agent credentials through. Named
# once because the grant above and the `secrets enable` in the bootstrap unit
# have to agree: a policy pointing at a mount nobody created is precisely the
@ -1959,6 +2021,37 @@ in
'';
};
ui.oidc.clientId = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "swarm-bao-ui";
description = ''
OAuth2 client id the store's `oidc` auth method logs browser users
in as, at authelia.
Swarm-wide and read-only because two hosts have to agree on it:
authelia registers the client (`glue-bao-ui-oidc-client.nix`) and
mints its secret, and the store's host reads that secret back out of
the store under a path composed from this id.
'';
};
ui.oidc.redirectUri = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "https://${cfg.ui.domain}/ui/vault/auth/oidc/oidc/callback";
defaultText = lib.literalExpression ''"https://''${services.hyperhive.swarm.bao.ui.domain}/ui/vault/auth/oidc/oidc/callback"'';
description = ''
Where authelia sends the browser back to after an OIDC login, and the
URI both authelia and the store's `oidc` role match **exactly**.
The format is the OpenBao UI's own route,
`/ui/vault/auth/<mount>/oidc/callback`, with the mount `oidc`. The
UI composes it from the page's origin, so it only matches when the
gateway serves the UI on port 443.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 8200;
@ -2196,6 +2289,7 @@ in
"swarm-bao-nats-tls-policy"
"swarm-bao-agent-pki"
"swarm-bao-nats-auth-policy"
"swarm-bao-operator-viewer-policy"
];
# 🚫 No `swarm.otel.scrapeTargets.bao` entry any more, and its absence is
@ -2598,6 +2692,14 @@ in
*) bao auth enable approle ;;
esac
# The UI's OIDC login; `swarm-bao-operator-viewer-policy` writes its
# config and role. Listed on the UI's login page, which shows a
# method as a tab only when it is listed.
case "$mounted" in
*'"oidc/"'*) ;;
*) bao auth enable -listing-visibility=unauth oidc ;;
esac
printf '%s' ${lib.escapeShellArg granterPolicyText} |
bao policy write ${lib.escapeShellArg granterPolicyName} -
@ -3236,6 +3338,84 @@ in
);
};
# The UI's OIDC login: the viewer policy, then the `oidc` auth method's
# config and the role that hands `admins` that policy. The mount itself,
# and the granter's grants on it, exist only once `swarm-bao-granter-role`
# has run with a bootstrap token that carries `sys/auth/oidc`. Until then
# this writes the policy and stops with exit 0: nothing is broken, the UI
# still takes a token, and a day of retries would change nothing.
systemd.services.swarm-bao-operator-viewer-policy = lib.mkIf haveGranter {
description = "write the bao UI's OIDC login: the operator viewer policy, the oidc config and its role";
after = [
"container@${cfg.machine}.service"
"swarm-bao-controller-policy.service"
]
++ granterAfter;
requires = [ "swarm-bao-pki.service" ];
wantedBy = [ "multi-user.target" ];
path = [
baoCli
pkgs.coreutils
];
environment = granterEnv;
# Same unseal wait as its siblings above, for the reason stated there.
startLimitBurst = 2880;
startLimitIntervalSec = 90000;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 30;
};
script = ''
set -euo pipefail
${granterLogin}
printf '%s' ${lib.escapeShellArg operatorViewerPolicyText} |
bao policy write ${lib.escapeShellArg operatorViewerPolicyName} -
# Asks the granter's own token, through the `default` policy's
# `sys/capabilities-self`, rather than probing the mount: the granter
# holds no `sys/auth` to list mounts with.
caps="$(bao token capabilities auth/oidc/config)"
case "$caps" in
*update*) ;;
*)
echo "the granter may not configure auth/oidc yet (capabilities: $caps), so the UI's OIDC login stays off and token login is unchanged." >&2
echo "one-time step, as root on this host (docs/getting-started/setup.md):" >&2
${lib.concatMapStringsSep "\n" (l: "echo ${lib.escapeShellArg " ${l}"} >&2") (
granterSetupSteps ++ [ "systemctl restart swarm-bao-operator-viewer-policy" ]
)}
exit 0
;;
esac
# Published by ./swarm-secret-publisher.nix on authelia's host,
# which this boot does not wait on, so absence is retried.
if ! secret="$(bao kv get -field=value ${lib.escapeShellArg "${credentialMountPath}/${baoUiClientLeaf}"} 2>"$err")"; then
echo ${lib.escapeShellArg "the store did not return ${credentialMountPath}/${baoUiClientLeaf}: the UI's OIDC client secret is not published yet."} >&2
cat "$err" >&2
exit 1
fi
if [ -z "$secret" ]; then
echo ${lib.escapeShellArg "the store returned an empty ${credentialMountPath}/${baoUiClientLeaf}."} >&2
exit 1
fi
# bao fetches authelia's discovery document on this write, so an
# unreachable or untrusted authelia fails it and the unit retries.
printf '%s' "$secret" |
bao write auth/oidc/config ${
lib.concatMapStringsSep " " lib.escapeShellArg operatorViewerOidcConfig
}
# A JSON body on stdin, because `bound_claims` is a map.
printf '%s' ${lib.escapeShellArg operatorViewerRole} |
bao write auth/oidc/role/${lib.escapeShellArg operatorViewerPolicyName} -
'';
};
# The CA bind source is written at runtime by a host unit, so the
# container has to start after it — otherwise nspawn sets up a mount
# over a file that does not exist yet.

View file

@ -78,6 +78,9 @@ let
# Missing from here, authelia mints the value and nothing carries it, so
# the reader waits on a path that is never written.
hyperhiveCfg.swarm.bao.otel.clientId
# The store's browser UI, whose `oidc` auth method the store's host
# configures with this secret.
hyperhiveCfg.swarm.bao.ui.oidc.clientId
];
# The swarm controller's OIDC client, which it reads back from the store

View file

@ -151,7 +151,7 @@ let
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
) baoGrantWithConsumers.systemd.services;
# The twelve units that write a `swarm-*` grant, by name, for the discovery
# The thirteen units that write a `swarm-*` grant, by name, for the discovery
# control below.
grantingUnitNames = [
"swarm-bao-controller-policy"
@ -166,6 +166,7 @@ let
"swarm-bao-nats-tls-policy"
"swarm-bao-agent-pki"
"swarm-bao-nats-auth-policy"
"swarm-bao-operator-viewer-policy"
];
# Comment lines dropped first: both the HCL and the scripts explain
@ -217,7 +218,9 @@ let
];
in
# A login and a seal-status check are unauthenticated: no policy grants them.
if a 0 == "login" || a 0 == "status" then
# A token's own capabilities are `sys/capabilities-self`, which the
# `default` policy grants every token.
if a 0 == "login" || a 0 == "status" || (a 0 == "token" && a 1 == "capabilities") then
null
else if a 0 == "policy" && a 1 == "write" then
need "sys/policies/acl/${a 2}" cu
@ -235,6 +238,18 @@ let
need (a 1) cu
else if a 0 == "read" then
need (a 1) [ "read" ]
# KV v2 inserts `data/` after the mount, the first segment.
else if a 0 == "kv" && a 1 == "get" then
let
segs = lib.splitString "/" (a 2);
in
need (lib.concatStringsSep "/" (
[
(lib.head segs)
"data"
]
++ lib.tail segs
)) [ "read" ]
else if a 0 == "list" then
need (a 1) [ "list" ]
else if a 0 == "delete" then
@ -780,24 +795,24 @@ let
}
{
# A store host without the granter's pair writes its grants some other
# way, so none of the twelve units may exist. Without this arm
# way, so none of the thirteen units may exist. Without this arm
# `lib.mkIf haveGranter` could be dropped from any of them and every other
# case here would still pass.
name = "without the granter's pair none of the twelve granting units render";
name = "without the granter's pair none of the thirteen granting units render";
ok =
let
s = baoGranterOptOut.systemd.services;
in
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
# The control: the same store with the pair renders all twelve.
# The control: the same store with the pair renders all thirteen.
&& lib.all (unit: baoGrantHere.systemd.services ? ${unit}) grantingUnitNames;
}
{
# 🩸 What replaced the silent skip. With no bootstrap token the twelve still
# 🩸 What replaced the silent skip. With no bootstrap token the thirteen still
# render, and a refused granter fails them with the step that fixes it.
# A store host that never named a token is told to name one, since the
# unit that sets the granter up renders only where it has.
name = "a store host without a bootstrap token renders the twelve, each failing loudly with the one-time step";
name = "a store host without a bootstrap token renders the thirteen, each failing loudly with the one-time step";
ok =
let
s = baoGranterNoToken.systemd.services;
@ -876,7 +891,7 @@ let
{
# The granter's grants, whole. Pinned as the full list, because an added
# path or capability is exactly what a presence check misses.
name = "the granter's policy is exactly these seventeen stanzas";
name = "the granter's policy is exactly these twenty stanzas";
ok =
let
cu = [
@ -956,6 +971,18 @@ let
path = "pki-agents/roles/swarm-*";
caps = cu;
}
{
path = "auth/oidc/config";
caps = cu;
}
{
path = "auth/oidc/role/swarm-*";
caps = cu;
}
{
path = "secret/data/swarm/services/swarm-bao-ui/oidc/client";
caps = [ "read" ];
}
];
}
{
@ -979,7 +1006,11 @@ let
"auth/cert/certs/hive-x"
"sys/auth"
"sys/auth/cert"
"sys/auth/oidc"
"sys/auth/oidc/tune"
"sys/auth/x"
"auth/oidc/role/x"
"secret/data/swarm/services/x/oidc/client"
"auth/token/create"
"auth/token/create-orphan"
"secret/data/x"
@ -1185,8 +1216,8 @@ let
}
{
# What makes the case above mean something: discovery by the granter's
# certificate reaches all twelve units, and each yields calls.
name = "the granter-policy check sees all twelve granting units, and parses calls from each";
# certificate reaches all thirteen units, and each yields calls.
name = "the granter-policy check sees all thirteen granting units, and parses calls from each";
ok =
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)
@ -1249,6 +1280,7 @@ let
"sys/auth"
"sys/auth/cert"
"sys/auth/approle"
"sys/auth/oidc"
"sys/policies/acl/bao-granter"
"auth/cert/certs/bao-granter"
]
@ -1344,6 +1376,99 @@ let
lib.any (a: !a.assertion && names a) baoGranterOddAgentRole.assertions
&& !(lib.any (a: !a.assertion && names a) baoGrantHere.assertions);
}
# ── the UI's OIDC login ─────────────────────────────────────────────────
{
# What an `admins` login through the UI holds: the key tree and KV
# metadata. Any `data/` path would hand a browser session every secret
# in the store, and any `sys/` one more than the UI needs.
name = "the operator viewer policy is list and read on KV metadata, and nothing under data/ or sys/";
ok =
let
viewer = grantsIn baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script;
in
viewer == [
{
path = "secret/metadata/*";
caps = [
"list"
"read"
];
}
]
&& 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.hasPrefix "sys/" g.path) viewer);
}
{
# Route (a): enabling an auth method stays a bootstrap-token act. The
# granter configures the `oidc` mount; it never creates or tunes one.
name = "the granter's policy has no sys/auth path, and the bootstrap policy holds sys/auth/oidc";
ok =
!(lib.any (g: lib.hasPrefix "sys/auth" g.path) granterGrants)
&& grantFor bootstrapGrants "sys/auth/oidc" != null;
}
{
# Asked before attempted, like the cert and approle mounts, so re-running
# the step on a store that has the mount is a no-op; listed, or the UI's
# login page shows no OIDC tab.
name = "the granter's role unit enables the oidc mount once, listed on the UI's login page";
ok =
let
g = baoGrantHere.systemd.services.swarm-bao-granter-role.script;
in
lib.hasInfix "*'\"oidc/\"'*) ;;" g
&& lib.hasInfix "bao auth enable -listing-visibility=unauth oidc" g;
}
{
# Before the step the granter lacks `auth/oidc/*`: the unit writes the
# policy, says what to run, and exits 0 rather than retrying for a day.
# The policy write comes first so it lands either way; the capability
# check comes before the secret read and the config write it guards.
name = "the viewer unit writes its policy, then stops with exit 0 while the granter may not configure oidc";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script;
at = needle: lib.stringLength (lib.head (lib.splitString needle s));
probe = "caps=\"$(bao token capabilities auth/oidc/config)\"";
in
lib.hasInfix probe s
&& at "bao policy write swarm-operator-viewer -" < at probe
&& at probe < at "exit 0"
&& at "exit 0" < at "bao kv get"
&& at "bao kv get" < at "bao write auth/oidc/config"
&& lib.hasInfix "systemctl restart swarm-bao-operator-viewer-policy" s;
}
{
# The client secret is on stdin, never an argument in /proc; the rest is
# the swarm's authelia and the UI's own client id.
name = "the oidc config names authelia and the UI's client, with the secret on stdin";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script;
in
lib.hasInfix "printf '%s' \"$secret\" |\n bao write auth/oidc/config" s
&& lib.hasInfix "'oidc_client_secret=-'" s
&& lib.hasInfix "'oidc_discovery_url=https://auth.t.local'" s
&& lib.hasInfix "'oidc_client_id=swarm-bao-ui'" s
&& lib.hasInfix "'default_role=swarm-operator-viewer'" s
&& lib.hasInfix "bao kv get -field=value secret/swarm/services/swarm-bao-ui/oidc/client" s
&&
(lib.hasInfix "oidc_discovery_ca_pem=@" s) == baoGrantHere.services.hyperhive.gateway.useSelfSigned;
}
{
# `admins` → the viewer policy, and only at the UI's own callback.
name = "the oidc role binds authelia's admins group to the viewer policy at the UI's callback";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script;
in
lib.hasInfix "bao write auth/oidc/role/swarm-operator-viewer -" s
&& lib.hasInfix ''"bound_claims":{"groups":["admins"]}'' s
&& lib.hasInfix ''"groups_claim":"groups"'' s
&& lib.hasInfix ''"token_policies":["swarm-operator-viewer"]'' s
&& lib.hasInfix ''"allowed_redirect_uris":["https://bao-ui.t.local/ui/vault/auth/oidc/oidc/callback"]'' s;
}
];
in
runGroup "bao-grants" cases

View file

@ -157,6 +157,28 @@ let
secretPublisherHere.systemd.services.swarm-secret-publish.script
);
}
{
# The store's UI login: authelia registers the client with the callback
# the store's `oidc` role allows, and the publisher carries its secret
# to the path `swarm-bao-operator-viewer-policy` reads. Not registered
# where authelia is not.
name = "the store UI's client is registered interactive at its callback, and the publisher carries its secret";
ok =
let
ui = lib.findFirst (
c: c.id == "swarm-bao-ui"
) null secretPublisherHere.services.hyperhive.swarm.authelia.oidc.clients;
in
ui != null
&& ui.kind == "interactive"
&& ui.redirectUris == [ "https://bao-ui.t.local/ui/vault/auth/oidc/oidc/callback" ]
&& lib.hasInfix "secret/swarm/services/swarm-bao-ui/oidc/client" (
secretPublisherHere.systemd.services.swarm-secret-publish.script
)
&& !(lib.any (
c: c.id == "swarm-bao-ui"
) grafanaRemoteAuthelia.services.hyperhive.swarm.authelia.oidc.clients);
}
{
# The same hole the controller's case above names, open a second time: the
# PKI script grew a third leaf and no case read it.