Skip to content
PtahOperator

Security model

The operator separates four authorities:

  1. A desired-state author may change PtahSchema but cannot approve a plan merely by editing that resource. They can, however, make approvals unnecessary: spec.policy.apply is a field of the resource they own, and selecting Always applies non-destructive plans with no approval at all. RBAC cannot close that, because the bypass is not an approval. See Who may turn the approval requirement off.
  2. An approver may read schemas and plans and create immutable approvals. The chart creates an optional ClusterRole but never binds it automatically.
  3. The controller may manage plans, Jobs, ConfigMaps, Leases, status, and Events. Its shipped ClusterRole contains no Secret permission. Retained, typed admission policies constrain its main-resource writes to structural Job, immutable plan, and immutable chunk shapes; a fail-closed webhook then reconstructs and compares the complete write intent through direct API reads.
  4. A Job receives only the credentials needed for its fixed operation through same-namespace Secret selectors resolved by the kubelet.

The separation above is about who may write an approval. It says nothing about who may decide one is not needed, and those are different questions with different answers.

spec.policy.apply lives on the desired-state resource. An author with the rights the example Role grants may set it to Always, after which non-destructive schema plans apply without an approval; PtahMigration exposes the same field. Separating the approver Role from the author Role does not prevent this, and no amount of RBAC on approval objects will, because nothing is approving anything.

Always is not wrong. It is the deliberate unattended mode, and an installation that wants it should have it. What matters is that choosing it is a decision someone made on purpose, rather than a default an author can reach without anyone else noticing.

Where independent approval is an operational requirement, install examples/approval-policy-guard.yaml. It is a ValidatingAdmissionPolicy, cluster-scoped and administrator-owned, so an author with complete rights over resources in their own namespace cannot edit, rebind or delete it. It covers both kinds and both CREATE and UPDATE: a guard that watched only updates is bypassed by creating the resource with Always already set.

What it refuses is the transition into Always, not the value itself. That distinction is load-bearing. The operator patches these resources to add and remove its operation finalizer, and its service account is not exempt, so a guard that refused every write leaving Always in place would stop operations from starting and stop a finished one from releasing its finalizer – an administrator who chose Always would have wedged every resource they chose it for. Leaving the field where an administrator put it is permitted, and so is moving back to OnApproval; arriving at Always from anywhere else is not.

Two things to check after installing it. A policy with no binding is inert and reads exactly like one in force, so confirm the binding exists and that its validationActions is Deny – Warn and Audit record the bypass rather than refusing it. And the binding’s namespace selector decides which namespaces are covered; the example covers all of them.

Start namespace-scoped bindings from the desired-state author (examples/desired-state-author-role.yaml) and diagnostic reader (examples/diagnostic-reader-role.yaml) examples. The chart’s optional approver ClusterRole remains unbound, so these three human permission sets can be assigned to different identities. Diagnostic access deliberately excludes Secrets and plan-chunk ConfigMaps; grant exact plan-chunk access separately for an approver reviewing one immutable plan.

These write boundaries reduce the effect of controller bugs and prevent its RBAC from becoming arbitrary workload or ConfigMap creation authority. The manager binary and its status-write authority remain trusted: the admission layers do not claim to contain a malicious replacement image that can forge the status records used to reconstruct intent.

Database-operation Pods disable service-account token mounting and service-link environment injection. Every container runs as non-root with a read-only root filesystem, RuntimeDefault seccomp, no Linux capabilities, no privilege escalation, a deadline, bounded memory-backed work volumes, and no automatic Job retry.

Before dispatch, the controller persists a canonical, credential-free snapshot of the built-in Kubernetes mutations that may affect an operation Pod. It reads ServiceAccount names and image-pull-secret references, LimitRange quantities, RuntimeClass scheduling and overhead, and PriorityClass values, but never reads the referenced Secret data. A fail-closed validating webhook checks the final post-mutation Pod before scheduling. Only the exact snapshotted mutations are accepted; executable, environment, volume, and security fields remain exact. The controller retains read-only Pod evidence permissions and is not granted Pod create or delete permission.

Operation Pod creation is additionally bound to the built-in Kubernetes Job controller and to the API server’s generated-name chain. The submitted generateName must equal the exact Job name plus -; the concrete Pod name must contain the API server’s at-most-58-character effective prefix and one five-character lowercase alphanumeric suffix. The reconciler repeats this check before trusting terminal Pod evidence. Exact Job tracking-finalizer removal is restricted to the same controller identity and cannot carry any other Pod mutation.

