Skip to content

Security model

The release namespace is part of the control plane

Section titled “The release namespace is part of the control plane”

The namespace the chart is installed into is a privileged administrative boundary and part of the operator’s trusted computing base. So is coordination.namespace when it names a different namespace. Everything else on this page assumes that only Ptah administrators can act in either one, and nothing the operator does replaces that assumption.

This follows from a Kubernetes rule, not from a choice this operator made. Whoever can create a Pod in a namespace can run it as any ServiceAccount in that namespace and mount any Secret there, so the right to create workloads in a namespace carries every permission its ServiceAccounts hold. Kubernetes says so in its RBAC good practices, and adds that boundaries inside a namespace should be considered weak. The release namespace holds the ServiceAccounts of the manager, the certificate rotator and the install hooks, and the Secret with the webhook’s TLS key. A principal that can create or modify Pods, Deployments, Jobs or any other workload there, exec into a Pod, or request a ServiceAccount token, can act as the controller. That principal is a Ptah administrator, whatever its Role is called. The coordination namespace holds the Lease that elects the active manager and the Leases that serialize every operation on a database, so a principal that can write Leases there can stop reconciliation or take a database’s turn.

What that means for a deployment:

  • Do not deploy application workloads into the release namespace or the coordination namespace.
  • Do not grant namespace-admin, edit, or any right to create or modify workloads there to a principal you would not trust to administer Ptah. The same applies to pods/exec, serviceaccounts/token and Lease writes.
  • Do not rely on a narrower Role inside either namespace as a security boundary. Kubernetes does not treat one as such, and neither does this operator.

A finding that starts from write access to either namespace describes an administrator doing administration, and the security policy lists it as out of scope.

What the chart checks, and what it cannot see

Section titled “What the chart checks, and what it cannot see”

When Helm can read the cluster, which is helm install, helm upgrade and a dry run with --dry-run=server, the chart reads the release namespace before it renders anything. It refuses the install or upgrade when:

  • the namespace is default or starts with kube-, which the whole cluster shares;
  • the namespace runs a Pod, ReplicationController, Deployment, StatefulSet, DaemonSet, ReplicaSet, Job or CronJob that does not carry this release’s app.kubernetes.io/instance label. Every workload the chart creates, and every Pod those workloads start, carries it.

The refusal names the namespace and up to five of the workloads it found. releaseNamespace.allowSharedNamespace: true turns it off. Set it only when everyone who can create workloads in that namespace is trusted to administer Ptah, because installing there makes them administrators.

The notes Helm prints after an install or upgrade warn, and refuse nothing, about RoleBindings in the release namespace, and in a separate coordination namespace, that give a subject other than this release’s ServiceAccounts create on pods, pods/exec, serviceaccounts/token, a workload kind or leases, or that bind the admin, edit or cluster-admin ClusterRole. This release’s ServiceAccounts are the ones its Pods run as: the manager, the certificate rotator, and the CRD hook. The chart knows them by the names it gives them, so a binding the release made for a ServiceAccount it has not created yet is still its own. A ClusterRole is judged by the rules it carries. For an aggregated ClusterRole those are the rules the aggregation controller wrote into it, so a grant that arrives through aggregation is warned about too.

The check catches plain mistakes. It is not the boundary, and the contract above holds whether the check runs or not. It cannot see:

  • ClusterRoleBindings, which grant the same rights in every namespace;
  • an external authorizer, such as a webhook authorizer or a cloud IAM mapping;
  • a grant made, or a workload deployed, after the install or upgrade it ran in;
  • anything at all in a render that cannot read the cluster. helm template, helm lint, a dry run without --dry-run=server, and a GitOps tool that renders the chart with helm template, as Argo CD does, all get empty lookups, and the check lets them through without a word. An installation made that way has to hold to the contract with no check at all.

The boundary the operator is built to hold lies between it and the application namespaces: the people who write PtahSchema, PtahMigration and approval resources there, the database credentials those namespaces hold, and the operation Pods that run there with those credentials. The authorities in Trust boundaries are that boundary, and the rest of this page says how each is held. Realm membership is one of them: a claim on a database reaches past its namespace only when a PtahRealm grants it, as Who may claim a database describes. The open work that hardens the rest:

  • #447: let operation Pods carry bounded metadata for third-party mutating admission.
  • #449: keep plan bytes out of Pod logs, which Pod logs carry plans explains.
  • #450: close the gaps at the human authority boundary.

