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

+77
View File
@@ -483,6 +483,83 @@ func tokenCmd() *cobra.Command {
f.StringVar(&user, "user", "", "the user it belongs to (administrators)")
f.BoolVar(&save, "save", false, "save it as this CLI's token")
cmd.AddCommand(create)
var grace, rexp string
var current, rsave bool
rotate := &cobra.Command{
Use: "rotate [ID]",
Short: "Give a token a new secret (shown once)",
Long: `Gives a token a new secret. It keeps its ID, name and permissions, so only
the programs that use it need the new secret. The old secret stops at once,
or after --grace (up to 7d) so you can update them without an outage. A
token that expires gets its lifetime again, from now, unless --expires says
otherwise (90d, or never).
steward token rotate --current --save # this CLI's own token`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id := ""
switch {
case current && len(args) == 0:
var me api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
return err
}
if me.TokenID == 0 {
return fmt.Errorf("this CLI isn't using a token (%s); give the token's ID", me.Method)
}
id = strconv.FormatInt(me.TokenID, 10)
case len(args) == 1 && !current:
id = args[0]
default:
return fmt.Errorf("give the token's ID, or --current")
}
body := api.TokenRotation{Grace: grace}
switch rexp {
case "":
case "never":
body.NoExpiry = true
default:
body.ExpiresIn = rexp
}
var t api.Token
if err := c.Do(ctx(), "POST", "/tokens/"+id+"/rotate", nil, body, &t); err != nil {
return err
}
if rsave {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
cfg.Token = t.Secret
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(t, nil)
}
fmt.Println(t.Secret)
if !flagQuiet {
if t.PrevExpiresAt != nil {
fmt.Fprintf(os.Stderr, "the old secret works until %s\n", t.PrevExpiresAt.Local().Format("2006-01-02 15:04"))
} else {
fmt.Fprintln(os.Stderr, "the old secret no longer works")
}
}
return nil
},
}
f = rotate.Flags()
f.StringVar(&grace, "grace", "", "keep the old secret working this long (30m, 24h, up to 7d)")
f.StringVar(&rexp, "expires", "", "a new lifetime from now (90d), or never")
f.BoolVar(&current, "current", false, "rotate the token this CLI uses")
f.BoolVar(&rsave, "save", false, "save the new secret as this CLI's token")
cmd.AddCommand(rotate)
cmd.AddCommand(&cobra.Command{
Use: "revoke ID", Short: "Revoke a token", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
+28 -1
View File
@@ -221,6 +221,26 @@ func readPassword(prompt string, fromStdin bool) (string, error) {
return string(b), nil
}
// readLine reads one line a byte at a time, so nothing after it is
// buffered away from the password prompt that follows.
func readLine(r io.Reader) string {
var b []byte
one := make([]byte, 1)
for {
n, err := r.Read(one)
if n == 1 {
if one[0] == '\n' {
break
}
b = append(b, one[0])
}
if err != nil {
break
}
}
return strings.TrimRight(string(b), "\r")
}
func localPrincipal(u *model.User) *auth.Principal {
return &auth.Principal{User: u, Method: "local", IP: "local"}
}
@@ -266,6 +286,13 @@ web UI, or import a Kea configuration with 'steward import kea'.`,
if len(users) > 0 {
return fmt.Errorf("Steward is already set up (%d users); add users with 'stewardd user add' or 'steward user add'", len(users))
}
// Asked for when not given, on a terminal.
if !cmd.Flags().Changed("user") && !stdin && term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprint(os.Stderr, "The first administrator's user name [admin]: ")
if name := strings.TrimSpace(readLine(os.Stdin)); name != "" {
username = name
}
}
if err := server.ValidUsername(username); err != nil {
return err
}
@@ -298,7 +325,7 @@ web UI, or import a Kea configuration with 'steward import kea'.`,
return nil
},
}
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account)")
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account); asked for on a terminal")
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.Flags().BoolVar(&withToken, "token", false, "also make an API token for the CLI")
return cmd