1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "slices"
10 "strings"
11 "time"
12
13 "connectrpc.com/connect"
14 "github.com/spf13/cobra"
15 "google.golang.org/protobuf/types/known/timestamppb"
16
17 "bnema.dev/ermite-api/gen/ermite"
18 "bnema.dev/ermite/internal/adapters/client"
19)
20
21// jsonToken is the stable JSON shape of a stored token. The secret is only
22// present in the output of `token create`.
23type jsonToken struct {
24 ID string `json:"id"`
25 Label string `json:"label"`
26 Scopes []string `json:"scopes"`
27 Repos []string `json:"repos"`
28 // BranchPrefixes limits the branches the token may write; empty means all.
29 BranchPrefixes []string `json:"branch_prefixes"`
30 ExpiresAt *string `json:"expires_at"`
31 CreatedAt *string `json:"created_at"`
32 LastUsedAt *string `json:"last_used_at"`
33}
34
35type jsonCreatedToken struct {
36 jsonToken
37 Secret string `json:"secret"`
38}
39
40func toJSONToken(t *ermite.Token) jsonToken {
41 scopes := make([]string, 0, len(t.GetScopes()))
42 for _, s := range t.GetScopes() {
43 scopes = append(scopes, client.ScopeName(s))
44 }
45 repos := t.GetRepos()
46 if repos == nil {
47 repos = []string{}
48 }
49 prefixes := t.GetBranchPrefixes()
50 if prefixes == nil {
51 prefixes = []string{}
52 }
53 return jsonToken{
54 ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes,
55 ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),
56 }
57}
58
59const jsonTokenHelp = `
60JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),
61repos (array; empty means every repository), branch_prefixes (array; empty means every branch),
62expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).`
63
64func newTokenCmd(env *cliEnv) *cobra.Command {
65 token := &cobra.Command{
66 Use: "token",
67 Short: "Manage API tokens on a remote server",
68 Long: "Create, list and revoke API tokens. Needs the token:admin scope (or the server's admin token).",
69 }
70 token.AddCommand(newTokenCreateCmd(env), newTokenListCmd(env), newTokenRevokeCmd(env))
71 return token
72}
73
74func newTokenCreateCmd(env *cliEnv) *cobra.Command {
75 var (
76 preset string
77 scopes []string
78 repos []string
79 prefixes []string
80 expires string
81 )
82 cmd := &cobra.Command{
83 Use: "create <label>",
84 Short: "Create a token and print its secret once",
85 Long: "Create a token. The secret is printed once on standard output (and nowhere else: pipe it\n" +
86 "or store it right away); details go to standard error. With --json, standard output is one\n" +
87 "object holding the token fields and the secret.\n\n" +
88 "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" +
89 " cli every scope, for your own daily use\n" +
90 " agent repo:write, mr:write\n" +
91 " runner runner:register (a CI runner host)\n" +
92 " ci repo:read\n" +
93 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
94 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
95 "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +
96 "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" +
97 "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" +
98 "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" +
99 "To give an AI agent its own space:\n" +
100 " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" +
101 "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +
102 "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,
103 Args: cobra.ExactArgs(1),
104 }
105 cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
106 cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
107 cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
108 cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)")
109 cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
110 _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
111 return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
112 })
113 _ = cmd.RegisterFlagCompletionFunc("scope", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
114 return client.ScopeNames(client.AllScopes()), cobra.ShellCompDirectiveNoFileComp
115 })
116 _ = cmd.RegisterFlagCompletionFunc("repo", func(c *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
117 return repoNames(env, c, toComplete), cobra.ShellCompDirectiveNoFileComp
118 })
119 asJSON := addJSONFlag(cmd)
120 cmd.RunE = func(cmd *cobra.Command, args []string) error {
121 req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now())
122 if err != nil {
123 return err
124 }
125 c, err := env.client(cmd)
126 if err != nil {
127 return err
128 }
129 if len(req.GetBranchPrefixes()) > 0 {
130 if err := requireBranchPrefixes(env.ctx(cmd), c); err != nil {
131 return err
132 }
133 }
134 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
135 if err != nil {
136 return c.Describe(err)
137 }
138 if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil {
139 return err
140 }
141 return printCreatedToken(cmd, res.Msg, *asJSON)
142 }
143 return cmd
144}
145
146// requireBranchPrefixes refuses, before anything is created, to ask a server
147// for a branch-limited token when it does not list token.branch_prefixes: such
148// a server ignores the field. A server whose features cannot be read is refused
149// too (fail closed).
150func requireBranchPrefixes(ctx context.Context, c *client.Client) error {
151 res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
152 if err != nil {
153 return c.Describe(err)
154 }
155 if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) {
156 return fmt.Errorf("the server does not support %s, so it would ignore --branch-prefix and create an unlimited token: nothing was created (upgrade the server, see `ermite version`)", client.FeatureTokenBranchPrefixes)
157 }
158 return nil
159}
160
161// checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than
162// asked for. A server that predates branch prefixes ignores the field and
163// creates an unlimited token: the secret is then not printed.
164func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error {
165 if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) {
166 return nil
167 }
168 return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+
169 "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId())
170}
171
172// buildCreateToken maps the flags of `token create` to the request.
173func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
174 label = strings.TrimSpace(label)
175 if label == "" {
176 return nil, errors.New("the label must not be empty")
177 }
178 scopes, err := client.MergeScopes(preset, scopeFlags)
179 if err != nil {
180 return nil, err
181 }
182 req := &ermite.CreateTokenRequest{Label: label, Scopes: scopes}
183 for _, r := range repoFlags {
184 if r = strings.TrimSpace(r); r != "" && !slices.Contains(req.Repos, r) {
185 req.Repos = append(req.Repos, r)
186 }
187 }
188 for _, bp := range prefixFlags {
189 if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) {
190 req.BranchPrefixes = append(req.BranchPrefixes, bp)
191 }
192 }
193 if expires != "" {
194 at, err := client.ParseExpiry(expires, now)
195 if err != nil {
196 return nil, err
197 }
198 req.ExpiresAt = timestamppb.New(at)
199 }
200 return req, nil
201}
202
203func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJSON bool) error {
204 if asJSON {
205 return writeJSON(cmd.OutOrStdout(), jsonCreatedToken{jsonToken: toJSONToken(res.GetToken()), Secret: res.GetSecret()})
206 }
207 t := res.GetToken()
208 repos := "all"
209 if len(t.GetRepos()) > 0 {
210 repos = strings.Join(t.GetRepos(), ", ")
211 }
212 branches := "all"
213 if len(t.GetBranchPrefixes()) > 0 {
214 branches = strings.Join(t.GetBranchPrefixes(), ", ")
215 }
216 noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
217 _ = fields(cmd.ErrOrStderr(), [][2]string{
218 {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
219 {" repos", repos},
220 {" branches", branches},
221 {" expires", shortTime(t.GetExpiresAt(), "never")},
222 })
223 noticef(cmd, "the secret below is shown once and cannot be retrieved later")
224 _, err := fmt.Fprintln(cmd.OutOrStdout(), res.GetSecret())
225 return err
226}
227
228func listTokens(ctx context.Context, c *client.Client) ([]*ermite.Token, error) {
229 return collect(ctx, func(page string) ([]*ermite.Token, string, error) {
230 res, err := c.Tokens().ListTokens(ctx, connect.NewRequest(&ermite.ListTokensRequest{PageToken: page}))
231 if err != nil {
232 return nil, "", c.Describe(err)
233 }
234 return res.Msg.GetTokens(), res.Msg.GetNextPageToken(), nil
235 })
236}
237
238func newTokenListCmd(env *cliEnv) *cobra.Command {
239 cmd := &cobra.Command{
240 Use: "list",
241 Short: "List stored tokens",
242 Long: "List stored tokens, newest first. Secrets are never shown. The server's admin token is not listed." + jsonTokenHelp + "\n--json prints an array of tokens.",
243 Args: cobra.NoArgs,
244 }
245 asJSON := addJSONFlag(cmd)
246 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
247 c, err := env.client(cmd)
248 if err != nil {
249 return err
250 }
251 tokens, err := listTokens(env.ctx(cmd), c)
252 if err != nil {
253 return err
254 }
255 if *asJSON {
256 out := make([]jsonToken, 0, len(tokens))
257 for _, t := range tokens {
258 out = append(out, toJSONToken(t))
259 }
260 return writeJSON(cmd.OutOrStdout(), out)
261 }
262 if len(tokens) == 0 {
263 noticef(cmd, "no tokens")
264 return nil
265 }
266 rows := make([][]string, 0, len(tokens))
267 for _, t := range tokens {
268 repos := "all"
269 if len(t.GetRepos()) > 0 {
270 repos = strings.Join(t.GetRepos(), ",")
271 }
272 branches := "all"
273 if len(t.GetBranchPrefixes()) > 0 {
274 branches = strings.Join(t.GetBranchPrefixes(), ",")
275 }
276 rows = append(rows, []string{
277 t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches,
278 shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
279 })
280 }
281 return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows)
282 }
283 return cmd
284}
285
286func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
287 return &cobra.Command{
288 Use: "revoke <id>",
289 Short: "Revoke a token",
290 Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" +
291 "A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" +
292 "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" +
293 "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.",
294 Args: cobra.ExactArgs(1),
295 ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
296 if len(args) > 0 {
297 return nil, cobra.ShellCompDirectiveNoFileComp
298 }
299 ids := complete(env, cmd, toComplete, listTokens, func(t *ermite.Token) (string, string) {
300 return t.GetId(), clean(t.GetLabel())
301 })
302 return ids, cobra.ShellCompDirectiveNoFileComp
303 },
304 RunE: func(cmd *cobra.Command, args []string) error {
305 c, err := env.client(cmd)
306 if err != nil {
307 return err
308 }
309 if _, err := c.Tokens().RevokeToken(env.ctx(cmd), connect.NewRequest(&ermite.RevokeTokenRequest{Id: args[0]})); err != nil {
310 return c.Describe(err)
311 }
312 _, err = fmt.Fprintf(cmd.OutOrStdout(), "revoked %s\n", args[0])
313 return err
314 },
315 }
316}