Because the manager watches PtahSchema across namespaces and each operation runs in its resource’s namespace, the controller has cluster-wide get on ServiceAccounts and list on LimitRanges. It has no ServiceAccount list or watch, no LimitRange get or watch, and no write verb for either resource. This is the minimum Kubernetes RBAC shape that permits resolving an arbitrary named ServiceAccount and the namespace-wide LimitRange admission set without reading Secret data.

Memory-backed emptyDir usage is charged to the writing container by Kubernetes. The chart defaults bound each volume, but production resource limits must also leave headroom for the runner binary, fetched schema, plan, and client scratch data in addition to the process heap.

The operator always resolves a tag to SHA-256 content and records that digest. It verifies policy output against the resolved digest, inspects the pinned artifact independently, and requires the Ptah schema artifact type. These checks provide content integrity and prevent artifact-type confusion. Verification policy ConfigMaps must be immutable. Plans, approvals, active operations, and post-Apply proof bind both the ConfigMap UID and exact policy digest, so deleting and recreating the same name cannot preserve authority.

The current Ptah verification policy can require a signature artifact to be attached, but that requirement is a presence check. It does not validate a cryptographic signature, key, certificate identity, issuer, or transparency log. Do not describe ArtifactVerified=True as publisher authenticity when a policy relies on that field.

For production publisher identity, verify the digest cryptographically in the artifact promotion pipeline or enforce an OCI admission/promotion policy before the digest is referenced by PtahSchema. Keep the resource digest-pinned after that decision. A later operator API may add an independently versioned verifier contract; it must bind its verifier image, trust policy bytes, and evidence into the plan rather than executing arbitrary user commands.

Plain HTTP registry transport is an explicit opt-in for trusted test or air-gapped networks. Sending a registry Secret over that transport additionally requires the Secret owner to set the fixed allowPlainHTTP key to exactly true; a schema author cannot authorize that downgrade alone. Plain HTTP cannot be combined with a custom CA.

Every registry credential Secret must contain a non-optional fixed registry key, whether it uses environment keys or Docker config JSON. Its authority-only host[:port] value must exactly match the OCI client’s effective request authority before any Ptah process or network request starts. This includes the request-host mapping applied by the OCI client; host case is normalized, but ports and trailing dots are not collapsed. The key name cannot be selected by a schema author. Docker config host entries and helpers still select the actual credential, while the separate fixed key proves the Secret owner’s consent to one effective authority without exposing that configuration to the guard. Observe and Plan run the credential-free authority guard between runner installation and the credentialed schema fetch. Registry credentials and database credentials remain routed to different containers.

Ptah never consumes custom CA bytes directly from the mutable ConfigMap volume. Resolve and Verify copy at most 1 MiB into a private runner snapshot before the first Ptah child starts. Observe and Plan make the credential-free authority guard hash and copy the selected bytes into a dedicated memory-backed EmptyDir; the later credentialed fetch mounts only that read-only snapshot. When registry authentication is configured, the same authentication Secret must contain the fixed caSHA256 key with the exact lowercase sha256:<64 hex> digest of the selected ConfigMap bytes. A missing, malformed, or mismatched grant stops the Job before any Ptah process or network request. The key name cannot be selected by a schema author. Anonymous registry access may use a custom CA without a Secret grant, but it is still size-bounded and snapshotted before use.

clientCertificateFrom remains in the alpha source shape for compatibility but is rejected by both API validation and Job construction. The pinned executor loads a client pair into a process-wide TLS configuration and cannot constrain certificate selection after a cross-host redirect. Re-enable this field only with an executor contract that selects the certificate against the effective TLS authority on every handshake.

Plan ConfigMaps contain schema-changing SQL, not credentials. They are intentionally inspectable by independently authorized approvers, but arbitrary schema names, defaults, comments, and literals may still be sensitive. Restrict ConfigMap and PtahSchemaPlan read access in application namespaces accordingly. The built-in approver role can read plan metadata but deliberately cannot read every ConfigMap cluster-wide. Grant a separate namespace Role restricted to the current plan chunk names, as described in Exact-plan approvals. What that access is used with is kubectl ptah, a read-only client that needs get on the schema, the plan and those ConfigMaps and nothing else; Read a plan carries the Role and the install.

