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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue