internal/server/api/delegations.go
Ref: Size: 5.4 KiB History
package api
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/a73x/eitri/internal/server/api/types"
"github.com/a73x/eitri/internal/server/delegation"
"github.com/a73x/eitri/internal/server/sshca"
"golang.org/x/crypto/ssh"
)
// SetDelegations wires the delegation keyring. Called once at startup when the
// jump gate is enabled; leaving it nil makes the four routes answer 503, which
// is the same condition that makes remote exec refuse.
//
// The keyring is taken concretely rather than behind an interface: it is a pure
// leaf package with no I/O, so a test builds a real one, and every rule about
// what a delegation may be lives inside it rather than being restated here.
func (a *API) SetDelegations(k *delegation.Keyring) { a.delegations = k }
// delegationTenant resolves the caller's tenant and refuses when this control
// plane has no CA to verify anything against.
func (a *API) delegationTenant(w http.ResponseWriter, r *http.Request) (string, bool) {
if a.delegations == nil {
http.Error(w, "this control plane has no SSH CA configured", http.StatusServiceUnavailable)
return "", false
}
return a.userCATenant(w, r)
}
// handleBeginDelegation returns the public key the caller is to sign, and the
// command that signs it. eitri holds the private half in memory and nowhere
// else; a restart drops it and the caller delegates again.
func (a *API) handleBeginDelegation(w http.ResponseWriter, r *http.Request) {
tenant, ok := a.delegationTenant(w, r)
if !ok {
return
}
pubLine, err := a.delegations.Begin(tenant)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
principal := a.delegations.Principal
a.audit(tenant, "delegation.begin", map[string]string{
"tenant": tenant, "fingerprint": fingerprintOf(pubLine),
})
writeJSON(w, http.StatusOK, types.DelegationChallenge{
PublicKey: pubLine,
Principal: principal,
Instructions: fmt.Sprintf(
"printf '%%s\\n' '%s' > eitri-delegation.pub && "+
"ssh-keygen -s <your-ca-key> -I eitri-delegation -n %s -V +8h eitri-delegation.pub && "+
"curl -X PUT -H \"Authorization: Bearer $EITRI_TOKEN\" -H 'Content-Type: application/json' "+
"--data \"{\\\"certificate\\\": \\\"$(cat eitri-delegation-cert.pub)\\\"}\" %s",
pubLine, principal, a.URL("/api/v1/delegations")),
})
}
// handleCompleteDelegation accepts the signed certificate. Every rule about
// what makes a certificate acceptable lives in the delegation package; this
// hands its refusal straight back, because each one names the fix.
func (a *API) handleCompleteDelegation(w http.ResponseWriter, r *http.Request) {
tenant, ok := a.delegationTenant(w, r)
if !ok {
return
}
var req types.DelegationRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Certificate == "" {
http.Error(w, "certificate is required", http.StatusBadRequest)
return
}
d, err := a.delegations.Complete(tenant, req.Certificate, a.tenantTrusts(tenant))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
a.audit(tenant, "delegation.complete", map[string]string{
"tenant": tenant, "ca_fingerprint": d.CAFingerprint, "key_id": d.KeyID,
"serial": strconv.FormatUint(d.Serial, 10), "expires_at": d.ExpiresAt.Format(time.RFC3339),
})
writeJSON(w, http.StatusOK, delegationResponse(d))
}
// handleGetDelegation reports the caller's live delegation, so its expiry is
// never a surprise. 404 when there is none — including one that has run out,
// which is the same situation and calls for the same next step.
func (a *API) handleGetDelegation(w http.ResponseWriter, r *http.Request) {
tenant, ok := a.delegationTenant(w, r)
if !ok {
return
}
d, live := a.delegations.Status(tenant)
if !live {
http.Error(w, "no live delegation for this tenant", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, delegationResponse(d))
}
// handleRevokeDelegation ends the delegation now. Idempotent.
func (a *API) handleRevokeDelegation(w http.ResponseWriter, r *http.Request) {
tenant, ok := a.delegationTenant(w, r)
if !ok {
return
}
a.delegations.Revoke(tenant)
a.audit(tenant, "delegation.revoke", map[string]string{"tenant": tenant})
w.WriteHeader(http.StatusNoContent)
}
// tenantTrusts answers "is this a CA this tenant registered?" against the
// store. A lookup that fails is reported as an error rather than as "not
// trusted", so a database hiccup never reads as a rejected certificate.
func (a *API) tenantTrusts(tenant string) func(ssh.PublicKey) (bool, error) {
return func(pub ssh.PublicKey) (bool, error) {
owner, ok, err := a.st.TenantForUserCA(sshca.AuthorizedKeyLine(pub))
if err != nil {
return false, err
}
return ok && owner == tenant, nil
}
}
func delegationResponse(d delegation.Delegation) types.Delegation {
return types.Delegation{
PublicKey: d.PublicKey,
CAFingerprint: d.CAFingerprint,
KeyID: d.KeyID,
// A string, like every other serial on this API: a uint64 exceeds what
// a JSON number survives in a JavaScript client.
Serial: strconv.FormatUint(d.Serial, 10),
Principals: d.Principals,
ExpiresAt: d.ExpiresAt.Format(time.RFC3339),
}
}
// fingerprintOf is best-effort: it names a key in an audit row, and a key that
// will not parse is not worth failing a request over.
func fingerprintOf(line string) string {
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
if err != nil {
return ""
}
return ssh.FingerprintSHA256(pub)
}