What the operator still checks about its own releases

Section titled “What the operator still checks about its own releases”

Trusting the release namespace means trusting the people who administer it, not every binary that has run there. A previous release that is stale or buggy is an ordinary failure, and these checks exist for it:

  • CRD schema identity. The CRD hook refuses a live schema version newer than the candidate’s, and two digests under one version, so an older image cannot narrow a newer schema. See Schema identity on a CRD.
  • The downgrade preflight. A manager does not start over stored state that a newer controller wrote. See The downgrade preflight.
  • The admission singleton. One release per cluster, checked when the chart renders and again by the runtime verifier before a manager starts. See What the runtime verifier requires.
  • Recreate and leader election. An old and a new manager never serve admission side by side, and only one of them reconciles. See Install the operator.
  • The controller-write guards. Typed admission policies and a webhook that rebuilds the expected object bound what the manager itself may write, so a bug cannot create a Job, a plan, a chunk or a plan’s projection outside its shape. See Admission.

None of these is a defense against an administrator acting in bad faith, and the release machinery does not try to be one: the contract above is what answers that.

The operator separates five 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 a schema plan that destroys nothing and changes no privilege 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, migrations, their plans and the chunks a schema plan’s SQL is stored in, and create immutable approvals for either family, and the acknowledgment that settles a migration run nobody accounted for. The chart creates an optional ClusterRole but never binds it automatically. RBAC decides who may approve; it does not by itself decide that the approver is a second person rather than the author. See Refusing a self-approval.
  3. The controller may manage plans and their chunks, Jobs, Leases, status, and Events, and create the ConfigMaps an Apply mounts its plan through. Its default ClusterRole grants Secret CREATE only for operation-bound delivery credentials, guarded by admission; it grants no Secret reads. Disabling resultDelivery.enabled removes that CREATE grant. Typed admission policies that ship with the release constrain its main-resource writes to structural Job, immutable plan, immutable chunk and immutable projection shapes; a fail-closed webhook then reconstructs and compares the complete write intent through direct API reads. Nobody else writes status: a policy the chart installs refuses a write to the status subresource of every operator kind from any identity but the manager’s ServiceAccount, a cluster administrator included, and another refuses anyone else a change to the copy of a migration’s unresolved-run record on its metadata. A person settles such a run with a PtahMigrationRunAcknowledgment, which admission stamps with who made it and the migration records.
  4. A Job receives only the credentials needed for its fixed operation through same-namespace Secret selectors resolved by the kubelet.
  5. A realm administrator decides which namespaces may manage a database more than one namespace reaches, by writing a cluster-scoped PtahRealm. An author names a realm and cannot grant it. See Who may claim a database.

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 schema plans that destroy nothing and change no privilege apply without an approval; PtahMigration exposes the same field, and there every plan applies. 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.

What Always does not waive is a change of authority. A schema plan that grants or revokes a privilege, changes a role or its members, hands an object to another owner, creates, alters or drops a row-security policy, or makes code run with its owner’s rights waits for an approval under Always as under OnApproval, and a destructive plan still needs allowDestructive as well. Whoever can push the artifact’s tag can still change the schema unattended; the changes of authority the operator can read in it wait for a person. The operator decides this from the SQL rather than from Ptah’s severity, and Plans that change privileges lists what it reads and what it cannot see: rights an engine grants by default, such as a view reading with its owner’s rights, SQL built at run time, and every PtahMigration.

That reading is of keywords, and it is a filter rather than a boundary. The boundary is the database login an Apply runs as: an Apply can do what that login can do and nothing more, so a login that cannot grant, create roles or own other roles’ objects keeps those out of every plan, read or not. Keep it to what the artifact manages, as Databases and privileges and Does the login need superuser? describe.

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.

The chart ships the guard that makes it one, on by default. applyPolicyGuard renders a ValidatingAdmissionPolicy and its binding, cluster-scoped and owned by the release, 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. The refusal names the field and says what to do instead: use OnApproval, or ask an apply-policy administrator to make the change.

