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 topods/exec,serviceaccounts/tokenand 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
defaultor starts withkube-, 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/instancelabel. 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 withhelm 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.
Where the product boundary is
Section titled “Where the product boundary is”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.
Recreateand 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.
Trust boundaries
Section titled “Trust boundaries”The operator separates five 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 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. - 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.
- 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.enabledremoves 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 aPtahMigrationRunAcknowledgment, which admission stamps with who made it and the migration records. - A Job receives only the credentials needed for its fixed operation through same-namespace Secret selectors resolved by the kubelet.
- 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.
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 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.
Refusing a self-approval
Section titled “Refusing a self-approval”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.
Who may claim a database
Section titled “Who may claim a database”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.
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.
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.
ConfigMap sources
Section titled “ConfigMap sources”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.
Git sources
Section titled “Git sources”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.
Source policies
Section titled “Source policies”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.comallowedConfigMaps: # the only ConfigMaps a ConfigMap source may name - inventory-schemamaxContentBytes: 1048576 # the most the selected files may hold togetherA 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 and approval visibility
Section titled “Plan and approval visibility”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.
Plan results stay out of logs
Section titled “Plan results stay out of logs”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.
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 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.
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. 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 ownmanaged-byvalue 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 toInSyncunder 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
PtahRealmlisting 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.