Approval admission fails closed. It binds names to UIDs, rejects a plan whose storage commit is incomplete, rejects changed policy bytes or target state, and makes the stamped decision immutable. Both approval webhook configurations use the non-configurable Fail policy, so an unavailable webhook cannot admit a caller-supplied identity stamp. Ordinary Kubernetes RBAC is not treated as field-level authorization.

The runner never invokes a shell. It checks command arguments against known credential values, derives and redacts standalone and escaped credentials from database URLs, bounds stdout and stderr, and validates a framed result containing the operation ID, coordination digest, and protocol version. The required spec.target.coordinationKey is a non-secret operator input; it is hashed with the normalized engine, and the plaintext key is never copied into status. Status otherwise stores only hashes, counts, classification, immutable references, and timestamps.

The credential-free target identity includes connection-security semantics, not merely host and database names. Rotating password or certificate bytes is allowed when the non-secret route and certificate paths stay fixed, while a change to TLS verification, channel binding, authentication requirements, or plaintext fallback invalidates the plan before the mutating child dispatches.

Raw drift details are parsed in memory and excluded from the framed result. Observe exposes at most 64 canonical category aggregates, each containing only a category from the closed v1 machine vocabulary, a positive count, and a severity. A syntactically valid but unknown category fails the operation; adding a category requires an explicit runner protocol update. The frame never carries object names, SQL, schema literals, or the native diff. driftFindingCount remains the complete aggregate count; driftFindingsTruncated=true explicitly reports that additional categories were omitted. Resolve and Verify follow the same boundary: native stdout is strictly decoded before a small typed descriptor or requirement-name set is emitted, arbitrary verification details and inspection metadata are discarded, and native stderr or executor errors can produce only generic typed failures. No Resolve, Verify, Observe, or Apply frame carries native stdout. Planning executes twice under the target Lease, requires byte-identical native plans, and validates the accepted bytes through a native Apply dry-run before publication. The independent operator classifier may raise destructive severity from the rendered SQL and never lowers executor metadata. It also rejects credential-bearing principal DDL.

Apply native stdout and stderr are never copied into the framed result or runner diagnostics, including failure paths. Only generic typed failures leave the runner. Stale Apply is classified as pre-mutation only for the exact native diagnostic bound to the reconstructed plan’s source fingerprint; altered, extra, or truncated output is treated as uncertain.

Treat access to Job Pod logs as a separate privilege. The controller needs it to harvest the framed result, while ordinary desired-state authors usually do not. A successful Plan frame necessarily transports exact plan bytes to the controller before they are committed to immutable chunks, so Pod-log access is at least as sensitive as plan-chunk access. Apply frames never contain native SQL output.

  • Apply namespace NetworkPolicies that allow executor Pods to reach only the required registry, DNS, and database endpoints. Start from the egress-policy example in examples/networkpolicy-egress.yaml. It covers both families and narrows by operation: a schema Apply runs bytes the operator already stored and is given no registry egress, while a migration Apply fetches its artifact and is. The policies go in the namespace the operation Pods run in, which is the namespace of the PtahSchema or PtahMigration rather than the release namespace, and they assume the registry and the database are in-cluster; for either one outside, replace the selector with an admission-controlled CIDR rather than opening Internet egress. A Pod no policy selects is not isolated at all, so TestTheEgressExampleSelectsEveryOperationPod builds one Pod per operation of both families and fails when one stops being covered.
  • Grant the database user the minimum DDL and introspection privileges needed for the selected schemas. See Database support and privileges and do not use a cluster-wide administrative account.
  • Pin manager, runner, and executor images by digest and verify their release provenance before installation.
  • Protect the shared target-lock namespace from untrusted Lease writers.
  • Install exactly one operator Helm release per cluster. Scale replicas within that release for high availability; the singleton admission configuration intentionally prevents ordinary independent-release ownership.
  • Assign one stable coordination key to every physical database and reuse it across all aliases, proxies, credentials, namespaces, and Ptah resource kinds.
  • Use separate database credentials for production and optional dev rehearsal targets.

Reporting a way across one of these boundaries

Section titled “Reporting a way across one of these boundaries”

Privately, to the address the security policy names, rather than on the issue tracker: an issue is public from the moment it is filed. The policy also says which findings are in scope and which are a deployment’s own decision.