Who counts as one is applyPolicyGuard.exemptGroups, the groups whose members may select Always. The default is system:masters alone. It is the one group Kubernetes itself puts beyond authorization, and its members could delete the policy anyway, so exempting them opens nothing and keeps a way to choose Always that needs no Helm upgrade. No other group is guessed: cluster-admin is a ClusterRole bound to whichever groups a distribution chose, and a chart that named one of them would exempt a different set of people on every distribution. Add the group that owns apply policy in your cluster, and the group of a GitOps controller that creates resources with Always (for a ServiceAccount, its system:serviceaccounts:<namespace> group). The chart refuses system:authenticated there, because exempting every authenticated identity is turning the guard off, and that is applyPolicyGuard.enabled: false, said openly. An installation that wants unattended apply for everyone sets it, and this section then does not apply to it.

What the guard 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, so the resource’s owner keeps editing everything else in it, and so is moving back to OnApproval; arriving at Always from anywhere else is not. Upgrading an installation whose resources already apply with Always changes nothing for them: they keep applying and their owners keep editing them. What changes is who may create the next one.

The binding covers every namespace, and its validationActions is Deny; Warn and Audit would record the bypass rather than refuse it. The envtest suite under test/envtest/admissionpolicy holds the installed policy to each request above, and the acceptance suite holds it to an impersonated author and administrator on a cluster.

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, plan chunks and ConfigMaps: the chunks are a plan’s SQL, and a ConfigMap may be the copy of it an Apply mounted. It reads the plan manifests, which carry the digests, the statement count and the privilege kinds, and operation Pod logs, since Plan results stay out of logs. Grant the SQL to reviewers, with the approver role or examples/approver-plan-reader-role.yaml.

These write boundaries reduce the effect of controller bugs and prevent its RBAC from becoming arbitrary workload or ConfigMap creation authority. The one ConfigMap the manager may create is an immutable copy of a chunk, owned by its plan, written while an Apply of that plan is being dispatched and holding exactly the bytes the plan records. 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.

An approval proves an authenticated approver. On its own it proves nothing about that approver being a second person, so an author with a Role broad enough to also approve – or a deployment that never separated the two Roles at all – could approve their own plan. approvals.requireDistinctApprover closes that gap. It is a chart value, owned by whoever installs the operator, and read once into the manager’s own flag; no field on PtahSchema or PtahMigration can reach it. It defaults to false, so an existing installation that never sets it keeps admitting the approvals it always did.

The switch was a per-resource field until this control’s first release exposed what that placement could not bind: spec.policy.requireDistinctApprover lived in the same spec the check judged, so an author who could also approve could turn it off, let that edit retire the current plan and its binding, wait for the operator to replan against the new spec, and approve the identical plan as themselves, with the resource carrying no record that the control was ever off. A guard that the party it constrains can also switch off is not a guard. Moving it into the chart is the same move #450 already made for the other approval control, the apply-mode guard: put the switch where the author’s own RBAC over their namespace cannot reach it.

A mutating webhook on PtahSchema and PtahMigration records the authenticated identity behind every CREATE and every UPDATE that changes spec other than spec.suspend, in two annotations only that webhook ever writes: it overwrites whatever a request carried for them, the same way the approval webhook overwrites a forged spec.approver, so an author cannot name someone else as the resource’s writer any more than they can name someone else as the approver. An update that leaves spec alone – a label, the finalizer the controller itself adds and removes, a status write, which is a separate subresource the annotations cannot reach at all – leaves the recorded identity untouched. When the installation’s flag is true, the approval webhook refuses an approval whose approver is exactly that identity, or whose resource carries no recorded identity at all.

The chart installs that mutating webhook’s two entries under the same flag, rather than leaving them bound whatever the flag says. An entry with failurePolicy: Fail refuses every PtahSchema and PtahMigration write while the manager is unreachable, including across an upgrade under a Recreate strategy; with the switch off, which is the default, nothing reads the recorded identity, so paying that coupling on every write buys nothing. One consequence follows directly: a resource written while the switch was off carries no recorded writer, and turning the switch on does not retroactively supply one. Its approvals are refused, by the same “no spec writer is recorded” reason a missing annotation always produces, until its spec is next changed and the now-installed webhook has a write to stamp. Suspending or resuming it is not such a change: the webhook does not count spec.suspend. An installer turning this on should expect that gap, not read it as the control misbehaving.

The record is metadata rather than spec or status on purpose. status is a subresource on both kinds, and the API server resets it to its previous value on every write through the main resource and drops it outright on create, so a mutating webhook on the main resource can never persist anything there. spec is the author’s own field to write; recording the mutation there would tie the two together in the one place meant to hold only their own intent, which is exactly the placement this control moved away from.

