Files
steward/internal/server/h_users.go
T
abrown38andClaude Opus 5.5 8a69038ab5 Admin name at init, token rotation, read-only forms, token role presets
- stewardd init asks for the first administrator's name on a terminal.
- Tokens can be rotated (steward token rotate, POST /tokens/{id}/rotate,
  Rotate in the web UI), with a grace period for the old secret, as in
  Jarl. Schema migration 2 adds the token columns.
- Read-only users see the settings and a subnet's settings locked,
  instead of editable fields without a Save button.
- The New token dialog only offers roles within what the user may do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:42:37 +01:00

485 lines
14 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
}
// maxRotationGrace is the longest an old secret may keep working.
const maxRotationGrace = 7 * 24 * time.Hour
// rotateToken: POST /tokens/{id}/rotate gives a token a new secret. It
// keeps its ID, name, user and grants, so only the programs that use it
// need the new secret. The old secret stops at once, or after the grace
// period. A token that expires gets its lifetime again, from now.
func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.TokenRotation
if err := decodeOptional(r, &in); 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)
}
// A restricted token may rotate itself and tokens no wider than
// itself, but not a wider one: it would learn that token's secret.
if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !auth.Narrower(t.Grants, p.Token.Grants)) {
return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants")
}
if t.RevokedAt != nil {
return conflict("token %d is revoked; make a new one", id)
}
now := time.Now()
var exp *time.Time
switch {
case in.ExpiresIn != "":
if in.NoExpiry {
return badRequest("no_expiry can't be combined with expires_in")
}
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)
}
e := now.Add(d)
exp = &e
case in.NoExpiry:
case t.ExpiresAt != nil:
if life := t.ExpiresAt.Sub(t.IssuedAt()); life > 0 {
e := now.Add(life)
exp = &e
}
}
var prevUntil *time.Time
if in.Grace != "" {
g, err := config.ParseDuration(in.Grace)
if err != nil || g < 0 || g > maxRotationGrace {
return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace)
}
until := now.Add(g)
if t.ExpiresAt != nil && t.ExpiresAt.Before(until) {
until = *t.ExpiresAt // the old secret never outlives its token
}
if g > 0 && until.After(now) {
prevUntil = &until
}
}
secret, hash := auth.RotateToken(t.PublicID)
if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil {
return err
}
if t, err = s.store.GetToken(r.Context(), id); err != nil {
return err
}
detail := "the old secret stops now"
if prevUntil != nil {
detail = "the old secret works until " + prevUntil.UTC().Format(time.RFC3339)
}
s.eng.Audit(r.Context(), p, "token.rotate", "token "+t.Username+"/"+t.Name, "success", detail)
out := presentToken(t)
out.Secret = secret
writeJSON(w, http.StatusOK, out)
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)
}