Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
405 lines
11 KiB
Go
405 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"steward/internal/api"
|
|
"steward/internal/auth"
|
|
"steward/internal/config"
|
|
"steward/internal/model"
|
|
"steward/internal/store"
|
|
)
|
|
|
|
func (s *Server) changeOwnPassword(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if p.Token != nil {
|
|
return forbidden("passwords can't be changed with an API token; log in to the web UI")
|
|
}
|
|
if p.User.ID == 0 {
|
|
return badRequest("%s on the local socket isn't a Steward user; use 'steward user update USER --password'", p.Name())
|
|
}
|
|
var in api.PasswordChange
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
u, err := s.store.GetUser(r.Context(), p.User.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Current) {
|
|
s.eng.Audit(r.Context(), p, "user.password", "user "+u.Username, "failure", "wrong current password")
|
|
return &httpError{http.StatusForbidden, "wrong_password", "the current password is wrong"}
|
|
}
|
|
hash, err := auth.HashPassword(in.Password)
|
|
if err != nil {
|
|
return badRequest("%v", err)
|
|
}
|
|
u.PasswordHash = hash
|
|
if err := s.store.UpdateUser(r.Context(), u); err != nil {
|
|
return err
|
|
}
|
|
keep := ""
|
|
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
|
|
keep = se.IDHash
|
|
}
|
|
s.store.DeleteUserSessions(r.Context(), u.ID, keep)
|
|
s.eng.Audit(r.Context(), p, "user.password", "user "+u.Username, "success", "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return nil
|
|
}
|
|
|
|
func grantsFrom(strs []string, role string) ([]model.Grant, bool, error) {
|
|
var out []model.Grant
|
|
set := false
|
|
if role != "" {
|
|
gs, ok := auth.Roles[role]
|
|
if !ok {
|
|
return nil, false, badRequest("unknown role %q (use %s)", role, strings.Join(auth.RoleNames(), ", "))
|
|
}
|
|
out = append(out, gs...)
|
|
set = true
|
|
}
|
|
for _, g := range strs {
|
|
gr, err := auth.ParseGrant(g)
|
|
if err != nil {
|
|
return nil, false, badRequest("%v", err)
|
|
}
|
|
out = append(out, gr)
|
|
set = true
|
|
}
|
|
if set && out == nil {
|
|
out = []model.Grant{}
|
|
}
|
|
return out, set, nil
|
|
}
|
|
|
|
func grantStrings(gs []model.Grant) []string {
|
|
out := []string{}
|
|
for _, g := range gs {
|
|
out = append(out, auth.FormatGrant(g))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func presentToken(t *model.Token) api.Token {
|
|
out := api.Token{Token: t, GrantStrings: grantStrings(t.Grants)}
|
|
if t.Grants == nil {
|
|
out.GrantStrings = []string{"(all of the user's)"}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error {
|
|
var in api.TokenInput
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(in.Name) == "" {
|
|
return badRequest("a token needs a name")
|
|
}
|
|
grants, set, err := grantsFrom(in.Grants, in.Role)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var exp *time.Time
|
|
switch {
|
|
case in.ExpiresAt != nil:
|
|
if in.ExpiresAt.Before(time.Now()) {
|
|
return badRequest("expires_at is in the past")
|
|
}
|
|
exp = in.ExpiresAt
|
|
case in.ExpiresIn != "":
|
|
d, err := config.ParseDuration(in.ExpiresIn)
|
|
if err != nil || d <= 0 {
|
|
return badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
|
|
}
|
|
t := time.Now().Add(d)
|
|
exp = &t
|
|
}
|
|
// A restricted token can only make tokens no wider than itself.
|
|
if p.Token != nil && p.Token.Grants != nil {
|
|
if !set {
|
|
grants, set = p.Token.Grants, true
|
|
} else if !auth.Narrower(grants, p.Token.Grants) {
|
|
return forbidden("a restricted token can only create tokens with a subset of its own grants")
|
|
}
|
|
}
|
|
secret, pid, hash := auth.NewToken()
|
|
t := &model.Token{UserID: u.ID, Name: strings.TrimSpace(in.Name), PublicID: pid, SecretHash: hash, ExpiresAt: exp}
|
|
if set {
|
|
t.Grants = grants
|
|
}
|
|
if err := s.store.CreateToken(r.Context(), t); err != nil {
|
|
return err
|
|
}
|
|
t.Username = u.Username
|
|
s.eng.Audit(r.Context(), p, "token.create", "token "+u.Username+"/"+t.Name, "success", strings.Join(grantStrings(t.Grants), " "))
|
|
out := presentToken(t)
|
|
out.Secret = secret
|
|
writeJSON(w, http.StatusCreated, out)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) createOwnToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if p.User.ID == 0 {
|
|
return badRequest("%s on the local socket isn't a Steward user and has no tokens; make the token for a user (steward token create NAME --user admin)", p.Name())
|
|
}
|
|
return s.createTokenFor(w, r, p, p.User)
|
|
}
|
|
|
|
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, userID int64) error {
|
|
ts, err := s.store.ListTokens(r.Context(), userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := []api.Token{}
|
|
for _, t := range ts {
|
|
if t.RevokedAt != nil && !queryBool(r, "all") {
|
|
continue
|
|
}
|
|
out = append(out, presentToken(t))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) listOwnTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if p.User.ID == 0 {
|
|
writeJSON(w, http.StatusOK, []api.Token{})
|
|
return nil
|
|
}
|
|
return s.listTokens(w, r, p.User.ID)
|
|
}
|
|
|
|
func (s *Server) listAllTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireAdmin(p); err != nil {
|
|
return err
|
|
}
|
|
return s.listTokens(w, r, 0)
|
|
}
|
|
|
|
func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t, err := s.store.GetToken(r.Context(), id)
|
|
if err != nil || t.UserID != p.User.ID && !p.IsAdmin() {
|
|
return notFound("token %d not found", id)
|
|
}
|
|
if err := s.store.RevokeToken(r.Context(), id); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return conflict("token %d is already revoked", id)
|
|
}
|
|
return err
|
|
}
|
|
s.eng.Audit(r.Context(), p, "token.revoke", "token "+t.Username+"/"+t.Name, "success", "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return nil
|
|
}
|
|
|
|
func presentUser(u *model.User) api.User {
|
|
return api.User{User: u, GrantStrings: grantStrings(u.Grants)}
|
|
}
|
|
|
|
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireAdmin(p); err != nil {
|
|
return err
|
|
}
|
|
us, err := s.store.ListUsers(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := []api.User{}
|
|
for _, u := range us {
|
|
out = append(out, presentUser(u))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
// ValidUsername checks a user name.
|
|
func ValidUsername(n string) error {
|
|
if len(n) < 1 || len(n) > 64 {
|
|
return badRequest("user names have 1 to 64 characters")
|
|
}
|
|
for _, c := range n {
|
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("._-@", c)) {
|
|
return badRequest("user names may have letters, digits, '.', '_', '-' and '@'")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyUserInput(u *model.User, in *api.UserInput) error {
|
|
role := ""
|
|
if in.Role != nil {
|
|
role = *in.Role
|
|
}
|
|
var strs []string
|
|
if in.Grants != nil {
|
|
strs = *in.Grants
|
|
}
|
|
grants, set, err := grantsFrom(strs, role)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if set || in.Grants != nil {
|
|
if grants == nil {
|
|
grants = []model.Grant{}
|
|
}
|
|
u.Grants = grants
|
|
}
|
|
if in.Admin != nil {
|
|
u.Admin = *in.Admin
|
|
}
|
|
if in.Disabled != nil {
|
|
u.Disabled = *in.Disabled
|
|
}
|
|
if in.Comment != nil {
|
|
u.Comment = *in.Comment
|
|
}
|
|
if in.Password != nil {
|
|
if *in.Password == "" {
|
|
u.PasswordHash = "" // tokens only
|
|
} else {
|
|
h, err := auth.HashPassword(*in.Password)
|
|
if err != nil {
|
|
return badRequest("%v", err)
|
|
}
|
|
u.PasswordHash = h
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireAdmin(p); err != nil {
|
|
return err
|
|
}
|
|
var in api.UserInput
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
if in.Username == nil {
|
|
return badRequest("a user needs a username")
|
|
}
|
|
u := &model.User{Username: strings.TrimSpace(*in.Username)}
|
|
if err := ValidUsername(u.Username); err != nil {
|
|
return err
|
|
}
|
|
if err := applyUserInput(u, &in); err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
|
if errors.Is(err, store.ErrConflict) {
|
|
return conflict("user %s already exists", u.Username)
|
|
}
|
|
return err
|
|
}
|
|
s.eng.Audit(r.Context(), p, "user.create", "user "+u.Username, "success", strings.Join(grantStrings(u.Grants), " "))
|
|
writeJSON(w, http.StatusCreated, presentUser(u))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) userByPath(r *http.Request) (*model.User, error) {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u, err := s.store.GetUser(r.Context(), id)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, notFound("user %d not found", id)
|
|
}
|
|
return u, err
|
|
}
|
|
|
|
func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireAdmin(p); err != nil {
|
|
return err
|
|
}
|
|
u, err := s.userByPath(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var in api.UserInput
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
wasAdmin := u.Admin && !u.Disabled
|
|
if in.Username != nil {
|
|
u.Username = strings.TrimSpace(*in.Username)
|
|
if err := ValidUsername(u.Username); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := applyUserInput(u, &in); err != nil {
|
|
return err
|
|
}
|
|
if wasAdmin && (!u.Admin || u.Disabled) {
|
|
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
|
|
return conflict("%s is the last administrator; make someone else an administrator first", u.Username)
|
|
}
|
|
}
|
|
if err := s.store.UpdateUser(r.Context(), u); err != nil {
|
|
if errors.Is(err, store.ErrConflict) {
|
|
return conflict("user %s already exists", u.Username)
|
|
}
|
|
return err
|
|
}
|
|
if u.Disabled || in.Password != nil {
|
|
s.store.DeleteUserSessions(r.Context(), u.ID, "")
|
|
}
|
|
s.eng.Audit(r.Context(), p, "user.update", "user "+u.Username, "success", strings.Join(grantStrings(u.Grants), " "))
|
|
writeJSON(w, http.StatusOK, presentUser(u))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireAdmin(p); err != nil {
|
|
return err
|
|
}
|
|
u, err := s.userByPath(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u.ID == p.User.ID {
|
|
return conflict("you can't delete yourself")
|
|
}
|
|
if u.Admin && !u.Disabled {
|
|
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
|
|
return conflict("%s is the last administrator", u.Username)
|
|
}
|
|
}
|
|
if err := s.store.DeleteUser(r.Context(), u.ID); err != nil {
|
|
return err
|
|
}
|
|
s.eng.Audit(r.Context(), p, "user.delete", "user "+u.Username, "success", "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) listUserTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
u, err := s.userByPath(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u.ID != p.User.ID && !p.IsAdmin() {
|
|
return forbidden("only administrators can see other users' tokens")
|
|
}
|
|
return s.listTokens(w, r, u.ID)
|
|
}
|
|
|
|
func (s *Server) createUserToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
u, err := s.userByPath(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u.ID != p.User.ID && !p.IsAdmin() {
|
|
return forbidden("only administrators can make tokens for other users")
|
|
}
|
|
return s.createTokenFor(w, r, p, u)
|
|
}
|