Group membership plays no part in the comparison, and a ServiceAccount is one identity like any other: the same ServiceAccount that wrote the spec and later submits the approval is refused exactly as a person doing both would be. A GitOps controller that writes every schema and migration spec makes every human approver distinct by construction, because no human ever appears as the recorded writer; this control is aimed at a human editing spec directly, and adds nothing where one already runs. What the check compares is exactly what the cluster’s authenticator put on the request – a username, and a UID where the authenticator supplies one – and that is also its limit. Impersonation, or a credential more than one person holds, is indistinguishable at that layer from two different people, and this control cannot see past it: identity here is only as good as the cluster’s own authentication.

And because the switch lives in the chart rather than in any namespace’s resources, it does not stop an author who can also edit or upgrade this Helm release: that is authority over the installation itself, wider than authority over one namespace’s schemas, and no admission control this operator ships reaches it. Closing that gap is RBAC over Helm releases and over the operator’s own namespace, not anything the operator can enforce from inside a cluster it does not administer. Where any of this matters, pair the control with the RBAC separation in Who may turn the approval requirement off rather than relying on either alone.

Which resources count as managing one database decides who can refuse whom: the operator refuses every claimant of a realm more than one resource claims without agreeing to share it. A claim anybody could make by writing a string would let a principal who can create a PtahSchema in one namespace, with no Secret and no database access, stop every resource of another namespace and learn that the key was in use.

So a claim reaches only as far as something grants it. A spec.target.coordinationKey is scoped to its resource’s namespace: the realm is the engine, the namespace and the key, and the same key elsewhere is another realm and another Lease. A claim across namespaces is made with spec.target.realmRef, and the PtahRealm it names lists the namespaces it admits. A resource elsewhere is refused with reason RealmNotAuthorized, runs nothing, and is left out of the census of the admitted resources, so the refusal falls on the resource that made the claim and on nobody else.

The boundary holds only while authors cannot write realms. Realms are cluster-scoped, so no namespaced Role grants them; grant them only through a ClusterRole such as examples/realm-administrator-role.yaml, and do not aggregate ptahrealms into the built-in edit or admin roles. The manager reads realms and holds no write verb on them. A realm lists namespaces by name rather than by selector, because a namespace’s labels are often writable by whoever administers that namespace.

Visibility follows the same line. A realm names the namespaces it admits, so it is for administrators, and the operator writes no status into it. A resource’s own status names the realm it asked for, its own namespace and counts of claimants, never another namespace or resource. A refused resource reads the same message whether the realm is missing, does not list its namespace or names another engine, so a tenant cannot probe for realms it may not use.

Listing a namespace in a realm is trusting it with the database. A listed namespace can still contest the realm by creating a second claimant, as any claimant can; what a realm withholds is that power from the namespaces it does not list.

The grant is to a namespace name, not to a namespace object, so it passes to whoever holds that name. A name listed before its namespace exists, or left listed after the namespace is deleted, goes to whoever creates the namespace next. Where tenants choose their own namespace names – OpenShift project self-provisioning, Capsule, HNC subnamespaces – that is any tenant, and the denial of service a realm exists to prevent comes back through the grant itself. Remove a namespace from every realm before deleting it, and do not list a name that does not exist yet on a cluster where tenants can create namespaces.

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.

A registry that requires a client certificate is not supported, and the API has no field for one. The pinned executor loads a client pair into a process-wide TLS configuration and cannot constrain certificate selection after a cross-host redirect. A client-certificate transport needs an executor contract that selects the certificate against the effective TLS authority on every handshake.

A source ConfigMap is trusted by Kubernetes RBAC on its namespace, as the resource that names it is: whoever may write the ConfigMap may change the desired state, and so what an approval is asked for. Grant writes to it as you grant writes to the resource. It has no verification policy, and the API refuses one beside it rather than accepting a gate nothing evaluates.

The manager copies the selected files into a snapshot ConfigMap in the same namespace. Whoever may read ConfigMaps there could already read the source, so the snapshot adds no reader, except where a role grants the source ConfigMap by name alone. The controller-write webhook admits the snapshot only as it rebuilds it from the source that moment: the selected files of the ConfigMap the owning resource names, owned by that resource. The manager’s RBAC grants delete on ConfigMaps for one purpose, removing a snapshot nothing names, and an admission policy refuses the manager the deletion of any ConfigMap that is not a snapshot.

