# ADR 5 — SSH access over the API<no value>

<!-- Generated by website/sync-docs.py from docs/adr/0005-ssh-access-over-the-api.md. Edit that file, not this one. -->

Status: accepted

## Context

ADR 4 made the management API shell-less on purpose. The API reads the state
of a node, drives its upgrade, and moves it through its lifecycle. It gives no
shell, for two reasons. First, a shell over an immutable system grants root
access to change things that will not persist. Second, to the question "how
does a node learn whom to obey", a pairing code over the console is a better
answer than a key baked into cloud-init. The API replaced SSH, and ADR 4 said
so.

Two facts do not fit well with that position.

First, Corium never removed SSH. It only stopped using it. The base image,
`quay.io/fedora/fedora-bootc`, ships `openssh-server`. The `minimal-plus`
manifest, which the standard image builds on, pulls it in. The base image
keeps `sshd` enabled through the default systemd preset, and nothing in the
Corium overlay turns it off. A node in service already listens on port 22. A
closed port does not keep it shut. An empty guest list does:
`disable_root: true` and `users: []` in `05-corium.cfg` mean that there is no
account to log into. The lock is real, but it comes from the absence of a
user, not the absence of a daemon.

Second, on day two you sometimes need a shell anyway. For example, a node
misbehaves in a way that the four API surfaces were never meant to reach, and
the correct tool is to log in and look around. Today, a key can reach a node
in only two ways. cloud-init is one, but it closed when the node first booted.
`cctl reset` is the other, but it removes the node from its cluster to change
one file. Neither fits "let me in to look."

Decision 7 is the constraint that shapes any answer. cloud-init is the only
authority over users and their SSH keys, so that two systems never race to
own an account. Whatever the API does with SSH, it must not become that
second authority.

## Decision

`apid` gets a small surface to manage authorized SSH keys. The surface is
narrow enough that decision 7 still holds.

The API manages keys for a user that **already exists**. It never creates an
account, sets a password, chooses a shell, or grants sudo. All of that stays
with cloud-init, so the authority over *who* can log in, and with what
privilege, does not change. The API owns key material, and only that.

The API keeps that material in its own file. Corium keeps the keys of a user
in `/var/lib/corium/ssh/<user>`, owned by root. `sshd` reads them through a
drop-in that Corium ships at `/etc/ssh/sshd_config.d/10-corium.conf`. The
drop-in is in `/etc` because `sshd` reads drop-ins from no other place. This
is the second deliberate exception to the Corium rule about shipping
configuration there:

    AuthorizedKeysFile .ssh/authorized_keys /var/lib/corium/ssh/%u

The file has mode `0644` in a `0711` directory, under a `/var/lib/corium` that
is also `0711`. These modes are necessary, not careless. **`sshd` opens an
authorized-keys file as the user who is logging in, not as root**, so it
silently skips a file that only root can read. The first working version of
this feature wrote the file with `0600` in a `0700` directory. The API
accepted the key, `list` returned it, and the journal recorded it as trusted.
But `sshd` refused every login, and no log said why. `StrictModes` is the rule
that actually governs these modes. It refuses a path that other users can
*write*, and other users cannot write any of these paths. The read bit gives
nothing away, because a public key is not a secret. For the same reason,
`list` is a readonly call.

The directories are `0711` and not `0755` because permission to pass through a
directory is not permission to list it. Nobody needs to list which users have
keys. Also, `/var/lib/corium` holds other things, for example the state
directory of the API, which holds the serving key of this node. That directory
stays `0700`. A user can pass through `/var/lib/corium`, but cannot open that
directory.

The two files never collide. A key that an operator placed by hand, or that
cloud-init wrote, is in the file of the user, and the API does not touch it. A
key that the API added is in the Corium file, and `list` and `revoke` act only
there. Each file has one writer.

Three calls, on an enrolled node:

- `POST /v1/access/ssh` (admin): adds a public key for a named user that
  already exists. It returns the SHA-256 fingerprint of the key.
