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>
This commit is contained in:
1 parent
db3f245962
commit
8a69038ab5
23 files changed
+418
-28
No files matched your search
@@ -150,13 +150,21 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
if !auth.CheckSecret(secret, t.SecretHash) {
|
||||
return nil, errors.New("wrong token secret")
|
||||
// After a rotation with a grace period the old secret works until
|
||||
// the grace period ends.
|
||||
if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) {
|
||||
return nil, errors.New("wrong token secret")
|
||||
}
|
||||
if now.After(*t.PrevExpiresAt) {
|
||||
return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended")
|
||||
}
|
||||
s.log.Warn("token used with its old secret; give this client the new one before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
if t.RevokedAt != nil {
|
||||
return nil, errors.New("the token was revoked")
|
||||
}
|
||||
now := time.Now()
|
||||
if t.ExpiresAt != nil && now.After(*t.ExpiresAt) {
|
||||
return nil, errors.New("the token has expired")
|
||||
}
|
||||
@@ -271,6 +279,9 @@ func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
|
||||
if se != nil {
|
||||
out.CSRFToken = se.CSRFToken
|
||||
}
|
||||
if p.Token != nil {
|
||||
out.TokenID = p.Token.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,86 @@ func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
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)}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
|
||||
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
|
||||
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
|
||||
m.HandleFunc("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken))
|
||||
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
|
||||
m.HandleFunc("POST "+p+"/users", s.authed(s.createUser))
|
||||
m.HandleFunc("PATCH "+p+"/users/{id}", s.authed(s.updateUser))
|
||||
|
||||
@@ -335,3 +335,48 @@ func TestImportKea(t *testing.T) {
|
||||
t.Errorf("import over a named subnet: %d %+v", code, third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRotation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var tok api.Token
|
||||
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "script", ExpiresIn: "30d"}, &tok)
|
||||
var rot api.Token
|
||||
if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "1h"}, &rot); code != 200 || rot.Secret == "" || rot.Secret == tok.Secret || rot.PrevExpiresAt == nil {
|
||||
t.Fatalf("rotate: %d %+v", code, rot)
|
||||
}
|
||||
if rot.ID != tok.ID || rot.Name != "script" || rot.ExpiresAt == nil || rot.ExpiresAt.Sub(*tok.ExpiresAt) < 0 {
|
||||
t.Errorf("rotated token %+v", rot)
|
||||
}
|
||||
if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
|
||||
t.Errorf("new secret: %d", code)
|
||||
}
|
||||
if code := e.call(tok.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
|
||||
t.Errorf("old secret in its grace period: %d", code)
|
||||
}
|
||||
var me api.Whoami
|
||||
e.call(rot.Secret, "GET", "/auth/whoami", nil, &me)
|
||||
if me.TokenID != tok.ID {
|
||||
t.Errorf("whoami token id %d, want %d", me.TokenID, tok.ID)
|
||||
}
|
||||
// Without a grace period the old secret stops at once.
|
||||
var rot2 api.Token
|
||||
e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, &rot2)
|
||||
if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 401 {
|
||||
t.Errorf("old secret without grace: %d", code)
|
||||
}
|
||||
if code := e.call(rot2.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
|
||||
t.Errorf("newest secret: %d", code)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "30d"}, nil); code != 400 {
|
||||
t.Errorf("a grace over 7 days: %d", code)
|
||||
}
|
||||
// A restricted token can't rotate a wider one.
|
||||
var narrow api.Token
|
||||
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "narrow", Role: "leases"}, &narrow)
|
||||
if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, nil); code != 403 {
|
||||
t.Errorf("narrow token rotating a wider one: %d", code)
|
||||
}
|
||||
if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(narrow.ID)+"/rotate", nil, nil); code != 200 {
|
||||
t.Errorf("a token rotating itself: %d", code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user