An operation Pod checks the snapshot against the content digest the operation was bound to before the container holding the database URL starts, so a snapshot replaced after a plan was approved is refused rather than read.

A Git source is trusted by whoever may push to the ref it names: they may change the desired state, and so what an approval is asked for, as whoever may write a source ConfigMap may. Name a branch only where pushes to it are reviewed, or name a tag or a commit. A commit holds only on a server that says it gives commits by id; on any other it is refused once its branch moves on, as A Git source says, and a tag is the pin that holds. It has no verification policy, and the API refuses one beside it.

The manager never reads the repository or the credential Secret. The Secret names its own scope in fixed keys a resource’s author cannot choose: host, the only host[:port] the credential is sent to, and caSHA256 or knownHostsSHA256, the digests of the trust it may be sent under. A resource that names the Secret with another URL, CA or known-hosts file is refused in the Pod before Ptah starts. A credential goes in the Secret, never in the URL, which the API refuses with one. Over ssh:// the known hosts are required: the fetch never trusts a host key on first use.

What a fetch reads is bounded: one commit, fetched shallowly; the selected path only, at most 1,000 files and 64 MiB; no submodule, symbolic link or LFS pointer, each refused by name; no checkout, hook or filter, so a repository cannot run anything in the Pod.

A team that wants to bound what its ConfigMap and Git sources may name sets sourcePolicyFrom on the resource: an immutable ConfigMap holding a YAML document with up to three rules.

allowedGitHosts: # the only host[:port] a Git source may name
- git.example.com
allowedConfigMaps: # the only ConfigMaps a ConfigMap source may name
- inventory-schema
maxContentBytes: 1048576 # the most the selected files may hold together

A rule that is absent refuses nothing, and a list that is present and empty allows nothing of its kind. A field the document does not define is refused, so a misspelled rule is not read as one kept. The manager reads the policy, never the source’s credential: a Git host it does not allow gets no Resolve Job, so the credential is never sent there; a ConfigMap it does not allow, or files that hold too much, leave no snapshot; a Git commit whose selected files hold too much is not recorded. The refusal names the rule.

A plan binds the policy’s object and bytes beside the source. Another policy, the same bytes in another ConfigMap included, retires the plan, and so does naming a policy where none was or dropping one; approval and Apply check the live policy again. The API refuses sourcePolicyFrom beside ociRef: an OCI source has its verification policy.

Plan chunks 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 PtahSchemaPlanChunk read access in application namespaces accordingly. One rule on that kind reads every plan in the namespace, and the built-in approver role carries it, as described in Exact-plan approvals. What that access is used with is kubectl ptah. Its read commands only read: they need get on the schema, the plan and its chunks and nothing else, and list approvals only where list on them is granted. kubectl ptah approve creates an approval as the person who runs it, and needs create on the approval kinds as well. kubectl ptah suspend, resume, refresh and set-source patch the resource as the person who runs them, and need patch on it, which no field narrows: an identity that may suspend a resource or point it at another artifact may edit the rest of its spec too, so grant it with the desired-state author’s verbs and not with the approver’s; Read a plan carries the Role and the install.

A plan that reached an Apply has one more copy. The Apply Pod holds no Kubernetes API credential and the kubelet mounts no custom resource, so the operator writes the plan into immutable ConfigMaps of its chunks’ names, owned by the plan, just before it creates the Apply Job, and they are deleted with the plan. Whoever may read ConfigMaps in the namespace reads the plans that were applied there. A plan that is waiting for a person, or that was never applied, has no ConfigMap.

The default runner sends its result over TLS with a Pod-bound token whose sole audience is operator.ptah.run/results. The exact plan is stored in confidential PtahResultRecord payloads before acknowledgment, then independently validated and published into immutable plan chunks. Neither SQL nor reusable delivery credentials belong in diagnostic logs, Events, or ordinary status. Access to result records exposes plans; old development credential records can also contain private delivery keys. Exclude result records from ordinary reader and author roles and cover them with encryption at rest and restricted backups. The diagnostic reader example grants no result-record access.

Kubelet rotates the explicit projected token. Automatic API-token mounting stays disabled, and the runner receives no API permissions. The receiver audience must not be configured as an accepted Kubernetes API audience. Admission keeps the projection out of helper containers. The receiver verifies TokenReview, the immutable original-Pod binding, and the live operation; a body that claims another resource, Job or Pod cannot acquire that identity. No per-operation Secret is created. A manager restart does not discard the stored result or require its original process key.

