Skip to content

feat(operator): report whether the Secret/ConfigMap write policy is in force (#2500) - #2580

Open
AhmedElBanna80 wants to merge 8 commits into
integration/v2from
security/2500-write-policy-off
Open

AhmedElBanna80 wants to merge 8 commits into
integration/v2from
security/2500-write-policy-off

Conversation

@AhmedElBanna80

@AhmedElBanna80 AhmedElBanna80 commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #2500

What this does

The operator's Secret/ConfigMap write policy (a ValidatingAdmissionPolicy plus binding) can be off with nothing reporting it: a server older than Kubernetes 1.30, an install under another namespace or ServiceAccount name than the bundle rendered, or a deleted policy or binding. This adds a read-only self-check and reports it three ways.

  • Operator self-check (internal/writepolicy). At start-up and every 5 minutes (--write-policy-check-interval) it gets the policy and binding by name, asks the API server who it is (SelfSubjectReview), and publishes knext_operator_write_policy_enforced (0/1). It logs one warning per change. It does not refuse to start (that stays a founder call). It runs on every replica, leader or not. An unreadable object or identity is 0, never 1.
  • What counts as enforced. The policy exists; failurePolicy is not Ignore; it covers create/update/delete on Secrets and ConfigMaps with no namespace or object narrowing; its matchCondition is an exact equality on the operator's own username; the binding names it, includes Deny, and is not scoped.
  • Alert KnextOperatorWritePolicyNotEnforced (== 0, 15m, warning).
  • knext doctor row write-policy: same verdict, plus the server version against the 1.30 floor. WARN when not enforced, never FAIL.
  • Parity. The Go verdict and the TS verdict are both held to one table, internal/writepolicy/testdata/verdict-cases.json (20 cases).

RBAC (exactly get on the two named objects)

A dedicated write-policy-reader ClusterRole (config/rbac/write_policy_reader_role.yaml): get on validatingadmissionpolicies and validatingadmissionpolicybindings, resourceNames the policy name. It is a hand-written role rather than a +kubebuilder:rbac marker on manager-role because a marker's resourceNames is a literal the bundle's name prefix does not reach; config/default rewrites the role's resourceNames and the manager's new WRITE_POLICY_NAME env from the policy's real prefixed name. SelfSubjectReview needs no rule (system:basic-user). Pinned by TestWritePolicyReaderRoleIsExactlyGetOnTheTwoNamedObjects (controller) and the rendered-bundle test; the existing TestOperatorCannotEditItsOwnWritePolicy now covers the new file too.

Tests

  • Go unit: shared verdict table (20 cases), monitor gauge / one-warning-per-change / recovery / every-replica / interval re-check, main wiring.
  • envtest on a real kube-apiserver (TestSelfCheckAgainstARealApiserver): matching -> 1; policy deleted, binding deleted, wrong username, Audit-only binding -> 0; an identity holding exactly the shipped role runs the check, cannot list, cannot read another policy, cannot delete; a forbidden read is Unverifiable and 0.
  • Install: rendered bundle's env, role resourceNames, and binding all follow the prefixed policy name.
  • Doctor: 32 tests (parity table + end to end through runDoctor: pass, policy missing, binding missing, renamed ServiceAccount, audit-only, 1.29 server, configured name, RBAC-denied, unreachable, no operator, read-only).
  • Metric golden regenerated; alert test added.
  • Mutation provers (exit codes, exact-once anchors, committed green first): scripts/mutation-prove-2500-write-policy-selfcheck.mjs 26/26 killed (includes "check forced to true" reds the deleted-policy case on a real apiserver); scripts/mutation-prove-2500-write-policy-ts.mjs 11/11 killed (alert + doctor). Registered in the prover lane and shard weights.

Not done in this PR: a kind-cluster lane assertion. The live behaviour (a real apiserver, a real SelfSubjectReview, real RBAC) is covered by the envtest above; there is no pre-1.30 server in CI to drive the version-floor path, so that path is covered by the doctor unit test only.

Docs summary

User-visible: new knext doctor row, new metric and alert, a new operator flag/env. Docs updated: apps/docs/content/docs/security.mdx (new section "Knowing whether the policy is in force"), cli.mdx (doctor), slos-alerting.mdx (metric + alert); internal docs/security/threat-model.md; changeset for @getknext/core. No issue, PR or ADR numbers in the user docs.

Round 2 (code review: the gauge read 1 while the policy was not enforcing)

Cases first in the shared verdict-cases.json (20 -> 37 cases), so Go and TS pin each one; seen red, then fixed.

  1. matchConstraints narrowing -> PolicyScope (gauge 0, doctor WARN). Any excludeResourceRules; a covering rule with resourceNames; a covering rule scoped Cluster; a covering rule whose apiVersions lacks v1/*. Namespaced/* scope and an unrestricted rule beside a restricted one still pass (table cases).
  2. Validation body -> PolicyModified (gauge 0, doctor WARN). The policy's variables (name + expression) and each validation's expression + messageExpression are compared with a canonical copy of the shipped CEL (canonical.go, CANONICAL_POLICY_CEL in write-policy.ts). message and reason are cosmetic and not compared. The shared fixture's base policy now carries the real shipped CEL instead of true. Drift is closed by tests in both languages that fail if config/admission changes without the copy (TestCanonicalPolicyCELEqualsTheShippedPolicy, TestSharedTableBasePolicyCarriesTheShippedCEL, and the TS twins); a real apiserver round-trips the shipped policy unchanged (envtest still green). Consequence, documented: an operator newer than the installed policy reads 0 until the bundle is reapplied.
  3. Alert blind spot. New KnextOperatorWritePolicyUnreported: up{job=~".*controller-manager-metrics-service"} unless on (namespace, pod) knext_operator_write_policy_enforced, 15m, warning. Keyed on a discovered target, not a bare absent() (which would fire on every cluster with no operator). The test checks the job selector against the rendered Service name (namePrefix + metrics_service.yaml) so it cannot select nothing. Side effect to review: the metric-contract knext.operator group now also allows the external emitter so up resolves, which also lets other externally emitted names pass in that group.
  4. Nits. Doctor: an unclassifiable read failure and unparseable output now WARN (they skipped). DEFAULT_POLICY_NAME comment fixed; the TS fallback (the bundle's rendered, prefixed name) and the Go flag default (unprefixed base name; the bundle always sets the env) differ on purpose, now documented and pinned by a test.

Docs: security.mdx, slos-alerting.mdx, cli.mdx, the threat model (Silent-off detection bullet) and the changeset now state exactly what is verified.

Mutation provers extended, run by exit code from a committed green tree with exact-once anchors: Go prover 33/33 killed (+7: exclude, resourceNames, Cluster scope, apiVersion, body comparison removed, canonical copy drift, shipped CEL changed); TS prover 22/22 killed (+3 alert rows for the companion alert, +5 doctor scope/body rows, +3 on the canonical spec for the WARN-not-skip nits and copy drift). A first run of the new rows had mutants whose residue comment swallowed the rest of the line; I fixed the rows and re-ran everything, so the counts above are from the fixed provers.

Escalation

Escalation trigger acknowledged: security invariant — adds a ClusterRole/ClusterRoleBinding (get on two named admission objects), an operator flag/env (--write-policy-name, WRITE_POLICY_NAME, --write-policy-check-interval), and a new CLI doctor row; no mutating endpoint, no secret handling, no CRD field change, and the operator still holds nothing that can write the policy.

Escalation trigger acknowledged: CLI surface — knext doctor gains a write-policy row (read-only kubectl gets, WARN-only).

Escalation trigger acknowledged: CRD — none touched (api/v1alpha1 unchanged); listed only because config/ and cmd/main.go are in the diff.

🤖 Generated with Claude Code

https://claude.ai/code/session_0198oVxWkEEDJJnVhES6ULnx

AhmedElBanna80 and others added 5 commits October 22, 2026 13:21
…in force (#2500)

The operator write ValidatingAdmissionPolicy can be off with nothing reporting
it: a server older than 1.30, an install under another namespace or service
account name, a deleted policy or binding. The operator now reads the policy and
binding (get, by name) and its own identity (SelfSubjectReview), publishes
knext_operator_write_policy_enforced (0/1, 0 when unverifiable), warns once per
change and re-checks every five minutes. It never refuses to start. knext doctor
mirrors the verdict (shared fixture), the alert fires on a 0, and the new role
grants get on exactly the two named objects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198oVxWkEEDJJnVhES6ULnx
…ives (#2500)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198oVxWkEEDJJnVhES6ULnx
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198oVxWkEEDJJnVhES6ULnx
…lace K03 with a reachable row (#2500)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198oVxWkEEDJJnVhES6ULnx
@vercel

vercel Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
knext-docs Ready Ready Preview Oct 22, 2026 4:17pm UTC

AhmedElBanna80 and others added 3 commits October 22, 2026 17:56
…ons; alert on a missing gauge (#2500)

Round-1 review. The gauge read 1 while the policy was not enforcing:
- matchConstraints narrowing (excludeResourceRules, resourceNames, Cluster
  scope, another apiVersion) was not detected: PolicyScope.
- the validation body was never checked, so validations: [true] was Enforced.
  The variables and validations are now compared with a canonical copy of the
  shipped CEL, held in lockstep by a test in each language: PolicyModified.
- the == 0 alert was blind when the gauge is absent: add
  KnextOperatorWritePolicyUnreported, keyed on a discovered operator target.
- doctor: an unclassifiable read or unparseable output is a WARN, not a skip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198oVxWkEEDJJnVhES6ULnx
…nion-alert detections (#2500)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198oVxWkEEDJJnVhES6ULnx
…t lands (#2500)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198oVxWkEEDJJnVhES6ULnx

This branch was successfully deployed

1 active deployment
Preview — dc26b80c Deployed Oct 22, 2026 by vercel[bot]
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.

1 participant