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
@@ -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(¤t, "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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user