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