a73x

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)
}