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