When resultDelivery.enabled=false, legacy Jobs instead write one frame to stdout. A Plan payload in that frame is sealed to a manager process key; logs hold ciphertext. Loss of that key can require another read-only Plan. This legacy transport is outside the stable 0.2.0 qualification profile.

Migration run error text that can disclose database values is removed from the structured result in both transports. Failure reports name the outcome and affected versions, not the database’s free-text description.

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 structured result containing the operation ID, coordination digest, and protocol version. A target sets exactly one of spec.target.coordinationKey and spec.target.realmRef, and both are non-secret operator inputs. A key is hashed with the normalized engine and the resource’s namespace, a realm’s name 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 structured result. Observe exposes one canonical category aggregate for each category the report found, 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 rather than being published or dropped. The vocabulary is the list the pinned Ptah can emit, recorded in support/ptah-drift-categories.json, and a Ptah pin that grows that list cannot merge until the vocabulary, the status enum and the CRD schema version follow it. The frame never carries object names, SQL, schema literals, or the native diff. The vocabulary fits under the frame’s and the status’s bound of 64 aggregates, so none is ever left out and driftFindingCount is their sum. Drift the report has no category for, such as a grant, crosses as drift with no aggregates at all: a zero count and a safe highest severity, and nothing that names the grant. 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 result carries native stdout. Planning executes twice under the target Lease, reads each plan from the file schema plan --output saved, requires the two files to be byte-identical, and validates the accepted bytes through a native Apply dry-run before publication. Every Plan and Apply read goes through the versioned JSON document Ptah prints under --json: a document of another contract version, an outcome the runner does not know, or a digest that does not name the exact plan bytes is refused, and nothing is matched in the text Ptah writes for a person. The independent operator classifier may raise destructive severity from the rendered SQL and never lowers executor metadata. It also rejects credential-bearing principal DDL.

Native stderr from Plan and Apply is discarded rather than forwarded: under --json it lists the planned statements, which can carry declared row values. Apply native stdout is never copied into the structured result or runner diagnostics, including failure paths, and neither is the error sentence its report carries. Only generic typed failures leave the runner. A stale plan is reported as stale_plan, whether the schema or the declared rows moved, and like every other Apply failure it stays uncertain until an observation settles it: another Pod of the same Job may have run, so no one child’s report proves that nothing was sent.

A successful Plan frame is the one frame that carries the plan: it transports the exact plan bytes through the authenticated receiver before they are committed to immutable chunks. See Plan results stay out of logs. Apply frames never contain native SQL output.

The runner also writes a summary of each frame into its container’s termination message, which the kubelet copies into Pod status, where anyone who can read the Pod can read it. It holds the operation id, two flags, an error code from a fixed vocabulary, the realm and target identity digests, a migration run’s outcome with the count and the first and last applied versions, and the frame’s SHA-256. Apart from that digest, the resource’s own status already carries all of it, and it holds no plan bytes, error text, SQL or native output. Pod status is written by the kubelet and by control-plane controllers, and no default user-facing role grants it, so forging a summary takes either the node’s credentials, which can already forge the log it stands in for, or a grant the default roles keep for the control plane.

  • 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. That test reads selectors; the acceptance suite checks enforcement. On both engines it applies the example in a cluster whose CNI enforces NetworkPolicy, adapted only where the example says to adapt it, and checks what each operation can reach: DNS always, the database and the registry exactly where the example grants them, and nothing else. The readings come from probe Pods labeled as each operation. Admission refuses a Pod that claims the operator’s own managed-by value without a Job the operator made, so the probes carry another value and a copy of the policies selects it; the copy differs from the example in that one value, which the suite checks. A real migration then runs to InSync under the example itself. A CNI that does not enforce NetworkPolicy makes every one of these policies a no-op, so check yours does before relying on them.
  • 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.
  • Keep the release namespace and the coordination namespace, which holds the shared target locks, to Ptah administrators, as the release namespace contract says.
  • 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, and Ptah resource kinds in the namespace. For a database that more than one namespace manages, create a PtahRealm listing exactly those namespaces, and keep write access to realms with the cluster’s administrators. Remove a namespace from every realm before deleting it, and where tenants can create namespaces, list only names that already exist.
  • 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.