173 lines
4.7 KiB
Go
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")
|
|
}
|