internal/server/api/usercas.go
Ref: Size: 4.9 KiB History
package api
import (
"net/http"
"github.com/a73x/eitri/internal/server/api/types"
"github.com/a73x/eitri/internal/server/sshca"
"golang.org/x/crypto/ssh"
)
// userCAPath is a tenant's user-CA collection — the endpoint every way of
// registering a CA ends up posting to.
func userCAPath(tenant string) string { return "/api/v1/tenants/" + tenant + "/user-cas" }
// noUserCARefusal explains why a tenant cannot be given a guest: it has
// registered no SSH user CA, and a guest bakes its tenant's CA set into the
// sshd trust it is created with. Nothing rewrites that set afterwards, so a VM
// created now is not merely unreachable until a CA arrives — it is unreachable
// for good, and delete-and-recreate is the only remedy. This is the whole
// reason the refusal sits at create, before anything has been spent.
//
// Every remedy named here lands in the same place: the console's Settings page,
// `eitri ca upload`, and the MCP ca_upload tool all POST this endpoint, which
// writes tenant_user_cas — the one set create reads, both to permit the create
// and to freeze onto the VM. Taking any of them satisfies this check, and the
// next create trusts what it wrote. All four are named because the caller may be a
// human at a browser, a human at a shell, a model holding a PAT, or a program
// with nothing but the API, and each can only act on the one it has.
func noUserCARefusal(tenant, uploadURL string) string {
return "tenant " + tenant + " has no registered SSH user CA: a guest trusts the CA set baked into it at " +
"create, so a VM created now would trust no certificate at all and nothing could ever reach it — " +
"registering a CA afterwards does not reach a guest that already exists. Register one first — the " +
"console's Settings page, `eitri ca upload <ca.pub>`, the MCP tool ca_upload, or POST " + uploadURL +
" — then create the VM."
}
// userCATenant picks the tenant a user-CA request operates on and authorizes
// the caller against it. The {tenant} path segment names it on the explicit
// /tenants/{tenant} routes; on the tenant-less sibling routes there is no
// segment and the caller's own credential names it. Either way mayActAs is the
// gate — an empty tenant (a principal with no tenant on a tenant-less route) is
// a 403. On refusal it writes the response and returns ok=false.
func (a *API) userCATenant(w http.ResponseWriter, r *http.Request) (string, bool) {
p := principalFromContext(r)
tenant := r.PathValue("tenant")
if tenant == "" {
tenant = p.Tenant
}
if !mayActAs(p, tenant) {
http.Error(w, "forbidden", http.StatusForbidden)
return "", false
}
return tenant, true
}
// handleUploadUserCA registers a BYO user-CA public key for a tenant: the
// {tenant} path segment on the explicit route, or the caller's own tenant on
// the tenant-less sibling. eitri stores only the pubkey (canonical line) — it
// never holds a user signing key. The caller must act for that tenant.
func (a *API) handleUploadUserCA(w http.ResponseWriter, r *http.Request) {
tenant, ok := a.userCATenant(w, r)
if !ok {
return
}
var req types.UserCARequest
if !decodeJSON(w, r, &req) {
return
}
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey))
if err != nil {
http.Error(w, "invalid public_key", http.StatusBadRequest)
return
}
line := sshca.AuthorizedKeyLine(pub)
if err := a.st.AddTenantUserCA(tenant, line, "tenant", req.Label, principalFromContext(r).Tenant); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.audit(tenant, "user-ca.upload", map[string]string{"tenant": tenant, "fingerprint": ssh.FingerprintSHA256(pub)})
writeJSON(w, http.StatusCreated, types.UserCAUploadResponse{Fingerprint: ssh.FingerprintSHA256(pub)})
}
func (a *API) handleListUserCAs(w http.ResponseWriter, r *http.Request) {
tenant, ok := a.userCATenant(w, r)
if !ok {
return
}
cas, err := a.st.ListTenantUserCAs(tenant)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
out := make([]types.UserCA, 0, len(cas))
for _, c := range cas {
pub, _, _, _, perr := ssh.ParseAuthorizedKey([]byte(c.Pubkey))
fp := ""
if perr == nil {
fp = ssh.FingerprintSHA256(pub)
}
out = append(out, types.UserCA{Fingerprint: fp, Label: c.Label, PubKey: c.Pubkey})
}
writeJSON(w, http.StatusOK, out)
}
// handleDeleteUserCA removes a registered CA by its public_key line.
func (a *API) handleDeleteUserCA(w http.ResponseWriter, r *http.Request) {
tenant, ok := a.userCATenant(w, r)
if !ok {
return
}
var req types.UserCARequest
if !decodeJSON(w, r, &req) {
return
}
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey))
if err != nil {
http.Error(w, "invalid public_key", http.StatusBadRequest)
return
}
if err := a.st.RemoveTenantUserCA(tenant, sshca.AuthorizedKeyLine(pub)); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}