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
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:
parent
3b27a2e5a1
commit
b14ff2796c
9 changed files with 413 additions and 18 deletions
|
|
@ -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
|
all-local names for you. On a store host that isn't all-local, set it and
|
||||||
rebuild first.
|
rebuild first.
|
||||||
|
|
||||||
`swarm-bao-granter-role` runs **on the host**. It enables the cert auth
|
`swarm-bao-granter-role` runs **on the host**. It enables the cert, approle
|
||||||
method, writes the `bao-granter` policy, and creates the `bao-granter` role,
|
and oidc auth methods, writes the `bao-granter` policy, and creates the
|
||||||
which accepts the leaf `/var/lib/swarm-bao-pki/granter.pem`. Every
|
`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
|
`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
|
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
|
sibling unit writes its own principal's policy and role. Every one runs on the
|
||||||
|
|
|
||||||
|
|
@ -432,8 +432,9 @@ whichever certificate the reader presents, unchanged.
|
||||||
|
|
||||||
OpenBao's built-in web UI is at `https://bao-ui.<swarm domain>/`
|
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
|
(`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
|
with the **OIDC** tab, or with a bao token under **Other**. The gateway vhost
|
||||||
proxies to an nginx inside the store's container on
|
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
|
`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
|
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
|
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
|
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.
|
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
|
## The constraint that decides where the root lives
|
||||||
|
|
||||||
A hive CA carries `nameConstraints=permitted;DNS:<hive domain>`, and **a swarm
|
A hive CA carries `nameConstraints=permitted;DNS:<hive domain>`, and **a swarm
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ path "sys/auth/approle" {
|
||||||
capabilities = ["create", "update", "sudo"]
|
capabilities = ["create", "update", "sudo"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path "sys/auth/oidc" {
|
||||||
|
capabilities = ["create", "update", "sudo"]
|
||||||
|
}
|
||||||
|
|
||||||
# The granter's own policy and role, and nothing it may write.
|
# The granter's own policy and role, and nothing it may write.
|
||||||
path "sys/policies/acl/bao-granter" {
|
path "sys/policies/acl/bao-granter" {
|
||||||
capabilities = ["create", "update"]
|
capabilities = ["create", "update"]
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
./otel.nix
|
./otel.nix
|
||||||
./glue-bao-readers-policy-order.nix
|
./glue-bao-readers-policy-order.nix
|
||||||
./glue-bao-tls.nix
|
./glue-bao-tls.nix
|
||||||
|
./glue-bao-ui-oidc-client.nix
|
||||||
./glue-controller-bao-identity.nix
|
./glue-controller-bao-identity.nix
|
||||||
./glue-forge-oidc-client.nix
|
./glue-forge-oidc-client.nix
|
||||||
./glue-grafana-oidc-client.nix
|
./glue-grafana-oidc-client.nix
|
||||||
|
|
|
||||||
35
nix/host-modules/glue-bao-ui-oidc-client.nix
Normal file
35
nix/host-modules/glue-bao-ui-oidc-client.nix
Normal 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 ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -188,11 +188,13 @@ let
|
||||||
#
|
#
|
||||||
# The first three are the per-principal grants. The next eight are what
|
# 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
|
# `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 and the `swarm-*` role agent certificates are issued through. No
|
||||||
# `root` delete there: every agent's cert-auth role pins that root by value,
|
# `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
|
# so replacing it would lock every agent out. The last three configure the
|
||||||
# are created with the bootstrap token by `swarm-bao-granter-role`.
|
# `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
|
# Piped as a shell-quoted argument like `controllerPolicyText`, so
|
||||||
# ../module-eval/bao-grants.nix can read it out of the unit script.
|
# ../module-eval/bao-grants.nix can read it out of the unit script.
|
||||||
|
|
@ -264,6 +266,18 @@ let
|
||||||
path "${agentPkiMountPath}/roles/swarm-*" {
|
path "${agentPkiMountPath}/roles/swarm-*" {
|
||||||
capabilities = ["create", "update"]
|
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
|
# 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.
|
# it; the controller reads it and holds no other copy.
|
||||||
controllerClientLeaf = "swarm/controller/swarm-controller/oidc/client";
|
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
|
# The KV v2 engine the controller writes agent credentials through. Named
|
||||||
# once because the grant above and the `secrets enable` in the bootstrap unit
|
# 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
|
# 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 {
|
port = lib.mkOption {
|
||||||
type = lib.types.port;
|
type = lib.types.port;
|
||||||
default = 8200;
|
default = 8200;
|
||||||
|
|
@ -2196,6 +2289,7 @@ in
|
||||||
"swarm-bao-nats-tls-policy"
|
"swarm-bao-nats-tls-policy"
|
||||||
"swarm-bao-agent-pki"
|
"swarm-bao-agent-pki"
|
||||||
"swarm-bao-nats-auth-policy"
|
"swarm-bao-nats-auth-policy"
|
||||||
|
"swarm-bao-operator-viewer-policy"
|
||||||
];
|
];
|
||||||
|
|
||||||
# 🚫 No `swarm.otel.scrapeTargets.bao` entry any more, and its absence is
|
# 🚫 No `swarm.otel.scrapeTargets.bao` entry any more, and its absence is
|
||||||
|
|
@ -2598,6 +2692,14 @@ in
|
||||||
*) bao auth enable approle ;;
|
*) bao auth enable approle ;;
|
||||||
esac
|
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} |
|
printf '%s' ${lib.escapeShellArg granterPolicyText} |
|
||||||
bao policy write ${lib.escapeShellArg granterPolicyName} -
|
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
|
# 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
|
# container has to start after it — otherwise nspawn sets up a mount
|
||||||
# over a file that does not exist yet.
|
# over a file that does not exist yet.
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ let
|
||||||
# Missing from here, authelia mints the value and nothing carries it, so
|
# Missing from here, authelia mints the value and nothing carries it, so
|
||||||
# the reader waits on a path that is never written.
|
# the reader waits on a path that is never written.
|
||||||
hyperhiveCfg.swarm.bao.otel.clientId
|
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
|
# The swarm controller's OIDC client, which it reads back from the store
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ let
|
||||||
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
|
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
|
||||||
) baoGrantWithConsumers.systemd.services;
|
) 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.
|
# control below.
|
||||||
grantingUnitNames = [
|
grantingUnitNames = [
|
||||||
"swarm-bao-controller-policy"
|
"swarm-bao-controller-policy"
|
||||||
|
|
@ -166,6 +166,7 @@ let
|
||||||
"swarm-bao-nats-tls-policy"
|
"swarm-bao-nats-tls-policy"
|
||||||
"swarm-bao-agent-pki"
|
"swarm-bao-agent-pki"
|
||||||
"swarm-bao-nats-auth-policy"
|
"swarm-bao-nats-auth-policy"
|
||||||
|
"swarm-bao-operator-viewer-policy"
|
||||||
];
|
];
|
||||||
|
|
||||||
# Comment lines dropped first: both the HCL and the scripts explain
|
# Comment lines dropped first: both the HCL and the scripts explain
|
||||||
|
|
@ -217,7 +218,9 @@ let
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
# A login and a seal-status check are unauthenticated: no policy grants them.
|
# 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
|
null
|
||||||
else if a 0 == "policy" && a 1 == "write" then
|
else if a 0 == "policy" && a 1 == "write" then
|
||||||
need "sys/policies/acl/${a 2}" cu
|
need "sys/policies/acl/${a 2}" cu
|
||||||
|
|
@ -235,6 +238,18 @@ let
|
||||||
need (a 1) cu
|
need (a 1) cu
|
||||||
else if a 0 == "read" then
|
else if a 0 == "read" then
|
||||||
need (a 1) [ "read" ]
|
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
|
else if a 0 == "list" then
|
||||||
need (a 1) [ "list" ]
|
need (a 1) [ "list" ]
|
||||||
else if a 0 == "delete" then
|
else if a 0 == "delete" then
|
||||||
|
|
@ -780,24 +795,24 @@ let
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# A store host without the granter's pair writes its grants some other
|
# 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
|
# `lib.mkIf haveGranter` could be dropped from any of them and every other
|
||||||
# case here would still pass.
|
# 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 =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGranterOptOut.systemd.services;
|
s = baoGranterOptOut.systemd.services;
|
||||||
in
|
in
|
||||||
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
|
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;
|
&& 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.
|
# 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
|
# 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.
|
# 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 =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGranterNoToken.systemd.services;
|
s = baoGranterNoToken.systemd.services;
|
||||||
|
|
@ -876,7 +891,7 @@ let
|
||||||
{
|
{
|
||||||
# The granter's grants, whole. Pinned as the full list, because an added
|
# The granter's grants, whole. Pinned as the full list, because an added
|
||||||
# path or capability is exactly what a presence check misses.
|
# 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 =
|
ok =
|
||||||
let
|
let
|
||||||
cu = [
|
cu = [
|
||||||
|
|
@ -956,6 +971,18 @@ let
|
||||||
path = "pki-agents/roles/swarm-*";
|
path = "pki-agents/roles/swarm-*";
|
||||||
caps = cu;
|
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"
|
"auth/cert/certs/hive-x"
|
||||||
"sys/auth"
|
"sys/auth"
|
||||||
"sys/auth/cert"
|
"sys/auth/cert"
|
||||||
|
"sys/auth/oidc"
|
||||||
|
"sys/auth/oidc/tune"
|
||||||
"sys/auth/x"
|
"sys/auth/x"
|
||||||
|
"auth/oidc/role/x"
|
||||||
|
"secret/data/swarm/services/x/oidc/client"
|
||||||
"auth/token/create"
|
"auth/token/create"
|
||||||
"auth/token/create-orphan"
|
"auth/token/create-orphan"
|
||||||
"secret/data/x"
|
"secret/data/x"
|
||||||
|
|
@ -1185,8 +1216,8 @@ let
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# What makes the case above mean something: discovery by the granter's
|
# What makes the case above mean something: discovery by the granter's
|
||||||
# certificate reaches all twelve units, and each yields calls.
|
# certificate reaches all thirteen units, and each yields calls.
|
||||||
name = "the granter-policy check sees all twelve granting units, and parses calls from each";
|
name = "the granter-policy check sees all thirteen granting units, and parses calls from each";
|
||||||
ok =
|
ok =
|
||||||
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
|
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
|
||||||
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)
|
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)
|
||||||
|
|
@ -1249,6 +1280,7 @@ let
|
||||||
"sys/auth"
|
"sys/auth"
|
||||||
"sys/auth/cert"
|
"sys/auth/cert"
|
||||||
"sys/auth/approle"
|
"sys/auth/approle"
|
||||||
|
"sys/auth/oidc"
|
||||||
"sys/policies/acl/bao-granter"
|
"sys/policies/acl/bao-granter"
|
||||||
"auth/cert/certs/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) baoGranterOddAgentRole.assertions
|
||||||
&& !(lib.any (a: !a.assertion && names a) baoGrantHere.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
|
in
|
||||||
runGroup "bao-grants" cases
|
runGroup "bao-grants" cases
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,28 @@ let
|
||||||
secretPublisherHere.systemd.services.swarm-secret-publish.script
|
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
|
# 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.
|
# PKI script grew a third leaf and no case read it.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue