Invariants
Rules the code depends on. Each one is load-bearing: something elsewhere is written assuming it holds, so changing one is a design decision, not a refactor. Code comments cite this file by name.
For what ssoossh deliberately does not do, and why, see Decisions. That page answers “why is there no X”; this one answers “what may I rely on”. For the operator-facing version of the same rules, see the security model.
Issuance
Section titled “Issuance”Server config is the outer bound. Nothing reachable over HTTP can make issuance more permissive than the loaded configuration allows: not longer-lived, not more capable. Client requests ask, the web UI narrows, server config gates. Options a deployment does not permit are trimmed rather than rejected, so both the requested and the narrowed sets are carried together and the approval page can show what was asked for alongside what would actually be granted. A human authorizing issuance has to see what they are authorizing.
Standing policy sits in the same shape, with the administrator in the “narrows” position: policy may only ever reduce what the config file already allows. See Certificate lifetime policy.
Every issuance is approved by a human in the browser, then signed. There is no path that turns a request into a certificate without passing through the sign queue.
no-touch-required is granted only for service certificates, and only for
an enrolled hardware-backed sk- key. Never for client-generated keys.
verify-required is never used.
There is no host certificate type, and no secure host-verification
mechanism to justify one. ssoossh host is local principal-mapping tooling
for AuthorizedPrincipalsCommand and has no server side. Do not add a host
type without solving host verification first.
Private keys never leave the machine that generated them. The server never generates or stores a private key; it records public key material and issuance metadata only. The client manages its own keypair.
The CA key is reached through a CAKeySource interface, never hardcoded
to one backend. Four sources sit behind that seam (an inline PEM, a key file,
an ssh-agent and a PKCS#11 token), which is also what lets the signer run as a separate,
minimally-privileged process.
Identity
Section titled “Identity”Group membership never appears in a certificate, and persisted group rows
never grant anything. Authorization – admin, SOC, auditor, fleet key admin,
and the certificate policy gates – is evaluated from the session identity
only. Roles and policy come from the session identity, never from database
rows, with one narrowing exception: with ldap.authoritative_role_groups on,
a role group (admin, SOC, auditor, fleet key admin) counts for a
directory-linked user only while the latest directory read still lists it, so
the sync can take a role away between logins, effective on the next request.
The directory never grants a role. Groups can change between sessions, so
model.User keys history to a stable subject identifier rather than storing
claims.
user_groups is a snapshot for notification fan-out and display: it answers
“who should this reach”, never “may this caller do this”. It was added by
directory sync (see LDAP enrichment), which
retired the older, stronger wording that group membership is never persisted
at all. What survives is the half that was load-bearing: nothing reads those
rows to grant anything. The one decision they feed is the narrowing above,
which can only remove a role group the session’s claim already carries, and
only when an operator opts in (see
Authoritative role groups).
Roles come from the session; account lists come from the database. The two halves of an identity are refreshed on opposite schedules, deliberately:
- Groups are read at login and never re-read, which is what makes the
session lifetime the revocation window for a role. Nothing stored can grant
one. With
ldap.authoritative_role_groupson, every request narrows them: a role group the latest directory read for a linked user no longer lists stops counting on that request, and counts again once a read lists it again. The session keeps the login’s claim, so narrowing never compounds. - Account lists and extra fields –
other_accounts,service_accounts, and the configured extras – are re-read from the database on every authenticated request, rebuilt the way a login builds them: the users row first, then the directory values over the top.
The second half is what stops a live session outliving a directory change. Approval linkage reads those lists to decide which principals a certificate may carry, so a session holding a login-time snapshot could go on approving a certificate for a shared account the directory had already taken away. The refresh fails open – a database error leaves the session’s values in place and logs, because no principals at all is a worse failure than briefly stale ones.
Session and authorization headers are never captured. The headers recorded
on an approval decision are a deliberate allowlist – User-Agent,
Accept-Language, X-Forwarded-For – not “every header minus a denylist”.
Cookie carries the live session token; neither it nor Authorization is
ever written to an audit record.
Client-supplied addresses never feed policy.
RequestedOptions.SourceAddresses is unverified. The address policy consults
is the one the server observed.
Client
Section titled “Client”The client never opens a listening port. No loopback redirect, no local callback server. The browser approval flow works without one.
Package boundaries
Section titled “Package boundaries”client/ may not import server/.
Anything shared travels through internal/ – internal/api for the HTTP
client, internal/apitypes for the wire contract. This is why
apitypes.CertificateRequestStatus duplicates model.CertificateRequestStatus
rather than importing it; server/model remains the source of truth for the
database, and Wire types covers how the two
are kept from drifting.
The wire contract is cross-repository. internal/apitypes and the
internal/principalsmap file format are also spoken by pam_ssoossh
(github.com/mnestor/ssoossh-pam),
which is written in C and shares no code with this repository. A breaking
change to either is a change in two repositories, not one. docs/wire-contract.json
is what makes such a change visible: it versions the shapes, and
make wire-contract-check gates it. See wire-types.md.
internal/ may not import back up into client/ or server/. A caller
maps its own config into whatever the internal package defines, which is why
api.Config exists rather than internal code taking a client/config.Config.