Skip to content

feat: auth & access - SSO support, TLS, and signing-key rotation (FB-896)#39

Open
kop wants to merge 24 commits into
mainfrom
kop/fb-896-auth-and-access-sso-support
Open

feat: auth & access - SSO support, TLS, and signing-key rotation (FB-896)#39
kop wants to merge 24 commits into
mainfrom
kop/fb-896-auth-and-access-sso-support

Conversation

@kop

@kop kop commented Jul 4, 2026

Copy link
Copy Markdown

Summary

Implements FB-896: engine-side authentication/SSO, full TLS (engine-listener + gateway upstream/downstream), and operator-owned JWT signing-key rotation.

  • Auth core (8daa31c): spec.auth on FireboltInstance — native/OIDC authorization servers, operator-provisioned admin credentials wiring and JWT signing key via cert-manager, rendered into every engine's instance.auth.* per packdb's closed config schema.
  • Engine-listener TLS (12d6e17): spec.tls.engine terminates TLS on each engine's query listener; the gateway re-encrypts its upstream connection to engines using the same CA.
  • Gateway downstream TLS (4292941): spec.tls.gateway terminates TLS on the gateway's client-facing Envoy listener, with matching probe-scheme and cert provisioning.
  • Signing-key rotation (e8dcaf0): optional signingKeys.rotationInterval/retainDuration drive a two-phase mint → promote → retain → remove state machine, gated at every step on a new FireboltEngineStatus.ObservedAuthHash field so a key is never promoted or removed until every engine has actually converged on the config that makes that step safe. The retain window is anchored at confirmed post-promotion convergence rather than the promotion decision, closing a validation-gap window a naive decision-time anchor would reopen.

All crypto material (signing keys, TLS certs) is provisioned via cert-manager Certificates against a user-supplied Issuer/ClusterIssuer — no bring-your-own-secret option, matching this operator's existing TLS design.

Known limitations (carried forward, not new)

  • No live TLS-handshake or JWT-issuance e2e test was run against a real firebolt-core engine — covered by unit/golden-file tests and cert-manager-backed e2e infra only for the apply/issue path.
  • DefaultEngineWebImage's CA-trust behavior when proxying to https://localhost:3473 with uiSidecar: true + engine TLS enabled is unverified against the real image.

Test plan

  • go build ./..., go vet ./..., go test ./... all clean
  • make lint clean (0 issues)
  • make manifests generate re-run confirmed idempotent
  • New unit tests for the full rotation lifecycle (mint/promote/retain/remove), including a composition test that a lagging engine concretely blocks both the promote and retire-eligible gates
  • Discriminating-test verification on the retain-window anchor fix (confirmed the test fails without the fix, passes with it)
  • e2e TLS handshake / JWT issuance against a real engine (not run — see known limitations)

Note

High Risk
Large CRD and behavioral change across authentication, TLS termination, and coordinated JWT signing-key rotation; misconfiguration or rotation bugs could break logins, cross-engine token validation, or TLS paths before engines start.

Overview
Replaces the placeholder spec.auth (mode + OAuth client-style OIDC) with an instance-wide, packdb-shaped auth model: enabled, embedded local login (admin password via Secret mount, cert-manager JWT signing keys), and multi-provider OIDC bearer validation (discovery URL, username mapping, JIT, JWKS/discovery TTLs). Adds spec.tls for gateway client-facing and engine listeners, with certificates only via cert-manager IssuerRef (no BYO Secret).

Status and rollout: status.auth tracks signing keys and a rotation state machine (Active / ValidationOnly / Removing, RetireEligibleAt); optional rotationInterval / retainDuration are validated at admission. FireboltEngine.status.observedAuthHash exposes the stable auth config hash so the instance controller can gate promote/retire on every engine converging. New AuthReady, EngineTLSReady, and GatewayTLSReady conditions are documented as not folded into top-level Instance Ready.

Operator surface: Reserved engine/gateway volumes for admin creds, per-kid signing secrets (auth-signing-* prefix), and TLS; instance.auth and endpoints join operator-owned engine config paths. Webhook adds ValidateAuth / ValidateTLS (re-runnable at reconcile), packdb duration parsing (1d etc.), and broad unit tests. Runtime wiring: cert-manager API on the scheme, Certificate RBAC, dependency and regenerated CRD/Helm JSON schema updates, and an expanded instance-full example.

Reviewed by Cursor Bugbot for commit e8dcaf0. Configure here.

@kop
kop requested a review from a team as a code owner July 4, 2026 18:14

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Negative JWT durations pass admission
    • FireboltInstance auth validation now rejects negative JWT durations, including maxTokenAge values that could bypass signing-key retain safety checks.

Create PR

Or push these changes by commenting:

@cursor push 7dbede6aa7
Preview (7dbede6aa7)
diff --git a/api/v1alpha1/fireboltinstance_webhook.go b/api/v1alpha1/fireboltinstance_webhook.go
--- a/api/v1alpha1/fireboltinstance_webhook.go
+++ b/api/v1alpha1/fireboltinstance_webhook.go
@@ -245,7 +245,7 @@
 // validateLocalAuth checks the admin-password reference and signing-key
 // policy that Enabled=true requires, the chosen JWT signing algorithm's
 // compatibility with the signing key's cert-manager algorithm, and the
-// format of the embedded server's optional JWT duration fields.
+// validity of the embedded server's optional JWT duration fields.
 func validateLocalAuth(local *LocalAuthSpec, base *field.Path) field.ErrorList {
 	var errs field.ErrorList
 
@@ -269,13 +269,13 @@
 		errs = append(errs, validateSigningKeyRotation(local, base.Child("signingKeys"))...)
 	}
 
