Auth and tokens

JACO has three distinct credentials and one filesystem-permission trust boundary. Knowing which one a command needs prevents the most common "why am I getting token_invalid?" mistakes.

Source-of-truth code: internal/controlplane/admission/admission.go.

The three credentials

Operator token

A 64-character hex string bound to an identity name (e.g. bootstrap, alice, ci-deploy). Used for every state-changing RPC over the cross-host gRPC listener. Issued by:

  • jaco cluster init — prints the first one once. Identity bootstrap. Carries allows_privileged = false.
  • jaco token issue --name <id> [--allow-privileged] — mints additional ones. The plaintext is printed once; only the SHA-256 hash is stored in raft as Token{identity, hashed_secret, issued_at, allows_privileged}.

allows_privileged flag (issue #119)

A token's persisted allows_privileged flag gates admission of manifests that set compose privileged: true or a non-empty security_opt: list. Apply checks BOTH the token flag AND a matching labels: { "jaco.io/allow-privileged": "true" } on the service before admitting; see Supported compose fields → Privileged services.

Issuing the flag is itself not gated on the caller already being privileged — any valid operator token can mint a privileged one. The second fence (the service label) is what defends against accidental privileged: true in a manifest from a non-privileged author.

Local unix-socket callers bypass the token check entirely (the socket permissions already gate operator-class access); the label fence still runs.

Revoked via jaco token revoke <id>. Revocation is a raft write applied on every node; the next RPC presented with the revoked token returns Error{code: token_revoked} cluster-wide within one apply (well under the spec's 5 s bar).

Pass via --token <hex> or JACO_TOKEN.

Join token

A single-use, 24-hour-TTL token used to attach a new node to an existing cluster. Hashed in raft as JoinToken{hashed_secret, issued_at, expires_at, consumed_at?}. The plaintext is printed once.

Mint with jaco node issue-join-token (operator-authenticated). Consume with jaco node join --token <hex> (or JACO_JOIN_TOKEN). Marked consumed_at on use; cannot be reused.

Cluster CA

The cluster's self-signed CA, generated by jaco cluster init. Replicated to every node via raft (private key included; never leaves the cluster boundary). Off-node CLI calls pin this cert via --ca-cert (default /var/lib/jaco/node/ca.crt, override via JACO_CA_CERT) so the dial verifies the daemon's server cert against the cluster CA.

Today, without --ca-cert, the dial falls back to InsecureSkipVerify with a one-line stderr warning — fine for v0 bootstrapping while the CA distribution UX is in flight, but pin it in any environment you care about.

jaco node issue-join-token --show-ca prints the cluster CA PEM to stdout so you can stash it on the joining node before running jaco node join.

Registry credentials

Container-registry credentials used at image-pull time are replicated across the raft and consumed by every node — operators set them once via jaco registry login and every node (including ones that join later) can pull from private registries without per-node ~/.docker/config.json.

The entity is RegistryCredential{registry, username, secret, updated_at}, keyed by a canonical host[:port][/namespace[/segment...]] string. The host portion folds Docker Hub variants to docker.io and preserves non-default ports verbatim; any optional /namespace suffix is lower-cased and trim-of-trailing-slash. Two credentials under the same host but different namespace prefixes (ghcr.io/personal vs ghcr.io/company) coexist as independent entries; the prior collapse to bare-host stomped them, which this scheme fixes.

At image-pull time the reconciler derives the full host[:port]/<repository-path> from the image ref and picks the stored credential whose key is the longest path-aligned prefix of that lookup key — so ghcr.io/personal/repo:tag selects ghcr.io/personal when present, falling back to a bare ghcr.io entry only when no namespace-scoped key matches. The chosen credential becomes the base64-encoded X-Registry-Auth blob threaded into image.PullOptions.RegistryAuth. A missing credential is not an error — the pull proceeds anonymously.

At-rest posture. Registry secrets sit in raft unencrypted, behind the same filesystem-permission + WireGuard-transport trust boundary as the cluster CA private key (ca_key in ClusterMeta) and ACME private keys (CertBlob). This is acceptable for the initial drop because the trust posture is symmetric across all replicated secrets; envelope encryption is a possible follow-up that should arguably cover certs/CA in the same pass rather than singling out registry creds (issue #101 notes this explicitly).

Values resolved from a jaco.yaml top-level environment: file are baked into the resolved compose bytes at apply time and ride raft as part of the per-deployment compose_yaml field — so they sit under the same trust posture as compose_yaml already does today, not a new secret category.

Replication semantics. Add/Remove gate on the raft leader and propagate via a normal raft Apply, so the credential is on every follower within one apply cycle (well under the spec's 5 s bar). FSMSnapshot includes repeated RegistryCredential registry_credentials = 18 so a fresh node that installs a snapshot picks up every credential without operator re-entry.

Read paths never expose the secret. jaco registry list returns a RegistryCredentialSummary (registry + username + updated_at); the wire type has no secret field. Audit events for upsert/remove carry registry + username only — symmetric with TOKEN_ISSUE which records only the identity. The only way to rotate a credential is to login again with the new secret; there is no read-back.

The unix-socket trust boundary

On a cluster node, the local daemon listens on /var/run/jaco/jaco.sock, mode 0660, group jaco. Anyone in the jaco group can drive the local daemon without presenting a bearer token — the socket's filesystem permissions ARE the auth boundary; the kernel enforces group membership at connect(2).

Audit events for socket-origin RPCs are attributed to the special identity local, so on-node actions remain traceable even though no token was involved.

isUnixPeer in admission.go fails closed: when peer info is missing or the network is anything other than unix, the bearer-token check applies. The bypass triggers only for genuine unix-socket peers — TCP loopback still requires a token.

Pre-init exception

Before jaco cluster init or jaco node join runs, the daemon is uninitialized. The admission gate accepts exactly three methods without a token:

  • Cluster.Init
  • Cluster.Join
  • Cluster.Status

Every other RPC returns Error{code: cluster_uninitialized} with Unavailable gRPC status. This is what jaco cluster status on a fresh node calls, so liveness probes work before the cluster exists.

After Init or Join completes, the gate flips and the standard token / socket-trust admission applies to every RPC.

Which auth path does each command use?

Every operator command honors the same dial shape, encoded in dialOperator():

  • --server <host:port> set → TCP path, bearer token required (--token or JACO_TOKEN).
  • --server unset → unix-socket path, no token; the socket file must exist (--socket or JACO_SOCKET overrides the default).

A subset of commands (rollback, delete, token *, node list) currently require --server; the unix-socket path for those RPCs is planned. The CLI pages list per-command auth explicitly.

Audit trail

Every state-changing RPC writes an AuditEvent{identity, type, payload} through raft. The identity is the resolved name (e.g. alice, ci-deploy) for token-authenticated RPCs, local for socket-trust RPCs, bootstrap for the first-token operator, or system for daemon-driven events (e.g. isolation_ruleset_reconciled).

See jaco audit and Status and errors for the audit-type enum.

  • Mint one operator token per human + per CI job. Bind each to a meaningful identity so audit events are useful.
  • Revoke as soon as a token leaves the password manager (someone left the team, a CI runner rolled keys, a token leaked into a log).
  • On-node operations from root (sudo jaco …) over the unix socket are simpler and safer than a token round-trip — use them when you're on the box.
  • Distribute the cluster CA cert with your operator-tools onboarding; do not rely on the InsecureSkipVerify fallback.

See also