- 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>
485 lines
14 KiB
Go
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)
|
|
}
|