-	if err := validateDurationField(base.Child("tokenExpiry"), local.TokenExpiry); err != nil {
+	if err := validateNonNegativeDurationField(base.Child("tokenExpiry"), local.TokenExpiry); err != nil {
 		errs = append(errs, err)
 	}
-	if err := validateDurationField(base.Child("maxTokenAge"), local.MaxTokenAge); err != nil {
+	if err := validateNonNegativeDurationField(base.Child("maxTokenAge"), local.MaxTokenAge); err != nil {
 		errs = append(errs, err)
 	}
-	if err := validateDurationField(base.Child("clockSkewTolerance"), local.ClockSkewTolerance); err != nil {
+	if err := validateNonNegativeDurationField(base.Child("clockSkewTolerance"), local.ClockSkewTolerance); err != nil {
 		errs = append(errs, err)
 	}
 
@@ -293,10 +293,10 @@
 
 	if oidc.JWT != nil {
 		jwtPath := base.Child("jwt")
-		if err := validateDurationField(jwtPath.Child("clockSkewTolerance"), oidc.JWT.ClockSkewTolerance); err != nil {
+		if err := validateNonNegativeDurationField(jwtPath.Child("clockSkewTolerance"), oidc.JWT.ClockSkewTolerance); err != nil {
 			errs = append(errs, err)
 		}
-		if err := validateDurationField(jwtPath.Child("maxTokenAge"), oidc.JWT.MaxTokenAge); err != nil {
+		if err := validateNonNegativeDurationField(jwtPath.Child("maxTokenAge"), oidc.JWT.MaxTokenAge); err != nil {
 			errs = append(errs, err)
 		}
 	}
@@ -365,6 +365,22 @@
 	return nil
 }
 
+// validateNonNegativeDurationField is validateDurationField plus a
+// non-negativity check for JWT durations whose defaults are zero or greater.
+func validateNonNegativeDurationField(path *field.Path, value string) *field.Error {
+	if value == "" {
+		return nil
+	}
+	d, err := parsePackdbDuration(value)
+	if err != nil {
+		return field.Invalid(path, value, `must be a valid duration string (e.g. "30s", "1h", "1d")`)
+	}
+	if d < 0 {
+		return field.Invalid(path, value, "must not be negative")
+	}
+	return nil
+}
+
 // validatePositiveDurationField is validateDurationField plus a positivity
 // check, for the one duration field (oidc.providers[].discovery.refreshInterval)
 // packdb itself rejects when non-positive.

diff --git a/api/v1alpha1/fireboltinstance_webhook_test.go b/api/v1alpha1/fireboltinstance_webhook_test.go
--- a/api/v1alpha1/fireboltinstance_webhook_test.go
+++ b/api/v1alpha1/fireboltinstance_webhook_test.go
@@ -520,6 +520,18 @@
 			wantError: true,
 		},
 		{
+			name: "negative local jwt duration is rejected",
+			auth: &AuthSpec{
+				Enabled: true,
+				Local: &LocalAuthSpec{
+					Admin:       validAdminSpec(),
+					SigningKeys: validSigningKeys(),
+					TokenExpiry: "-1h",
+				},
+			},
+			wantError: true,
+		},
+		{
 			name: "oidc.jwt duration fields validated the same way as local's",
 			auth: &AuthSpec{
 				Enabled: true,
@@ -534,6 +546,20 @@
 			wantError: true,
 		},
 		{
+			name: "negative oidc jwt duration is rejected",
+			auth: &AuthSpec{
+				Enabled: true,
+				Local:   &LocalAuthSpec{Admin: validAdminSpec(), SigningKeys: validSigningKeys()},
+				OIDC: &OIDCAuthSpec{
+					JWT: &OIDCJWTSpec{MaxTokenAge: "-1d"},
+					Providers: []OIDCProviderSpec{
+						{Name: "okta", DiscoveryURL: "https://okta.example.com/.well-known/openid-configuration", UsernameMapping: "{{ email }}"},
+					},
+				},
+			},
+			wantError: true,
+		},
+		{
 			name: "oidc provider jwks.cacheTTL and discovery.refreshInterval accept packdb's days unit",
 			auth: &AuthSpec{
 				Enabled: true,
@@ -637,6 +663,22 @@
 			wantError: true,
 		},
 		{
+			name: "negative maxTokenAge cannot lower signing key retain floor",
+			auth: &AuthSpec{
+				Enabled: true,
+				Local: &LocalAuthSpec{
+					Admin:       validAdminSpec(),
+					MaxTokenAge: "-1d",
+					SigningKeys: &SigningKeyPolicy{
+						CertManager:      validSigningKeys().CertManager,
+						RotationInterval: &metav1.Duration{Duration: 30 * 24 * time.Hour},
+						RetainDuration:   &metav1.Duration{Duration: time.Hour},
+					},
+				},
+			},
+			wantError: true,
+		},
+		{
 			name: "rotationInterval with retainDuration at least maxTokenAge is valid",
 			auth: &AuthSpec{
 				Enabled: true,

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit e8dcaf0. Configure here.

Comment thread api/v1alpha1/fireboltinstance_webhook.go
@kop kop changed the title FB-896: Auth & Access - SSO support, TLS, and signing-key rotation feat: auth & access - SSO support, TLS, and signing-key rotation (FB-896) Jul 7, 2026
@kop
kop force-pushed the kop/fb-896-auth-and-access-sso-support branch 2 times, most recently from 5833411 to 9adee73 Compare July 14, 2026 13:37

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Comment thread internal/controller/instance_gateway.go
@kop
kop force-pushed the kop/fb-896-auth-and-access-sso-support branch from 9adee73 to d3eeb31 Compare July 14, 2026 18:30

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Comment thread internal/controller/engine_reconcile.go
@kop
kop force-pushed the kop/fb-896-auth-and-access-sso-support branch from d3eeb31 to 0e51778 Compare July 14, 2026 19:07

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Comment thread internal/controller/instance_gateway.go Outdated
@cursor
cursor Bot requested review from fstr and gm42 July 14, 2026 19:14

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, and Cursor Security Agent left unresolved medium-severity findings (gateway TLS secret/key exposure and TLS downgrade window). Assigned gm42 and fstr for human review.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, and Cursor Security Agent left unresolved medium-severity findings (gateway TLS secret/key exposure and TLS downgrade window). Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

func validateReservedVolumeMounts(c *corev1.Container, base *field.Path, rules PodTemplateRules) field.ErrorList {
var errs field.ErrorList
for mi := range c.VolumeMounts {
if !isReservedVolumeMountName(c.VolumeMounts[mi].Name, rules.ReservedPrimaryVolumeMountNames) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review
Severity: HIGH

validateReservedVolumeMounts blocks sidecar/init mounts only by volume name, but it does not block user-defined pod volumes that reference operator-managed Secrets by secretName. A template author can define a new volume name that points at auth/TLS Secrets (for example signing-key secrets) and then mount that new volume in a sidecar.

Impact: users with template-write access can exfiltrate JWT signing private keys or other operator-managed secret material and forge credentials beyond intended secret-read boundaries.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit b776dae. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one open — it's the same policy question as the sidecar reserved-volume thread (#discussion_r3586622811, awaiting @fstr's call). Name-based blocking can't fully close it: a template author can reach an operator-owned Secret via a differently-named volume's secretName, or via env.valueFrom.secretKeyRef / envFrom / a projected or CSI volume. If we decide template-write should be lower-privilege than Secret-read, I'll add a check rejecting user volumes whose secretName targets an operator-owned Secret to narrow this; otherwise we accept it as risk. Deferring to that decision.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, and Cursor Security Agent left unresolved findings (TLS downgrade window and secret volume secretName bypass). Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

Comment thread internal/controller/instance_auth.go Outdated
},
wantField: "spec.template.metadata.finalizers",
},
// A user-supplied sidecar or init container must not be able to mount the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why shouldn't a sidecar be able to mount it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block guards a specific privilege-escalation path, not sidecars in general. An engine-class template author needs only template-write access — not Kubernetes Secret-read RBAC — yet the operator renders the auth/TLS Secret volumes (auth-admin, auth-signing-<kid>, tls-engine) at pod level. Without this check, such an author could add a sidecar that mounts one of those volumes and read the JWT signing private keys or the admin password straight off disk — i.e. obtain credentials they can't otherwise kubectl get secret. That's the escalation being blocked.

Two honest caveats:

  1. The block matches by volume name only, so it's structurally incomplete — a template author can declare a new pod-level volume pointing at the same Secret via secretName (and reach it other ways too: env.valueFrom.secretKeyRef, envFrom, projected/CSI volumes). This is exactly Cursor's open HIGH on operatorauthority.go. Name-matching alone can't fully close it.
  2. If engine-class template authors in our model are effectively cluster admins (who could read those Secrets anyway), the restriction is friction for no real gain.

So the real question is: do we treat template-write as a lower privilege than Secret-read? If yes, I'd keep the block and additionally reject user volumes whose secretName targets an operator-owned Secret to narrow (not fully close) the bypass. If no (template authors are trusted), I'll drop the block and mark the Cursor findings accepted-risk. Which matches our threat model?

Comment thread api/v1alpha1/fireboltinstance_types.go Outdated
Comment thread api/v1alpha1/fireboltinstance_types.go
@gm42

gm42 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

(AI-assisted review)

Review notes: PR #39 — auth/SSO, TLS, signing-key rotation (FB-896)

Overall this is a well-designed and carefully documented change. The rotation
state machine is thoughtfully put together: one step per reconcile keeps every
transition idempotent and crash-safe, the retain window is anchored at
confirmed post-promotion convergence rather than at the promotion decision,
generation-1 keeps its pre-rotation Certificate name so upgrades never force a
re-mint, and the packdb-side constraints (startup-only key reads, PKCS8
encoding, RotationPolicy: Never) were verified against engine source rather
than assumed. The doc comments throughout are excellent.

The notes below are mostly about closing the gap between the design and what
is exercised in practice.

1. Engine convergence lookup (worth addressing before merge)

The rotation gates rely on enginesConvergedOn (instance_auth.go:636) to
enumerate the instance's engines, and it currently does so with a label
selector on firebolt.io/instance. As far as I can tell, FireboltEngine CRs
don't carry that label: engines bind to an instance via spec.instanceRef,
there is no mutating webhook or controller write that stamps the label onto
the engine CR, and the examples / e2e helpers don't add it either. The
existing instance→engine watch mapping (engine_controller.go:1301) matches
on spec.instanceRef for the same reason.

With no engines matching the selector, the List comes back empty and the
"no engines is vacuously converged" branch returns true — so in a live
cluster the promote / retain-anchor / remove steps would advance without
actually waiting on engine convergence. That would quietly re-open the
sign-vs-validate window the gating was designed to close, and could allow an
old key's Secret to be removed while a slower engine still wants it for
validation.

The unit tests pass because the fixture builder (engineWithHash,
instance_auth_test.go:539) creates engines with the label already set, so
the assumption isn't exercised against a realistic CR.

Suggested fix is small: enumerate engines the same way the watch mapper does
(match on spec.instanceRef, or add a field index for it) instead of the
label selector, and adjust the fixtures accordingly.

2. TLA+ coverage

Two places where the formal side could catch up with the code:

  • The rotation state machine (mint → promote → retain → remove, gated on
    cross-resource convergence) is the kind of protocol this repo usually
    models under formal/. The doc comments already state the key invariants
    in prose ("only one non-Active key outstanding", "safe to interrupt at any
    point"); capturing them in a spec would let TLC check them, and modeling
    the gate would also have required pinning down "the set of engines" —
    which is where item 1 hides.
  • The Instance Ready roll-up now includes EngineTLSReady /
    GatewayTLSReady, but FireboltInstance.tla wasn't extended; the
    state-cover harness represents a TLS-disabled instance instead
    (instance_tla_state_test.go). Reasonable as a first step, but it leaves
    the TLS-enabled region of the roll-up outside the model. Also worth noting
    the TLC model checker required check is path-scoped and didn't run on
    this PR at all.

Neither has to block the merge, but a follow-up issue for the rotation spec
would keep the "a change in one belongs in both" convention intact.

3. E2E coverage

test/e2e/ is unchanged, so auth, TLS, and rotation currently ship with
unit / golden-file coverage only. The PR is upfront about this in the known
limitations, which is appreciated. Two considerations for a follow-up:

  • The comment in ensureSigningKeys (instance_auth.go:278) says coverage
    for the apply-and-issue path "lives in e2e" — the cert-manager install in
    the e2e helpers does exist, but no e2e spec exercises the path yet, so the
    comment is a bit ahead of reality. Worth rewording or, better, making true.
  • Since envtest deliberately can't cover the Certificate apply path (no
    cert-manager CRDs), e2e is the only harness that can observe it — and the
    convergence gating in particular really needs a real engine rolling to be
    meaningfully tested. A minimal spec (enable auth, trigger a rotation,
    assert promote waits for the engine) would cover both.

4. Smaller items

  • Docs: docs/architecture.mdx isn't updated, while the PR adds new
    conditions, a new state machine, and cert-manager Certificate RBAC —
    the repo convention asks for the architecture doc to move in the same
    change. AGENTS.md could also mention the new spec.auth / spec.tls
    surface and reserved volumes.
  • kubectl-firebolt: internal/infra/ wasn't touched; it compiles, but a
    quick pass to confirm the plugin's view of readiness conditions and the new
    CRD surface still lines up would satisfy the "verify against the plugin"
    rule for large changes.
  • Rotation observability: if any engine stops reconciling, rotation
    waits indefinitely — which is the right fail-safe default, but nothing
    currently surfaces "rotation is waiting on engine X" (AuthReady stays
    True). A condition or event for a stalled rotation would help operators.
  • PR description drift: the embedded summary still says the TLS
    conditions are not folded into top-level Instance Ready, but b776dae
    gates Ready on them — worth refreshing the description so reviewers reason
    from the current behavior.

Comment thread api/v1alpha1/fireboltinstance_types.go Outdated
Comment thread api/v1alpha1/operatorauthority.go
Comment thread api/v1alpha1/fireboltinstance_types.go
Comment thread examples/instance-auth-tls.yaml

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewer gm42 is already assigned; assigning fstr for continued review.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

@cursor
cursor Bot requested a review from fstr July 16, 2026 11:31
kop added 2 commits July 16, 2026 15:19
**Summary**

Phase 1 of FB-896: instance-wide authentication (native username/password
plus OIDC bearer-token validation), rendered identically into every
engine's instance.auth.* config and enforced by a validating webhook that
mirrors packdb's AuthConfig::Validate() rules directly.

- AuthSpec/LocalAuthSpec/OIDCAuthSpec CRD API on FireboltInstance, with
  admin/passwordLogin/signingKeys grouped under LocalAuthSpec and
  oidc/preferredAuthorizationServer at the top level.
- cert-manager-only signing-key provisioning: one non-rotating Certificate
  per instance (PKCS8, auto-renew disabled since packdb reads keys only at
  startup); admin password is always user-supplied via Secret ref.
- Engine propagation: InstanceInfo carries resolved auth + signing-key
  Secret names, buildConfigMap renders instance.auth.* per the closed
  schema, engine pods mount the signing-key and admin-password Secrets,
  and AnnotationAuthHash now hashes the full AuthSpec (not just Secret
  names) so config-only changes correctly trigger a new StatefulSet
  generation.
- Lockdown: instance.auth added to OperatorOwnedEngineConfigPaths and the
  reserved engine volume-name set so customEngineConfig cannot override
  operator-managed auth.
- Webhook validation closes gaps found by reading packdb's C++ source
  directly: provider names may not start with "_", preferredAuthorizationServer
  must name a configured server, and all packdb duration-string fields
  (tokenExpiry, refreshInterval, etc.) are now parsed and validated,
  including the positivity rule on discovery.refreshInterval.

**Test Plan**

- `make test`: all packages pass, internal/controller (envtest) at 76.5%
  coverage.
- `make lint`: 0 issues.
- New internal/controller/schema_conformance_test.go validates rendered
  config.yaml (disabled/native/OIDC) against a vendored copy of packdb's
  application-config.schema.json.
- Cross-checked every semantic auth rule directly against packdb's
  src/PackDB/Auth/AuthConfig.cpp and confirmed cert-manager's PKCS8
  encoding is accepted by SigningKeyManager.cpp.
- Booting a real firebolt-core binary against the rendered config was
  intentionally skipped for this phase in favor of the static
  verification above.
…FB-896)

**Summary**

Phase 2 of FB-896, bundled with the gateway-upstream-reencrypt slice of
Phase 3 (a hard technical requirement, not scope creep — see below):
spec.tls.engine now provisions and enforces TLS on every engine's
http-query listener.

- Engine TLS is a full replace, not additive: packdb's
  EndpointConfig::ApplyToLegacyConfig wipes the plaintext http_port
  default the instant any endpoints.http.listeners entry is rendered, so
  enabling spec.tls.engine turns port 3473 into TLS-only rather than
  opening a second port.
- cert-manager provisions one Certificate per instance with a
  namespace-wide wildcard SAN (*.<namespace>.svc.cluster.local, covering
  every engine's stable routing Service without tracking the live engine
  set) plus "localhost" for the web-UI sidecar's loopback connection.
  Readiness gates on ca.crt presence (not just tls.crt/tls.key): the
  gateway's trusted_ca needs it, so an issuer that never populates it
  (some ACME configs) correctly stays pending forever instead of silently
  breaking every gateway pod's config load.
- Because plaintext disappears from port 3473, the gateway's
  dynamic_forward_proxy cluster gains an upstream transport_socket
  (Envoy UpstreamTlsContext) re-encrypting gateway->engine traffic, and
  the engine web UI sidecar's backend URL switches to https. Both are
  gated on the same Status.EngineTLS readiness as the engine side.
- Envoy upstream TLS uses a static match_typed_subject_alt_names suffix
  matcher against the wildcard SAN, not auto_sni/auto_san_validation:
  the latter silently no-ops once typed_extension_protocol_options is
  configured on the cluster, and skips SAN validation on health-check
  connections entirely (health checks never traverse the HTTP router
  filter that populates it). Verified against Envoy's own source that
  transport_socket set on the parent dynamic_forward_proxy Cluster is
  inherited verbatim into every synthesized per-authority sub-cluster.
- customEngineConfig can no longer override the rendered endpoints
  section (added to OperatorOwnedEngineConfigPaths), mirroring the auth
  lockdown from Phase 1.
- Drift detection: AnnotationEngineTLSHash mirrors AnnotationAuthHash's
  reasoning (a Secret-name change behind an identically-named volume is
  invisible to VolumeMounts equality). The transport_socket's %s
  placeholder renders to nothing (not a blank line) when TLS is
  disabled, so contentHash(envoyYAML) stays identical for every existing
  non-TLS instance — no incidental gateway rollout on upgrade.
- Confirmed directly against packdb source that the kubelet health probe
  port (8122) is bound from an independent health_check_port config key,
  untouched by endpoints.http/https rendering.

**Known gaps, explicit not silent**

- The live TLS handshake (cert-chain trust, SAN matching, health checks
  over TLS) is unverified: no shape/unit test can exercise it, and the
  e2e suite (which already installs cert-manager) was not run for this
  phase — accepted as a gap for now rather than assumed covered.
- uiSidecar: true + engine TLS is a likely breakage: DefaultEngineWebImage
  is a separately-built nginx image this repo doesn't control, and
  whether its embedded config trusts an internal CA when proxying to
  https://localhost:3473 has not been checked against the real image.
- spec.tls.gateway (downstream client->gateway termination) remains
  reserved, unwired CRD scaffolding — deliberately not validated by the
  webhook yet so it doesn't imply a feature that doesn't exist.

**Test Plan**

- `make build`/`make vet`/`make lint`: clean.
- `make test`: all packages pass, internal/controller (envtest) at 76.2%
  coverage.
- Schema-conformance test extended with engine-tls and
  auth-and-engine-tls fixtures against the vendored packdb schema.
- New discriminating tests for: customEngineConfig endpoints lockdown,
  TLS-secret-name drift, ca.crt-required readiness gating, and
  transport_socket byte-identical rendering when TLS is disabled — each
  confirmed to fail without its corresponding fix via temporary revert.
kop added 7 commits July 16, 2026 15:19
…mes (FB-896)

Cursor Security (high): user-supplied sidecars and init containers
passed pod-template validation with their volumeMounts unchecked, while
the operator renders the auth/TLS Secret volumes (auth-admin,
auth-signing-<kid>, tls-engine) at pod level. A template author who
cannot read those Secrets directly could mount one from a sidecar and
read admin credentials or JWT signing keys off disk.

validateContainersAgainstRules (sidecars) and
validateInitContainersAgainstRules now reject any volumeMount naming an
operator-owned volume, via isReservedVolumeMountName (covering the
dynamic auth-signing-<kid> set by prefix) — matching the primary
container's existing check and the model already documented on
operatorOwnedEngineVolumeNames.
…eway (FB-896)

Cursor Security (medium): the gateway mounted the whole engine TLS
Secret to read ca.crt for upstream validation, needlessly exposing the
engine listener's private key (tls.key) to the gateway pod.

The engine-CA volume now projects only ca.crt via Items, so a gateway
compromise cannot exfiltrate the engine's private key. Envoy already
points solely at the ca.crt path, so the change is transparent.
Cursor Security (medium): EngineTLSReady/GatewayTLSReady were
deliberately excluded from the Ready roll-up, so an Instance reported
Ready=True while the gateway served plaintext on its client-facing
listener during cert provisioning — a TLS downgrade window that
advertised a secure posture it had not reached.

setInstanceReadyRollup now includes EngineTLSReady and GatewayTLSReady.
They report True/"Disabled" when their feature is off, so a non-TLS
instance is unaffected; when TLS is requested the Instance holds
Ready=False (reason CertificatePending) until the certificate issues.
No deadlock: cert-manager issues certs independently and engines gate
their own reconcile on Status.EngineTLS, not the top-level Ready. The
TLA+ state/property harness materializes a TLS-disabled instance
(conditions True/"Disabled") since the formal model does not track TLS.
… (FB-896)

enginesConvergedOn listed engines with a MatchingLabels selector on
firebolt.io/instance, but that label is only stamped on the operator's
own child resources, never on the FireboltEngine CR (engines bind to
their instance via spec.instanceRef). In a live cluster the List
returned zero engines, so the promote / retain-anchor / remove rotation
gates were vacuously 'converged' and advanced without waiting for
engines to observe the new signing keys — re-opening the sign-vs-validate
window the gating exists to close. Unit tests only passed because the
fixture stamped a label real engines never carry.

List all engines in the namespace and filter on spec.instanceRef, exactly
as the instance->engine watch mapper (instanceToEngines) does; switch the
engineWithHash fixture to set Spec.InstanceRef.
… (FB-896)

Flip the crypto defaults across the shared CertManagerSpec — so auth
signing keys and both engine/gateway TLS listeners now default to ECDSA
with the P-384 curve, and the embedded auth server's JWT signing
algorithm defaults to ES384. The three defaults are interdependent
(the RS* families require an RSA key, the ES* families an ECDSA key), so
they move together.

Also add a validateCertManagerKey admission check rejecting an
algorithm/size mismatch (RSA outside 2048..8192, ECDSA not in
{256,384,521}). kubebuilder applies the algorithm and size defaults
independently, so without this a partial override (e.g. algorithm=RSA
with the ECDSA-shaped default size) would pass admission and fail later
at cert-manager. The Go compatibility mirror is updated to the new
empty-value defaults in lockstep.
…FB-896)

Add a SecretRef alternative to CertManager on TLSListenerSpec, so a TLS
listener can consume a pre-issued Secret (tls.crt/tls.key, plus ca.crt on
the engine listener) instead of provisioning a cert-manager Certificate —
for certificates from a CA the cluster has no cert-manager integration
with. Exactly one of certManager/secretRef is required when a listener is
enabled (validated in validateTLSListener and re-run by the controller as
defense-in-depth). When secretRef is set the controller provisions no
Certificate and populates Status.*TLS from the referenced Secret once its
required keys are present; it never creates, mutates, or GCs that Secret.

Signing keys are intentionally NOT given a bring-your-own option — they
stay cert-manager-only (SigningKeyPolicy). The now-stale doc comments that
claimed TLS material is always cert-manager-provisioned are corrected.
…ow (FB-896)

mTLS (D): add spec.tls.gateway.clientCASecretRef. When set, the gateway's
client-facing listener requires a client certificate and verifies it
against the referenced Secret's ca.crt (rendered as require_client_certificate
+ a validation_context, with the CA projected read-only into the Envoy pod).
Status.GatewayTLS is gated on that Secret being present, so the listener and
mount only appear once the file exists. Honored only for the gateway;
setting it on spec.tls.engine is rejected at admission (engine-side
verify-client is not wired yet).

Fail closed (F): when gateway TLS is requested but the certificate has not
been issued yet, omit the client-facing listener entirely instead of
serving plaintext (buildFailClosedEnvoyConfigYAML). The client port then
refuses connections rather than exposing cleartext, and Instance readiness
stays gated on the GatewayTLSReady condition. Both probes move to /healthz
on the always-plaintext metrics port: the client listener may be absent
(fail-closed) or require a client cert the kubelet can't present (mTLS), so
probing it would wedge readiness — while preStop's /healthcheck/fail still
flips the process-wide health_check filter, preserving graceful drain.
@kop
kop force-pushed the kop/fb-896-auth-and-access-sso-support branch from 1c4d69f to 492e8ef Compare July 16, 2026 12:21

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent has an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

@kop
kop force-pushed the kop/fb-896-auth-and-access-sso-support branch from 950a659 to 492e8ef Compare July 16, 2026 13:13

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

…ft, and webhook gaps (FB-896)

Addresses the third review round (7×P1, 2×P2). All changes are unit-tested
where the seam allows; cert-issuance and protocol-switch paths are e2e-only
(envtest has no cert-manager CRDs).

Engine TLS provisioning (P1, coupled, e2e-only):
- #1 Per-generation engine serving certificates. The old instance-wide wildcard
  SAN (*.<ns>.svc.cluster.local) can never match packdb's two-label node
  hostnames, so TLS generations never became Ready. Each blue-green generation
  now gets its own cert-manager Certificate whose SANs cover that generation's
  pods (*.<gen-headless>.<ns>.svc.cluster.local — scale-safe), the routing
  Service, and localhost; node hosts render as full .svc.cluster.local FQDNs.
  The engine controller becomes a cert-manager consumer (new
  EnsureEngineTLSCert applied before the StatefulSet). The instance-wide cert
  narrows to a CA anchor: it supplies the issuer ca.crt the gateway trusts and
  the Status.EngineTLS signal; the gateway path is unchanged.
- #2 CA chain to engine TLS. packdb reuses certificate_file as its own curl CA
  bundle, so a CA-signed leaf could not self-validate. The entrypoint now
  assembles a leaf+CA bundle at a writable path and points certificate_file at
  it.
- Consequence: bring-your-own Secret (secretRef) is no longer supported on the
  ENGINE listener — a static cert cannot cover unbounded per-generation
  hostnames under packdb's forced hostname verification. Rejected at admission;
  gateway secretRef is unaffected. (Reverses the engine half of the prior
  round's topic-E.)

Rollout coordination and drift (P1):
- #3 Gate gateway re-encryption on engine-fleet convergence. New
  FireboltEngineStatus.ObservedEngineTLSHash (copied from the STS annotation in
  computeStable) and EngineTLSStatus.Reencrypting let the gateway switch to TLS
  only once every engine has rolled onto it, and retain the trust anchor
  through the drain on disable. Narrows — does not eliminate — the
  mixed-protocol rollout window (packdb is all-or-nothing).
- #4 Roll engines when the admin Secret data changes. Its ResourceVersion is
  folded into authHash at both the engine-resolve and instance-convergence
  sites, so an in-place password rotation triggers a rollout. ResourceVersion,
  never a hash of the password bytes.
- #5 Roll the gateway when its bring-your-own TLS Secret data changes. The
  Secret's ResourceVersion feeds the gateway configHash so an in-place cert
  rotation rolls the Deployment.

Signing keys (P1):
- #7 Support live signing-key algorithm/size migration. SigningKeyStatus now
  records the issued algorithm/size; an alg/size change mints a fresh named key
  via the rotation machinery (rotationPolicy:Never cannot regenerate in place).

Webhook validation gaps:
- #6 (P1) Enforce exact ECDSA curve↔JWT algorithm pairing (ES256↔P-256,
  ES384↔P-384, ES512↔P-521); RSA keeps its range check.
- #8 (P2) Reject duplicate OIDC provider names (packdb's IssuerRegistry throws).
- #9 (P2) Reject non-positive signing-key rotationInterval (perpetual churn).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

kop added 2 commits July 17, 2026 10:33
…teway roll, cert cleanup (FB-896)

Addresses the fourth review round (3xP1, 4xP2). Four were regressions the
round-3 commit introduced and shipped green because envtest has no cert-manager,
so the per-generation-cert and gateway-hash paths went unexercised.

- #1 (P1) signingAlgorithm and the signing key size are immutable after first
  set: CEL transition rules on LocalAuthSpec.SigningAlgorithm and (scoped to the
  signing path) SigningKeyPolicy.CertManager, re-enforced in the validating
  webhook for a clearer message. packdb exposes one global signing_algorithm and
  derives every key's curve from it, so a mixed-curve keyset is unrepresentable
  and in-place migration is unsound. The round-3 mint-on-drift (which produced
  exactly that invalid keyset) is removed. Supersedes round-3's live-migration
  decision.
- #2 (P1) the fail-closed gateway rolls while its bring-your-own client-CA is
  still pending: gatewayTLSSecretVersions gates the client-CA on
  gatewayDownstreamTLSReady (matching the mount) and tolerates NotFound, instead
  of hard-erroring and aborting the whole gateway reconcile (which left the prior
  plaintext/one-way listener up — fail-open).
- #3 (P1) auth disable preserves the monotonic SigningKeyGeneration counter, so
  re-enable mints a fresh generation instead of reusing leftover signing-1 key
  material (cert-manager reuses it behind the existing Secret name, resurrecting
  previously-issued tokens).
- #4 (P2) per-generation engine TLS Certificates and their cert-manager-derived
  Secrets are swept on the same generation lifecycle as STS/Svc/CM
  (gcOrphanedResources) and on engine deletion (reconcileDelete), Certificate
  before Secret, tolerant of a missing cert-manager CRD (envtest). Corrects the
  false GC claim in ensureEngineTLSCert's doc.
- #5 (P2) tlsHash folds the engine serving-cert key algorithm/size so a policy
  edit reissues the per-generation cert; the engine issuerRef is made immutable
  instead of folded, avoiding a cross-CA-anchor reissue mid-roll.
- #6 (P2) the gateway rolls when the engine-CA anchor Secret is reissued in
  place: its ResourceVersion is folded into the gateway config hash while
  re-encryption is active.
- #7 (P2) the client-ca gateway volume name is reserved
  (operatorOwnedGatewayVolumeNames), so a user sidecar cannot collide with it.

No secret bytes are hashed anywhere (ResourceVersion / labels only), keeping the
round-2 CodeQL dismissal valid.

Claude-Session: https://claude.ai/code/session_01QCMwtCk3iXjQyJHGMJEF1q
…coverage (FB-896)

The e2e suite never installed cert-manager and no spec exercised spec.auth /
spec.tls, so the FB-896 auth/TLS feature (rounds 3-4) had zero end-to-end
coverage — the gap that let four regressions ship green. This wires cert-manager
into the suite and adds the first auth/TLS spec.

- Register certmanagerv1 in the four in-process manager/client schemes, so the
  operator (run in-process as cluster-admin) can create Certificates.
- InstallCertManager now waits on all three cert-manager deployments; new
  EnsureCAClusterIssuer / DeleteCAClusterIssuer stand up a CA-backed ClusterIssuer
  (SelfSigned root -> CA Certificate -> CA ClusterIssuer). A CA-backed issuer is
  required because engine TLS readiness gates on ca.crt. Wired into
  SynchronizedBeforeSuite (guarded/idempotent) and AfterSuite; no CI/Makefile
  change (cert-manager images pull from upstream).
- Instance mutate hook (createInstanceWithMutate / SetupTestInstanceWithMutate)
  plus helpers to create the admin Secret, poll instance conditions, and verify
  the engine TLS listener from a client pod.
- New auth_tls_test.go (Core): an auth + engine/gateway-TLS instance reaches
  Ready with AuthReady/EngineTLSReady/GatewayTLSReady True; the engine serves a
  per-generation certificate that curl verifies against the CA (proving the SAN
  and CA chain); an in-place signingAlgorithm change is rejected by the API
  server; and per-generation engine certs/secrets are reclaimed on engine
  deletion.

Extended/Comprehensive coverage tiers are gated on this landing green in CI.

Claude-Session: https://claude.ai/code/session_01QCMwtCk3iXjQyJHGMJEF1q

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

…leanup (FB-896)

reconcileDelete and gcOrphanedResources list cert-manager Certificates to
reclaim per-generation engine TLS certs/secrets (round-4 #4). The list must
tolerate not only a missing cert-manager CRD (IsNoMatchError, e.g. envtest) but
also the Certificate type being absent from the client's scheme
(IsNotRegisteredError) — as in fake-client unit tests that build a minimal
scheme. The outerharness property test TestEngineOuterStateMachine failed
intermittently (rapid only reaches the DeleteEngine -> Reconcile path under some
seeds; the dev CI leg hit it, local/latest did not) with "no kind is registered
for the type v1.CertificateList in scheme". Both errors mean no Certificates can
exist for this client, so there is nothing to reclaim; in production the operator
always registers certmanagerv1, so neither fires (and a genuinely missing
registration would already have failed Certificate creation loudly).

Fix: a shared certKindUnavailable(err) helper (IsNoMatchError ||
IsNotRegisteredError) used by both cleanup list guards.

Claude-Session: https://claude.ai/code/session_01QCMwtCk3iXjQyJHGMJEF1q

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

kop added 2 commits July 17, 2026 12:56
…n auth hash, cert-gen readiness, engine issuer CEL (FB-896)

Address 5 real findings from the fifth review round (a sixth was a false
alarm against the shipped CRDs). #1 is deferred as a documented known
limitation per product decision.

- #2 fail-closed gateway mTLS: clear Status.GatewayTLS when the client-CA
  Secret is pending so gatewayDownstreamTLSReady flips false and the
  fail-closed listener-omission path takes over, instead of leaving stale
  one-way/old-CA pods accepting clients the new policy should reject.

- #3 fold each signing key's Secret ResourceVersion (never the key bytes)
  into authHash at both the render (resolveInstanceInfo) and convergence
  (enginesConvergedOn) sites, so a same-kid/same-name reissue rolls the
  fleet rather than leaving engines split across old/new key bytes.

- #4 gate gateway/engine/signing readiness on the cert-manager Certificate
  being Ready for its CURRENT generation (per-condition ObservedGeneration),
  not just on the Secret carrying key material. A failed re-issuance keeps
  serving the still-valid old cert (status retained) but reports degraded —
  the deliberate opposite of the #2 fail-closed authorization control.

- #5 freeze the engine TLS issuerRef via a field-scoped CEL transition rule
  on spec.tls.engine so it holds even when the validating webhook is
  disabled (the shipped Helm default); the webhook re-check is kept for a
  clearer message. Field-scoping leaves the gateway issuer mutable.

- #1 (transient protocol-straddle 503s while toggling engine TLS on a live
  fleet) documented as a known limitation in examples/instance-auth-tls.yaml;
  the real fix is architectural (overlapping listeners) and deferred.

Tests: new unit tests for each fix (incl. a mutation-verified stale-generation
case) and a webhook-free envtest CEL suite. Regenerated CRDs via make manifests.

Claude-Session: https://claude.ai/code/session_01QCMwtCk3iXjQyJHGMJEF1q
…signment (FB-896)

Legit's generic-secret rule flagged instance_auth_test.go:602
(`AdminSecretVersion: probeAdmin.ResourceVersion`) by proximity to the
"...SecretVersion" field name. The value is a Kubernetes Secret ResourceVersion
(a monotonic counter), not a credential. Value-based suppression in .legitignore,
matching the existing FB-896 test-fixture suppressions (inline
`// legit:ignore-secrets` is not honored by this integration).

Claude-Session: https://claude.ai/code/session_01QCMwtCk3iXjQyJHGMJEF1q

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

The engine-TLS CEL test's listener-builder helper always received "ca-a"
(the mutate hook, not the builder, sets "ca-b"), which golangci-lint's
unparam linter flagged. Hardcode the issuer inside the (renamed) helper.

Claude-Session: https://claude.ai/code/session_01QCMwtCk3iXjQyJHGMJEF1q

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

… drift witness, dual-CA trust bundle (FB-896)

Round-6 review raised four findings; implement the three that cluster as
coordinated-rotation / staged-trust-transition work (#1, #3, #4). #2 (mutable TLS
alg/size silently ignored under rotationPolicy:Never) remains a separate deferred fix.

Shared primitive: gatewayRolloutComplete / gatewayServingCurrentConfig observe whether
every gateway pod is actually serving the current config (pod-template config-hash
match + rollout complete) — the signal #1 and #4 both gate on. The config-hash match is
load-bearing: ensureGatewayTLS runs before the gateway Deployment is applied in a
reconcile, so a plain rollout check would pass against the previous config.

#1 Stage a fail-closed rollout before serving a tighter gateway client posture
(plaintext->TLS, one-way->mTLS). A posture ordinal (recorded in the new
GatewayTLSStatus.Mode) distinguishes a tightening transition from a loosening/steady
one; on a tightening transition the operator withholds Status.GatewayTLS and serves
fail-closed until gatewayServingCurrentConfig confirms the old, looser pods are gone,
then serves secure. Closes the transient fail-open window (maxUnavailable=0 rolling
update kept old insecure pods reachable behind the same Service). Loosening/steady
transitions skip staging.

#3 Witness cert-manager Certificate.Status.Revision on each signing key
(SigningKeyStatus.ObservedCertRevision); an unexpected bump on an existing kid — a
regeneration of the key material under a stable kid — is surfaced as a Warning event
(new FireboltInstanceReconciler.EventRecorder). AuthReady stays true and the round-5
signing-Secret-ResourceVersion-in-authHash convergence roll is unchanged: a fresh-kid
rebuild would be slower to a consistent active key and cannot recover tokens a
destructive regeneration has already destroyed. Observability only.

#4 Mount an operator-assembled engine trust BUNDLE (ensureEngineCABundle): the union of
every live generation's CA plus the instance anchor, deduped and self-pruning as
generations retire, so the gateway keeps trusting engines across a CA rotation behind
the (name-immutable) issuer. The engine's blue-green cutover (the Service-selector flip
in computeCreating) is gated on the generation's CA fingerprint appearing in the
gateway's confirmed-rolled set (Status.RolledEngineTrustCAs, published only once
gatewayServingCurrentConfig holds). Race-free and non-over-blocking: same-CA rollouts
find their CA already trusted and never wait; only a genuine CA rotation does. The gate
is vacuous until the gateway actually re-encrypts upstream (engineUpstreamTLSReady) — a
must, or the initial enable ramp would deadlock (info.TLS is set once the anchor is
ready, but the trust bundle is only published once the gateway re-encrypts, which
requires the fleet to cut over first).

Local: go build/vet, go test ./... (all packages), the outerharness property test,
golangci-lint (0 issues), and make manifests + CRD conformance all green. New CRD status
fields: gatewayTLS.mode, auth.signingKeys[].observedCertRevision, rolledEngineTrustCAs.

Claude-Session: https://claude.ai/code/session_01QCMwtCk3iXjQyJHGMJEF1q

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

…ecycle (FB-896)

Round-7 review raised six findings; implement all six. Five refine the round-6
coordinated-rotation work (5ab49a6); #4 is a pre-existing readiness-accuracy gap
that round-6 put in sharper relief. They cluster into three subsystems.

Gateway posture transition (#1, #2, #5):

#1 Close the fail-open window on a posture tighten instead of relocating it.
Round-6 staged fail-closed but the looser->fail-closed roll was itself a
maxUnavailable=0 rollout, so old looser pods kept serving behind the one
client-facing Service selector throughout the roll. gatewayRollingUpdateStrategy
now drops old pods to zero endpoints first (maxUnavailable=100%, maxSurge=0)
whenever the gateway is fail-closed pending a tighter posture
(gatewayDownstreamTLSPending) — a brief reject-all in exchange for never being
fail-open. Steady state and the fail-closed->secure roll keep the zero-downtime
default. Fully eliminating the window (no reject-all AND no fail-open) still needs
the deferred dual-listener architecture.

#2 Treat a client-CA replacement as a tightening transition. A swap CA-A->CA-B
keeps the posture ordinal at MutualTLS on both sides, so it previously rolled
without staging, leaving old pods trusting the retired CA-A during the roll.
Record the served client CA's fingerprint (new GatewayTLSStatus.ClientCAFingerprint,
a SHA-256 of the public ca.crt) and stage fail-closed when it changes. A blank
recorded fingerprint (a status written before this field existed) is recorded
rather than restaged, so an operator upgrade does not force a one-time reject-all
on every already-serving mTLS gateway.

#5 Report GatewayTLSReady only once the secure config is actually serving.
ensureGatewayTLS runs before the Deployment is applied, so it used to flip Ready
as soon as the certificate was ready — while the still-Ready fail-closed pods
(metrics-port probe) kept GatewayReady>0 and the client port still rejected; with
an invalid BYO certificate that never loads, forever. Gate the condition on
gatewayServingCurrentConfig (reason SecureRolloutPending). Scope the check to the
transition out of a not-ready state so a later zero-downtime renewal does not flap
the Instance out of Ready; a genuine re-tighten resets the condition and re-gates.

Engine CA trust bundle (#3, #6):

#3 Stop pinning the retired anchor CA. ensureEngineCABundle assembled the union of
live per-generation CAs plus the instance anchor, but the anchor is
rotationPolicy:Never with a ~100yr lifetime, so its ca.crt stays pinned to the CA
that signed it and a retired CA never pruned out after its generations drained.
Assemble from live per-generation CAs only (the certs engines actually serve),
keeping the anchor solely as a fallback when no per-generation CA is discoverable
so the bundle is never emptied out from under a re-encrypting fleet.

#6 Preserve confirmed trust state on a bundle-assembly error. A failed
ensureEngineCABundle returned nil fingerprints, which were still published,
clobbering the last-confirmed Status.RolledEngineTrustCAs and wrongly blocking
engine cutovers the gateway could still serve. The guard now lives in
publishRolledEngineTrustCAs (bundleErr parameter): on error it preserves the prior
set. The clear-on-disable path is unaffected (assembly returns no error then).

Readiness semantics (#4):

Split the engine-TLS signal so the Instance does not advertise Ready over a
still-plaintext gateway->engine hop. EngineTLSReady is now convergence-gated (True
only once the fleet is re-encrypting; False/Converging while provisioned but not
yet converged) and feeds the Ready roll-up. The engine roll that produces that
convergence is unblocked by the provisioned fact (Status.EngineTLS != nil) in
resolveInstanceInfo, NOT by the condition — gating the roll on the now-convergence-
gated condition would deadlock the enable ramp (engines never roll -> never
converge -> condition never True). A stopped engine still converges via a trivial
zero-replica roll, so the Converging state is transient.

New CRD status field: gatewayTLS.clientCAFingerprint. Tests cover all six findings
(including the #5 renewal anti-flap and the #4 no-deadlock regression) and update
the round-6 staged tests to the new three-phase readiness timing. Local: go
build/vet, golangci-lint (0 issues), go test ./... (all packages), the outerharness
property path, and make manifests + CRD conformance all green.

Claude-Session: https://claude.ai/code/session_01QCMwtCk3iXjQyJHGMJEF1q

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

…tion/UX fixes (FB-896)

Round-8 review raised six findings; implement all six. Several hinge on
cert-manager rotationPolicy:Never, under which a re-issuance reuses the private
key and only re-writes the certificate — bumping Certificate.Status.Revision and
the Secret's ResourceVersion without changing key material. Both packdb-dependent
findings (#3, #5) were confirmed against firebolt-db/packdb source.

Correctness core (#1, #2, #4):

#1 Roll engine pods when their per-generation serving certificate is re-issued.
The engine drift hash is generation-independent (anchor Secret name + key
policy), so a cert-manager re-issuance of the leaf packdb loaded at startup was
invisible: running pods kept serving stale material until an unrelated restart —
eventually an expired cert, or a leaf a rotated CA's refreshed trust bundle no
longer accepts. Latent under the ~100yr duration, but reached by any issuer that
caps the lifetime (Vault/PCA/ACME) or a manual `cmctl renew`. The signal cannot
ride tlsHash (the instance side reconstructs that hash with no generation
context, so folding a per-generation value would deadlock the re-encryption
convergence gate); instead resolveInstanceInfo reads the current generation's
serving-cert fingerprint live into ResolvedEngineTLSInfo.ServingCertFP, and
computeStable rolls a fresh blue-green generation — coordinated through the
existing trust-bundle cutover gate — when it differs from the fingerprint
recorded for that SAME generation (FireboltEngineStatus.ObservedEngineServingCert
FP/Gen). The generation guard is essential: every new generation issues its own
fresh-key certificate, so its fingerprint legitimately differs from the prior
one; comparing across a generation boundary would read every ordinary
spec-driven roll as a re-issuance and roll forever.

#2 Freeze engine and gateway TLS key algorithm/size while the listener stays
enabled. The stable-name anchor and gateway certificates reuse their existing
key under rotationPolicy:Never, so an in-place algorithm/size edit cannot
regenerate the key to match and wedges the Certificate. Field-scoped CEL
transition rules on TLSSpec.Engine/Gateway plus a webhook re-check
(validateImmutableTLSKeyParams) reject it, mirroring the existing signing-key and
engine-issuerRef immutability; a disable/re-enable (fresh key material) is still
permitted, so the round-7 per-generation reissue path stays meaningful on
first-enable and re-enable.

#4 Detect signing-key identity rather than treating every certificate revision
as a key regeneration. Under rotationPolicy:Never a cert-only reissuance (an
issuer-capped lifetime or a manual renewal) bumps Status.Revision while reusing
the key, so the previous revision-based witness raised a false "tokens can no
longer be validated" warning and the ResourceVersion folded into authHash forced
an unnecessary fleet rollout. Both now key off the public-key fingerprint
(SHA-256 of the SubjectPublicKeyInfo parsed from tls.crt — the public key, never
the private bytes): the SigningKeyMaterialRegenerated warning fires only when the
public key actually changes, and authHash folds the fingerprint at both mirror
sites (resolveInstanceInfo and enginesConvergedOn) in place of the ResourceVersion.
A cert-only reissuance no longer rolls the fleet; a genuine key replacement still
does. Because authHash's value changes, every auth-enabled instance performs one
blue-green engine roll when this operator upgrade lands.

Validation / UX (#3, #5, #6):

#3 Include clock-skew tolerance in the signing-key retention floor. packdb
accepts a token until iat + maxTokenAge + clock_skew_tolerance (specs/
authentication.md; default 30s), so the retained key must outlive that whole
window. validateSigningKeyRotation now requires retainDuration >= maxTokenAge +
the resolved local clock-skew tolerance (packdbDefaultClockSkew when unset; a bad
value falls back to the default rather than lowering the floor).

#5 Accept the multi-component day durations packdb accepts. packdb's duration
parser (Duration.cpp) is a component tokenizer that allows "d" (days) combined
with the standard units in any order, e.g. "1d12h"; the webhook previously
accepted only Go grammar or a single "<num>d" suffix and over-rejected valid
config at admission. parsePackdbDuration now tokenizes components faithfully
(fast-pathing standard strings through time.ParseDuration).

#6 Report the correct scheme for TLS-enabled port-forwards. The kubectl-firebolt
gateway and engine port-forward commands printed http://localhost unconditionally,
but each reaches a listener that terminates TLS on the same forwarded port when
enabled. The commands now inspect the referenced Instance (gateway TLS directly,
engine TLS via spec.instanceRef) and print https://, degrading to a
protocol-neutral endpoint if the spec cannot be read; the --print-commands path
is unaffected.

New CRD status fields: fireboltengine observedEngineServingCertFP/Gen; the
signing-key witness moves to observedPublicKeyFingerprint (a pointer, keeping
SigningKeyStatus compact). New CEL rules on spec.tls.engine/gateway.certManager.
Tests cover all six, including the #4 cert-only-reissuance-does-not-warn/roll
distinction and a #1 cross-generation regression proving a freshly-rolled
generation is not mistaken for an in-place re-issuance. Local: golangci-lint (0
issues), go test ./... , the outerharness property path, and make manifests + CRD
conformance all green.

Claude-Session: https://claude.ai/code/session_01QCMwtCk3iXjQyJHGMJEF1q

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Risk: high. Not approving: this auth/SSO/TLS/signing-key change exceeds the medium risk threshold, Cursor Security Agent left an unresolved HIGH finding (secret volume secretName bypass), and fstr has requested changes. Reviewers gm42 and fstr are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Infra Reviewer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants