Files
2026-08-15 07:13:00 +00:00

173 lines
4.7 KiB
Go

package adminapi
import (
"errors"
"net/http"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/auth"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/store"
)
// createAdminKey handles POST /api/v1/keys.
func (s *Server) createAdminKey(w http.ResponseWriter, r *http.Request) error {
return s.createKey(w, r, nil)
}
// createProjectKey handles POST /api/v1/projects/{name}/keys.
func (s *Server) createProjectKey(w http.ResponseWriter, r *http.Request) error {
p, err := s.project(r)
if err != nil {
return err
}
return s.createKey(w, r, p)
}
// createKey mints a key and returns it with its token.
//
// This is the only response in the whole API that carries a credential. It is
// marked no-store, and the token is never written anywhere else: not to the
// log, not to the database (only its SHA-256 is), and not to any later
// response. Losing it means minting a new key.
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, project *store.Project) error {
var req api.CreateKeyRequest
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
return err
}
if err := checkText("name", req.Name, maxKeyNameLen); err != nil {
return err
}
if req.ExpiresAt != nil && !req.ExpiresAt.After(time.Now()) {
return api.Errorf(api.CodeBadRequest, "expires_at is in the past")
}
token, keyID, hash, err := auth.Mint()
if err != nil {
return err
}
k := &store.APIKey{
ID: keyID,
SecretHash: hash[:],
Scope: store.ScopeAdmin,
Name: req.Name,
ExpiresAt: copyTime(req.ExpiresAt),
}
var projectName string
if project != nil {
k.Scope = store.ScopeProject
k.ProjectID = &project.ID
projectName = project.Name
}
if err := s.DB.CreateKey(r.Context(), k); err != nil {
return err
}
httpx.LogAttr(r.Context(), "created_key_id", k.ID)
noStore(w)
httpx.WriteJSON(w, http.StatusCreated, api.CreateKeyResponse{
Key: keyOf(k, projectName),
Token: token,
})
return nil
}
// listKeys handles GET /api/v1/keys, admin only.
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request) error {
ks, err := s.DB.ListKeys(r.Context(), nil)
if err != nil {
return err
}
names, err := s.projectNames(r.Context())
if err != nil {
return err
}
out := api.KeyList{Keys: make([]api.Key, 0, len(ks))}
for _, k := range ks {
var name string
if k.ProjectID != nil {
name = names[*k.ProjectID]
}
out.Keys = append(out.Keys, keyOf(k, name))
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// listProjectKeys handles GET /api/v1/projects/{name}/keys.
func (s *Server) listProjectKeys(w http.ResponseWriter, r *http.Request) error {
p, err := s.project(r)
if err != nil {
return err
}
ks, err := s.DB.ListKeys(r.Context(), &p.ID)
if err != nil {
return err
}
out := api.KeyList{Keys: make([]api.Key, 0, len(ks))}
for _, k := range ks {
out.Keys = append(out.Keys, keyOf(k, p.Name))
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// revokeKey handles DELETE /api/v1/keys/{key_id}.
//
// Admins may revoke anything; a project key may revoke keys belonging to its
// own project, which includes itself. That last case is deliberate: a CI runner
// that believes its token leaked should be able to burn it without waiting for
// an operator.
func (s *Server) revokeKey(w http.ResponseWriter, r *http.Request) error {
if err := httpx.NoBody(r); err != nil {
return err
}
ident, err := s.identity(r)
if err != nil {
return err
}
id := r.PathValue("key_id")
if !auth.ValidKeyID(id) {
// Malformed ids are rejected before the query so the endpoint cannot be
// used to probe the table with arbitrary strings.
return api.Errorf(api.CodeBadRequest, "malformed key id")
}
k, err := s.DB.KeyByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
// A non-admin must not be able to tell "no such key" from "someone
// else's key": that would turn this endpoint into an oracle for
// which key ids exist.
if !ident.IsAdmin() {
return errNotYourKey()
}
return api.Errorf(api.CodeNotFound, "no such key")
}
return err
}
if !ident.IsAdmin() {
if k.ProjectID == nil || !ident.Owns(*k.ProjectID) {
return errNotYourKey()
}
}
if err := s.DB.RevokeKey(r.Context(), id); err != nil {
if errors.Is(err, store.ErrNotFound) {
return api.Errorf(api.CodeNotFound, "no such key")
}
return err
}
// Revocation must take effect now, not when the auth cache entry expires.
s.Auth.V.Invalidate()
httpx.LogAttr(r.Context(), "revoked_key_id", id)
w.WriteHeader(http.StatusNoContent)
return nil
}
func errNotYourKey() error {
return api.Errorf(api.CodeForbidden, "this key does not have access to that key")
}