Skip to content

Commit 602c8db

Browse files
njhensleymchmarny
andauthored
fix(validator): roll back cluster-admin RBAC on prep failure (#2128)
Signed-off-by: Nathan Hensley <nhensley@nvidia.com> Co-authored-by: Mark Chmarny <mchmarny@users.noreply.github.com>
1 parent b0719a4 commit 602c8db

4 files changed

Lines changed: 394 additions & 28 deletions

File tree

pkg/validator/job/rbac.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,15 @@ func EnsureRBAC(ctx context.Context, clientset kubernetes.Interface, namespace,
110110
return nil
111111
}
112112

113-
// CleanupRBAC removes the per-run ServiceAccount and ClusterRoleBinding.
113+
// CleanupRBAC removes the per-run ClusterRoleBinding and ServiceAccount.
114114
// Ignores NotFound errors (idempotent). Call once at end of validation run.
115115
//
116+
// The ClusterRoleBinding is deleted BEFORE the ServiceAccount: revoking the
117+
// binding immediately closes the cluster-admin escalation window, whereas an
118+
// orphaned ServiceAccount with no binding carries no privileges. If the binding
119+
// delete fails, the ServiceAccount delete still runs so we do not leave both
120+
// behind.
121+
//
116122
// When both deletes fail, the returned StructuredError wraps the joined
117123
// underlying errors via stderrors.Join so callers can inspect individual
118124
// failures with errors.Is / errors.As.
@@ -122,15 +128,15 @@ func CleanupRBAC(ctx context.Context, clientset kubernetes.Interface, namespace,
122128

123129
var errs []error
124130

125-
if err := clientset.CoreV1().ServiceAccounts(namespace).Delete(ctx, saName, metav1.DeleteOptions{}); err != nil {
131+
if err := clientset.RbacV1().ClusterRoleBindings().Delete(ctx, crbName, metav1.DeleteOptions{}); err != nil {
126132
if !apierrors.IsNotFound(err) {
127-
errs = append(errs, errors.Wrap(errors.ErrCodeInternal, "failed to delete ServiceAccount", err))
133+
errs = append(errs, errors.Wrap(errors.ErrCodeInternal, "failed to delete ClusterRoleBinding", err))
128134
}
129135
}
130136

131-
if err := clientset.RbacV1().ClusterRoleBindings().Delete(ctx, crbName, metav1.DeleteOptions{}); err != nil {
137+
if err := clientset.CoreV1().ServiceAccounts(namespace).Delete(ctx, saName, metav1.DeleteOptions{}); err != nil {
132138
if !apierrors.IsNotFound(err) {
133-
errs = append(errs, errors.Wrap(errors.ErrCodeInternal, "failed to delete ClusterRoleBinding", err))
139+
errs = append(errs, errors.Wrap(errors.ErrCodeInternal, "failed to delete ServiceAccount", err))
134140
}
135141
}
136142

pkg/validator/job/rbac_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,16 @@ package job
1616

1717
import (
1818
"context"
19+
stderrors "errors"
1920
"testing"
2021

22+
"github.com/NVIDIA/aicr/pkg/errors"
23+
corev1 "k8s.io/api/core/v1"
24+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2125
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/apimachinery/pkg/runtime"
27+
k8sfake "k8s.io/client-go/kubernetes/fake"
28+
clienttesting "k8s.io/client-go/testing"
2229
)
2330

2431
func TestEnsureRBAC(t *testing.T) {
@@ -168,3 +175,71 @@ func TestCleanupRBACNotFound(t *testing.T) {
168175
t.Fatalf("CleanupRBAC() on nonexistent resources should not error, got: %v", err)
169176
}
170177
}
178+
179+
// TestCleanupRBACDeletesClusterRoleBindingBeforeServiceAccount guards the
180+
// ordering fix: the cluster-admin ClusterRoleBinding must be revoked BEFORE the
181+
// ServiceAccount is deleted, so the privilege-escalation window closes first. A
182+
// reactor records delete order without handling the delete (returns handled =
183+
// false to fall through to the tracker). Reverting the reorder in CleanupRBAC
184+
// flips the recorded order and fails this test.
185+
func TestCleanupRBACDeletesClusterRoleBindingBeforeServiceAccount(t *testing.T) {
186+
cs := k8sfake.NewSimpleClientset()
187+
188+
var order []string
189+
cs.PrependReactor("delete", "*", func(action clienttesting.Action) (bool, runtime.Object, error) {
190+
order = append(order, action.GetResource().Resource)
191+
return false, nil, nil // fall through to the default tracker
192+
})
193+
194+
if err := CleanupRBAC(context.Background(), cs, "ns", "run"); err != nil {
195+
t.Fatalf("CleanupRBAC() error = %v, want nil", err)
196+
}
197+
198+
want := []string{"clusterrolebindings", "serviceaccounts"}
199+
if len(order) != len(want) {
200+
t.Fatalf("delete order = %v, want %v", order, want)
201+
}
202+
for i := range want {
203+
if order[i] != want[i] {
204+
t.Fatalf("delete order = %v, want %v", order, want)
205+
}
206+
}
207+
}
208+
209+
// TestCleanupRBACClusterRoleBindingDeleteFailureSurfaced proves CleanupRBAC
210+
// fails closed AND does not short-circuit: a ClusterRoleBinding delete error
211+
// (anything other than NotFound) is surfaced as an ErrCodeInternal error rather
212+
// than swallowed, so a leaked cluster-admin binding cannot masquerade as a
213+
// clean teardown — and cleanup still proceeds to delete the ServiceAccount so a
214+
// binding failure does not strand the SA behind an early return.
215+
func TestCleanupRBACClusterRoleBindingDeleteFailureSurfaced(t *testing.T) {
216+
const ns, runID = "ns", "run"
217+
saName := ServiceAccountName(runID)
218+
219+
// Seed the ServiceAccount so its post-cleanup absence proves the delete ran.
220+
cs := k8sfake.NewSimpleClientset(&corev1.ServiceAccount{
221+
ObjectMeta: metav1.ObjectMeta{Name: saName, Namespace: ns},
222+
})
223+
224+
wantCause := stderrors.New("apiserver unavailable")
225+
cs.PrependReactor("delete", "clusterrolebindings", func(clienttesting.Action) (bool, runtime.Object, error) {
226+
return true, nil, wantCause
227+
})
228+
229+
err := CleanupRBAC(context.Background(), cs, ns, runID)
230+
if err == nil {
231+
t.Fatal("CleanupRBAC() error = nil, want error when ClusterRoleBinding delete fails")
232+
}
233+
if !stderrors.Is(err, errors.New(errors.ErrCodeInternal, "")) {
234+
t.Errorf("CleanupRBAC() error = %v, want ErrCodeInternal", err)
235+
}
236+
if !stderrors.Is(err, wantCause) {
237+
t.Errorf("CleanupRBAC() error = %v, want wrapped underlying cause", err)
238+
}
239+
240+
// Despite the binding-delete failure, the ServiceAccount delete must still
241+
// have run — proving CleanupRBAC does not return early on the first error.
242+
if _, getErr := cs.CoreV1().ServiceAccounts(ns).Get(context.Background(), saName, metav1.GetOptions{}); !apierrors.IsNotFound(getErr) {
243+
t.Errorf("ServiceAccount %q still present after cleanup (Get err = %v); cleanup returned early on binding failure", saName, getErr)
244+
}
245+
}

pkg/validator/validator.go

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,12 @@ func (v *Validator) prepareCluster(
133133
ctx context.Context,
134134
validationInput *v1.ValidationInput,
135135
snap *snapshotter.Snapshot,
136-
) (*clusterState, error) {
136+
) (cs *clusterState, err error) {
137137

138138
// Use PropagateOrWrap so a coded inner error (e.g. an invalid kubeconfig
139139
// classified as a deterministic config error) survives instead of being
140140
// blanket-relabeled ErrCodeInternal, which would mask it as retryable.
141141
var clientset kubernetes.Interface
142-
var err error
143142
kubeconfig := strings.TrimSpace(v.Kubeconfig)
144143
switch {
145144
case kubeconfig == "":
@@ -166,6 +165,32 @@ func (v *Validator) prepareCluster(
166165
return nil, errors.PropagateOrWrap(rbacErr, errors.ErrCodeInternal, "failed to ensure RBAC")
167166
}
168167

168+
// Privileged RBAC (the per-run cluster-admin ClusterRoleBinding) now exists.
169+
// Register an immediate rollback so any later failure in prepareCluster
170+
// revokes it before returning, instead of leaking a privileged identity
171+
// until manual cleanup. On the success path err is nil and the binding is
172+
// retained — the caller's deferClusterCleanup owns success-path teardown, so
173+
// this defer must not double-clean.
174+
//
175+
//nolint:contextcheck // rollbackRBAC uses a fresh context: parent may be canceled
176+
defer func() {
177+
if err != nil {
178+
if rollbackErr := v.rollbackRBAC(clientset); rollbackErr != nil {
179+
// The privileged binding could not be revoked after a
180+
// preparation failure. Fold the rollback failure into the
181+
// returned error so the operator sees BOTH the original cause
182+
// and the leaked cluster-admin binding — the prep error alone
183+
// would hide that manual cleanup is now required. Keep it a
184+
// coded StructuredError wrapping the joined causes so callers
185+
// can still match ErrCodeInternal and inspect either error.
186+
err = errors.WrapWithContext(errors.ErrCodeInternal,
187+
"preparation failed and RBAC rollback failed; cluster-admin binding may be orphaned",
188+
stderrors.Join(err, rollbackErr),
189+
map[string]any{"runID": v.RunID, "namespace": v.Namespace})
190+
}
191+
}
192+
}()
193+
169194
if cmErr := v.ensureDataConfigMaps(ctx, clientset, snap, validationInput); cmErr != nil {
170195
return nil, errors.PropagateOrWrap(cmErr, errors.ErrCodeInternal, "failed to create data ConfigMaps")
171196
}
@@ -183,27 +208,62 @@ func (v *Validator) prepareCluster(
183208
}, nil
184209
}
185210

186-
// deferClusterCleanup registers deferred cleanup for RBAC and data ConfigMaps.
187-
// Both cleanup steps share a single deadline so a stalled apiserver cannot
188-
// extend total post-run blocking time to 2 * K8sCleanupTimeout. Cleanup
189-
// failures are surfaced at structured-log level so operators see when
190-
// resources may have been orphaned in the validator namespace.
191-
func (v *Validator) deferClusterCleanup(clientset kubernetes.Interface) {
211+
// deferClusterCleanup performs success-path teardown of RBAC and data
212+
// ConfigMaps. Both cleanup steps share a single deadline so a stalled apiserver
213+
// cannot extend total post-run blocking time to 2 * K8sCleanupTimeout.
214+
//
215+
// RBAC is privileged (a per-run cluster-admin ClusterRoleBinding), so a failure
216+
// to revoke it is returned to the caller and promoted into the run's error —
217+
// fail closed, never leak cluster-admin silently. ConfigMap cleanup is not
218+
// privileged, so its failure stays warning-only and does not fail the run.
219+
func (v *Validator) deferClusterCleanup(clientset kubernetes.Interface) error {
192220
if !v.Cleanup {
193-
return
221+
return nil
194222
}
195223
//nolint:contextcheck // Fresh context: parent may be canceled during cleanup
196224
cleanupCtx, cancel := context.WithTimeout(context.Background(), defaults.K8sCleanupTimeout)
197225
defer cancel()
198226

227+
var rbacErr error
199228
if cleanupErr := job.CleanupRBAC(cleanupCtx, clientset, v.Namespace, v.RunID); cleanupErr != nil {
200-
slog.Warn("failed to cleanup RBAC; resources may be orphaned",
229+
slog.Error("failed to cleanup RBAC; cluster-admin binding may be orphaned",
201230
"runID", v.RunID, "namespace", v.Namespace, "error", cleanupErr)
231+
rbacErr = errors.PropagateOrWrap(cleanupErr, errors.ErrCodeInternal, "failed to revoke privileged RBAC")
202232
}
203233
if cmErr := v.cleanupDataConfigMaps(cleanupCtx, clientset); cmErr != nil {
204234
slog.Warn("failed to cleanup ConfigMaps; resources may be orphaned",
205235
"runID", v.RunID, "namespace", v.Namespace, "error", cmErr)
206236
}
237+
return rbacErr
238+
}
239+
240+
// rollbackRBAC revokes the per-run RBAC created earlier in prepareCluster when a
241+
// later preparation step fails. It uses a fresh bounded context because the
242+
// caller's ctx may already be canceled — the very condition that can trigger the
243+
// failure. Revoking the cluster-admin ClusterRoleBinding closes the privilege
244+
// escalation window immediately; the surrounding prepareCluster call still
245+
// returns its error, so the run fails closed regardless of this rollback's
246+
// outcome. Respects v.Cleanup for parity with the success-path teardown: a
247+
// caller that disabled cleanup has opted into managing teardown manually, so a
248+
// disabled-cleanup run performs no rollback and returns nil.
249+
//
250+
// Returns the CleanupRBAC error (still logged) so the caller can fold a failed
251+
// revocation into the run's error and surface that cluster-admin may be
252+
// orphaned; returns nil when cleanup is disabled or the revocation succeeds.
253+
func (v *Validator) rollbackRBAC(clientset kubernetes.Interface) error {
254+
if !v.Cleanup {
255+
return nil
256+
}
257+
//nolint:contextcheck // Fresh context: parent may be canceled during rollback
258+
cleanupCtx, cancel := context.WithTimeout(context.Background(), defaults.K8sCleanupTimeout)
259+
defer cancel()
260+
261+
if cleanupErr := job.CleanupRBAC(cleanupCtx, clientset, v.Namespace, v.RunID); cleanupErr != nil {
262+
slog.Error("failed to roll back RBAC after preparation failure; cluster-admin binding may be orphaned",
263+
"runID", v.RunID, "namespace", v.Namespace, "error", cleanupErr)
264+
return cleanupErr
265+
}
266+
return nil
207267
}
208268

209269
// ValidatePhases runs the specified phases sequentially and returns one
@@ -215,7 +275,7 @@ func (v *Validator) ValidatePhases(
215275
phases []Phase,
216276
validationInput *v1.ValidationInput,
217277
snap *snapshotter.Snapshot,
218-
) ([]*PhaseResult, error) {
278+
) (results []*PhaseResult, err error) {
219279

220280
if len(phases) == 0 {
221281
phases = PhaseOrder
@@ -226,15 +286,15 @@ func (v *Validator) ValidatePhases(
226286
// Lower any nccl-benchmark-runtime-ref into its inline carrier by reading the
227287
// referenced template from the --data tree. Fails fast on a bad ref before
228288
// deploying any Jobs.
229-
if err := v.resolveBenchmarkRuntimeRef(ctx, validationInput); err != nil {
230-
return nil, err
289+
if refErr := v.resolveBenchmarkRuntimeRef(ctx, validationInput); refErr != nil {
290+
return nil, refErr
231291
}
232292

233293
// Pre-flight: evaluate the top-level and readiness-phase constraints
234294
// against the snapshot. Fails fast before deploying any Jobs if
235295
// prerequisites aren't met.
236-
if err := checkReadiness(validationInput, snap); err != nil {
237-
return nil, err
296+
if readyErr := checkReadiness(validationInput, snap); readyErr != nil {
297+
return nil, readyErr
238298
}
239299

240300
cat, err := catalog.LoadWithDataProvider(ctx, v.dataProvider, v.Version, v.Commit)
@@ -260,9 +320,18 @@ func (v *Validator) ValidatePhases(
260320
return nil, err
261321
}
262322
defer close(cs.stopCh)
263-
defer v.deferClusterCleanup(cs.clientset) //nolint:contextcheck // cleanup uses fresh context
323+
// Promote a privileged (RBAC) cleanup failure into the run's error, but only
324+
// when there is no prior real error — a genuine phase failure takes
325+
// precedence over a cleanup problem.
326+
//
327+
//nolint:contextcheck // deferClusterCleanup uses a fresh context: parent may be canceled
328+
defer func() {
329+
if cleanupErr := v.deferClusterCleanup(cs.clientset); cleanupErr != nil && err == nil {
330+
err = cleanupErr
331+
}
332+
}()
264333

265-
results, err := v.runPhases(ctx, func(phase Phase) (*PhaseResult, error) {
334+
results, err = v.runPhases(ctx, func(phase Phase) (*PhaseResult, error) {
266335
return v.runPhase(ctx, cs.clientset, cs.factory, cat, phase, validationInput)
267336
}, cat, phases)
268337
if err != nil {
@@ -324,19 +393,19 @@ func (v *Validator) ValidatePhase(
324393
phase Phase,
325394
validationInput *v1.ValidationInput,
326395
snap *snapshotter.Snapshot,
327-
) (*PhaseResult, error) {
396+
) (result *PhaseResult, err error) {
328397

329398
// Lower any nccl-benchmark-runtime-ref into its inline carrier before the
330399
// phase runs (or is skipped), so a bad ref fails fast even offline.
331-
if err := v.resolveBenchmarkRuntimeRef(ctx, validationInput); err != nil {
332-
return nil, err
400+
if refErr := v.resolveBenchmarkRuntimeRef(ctx, validationInput); refErr != nil {
401+
return nil, refErr
333402
}
334403

335404
// Readiness pre-flight — before the no-cluster short-circuit, matching
336405
// ValidatePhases: constraints are evaluated inline against the snapshot
337406
// even in test mode.
338-
if err := checkReadiness(validationInput, snap); err != nil {
339-
return nil, err
407+
if readyErr := checkReadiness(validationInput, snap); readyErr != nil {
408+
return nil, readyErr
340409
}
341410

342411
cat, err := catalog.LoadWithDataProvider(ctx, v.dataProvider, v.Version, v.Commit)
@@ -361,7 +430,16 @@ func (v *Validator) ValidatePhase(
361430
return nil, err
362431
}
363432
defer close(cs.stopCh)
364-
defer v.deferClusterCleanup(cs.clientset) //nolint:contextcheck // cleanup uses fresh context
433+
// Promote a privileged (RBAC) cleanup failure into the run's error, but only
434+
// when there is no prior real error — a genuine phase failure takes
435+
// precedence over a cleanup problem.
436+
//
437+
//nolint:contextcheck // deferClusterCleanup uses a fresh context: parent may be canceled
438+
defer func() {
439+
if cleanupErr := v.deferClusterCleanup(cs.clientset); cleanupErr != nil && err == nil {
440+
err = cleanupErr
441+
}
442+
}()
365443

366444
return v.runPhase(ctx, cs.clientset, cs.factory, cat, phase, validationInput)
367445
}

0 commit comments

Comments
 (0)