0018. UI backend: the read and approval server implementation
Status: Accepted, amended five times · Date: 2026-10-25 · Design section: CLI and UI, roadmap item 5
Decided: One Server type implements all three handler interfaces over a single uncached client; settled the four approval refusals; fixed loadBundle resolving the TalosCluster's own namespace instead of the backend's, the same bug ADR 0011 had already fixed on the controller side; granted the nodes get/list findKubernetesNode had always read without it; added a chart-vs-config/ RBAC drift test after the chart's own copy of this ClusterRole was found silently narrower than config/rbac/ichor-ui/role.yaml; extended that drift test to the controller's manager-role and manager-secrets-role, which found and fixed missing talosoperations, Argo CD applications and metrics-cert Secret grants
Context
ADR 0016 made ClusterService, MachineService and NodeService exist as
compiling Go interfaces (ichorv1alpha1connect.*Handler) and committed the
generated code under gen/go/. Nothing implemented them. This unit is that
implementation, registered on cmd/ichor-ui: nine RPCs across the three
services, the Talos-reading half of NodeService, secret masking for
GetEffectiveConfig, a resumable log stream, graceful termination, and a
UI-backend-only RBAC ClusterRole that stays out of the controller's
manager-role.
Versions consulted: connectrpc.com/connect v1.21.0 and
github.com/siderolabs/talos/pkg/machinery v1.14.3, both already pinned in
go.mod. Everything below was checked against the vendored module source
in the Go module cache (go.yaml.in/yaml/v4's actual decode behavior,
machinery's config.Provider.RedactSecrets, the COSI resource shapes under
pkg/machinery/resources/{hardware,block,network,perf}) rather than taken
on faith from documentation; no WebFetch against talos.dev was needed since
the module source answered every question this unit ran into. Context7 and
Serena were unavailable for this session, as noted by the task; rg and
direct reads of the module cache stood in for both.
Decision
One Server type, three handler interfaces
internal/uiapi.Server (server.go) embeds client.Client and implements
ClusterServiceHandler, MachineServiceHandler and NodeServiceHandler on
the same type. All three read the same two CRDs (TalosCluster,
TalosMachine) through the same client; splitting them into three Go types
would only have produced three copies of the same Get/List plumbing.
cmd/ichor-ui/main.go constructs one Server and registers all three
generated handlers on one http.ServeMux.
The Kubernetes client is a plain, uncached client.New(cfg, ...), not a
ctrl.Manager's cached/informer-backed one: this backend is a stateless,
request-driven reader, not a reconciler, so there is no watch loop to
justify an informer cache, and avoiding one keeps the RBAC ClusterRole
honest about what it actually needs (get/list, never watch).
Approval's four refusals (machine_service.go)
ApproveMachine refuses, in order, before it writes anything:
- Already approved --
CodeFailedPrecondition, no mutation at all. Re-approval does not try to detect "did anything actually change"; it refuses outright, so "approving an already-approved machine causes no reconfiguration" holds by construction rather than by a diff that could itself be wrong. - Weak identity -- re-applies ADR 0011's rule (UUID, serial and at
least one permanent MAC all present) to the
TalosMachine's already-recordedspec.identity, viaichorv1alpha1.TalosMachineIdentity.IsStrongIdentity(identity.goitself was later deleted once the rule moved there; see ADR 0033). This is a deliberate second check, not a second rule:internal/controller's own call site converts its ownHardwareIdentityto the CRD type and calls the same method, and discovery refusing to auto-register a weak identity does not guarantee everyTalosMachinethis server ever sees went through discovery (a future registration path, or a hand-written fixture, might not have). MACHINE_ROLE_UNSPECIFIED--CodeInvalidArgument. The wire enum's zero value is refused rather than stored as an emptyTalosMachineSpec.spec.role:pkg/render.BuildConfig's call to machinery'smachine.ParseTypedoes not itself reject an empty role (an empty role resolves to a worker silently, machinery's own choice, not this package's to rely on changing), so the check is explicit here and again inGetEffectiveConfigrather than assumed downstream.
RejectMachine refuses to touch an already-approved machine
(CodeFailedPrecondition -- "reject" has no meaning post-approval; removal
is a different, later operation) and, for a pending machine, deliberately
mutates nothing and deletes nothing. Deleting it was the real risk design
wanted guarded against: ADR 0011's "same machine returning" branch only
short-circuits for an object that still exists under its own name, so
deleting a rejected machine would let the next discovery pass recreate it
as fresh and pending, making the rejection meaningless the moment discovery
runs again. Leaving the object alone, with its Approved: false untouched,
is the whole fix.
Gap to report: the proto has no "rejected" state distinct from
"pending." RejectMachineResponse carries only an AuditRecord; the
Machine message's approved bool cannot represent "explicitly turned
away" versus "never looked at," so a rejected machine looks identical to a
freshly discovered one in ListMachines/GetMachine. Recording that
durably (an annotation, or a proto field) is a contract decision, left to
the design per the task's instruction not to add fields speculatively.
Audit (AuditRecord)
actor is the fixed string "ichor-ui" (machine_service.go's actor
constant), not a per-caller identity: there is no authentication yet
(OIDC/impersonation is roadmap phase 9), so anything else would be
fabricated. AuditRecord is returned to the caller and nothing more --
not also written as a Kubernetes Event or any other durable log. Adding
an EventRecorder would mean wiring a client-go broadcaster this backend
has no other use for, purely to duplicate what the response already states;
skipped under ponytail's "does this need to exist."
Secrets masking is machinery's job, not a path denylist
effectiveconfig.go does not classify any field path as secret by hand.
pkg/render.BuildConfig (new, exported from the previous Render's body;
see below) returns the merged config.Provider before encoding, and
effectiveConfigValues renders it twice -- once plain, once through
Provider.RedactSecrets(secretMask) -- then flattens both to
path->JSON-value maps (leafPaths/walkLeaves) and calls any leaf where
the two differ secret: true, taking the redacted copy's value for the
wire. A leaf is never read from the plain tree once it is known to differ.
This was the deliberate choice over a pkg/fieldmeta-style prefix table:
machinery's own config/types/v1alpha1/v1alpha1_redact_test.go shows
RedactSecrets already covers tokens, CA keys (never certs -- a cert is
public by definition and stays unredacted), the cluster bootstrap/discovery
secret, aescbc/secretbox encryption secrets, registry auth (username stays,
password/token/client-key don't), wireguard private keys, VIP cloud API
tokens and disk-encryption passphrases. Re-deriving that list by hand would
duplicate machinery's own authority on its config shape and silently fall
behind every time machinery adds a secret-bearing field; diffing against
machinery's own redaction cannot fall behind it.
Two things found empirically while building the leak test, both now load-bearing comments in the code:
Provider.RedactSecretsdoes not always honor the replacement string it is given: the cluster bootstrap token field is hardcoded to"***"regardless of the caller's argument (confirmed by readingv1alpha1_redact_test.go, which asserts"***"for that one field while every other redacted field uses the test's own"**.***").secretMaskis therefore fixed at the literal"***"in this package -- changing it would make that one field's mask silently diverge from every other masked field's.- A
[]byte-typed field (a CA key) decodes back from YAML as a plain Gostring, and the value itself is already base64 text in the YAML machinery wrote, not the raw PEM text -- this is machinery's own rendering choice for[]bytefields, observed rather than documented.GetEffectiveConfig's wire value for such a field is therefore base64, which is still safe (nothing of the real key survives a correct redaction) but not literally the string"***"; the test (effectiveconfig_test.go) checks both the raw and base64-encoded forms of the two CA keys for exactly this reason.
Origin, without a second renderer
A leaf's ConfigOrigin is the last (bottom-up, so highest-priority)
applicable TalosConfigPatch whose own documents -- rendered in
isolation via render.DocumentFromPatch (newly exported for this reason)
plus container.New(doc).EncodeBytes(...), never diffed against the merged
tree -- set that exact leaf path. A leaf no patch's own document sets is
bucketed as {layer: CONFIG_LAYER_SYSTEM, source: "system"}. This needed
no second render pass over the whole config (the earlier draft of this
design called render.Render once per patch-prefix and diffed consecutive
outputs; dropped once DocumentFromPatch made rendering each patch's own,
isolated contribution directly possible) -- patchOrigins runs once over
the already-known list of applicable patches.
Simplification recorded here rather than tested further: everything not
claimed by a patch is bucketed as CONFIG_LAYER_SYSTEM/"system", with no
split between "Ichor's own system-layer generation" (the control-plane
taint) and "whatever machinery's generate.NewInput().Config() fills by
default" (cluster name, default install image, the discovery identity
document). The design's own ConfigOrigin.source comment allows both
"system" and "cluster" as non-patch sources without defining the line
between them; splitting it further wasn't something this unit had a
concrete screen requirement to resolve against.
pkg/render changes: BuildConfig and DocumentFromPatch exported
pkg/render/render.go gained two exports, both pure refactors of code that
already existed, not new rendering logic:
BuildConfigisRender's existing body, stopping beforeEncodeBytes/hashing and returning thecoreconfig.Provideritself.Rendernow callsBuildConfigthen encodes -- identical behavior, confirmed bypkg/render's existing test suite passing unchanged.DocumentFromPatch(wasdocumentFromPatch) is the same per-document kind switch, now exported sopatchOriginscan convert one patch's documents without reimplementing the kind dispatch.
This package is one pkg/render other agents are also working in: the
task flagged that a fix rejecting an empty TalosMachineSpec.spec.role
(machinery's machine.ParseType("") otherwise resolves to a worker
silently) might already be landing elsewhere. It is not present in this
worktree's pkg/render/render.go as of this change, and this unit's own
refactor does not touch machine.ParseType or add any such check inside
pkg/render itself -- ApproveMachine and GetEffectiveConfig each guard
against an empty/unspecified role on their own side instead (see above and
node_service.go), independent of whatever lands in pkg/render later, so
neither relies on a check that might not be there yet.
Resume tokens (resumetoken.go)
A per-machine {time, ordinal} checkpoint, JSON-then-base64-encoded,
exactly matching ADR 0016's "last-seen log timestamp plus a monotonic
ordinal to break ties within the same timestamp, joined and base64-encoded
as one token covering every machine in the request." Opaque to the client,
as specified -- nothing but this package ever decodes it.
StreamNodeLogs streams Talos's Dmesg API (talosclient_machinery.go):
the request carries repeated ObjectRef machines and a resume token, no
service/container selector, so a whole-machine kernel log is the only log
source the proto's shape can mean (a per-container log, which would need an
id, is a gap to report if the UI ever needs it). LogLine.Time is when
this backend observed the line, not a timestamp parsed out of it: Dmesg's
lines carry a boot-relative monotonic offset, not wall-clock time, in the
vendored module's resource shape (no controller source ships with
pkg/machinery to check the exact format against), so the backend's own
clock is the only stable ordering key available without guessing at an
unverified text format.
Known limit, recorded rather than hidden: resuming after a backend
drain re-opens a fresh Dmesg stream on the new pod; Talos's own tail
flag replays some recent backlog, but each replayed line gets a new
LogLine.Time from the new pod's clock, not the original observation time.
A resumed stream therefore dedupes on "have I sent a line with this
observed time or later" within one backend process, not truly exactly
across a drain -- a small window of possible gap or re-delivery right at
the reconnect point is accepted rather than solved, since Dmesg offers
nothing stable to key an exact resume on. This is explicitly why logs don't
get the same "exact resumable" treatment architecture.md's "streams that
resume from a token" reserves for a resourceVersion-keyed watch.
NodeTalosClient: the read-only Talos surface (talosclient.go, talosclient_machinery.go)
A four-method interface (Hardware, Disks, NetworkInterfaces,
ResourceUsage, plus StreamLogs) returning the wire types
(gen/go/ichor/v1alpha1.Hardware etc.) directly -- no parallel domain
struct, since nothing else in this package needs a Talos-shaped value that
isn't about to be put straight on a response. NewMachineryNodeTalosClient
dials maintenance mode (insecure, unauthenticated) when the machine isn't
approved yet -- no CA trust exists for it -- or mints a role.Reader
client certificate from the bundle otherwise ("readers who can access
read-only APIs that do not expose secrets," pkg/machinery/role's own doc
comment), matching "Trust": the UI backend never holds an admin identity.
Per field, mapped from COSI resources confirmed in the vendored module
(hardware.SystemInformation/Processor/MemoryModule, block.Disk,
network.LinkStatus/AddressStatus, perf.CPU/Memory):
Disk.by_id_pathcomes from scanningblock.Disk.Symlinksfor a/by-id/path, since the resource carries no dedicated field for it.Disk.roleandNetworkInterface.aliasare alwaysDISK_ROLE_UNSPECIFIED/empty: disk-role assignment andLinkAliasConfigdon't exist on the CRD yet (the same gap ADR 0016 already noted forApproveMachineRequest), so there is no source of truth to fill them from. Both will populate automatically once that CRD work lands, with no change needed here.- Unverified shortcut:
ResourceUsage.cpu_cores_usedis derived fromperf.CPU.CPUTotal.Idleas(100-idle)/100 * cores, assumingCPUTotal's fields are already a percentage for the latest sample window. The vendored module ships only the resource's shape, not the controller that populates it, so this was never checked against a real Talos node's live values. Upgrade by samplingperf.CPUtwice and computing a rate once this is checked against a live cluster.
This file (like internal/controller's own *_machinery.go files) has no
direct unit test: it is a thin adapter over a real gRPC client, and no
Talos call may reach a real node from a test. NodeTalosClient's consuming
logic in node_service.go is tested through a hand-written fake instead.
RBAC: kept out of manager-role with a build tag, not a path change
internal/uiapi/rbac_markers.go carries every +kubebuilder:rbac marker
for this package's own ClusterRole, behind //go:build ichorui_rbac --
a tag go build/go test/go vet never set, and the file has nothing but
a package clause and comments, so excluding it changes nothing about what
the package does or how it's tested. make manifests (paths="./...", no
special tags) therefore never discovers these markers and never merges them
into the controller's manager-role. A new, purely additive Makefile
target, manifests-ichor-ui, passes --load-build-tags=ichorui_rbac and
scopes paths="./internal/uiapi/...", writing config/rbac/ichor-ui/role.yaml
under its own roleName=ichor-ui-role.
This was chosen over narrowing manifests's own paths="./..." argument
(which would also have worked, since ./cmd/ichor-ui and ./cmd can be
told apart by whether the glob is recursive) because other agents are
concurrently editing this Makefile and were told not to expect its existing
targets rewritten; the build-tag approach needed zero changes to any
existing line, confirmed by running make manifests before and after and
diffing config/: empty.
The resulting ClusterRole (get/list on talosclusters and
talosconfigpatches, get/list/update -- never create, patch or
delete -- on talosmachines, get on secrets) is deliberately narrower
than the controller's: no watch (no cache/informer, see above), no
events, no finalizers, no status subresource (approval writes
spec through the main resource endpoint via client.Update, not
Patch, so update is the only verb ever exercised). secrets: get
mirrors a tradeoff ADR 0011 already accepted for the controller for the
same structural reason: TalosCluster.spec.secretsRef can name a Secret in
any namespace, so nothing narrower than cluster scope can express "the
Secret this particular TalosCluster names," short of restricting
TalosCluster to one namespace -- a design decision neither ADR made
unilaterally.
config/rbac/ichor-ui/ (service_account.yaml, role.yaml (generated),
role_binding.yaml, kustomization.yaml) is a standalone kustomize base,
deliberately not added as a resource of config/rbac/kustomization.yaml
or config/default/kustomization.yaml: ichor-ui has no Deployment or
Service manifest yet anywhere in config/ (that is a later work unit, per
ADR 0017's own scope boundary), so wiring RBAC into the controller's install
path would deploy permissions for a workload that doesn't exist there yet.
cmd/ichor-ui/main.go: wiring and graceful termination
main.go gained a Kubernetes client (ctrl.GetConfigOrDie() +
client.New, the scheme built the same way cmd/main.go builds its own:
clientgoscheme.AddToScheme + ichorv1alpha1.AddToScheme), a draining
channel, a uiapi.Server, and the three generated handlers registered on
the existing mux alongside spaHandler. Nothing in webfs_dev.go,
webfs_embed.go or the SPA-fallback logic changed.
Graceful termination: draining is closed exactly once, at the same point
shutdown already began (<-ctx.Done()), before srv.Shutdown is
called. StreamNodeLogs's own select (done vs. s.Draining) notices on
its next scheduling and returns CodeUnavailable with a message asking the
client to reconnect with its latest resume token -- "graceful termination
that asks clients to reconnect" (architecture.md). Any already-finished
unary call is unaffected; srv.Shutdown's own timeout is what the draining
signal exists to make unnecessary for an in-flight stream specifically,
rather than making that stream wait out the whole shutdown window.
Consequences
- Nine RPCs across three services are implemented and tested against
envtest (
internal/uiapi/*_test.go, 20 Ginkgo specs plus plain-Go tests forstrongIdentityand the resume token).make build test lintpasses unchanged for the rest of the repository. - The masking guarantee rests on
config.Provider.RedactSecrets, not on this package's own judgment of which fields are secret. If machinery ever stops redacting a field it used to, this package's output changes with it automatically (for better, if machinery widens its own redaction, or worse, if it narrows it) -- a dependency worth re-confirming on anypkg/machineryversion bump, the same way a version-contract bump already gets scrutiny per principle 4. pkg/render/render.gonow exportsBuildConfigandDocumentFromPatch; both are refactors of existing code, confirmed behavior-identical by the package's own pre-existing test suite passing unchanged.config/rbac/ichor-ui/exists but is not deployed by any kustomization yet; wiring it in is the job of whichever work unit addsichor-ui's Deployment/Service manifests.- Gaps reported rather than silently worked around, two of which the
amendment below closes: no proto-level "rejected" state for a
TalosMachine(closed), andStreamNodeLogshaving no way to select a specific service/container log (closed). Still open:AuditRecord.actoris a fixed placeholder pending OIDC (roadmap phase 9);ResourceUsage.cpu_cores_used's formula is unverified against a live node.
A build-tag file is not type-checked by default, and two gaps that let it drift silently
Found auditing this ADR's own mechanism, not while building it: putting
rbac_markers.go's content behind //go:build ichorui_rbac correctly keeps
its markers out of manager-role (the stated goal), but it has a side
effect nobody had named yet -- go build ./..., go test ./..., go vet ./... and golangci-lint's default run never compile that file at all,
since none of them ever sets that tag:
$ go list -f '{{.GoFiles}}' ./internal/uiapi/
[bundle.go cluster_service.go ... ] # rbac_markers.go absent
$ go list -tags ichorui_rbac -f '{{.GoFiles}}' ./internal/uiapi/
[... rbac_markers.go] # present only with the tag
A syntax or type error in that file would pass every check in the repo
silently. rbac_markers.go is comment-only today so this never bit, but the
next file anyone puts behind a build tag for the same "keep it out of a
default scan" reason will hit this the first time it has an actual mistake
in it. Fixed in make vet, the one place now: a second go vet -tags ichorui_rbac ./internal/uiapi/... pass after the untagged one, so the file
is compiled on every make test/CI run without the tag ever reaching
manifests, build, or anything that produces a binary. One place, not
two: golangci-lint's own build-tag config was considered and rejected so
that only make vet's recipe needs to know this tag exists.
Two related gaps closed in the same pass, both CI-level rather than
internal/uiapi-level:
manifests-ichor-uiwas defined but never called from anywhere -- notmanifests, not CI.config/rbac/ichor-ui/role.yamlcould silently fall behindrbac_markers.go's actual markers indefinitely; nothing would ever regenerate it except a developer remembering the target's name.manifestsnow depends onmanifests-ichor-ui, so onemake manifestsrun (or the CI job below) regenerates bothClusterRoles together.- No CI step compared generated output against what's committed, for
anything --
config/crd/bases,config/rbac/*(includingichor-ui/role.yamlonce wired in above), andpkg/typedconfig'szz_generated.*.gocould all drift from their source silently; onlydist/chart/chart_test.go's chart-vs-config/check existed, one layer short of source-vs-config/. Added asmanifests-drift, a job in.github/workflows/test.ymlrun in parallel withtest:make manifests generate fmt(fmt, not justgenerate-- see its own comment: rawcontroller-gen/mirrorgen output isn't always import-sorted the waygo fmtleaves it, and skipping it produced a false positive against already-correct, already-committed files during this change), thengit diff --exit-code, failing with a message naming the fix (make manifests generate fmt, then commit). Verified by hand: adding an unregenerated field toTalosClusterSpecand running the same two commands fails with exactly that diff and message; reverting the field makes it pass again.
Amendment (same date): approval tri-state and service-selectable logs
Two gaps this ADR originally reported back were handed back as real work:
RejectMachine's own design (never deleting the machine) left no way to
record that it had been rejected, and StreamNodeLogs could only ever
mean the kernel log. Both proto messages change; both changes are additive
or a pre-release breaking change the design explicitly authorized (v1alpha1,
not yet shipped to any real client).
TalosMachineSpec.Approved (bool) becomes ApprovalState (tri-state)
A bool cannot represent pending/approved/rejected without a second field
claiming the same truth, which is exactly the anti-pattern to avoid: two
fields that can both answer "is this approved," with nothing stopping them
from disagreeing. api/v1alpha1/talosmachine_types.go removes Approved bool and adds ApprovalState string (pending/approved/rejected,
enum-validated, defaulting to pending), plus an IsApproved() method so
every caller asks the same one question the same one way rather than
comparing the string literal directly. machine.proto's Machine.approved
(field 8) is replaced the same way: reserved 8; reserved "approved"; plus
a new MachineApprovalState approval_state = 11 enum field, mirroring the
CRD's three states exactly.
This is a real breaking change to both the CRD and the wire type, pre-
authorized for exactly this reason (pre-release, nothing depends on the old
shape yet). make proto-breaking confirms it as the one and only breaking
finding (Previously present field "8" with name "approved" on message "Machine" was deleted) -- the new approval_state field and
StreamNodeLogsRequest.service (below) are both pure additions and buf breaking reports nothing for either, exactly as expected.
Every caller of the old Spec.Approved/Machine.approved had to move to
the new shape: internal/controller (talosmachine_controller.go's
approval gate, discovery_test.go's assertion), internal/webhook/v1alpha1
(the quorum-counting delete guard and its fixtures), internal/cli
(init_run.go's first-control-plane approval and its test), and this
package's own convert.go/machine_service.go/node_service.go and their
tests. Every one of these is the same one-line mechanical swap
(m.Spec.Approved -> m.Spec.IsApproved(), or setting ApprovalState
instead of Approved); none changes behavior.
Reversibility: rejecting is not permanent
RejectMachine sets ApprovalState to rejected (never deletes the
object, for the same reason as before -- deleting it would let discovery
recreate it as fresh and pending, making the rejection meaningless the
moment discovery runs again; internal/uiapi/discovery_integration_test.go
drives the real, exported controller.DiscoveryReconciler.Discover against
a rejected fixture to prove this directly rather than trusting the
reasoning). ApproveMachine's only refusal is an already-approved
machine; a rejected one is not approved, so approving it succeeds and
moves it straight to approved -- this is the entire mechanism by which a
rejection is undone, and it needed no new RPC or field.
Decision: reversible. The alternative -- a rejection that can never be
undone -- turns a misclick into a permanent dead end for that piece of
hardware, with no UI gesture this phase defines for "undo." Ichor's own
design leans the other way throughout (drift policy, lock re-acquisition,
forceRemove, reuse after reset): operator mistakes get a way back, not a
one-way door. The cost is that "rejected" means "not currently wanted,"
not "permanently barred" -- acceptable, since nothing in the design asks
for the stronger guarantee, and a stronger one (a reject that really can
never be undone) is easy to add later as an annotation or a second state if
a real incident ever calls for it.
RejectMachine refuses to touch an already-approved machine (removal is
the correct operation there, a different and later one); rejecting an
already-rejected machine is allowed and simply records the newest reason.
StreamNodeLogsRequest.service: resolving architecture.md's open question
Added as an optional string service = 3: empty means Talos's kernel log
(Dmesg, the only source this RPC could reach before), any other value is
a Talos service name (kubelet, etcd, trustd, apid, machined, ...),
read through Talos's Logs API. NodeTalosClient.StreamLogs gained the
same parameter; talosclient_machinery.go branches on it, using
constants.SystemContainerdNamespace with common.ContainerDriver_CONTAINERD
for the non-empty case -- confirmed, not guessed, by reading
cmd/talosctl/cmd/talos/{logs,root,image}.go in the vendored
github.com/siderolabs/talos source (not just pkg/machinery): that is
exactly the namespace/driver talosctl logs <service> resolves to with no
flags given, and the RPC's id parameter is the service name verbatim.
Architecture.md's open question -- "Can os:reader read logs, or does
the UI backend need more?" -- is answered: yes, for both RPCs this unit
uses. internal/app/machined/pkg/system/services/machined.go in the same
vendored source carries the authoritative per-method role table (not
pkg/machinery, which ships no server-side policy at all), and both
entries read:
"/machine.MachineService/Dmesg": role.MakeSet(role.Admin, role.Operator, role.Reader),
"/machine.MachineService/Logs": role.MakeSet(role.Admin, role.Operator, role.Reader),
role.Reader is in both sets. The UI backend's read-only identity
(role.MakeSet(role.Reader), talosclient_machinery.go) needs nothing
more for either RPC. This is recorded here, not left as a runtime surprise:
if a future Talos version narrows either table, StreamLogs's error
wrapping states plainly that it was using "this backend's read-only
(role.Reader) Talos identity" when the call failed, so a PermissionDenied
reads as a Talos policy change, not a mystery.
Amendment (2026-10-26): loadBundle was resolving the wrong namespace
Found live, not in review: internal/uiapi/bundle.go's loadBundle read
cluster.Namespace to find the secrets bundle Secret, instead of this
backend's own namespace. ADR 0011 settled this exact question for
internal/controller's own loadBundle -- a TalosCluster may live in
any namespace the reader watches, but Kubernetes RBAC cannot express "the
Secret this TalosCluster names, wherever it happens to live" at
anything narrower than cluster-wide Secret read, so the Secret is
resolved in the reader's own fixed namespace instead, and
internal/uiapi/rbac_markers.go's own marker already only grants
get/update/patch on ichor-webhook-server-cert/
ichor-metrics-server-cert plus namespace-scoped list/watch/create
in ichor-system -- never a cluster-wide Secret read. internal/ uiapi's copy of loadBundle never got the matching fix when ADR 0011
landed on the controller side: it read cluster.Namespace directly,
which is wrong for the identical reason and was invisible in every
existing test because every fixture happened to put the TalosCluster
and the Secret in the same namespace (testNamespace, "default").
A TalosCluster outside the backend's own namespace would have made
GetEffectiveConfig fail with Forbidden (RBAC permits reads only in
ichor-system); one living inside it only worked by coincidence, not
because the code resolved the right namespace on purpose.
Fixed to match internal/controller's own shape exactly:
Server.ControllerNamespace (set from POD_NAMESPACE in cmd/ichor-ui/ main.go, the same env var cmd/main.go already reads for the
controller) is what loadBundle resolves against now, never cluster. Namespace; an empty ControllerNamespace fails loudly
("controller namespace is empty (is POD_NAMESPACE set?)") rather than
risking a broader read than intended, the same discipline internal/ controller/bundle.go already has. internal/uiapi/bundle_test.go is new
and mirrors internal/controller/bundle_test.go directly: resolves the
Secret in the given namespace even when the TalosCluster's differs,
fails explicitly when the namespace is empty, names the namespace it
looked in when the Secret is missing. effectiveconfig_test.go gained a
fourth test creating the TalosCluster and the Secret in two different,
real namespaces end to end through Server.GetEffectiveConfig itself
(not just the loadBundle unit), to show the whole RPC path, not only
the helper, resolves correctly now.
Amendment (2026-10-26): the ClusterRole had no nodes rule, so every GetNode was Forbidden
Found by a security review, not in use: node_service.go's
findKubernetesNode -- GetNode's lookup of the corev1.Node behind a
TalosMachine, by Get on Spec.Hostname or, failing that, a List over
every Node matched against the Talos endpoint address -- was there from
this ADR's first version, but rbac_markers.go never carried a matching
+kubebuilder:rbac marker for nodes. Nothing caught it: envtest does not
enforce RBAC, so every existing test against a fake client passed
regardless of what the generated ClusterRole actually said; only a real
deployment's Get/List would have returned Forbidden, surfaced to the
UI as CodeInternal (GetNode's own error wrapping). Fixed by adding
// +kubebuilder:rbac:groups="",resources=nodes,verbs=get;list
to rbac_markers.go and regenerating (make manifests, which calls
manifests-ichor-ui): config/rbac/ichor-ui/role.yaml and
dist/chart/templates/rbac/ichor-ui-role.yaml both gained the rule, in the
same ClusterRole as talosclusters/talosconfigpatches/talosmachines
-- never namespace=, since a Node is cluster-scoped in the Kubernetes
API itself and has no namespace a Role could qualify into. This is the
one marker in the file where that is not a choice: every other ClusterRole
rule here could, in principle, have been narrowed to a Role if some other
design decision had fixed its resource to one namespace (secrets's own
namespace=ichor-system is exactly that, chosen once loadBundle stopped
reading TalosCluster.spec.secretsRef's namespace -- see ADR 0011, which
also records this same shape regressing into a cluster-wide grant twice
before). nodes has no such escape; get;list only, matching exactly the
two calls findKubernetesNode makes and nothing wider (no watch, since
this package keeps no informer cache, consistent with the rest of this
ADR's "RBAC: kept out of manager-role" section above).
dist/chart/chart_test.go's checkNoClusterWideSecretsRead guards only
rules naming secrets; it does not, and should not, fire on this nodes
rule, and go test ./dist/chart/... confirms it doesn't.
No new mechanism added to catch a missing marker itself. envtest's own
non-enforcement of RBAC is a real gap -- a marker can be deleted or never
written, and every test in this package still passes -- but closing it
would mean either standing up a real API server with RBAC turned on (a
different, heavier test setup change than this fix, and one that would
affect every other internal/uiapi test, not just this file) or hand-
writing a second list of "every client-go type this package touches" to
diff against rbac_markers.go's markers, which is exactly the kind of
parallel bookkeeping that drifts the same way the markers themselves just
did. Left unfixed and reported here instead.
Amendment (2026-10-26): the chart's ClusterRole had silently fallen behind config/, and nothing would have caught it a third time
The previous amendment's own report named this exact risk: "dist/chart's
templates and config/'s manifests have no automatic drift check tying them
together... a future hand-edit of either alone would go unnoticed by any
test." It was not hypothetical. dist/chart/templates/rbac/ichor-ui-role.yaml
had talosclusters and talosconfigpatches sharing one apiGroups entry
with only get/list, while config/rbac/ichor-ui/role.yaml (regenerated
from rbac_markers.go when PatchService, ADR 0035, added
create/update/delete on talosconfigpatches) had moved on. A Helm
install granted none of the three: every TalosConfigPatch create, update
or delete PatchService makes came back Forbidden, surfaced to the UI as
CodeInternal. The chart's hand-written copy had not tracked the generated
one since PatchService landed.
Fixed in the chart (dist/chart/templates/rbac/ichor-ui-role.yaml): split
talosconfigpatches back into its own apiGroups entry carrying
create/delete/get/list/update, matching config/rbac/ichor-ui/ role.yaml exactly. The comment explaining why the secrets rule lives in
its own file (ichor-ui-secrets-role.yaml) instead was left untouched --
that split is deliberate packaging, not drift, and stays.
The fix is a test, not a sync. config/rbac/ichor-ui/role.yaml is a
generated manifest; dist/chart/templates/rbac/ichor-ui-role.yaml is a Helm
template carrying {{ .Release }} substitutions and its own grouping of
apiGroups entries -- there is no byte-identical form to propagate the way
checkCRDsMatchConfig already propagates config/crd/bases into the
chart's CRD templates. Hand-mirroring was the original design (this ADR's
"RBAC" section above) and it had now drifted twice (the nodes rule in the
previous amendment, talosconfigpatches's verbs here) with no CI signal
either time. The decision is not to give up on keeping the chart in sync by
hand -- a generator can't produce Helm template syntax from a plain
manifest -- but to make a drift between them fail loudly instead of
shipping silently. dist/chart/chart_test.go gained
checkUIBackendRBACMatchesConfig: it loads config/rbac/ichor-ui/role.yaml
from disk, renders the chart the same way every other check in that file
already does, and compares both the ClusterRole's and the namespaced
Role's rules as a set of (apiGroup, resource, verb) triples -- not YAML
text, so grouping resources or verbs into one apiGroups entry versus
several never causes a false failure. The namespaced Role is matched by
rule content, not by name: config/'s copy names it ichor-ui-role (same
name as the ClusterRole, disambiguated only by kind), while the chart
gives it its own fixed name, ichor-ui-secrets-role, for the packaging
reason that file's own comment already explains; nothing about that name
difference is itself drift, so the test does not fail on it.
Proven to actually fail, not just reasoned to: removing update from the
chart's talosconfigpatches verbs and running go test ./dist/chart/... -run TestChartInvariants -count=1 produced
chart_test.go:119: helm template ichor .: ClusterRole ichor-ui-role is
missing ichor.maesh.dev/talosconfigpatches:update (present in
../../config/rbac/ichor-ui/role.yaml); add it to
dist/chart/templates/rbac/ichor-ui-role.yaml
for both the ichor and something-else release-name subtests, naming the
exact missing grant and which file to fix. Restoring the verb made the
suite pass again. checkNoClusterWideSecretsRead and
checkUIBackendRBACIsSeparate both still pass unchanged.
The same check, extended to the controller's manager-role. The drift
check had only looked at ichor-ui-role, so the same drift in the
controller's own RBAC went unseen: config/rbac/role.yaml (controller-gen
output) carried talosoperations, talosoperations/status and the Argo CD
applications rules, and the chart's manager-role.yaml had none of them
(since a328ca6, which added TalosOperation). A Helm install would have
been Forbidden on TalosOperation (no upgrade could run) and unable to read
Application state (the CNI handover could never finish).
checkControllerRBACMatchesConfig reuses checkRBACRulesMatch unchanged and
compares config/rbac/role.yaml's ClusterRole manager-role with the
chart's manager-role, and its namespaced Role manager-role with the
chart's manager-secrets-role (the chart splits secrets out; chart names are
release-templated, so they are matched by suffix). Not compared, on purpose:
leader-election-role, metrics-auth-role and metrics-reader (static
kubebuilder scaffolds, not generated from markers) and the per-CRD
admin/editor/viewer roles (end-user roles, not the controller's identity).
The shared triple now also carries resourceNames; without it a name-scoped
grant for one Secret hid a missing one for another. That immediately found a
fourth gap: manager-secrets-role lacked get/patch/update on
ichor-metrics-server-cert, which cmd/main.go's cert rotator needs. Fixed
in the chart with the other three.
Proven both ways: before the chart fix the check failed naming every missing
talosoperations, talosoperations/status and applications verb (and the
three metrics-cert verbs); after it the suite passes, and removing
applications:watch made it fail with ClusterRole manager-role is missing argoproj.io/applications:watch (present in ../../config/rbac/role.yaml); add it to dist/chart/templates/rbac/manager-role.yaml.
Amendment: RBAC proven by an enforcing test
GetEffectiveConfig failed live with cannot list resource "talosstorages": render.ResolveSystemInputs
lists TalosStorage as the ServiceAccount (ADR 0050) and rbac_markers.go had no rule for it. Added
talosstorages list (no get, no watch: one non-cached List per call). An audit of every Get/List in
internal/uiapi and the render inputs it calls (ResolveSystemInputs, ResolveRegistryAuth, secretsbundle.Load)
found no other gap: Secrets are get in the controller namespace, argocd-cm and Applications are already granted.
internal/uiapi/rbac_enforced_test.go now loads config/rbac/ichor-ui/role.yaml into envtest (whose apiserver
enforces RBAC) and runs GetEffectiveConfig as a client impersonating system:serviceaccount:ichor-system:ichor-ui,
so a read the generated role does not grant fails the suite with Forbidden. A new render input needs a marker, and
a handler added to that spec is covered.
Amendment 2026-11-05 (ADR 0130): no reason is asked for on approve or reject. reason on RejectMachineRequest, ApproveMachineRequest and AuditRecord's typed use is deprecated and never read; a rejection records the actor and the time only, and a rejected machine that is rejected again simply records the newest decision.