Security model
Adjoint's security story rests on one architectural decision: your data never enters our infrastructure. The control plane orchestrates; the data lives, moves, and is queried exclusively inside your AWS account. This page lays out precisely what each side holds, how credentials move, and — just as important — what Adjoint does not do yet.
What the control plane stores
Adjoint's control plane database contains metadata only:
- Orgs, users (email + bcrypt password hash), memberships, and invites (token hashes, never raw tokens).
- The catalog: golden names, versions, sizes, lineage, share scopes.
- Source registrations: host, port, database name, sslmode, and the name of the credentials Secret in your cluster — no password column exists.
- Instances: id, owner, size, TTL, lifecycle state, and the in-cluster endpoint name — no database credentials.
- Cluster registrations (agent enrollment token, stored as a SHA-256 hash), refresh-token hashes, quotas, and the audit log.
- Each cluster's OpenTofu state, one blob per cluster, encrypted at rest
with AES-256-GCM (key
ADJ_STATE_ENC_KEY, held in the control plane's deployment secret, never in the database). This is the one entry on this list that isn't a one-way hash — the control plane can decrypt it, and we document that plainly rather than overclaim: it's a deliberate design choice (state custody needs to be server-readable so any admin'sadjcan pull it), not an oversight. What's in it: your AWS infrastructure topology (account/resource ids, IAM role ARNs, the EKS endpoint + cluster CA, VPC/subnet/FSx ids) and Adjoint-issued secrets the control plane already knew anyway (the agent enrollment token, the registry pull password). It contains no customer AWS credentials — your AWS provider credentials never enter tofu state — and no database data. See Where provisioning state lives for the lock/sync mechanics around it.
Other than that state blob, no table stores a database password, a source password, or your data. The only other secrets at rest on our side are one-way hashes.
How database credentials move
adj creds <instance> is a live broker read, not a vault lookup:
- The control plane checks that you are the instance's owner — even an org admin gets a 403 for someone else's instance.
- It relays the request over the agent's outbound bridge connection into your cluster.
- The agent reads the instance's credentials Secret (generated by CloudNativePG inside your cluster when the instance was created) and returns it in-memory.
- The CLI prints it and forgets it — credentials are never written to the CLI config, the control-plane database, or any log. The audit trail records that a read happened, never the value.
The password itself only ever exists in your cluster (as a Kubernetes Secret) and on your screen. Credential TTL/rotation is not implemented yet — a credential stays valid for the life of the instance; destroying or resetting the instance retires it.
Accounts and sessions
- Passwords: minimum 8 characters, hashed with bcrypt. (No complexity rules today; use a password manager.)
- Sessions: short-lived access tokens (15 minutes) plus opaque refresh tokens (30 days) stored server-side as hashes. Refresh tokens rotate on every use, and presenting an already-used token revokes all of that user's sessions — the standard theft-detection pattern.
adj account sessionslists active sessions;adj logout --alland any password change revoke every other session. Disabled users are cut off at both login and refresh.
Audit trail
Every privileged action lands in a per-org, SHA-256 hash-chained audit
log, written in the same database transaction as the action itself. Each
event's hash covers the previous event's hash, so retroactive edits break
the chain. adj org audit --verify re-derives the entire chain and fails
loudly if any link is broken. See
Usage and audit.
Isolation between developers
Each developer's instances run in their own Kubernetes namespace
(ns-<user>), and a policy pack enforces the walls:
- Default-deny network policy on every developer and pool namespace: no cross-namespace connections (including to other developers' databases on 5432), no external egress; only DNS, the operator's control traffic, and in-namespace communication are allowed. Enforced by the AWS VPC CNI's network-policy agent.
- Image allowlist: developer namespaces may only run the CloudNativePG Postgres images.
- Pod security (Kubernetes
baselineprofile) and mandatory resource limits, enforced at admission.
These policies are continuously re-generated — hand-deleting a NetworkPolicy in the cluster gets it re-created. The policy pack ships with an offline test suite; a live network penetration pass against a running cluster is on our bench plan but has not been performed yet — we say so here so you can scope your own testing (we'll gladly support a pentest against your deployment).
Source credentials (imports)
The username/password you provide at adj source create are applied
directly to your data-plane cluster as a Kubernetes Secret in the
adjoint-import namespace — they never transit or persist in Adjoint's
control plane. At-rest protection for that Secret is your cluster's etcd/EBS
encryption (EKS encrypts by default); Adjoint adds no application-layer
encryption on top. Import connections to your source honor the --sslmode
you register (default require).
Connections and TLS — current reality
By default, instances are reachable only inside the data-plane VPC (the
endpoint is an in-cluster service name) — nothing is exposed to the
internet, and the laptop-to-database path is whatever your team's VPC
access is (VPN, bastion, or kubectl port-forward via adj kubeconfig).
Orgs can additionally opt in to public, TLS-required endpoints: an
admin runs adj org policy set public-endpoints on (default off, and the
change is audited). With it on, newly launched instances get a
crypto-random capability hostname
(e-<instance-token>.c-<cluster-token>.db.<domain>) reachable from the
internet on port 5432. The router that fronts it refuses any non-TLS
connection outright and never terminates or inspects the encrypted
session — it reads only the TLS SNI to route, passthrough-style; the TLS
session itself terminates at the CloudNativePG instance's own certificate,
signed by a private CA generated per cluster. adj creds returns a
sslmode=verify-full URI and saves that CA locally so the client validates
the server's identity, not just encrypts the wire. Password auth is
unchanged and still brokered live, never stored. The router also
rate-limits connections per source IP and globally.
Full details, the hostname/token model, rotation (adj rotate-endpoint),
and the in-cluster fallback: Connectivity.
What Adjoint does not have today
Stated plainly so you can evaluate against your requirements:
- No SSO, SAML, SCIM, or MFA. Auth is email + password. On the roadmap as part of the paid tier.
- No PII masking. Imports copy your data as-is — anyone who can clone a golden sees real rows. Treat golden access as production-data access in your own policies (quotas, share scopes, and the audit trail are the current controls). Automatic PII scrubbing with referential integrity is the flagship paid-tier feature in development.
- No credential rotation on live instances (destroy/reset retires credentials).
- No third-party penetration test has been performed yet.
Source and org deletion are both self-service (adj source rm, adj org delete) — see Uninstall for the full walkthrough,
including the zero-cluster precondition and the one-time final audit
export.
Questions, disclosure reports, or security review requests: [email protected].