- `GET /v1/access/ssh` (readonly): returns the installed keys, by fingerprint
  and comment. A public key is not a secret. The ability to read what a node
  trusts is worth more than the little that hiding it would give.
- `DELETE /v1/access/ssh/{fingerprint}` (admin): removes one key.

The add and remove calls require admin because of what they grant, not
because of what they change. Every other call in this API acts inside the
guard rails of the machine. A shell goes outside them. No role is above admin,
so this is the most consequential action that `apid` does. `apid` logs it
loudly, by fingerprint and caller. It never treats the public key as the
thing to hide, and it never logs a shell.

`cctl reset` removes `/var/lib/corium/ssh`. The keys that the API added go
away with the enrolment that authorized them. The account stays, because it
belongs to cloud-init, and reset does not change the state of another system.

## Consequences

This is the first deliberate exception to "the API has no shell". We name it
as an exception, and we do not pretend that the line held. The exception is
narrow. The API grants a key, not a command. The shell that the key opens
belongs to the operating system. The `sshd` of the OS, its PAM stack, and its
SELinux policy control that shell, and `apid` mediates none of them. `apid`
decides who is trusted. It does not become the thing they talk to.

Corium now depends openly on the presence of an enabled `sshd` in the base
image. Before this decision, Corium inherited this property without stating
it. The digest pin of the base image also pins this dependency, and each
release checks it again. The dependency also raises a question that this ADR
does not settle: will Corium *own* the decision to run `sshd` at all, instead
of inheriting an enabled daemon? A follow-up will answer it. This decision
only makes the inherited daemon usable on purpose.

SELinux does not let `sshd` read an authorized-keys file outside a home
directory unless the file has the correct label. Corium labels these files
`ssh_home_t`, the type that `~/.ssh` already has. This labeling is the cost of
keeping the Corium keys in their own file. Corium never pays that cost by
weakening enforcement.

The method that applies the label needs a precise description, because the
obvious method is not available. A file-context rule in the policy
(`semanage fcontext`) is the durable method, and Corium cannot ship it.
`semanage` writes into `/var/lib/selinux`. The install seeds `/var`, and an
image never updates it. Thus a rule added that way reaches the machines
installed with it, and no machine upgraded into it. The same limit stopped
Corium from shipping its own SELinux module.

So Corium applies the label with `chcon`, twice: when it writes a key file,
and again every time `corium-apid` starts. The second pass is necessary, not
extra caution. A `restorecon -R /var`, a policy update, or a filesystem
relabel resets these files to the default type for `/var`. We measured the
result on a node, and did not only reason about it. A working login became
`Permission denied`, with no message in any log, on a machine whose API still
reported the key as trusted. Because Corium applies the label again at start,
a relabeled machine repairs itself on the next boot. The same pass widens the
modes that an earlier release wrote. This is what fixes a node that was
upgraded into this feature instead of installed with it.

**One window stays open, and this record will not pretend otherwise:** if a
relabel happens while the daemon runs, access breaks until `corium-apid`
starts again. To close the window fully, Corium needs the policy rule that it
cannot ship. A watch on the file, or a new label check on every call, would
make the gap smaller but not close it. Neither seemed worth the extra
machinery for a failure that a service restart fixes.

## Alternatives considered

**Model `users:` and `ssh_authorized_keys:` in the `corium:` schema, applied
over `POST /v1/config`.** Rejected for two reasons. First, the configuration
endpoint refuses requests after a node bootstraps, and this feature exists
exactly for that period. Second, users in the schema would make Corium an
owner of accounts. That is the collision with decision 7 that this design
avoids, not a way around it.

**A dedicated Corium-owned break-glass account, which the API creates only
when it adds the first key.** This is attractive, because it needs nothing
from cloud-init. Rejected, because to own an account is to own its password,
its shell, and its sudo policy. Decision 7 keeps exactly those things out of
the control of Corium. To manage a key for a user that someone else created
is a much smaller claim.

**An `api.insecure`-style open door: empty passwords, or SSH with no key at
all.** No.
