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:
abrown38andClaude Opus 5.5 committed 2026-10-14 13:42:37 +01:00
1 parent db3f245962
commit 8a69038ab5
23 files changed
+418 -28

No files matched your search

+6
View File
@@ -187,6 +187,12 @@ CREATE TABLE audit (
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX audit_time ON audit (time);
`,
// 2: token rotation: a new secret, the old one working for a grace period.
`
ALTER TABLE tokens ADD COLUMN rotated_at TEXT;
ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
`,
}
+23 -3
View File
@@ -132,13 +132,13 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
return nil
}
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at`
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at, t.rotated_at, t.prev_secret_hash, t.prev_expires_at`
func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
t := &model.Token{}
var grants, exp, lu, rev sql.NullString
var grants, exp, lu, rev, rot, pexp sql.NullString
var ca string
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev); err != nil {
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev, &rot, &t.PrevSecretHash, &pexp); err != nil {
return nil, err
}
if grants.Valid {
@@ -150,6 +150,7 @@ func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
}
}
t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev)
t.RotatedAt, t.PrevExpiresAt = parseTSPtr(rot), parseTSPtr(pexp)
t.CreatedAt = parseTS(ca)
return t, nil
}
@@ -228,6 +229,25 @@ func (s *Store) RevokeToken(ctx context.Context, id int64) error {
return nil
}
// RotateToken gives a token a new secret and expiry. With prevUntil set,
// the old secret keeps working until then; otherwise it stops at once. A
// revoked token can't be rotated (ErrNotFound).
func (s *Store) RotateToken(ctx context.Context, id int64, secretHash string, expiresAt, prevUntil *time.Time) error {
prevHash := `''`
if prevUntil != nil {
prevHash = `secret_hash`
}
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET prev_secret_hash = `+prevHash+`, prev_expires_at = ?, secret_hash = ?, expires_at = ?, rotated_at = ? WHERE id = ? AND revoked_at IS NULL`,
tsPtr(prevUntil), secretHash, tsPtr(expiresAt), ts(time.Now()), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchToken records a token's use.
func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
_, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id)