Security model
Trust boundaries
Section titled “Trust boundaries”The operator separates four authorities:
- A desired-state author may change
PtahSchemabut cannot approve a plan merely by editing that resource. They can, however, make approvals unnecessary:spec.policy.applyis a field of the resource they own, and selectingAlwaysapplies 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. - An approver may read schemas and plans and create immutable approvals. The chart creates an optional ClusterRole but never binds it automatically.
- 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.
- A Job receives only the credentials needed for its fixed operation through same-namespace Secret selectors resolved by the kubelet.
Who may turn the approval requirement off
Section titled “Who may turn the approval requirement off”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.
OCI integrity and identity
Section titled “OCI integrity and identity”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 and approval visibility
Section titled “Plan and approval visibility”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.
Output handling
Section titled “Output handling”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.
Remaining deployment responsibilities
Section titled “Remaining deployment responsibilities”- 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 thePtahSchemaorPtahMigrationrather 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, soTestTheEgressExampleSelectsEveryOperationPodbuilds 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.