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, including mr:review for verdicts, 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 "Scopes are repo:read, repo:write, repo:admin, mr:write, mr:merge, issue:write and more; give\n" +
94 "issue:write (a server with the issues feature) to let a token write issues.\n" +
95 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
96 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
97 "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +
98 "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" +
99 "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" +
100 "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" +
101 "To give an AI agent its own space:\n" +
102 " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" +
103 "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +
104 "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,
105 Args: cobra.ExactArgs(1),
106 }
107 cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
108 cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
109 cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
110 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)")
111 cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
112 _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
113 return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
114 })
115 _ = cmd.RegisterFlagCompletionFunc("scope", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
116 return client.ScopeNames(client.AllScopes()), cobra.ShellCompDirectiveNoFileComp
117 })
118 _ = cmd.RegisterFlagCompletionFunc("repo", func(c *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
119 return repoNames(env, c, toComplete), cobra.ShellCompDirectiveNoFileComp
120 })
121 asJSON := addJSONFlag(cmd)
122 cmd.RunE = func(cmd *cobra.Command, args []string) error {
123 req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now())
124 if err != nil {
125 return err
126 }
127 c, err := env.client(cmd)
128 if err != nil {
129 return err
130 }
131 if len(req.GetBranchPrefixes()) > 0 {
132 if err := requireBranchPrefixes(env.ctx(cmd), c); err != nil {
133 return err
134 }
135 }
136 // A preset such as cli grows with the contract and must keep working on an
137 // older server: issue:write from a preset alone is dropped there, while an
138 // explicit --scope issue:write is refused.
139 if slices.Contains(req.Scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
140 if err := requireIssues(env.ctx(cmd), c); err != nil {
141 if !errors.Is(err, errNoIssues) || explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
142 return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err)
143 }
144 req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_ISSUE_WRITE })
145 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")
146 }
147 }
148 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
149 if err != nil {
150 return c.Describe(err)
151 }
152 if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil {
153 return err
154 }
155 return printCreatedToken(cmd, res.Msg, *asJSON)
156 }
157 return cmd
158}
159
160// explicitScope reports whether the --scope flags name want.
161func explicitScope(flags []string, want ermite.Scope) bool {
162 return slices.ContainsFunc(flags, func(f string) bool {
163 s, err := client.ParseScope(f)
164 return err == nil && s == want
165 })
166}
167
168// requireBranchPrefixes refuses, before anything is created, to ask a server
169// for a branch-limited token when it does not list token.branch_prefixes: such
170// a server ignores the field. A server whose features cannot be read is refused
171// too (fail closed).
172func requireBranchPrefixes(ctx context.Context, c *client.Client) error {
173 res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
174 if err != nil {
175 return c.Describe(err)
176 }
177 if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) {
178 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)
179 }
180 return nil
181}
182
183// checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than
184// asked for. A server that predates branch prefixes ignores the field and
185// creates an unlimited token: the secret is then not printed.
186func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error {
187 if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) {
188 return nil
189 }
190 return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+
191 "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId())
192}
193
194// buildCreateToken maps the flags of `token create` to the request.
195func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
196 label = strings.TrimSpace(label)
197 if label == "" {
198 return nil, errors.New("the label must not be empty")
199 }
200 scopes, err := client.MergeScopes(preset, scopeFlags)
201 if err != nil {
202 return nil, err
203 }
204 req := &ermite.CreateTokenRequest{Label: label, Scopes: scopes}
205 for _, r := range repoFlags {
206 if r = strings.TrimSpace(r); r != "" && !slices.Contains(req.Repos, r) {
207 req.Repos = append(req.Repos, r)
208 }
209 }
210 for _, bp := range prefixFlags {
211 if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) {
212 req.BranchPrefixes = append(req.BranchPrefixes, bp)
213 }
214 }
215 if expires != "" {
216 at, err := client.ParseExpiry(expires, now)
217 if err != nil {
218 return nil, err
219 }
220 req.ExpiresAt = timestamppb.New(at)
221 }
222 return req, nil
223}
224
225func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJSON bool) error {
226 if asJSON {
227 return writeJSON(cmd.OutOrStdout(), jsonCreatedToken{jsonToken: toJSONToken(res.GetToken()), Secret: res.GetSecret()})
228 }
229 t := res.GetToken()
230 repos := "all"
231 if len(t.GetRepos()) > 0 {
232 repos = strings.Join(t.GetRepos(), ", ")
233 }
234 branches := "all"
235 if len(t.GetBranchPrefixes()) > 0 {
236 branches = strings.Join(t.GetBranchPrefixes(), ", ")
237 }
238 noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
239 _ = fields(cmd.ErrOrStderr(), [][2]string{
240 {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
241 {" repos", repos},
242 {" branches", branches},
243 {" expires", shortTime(t.GetExpiresAt(), "never")},
244 })
245 noticef(cmd, "the secret below is shown once and cannot be retrieved later")
246 _, err := fmt.Fprintln(cmd.OutOrStdout(), res.GetSecret())
247 return err
248}
249
250func listTokens(ctx context.Context, c *client.Client) ([]*ermite.Token, error) {
251 return collect(ctx, func(page string) ([]*ermite.Token, string, error) {
252 res, err := c.Tokens().ListTokens(ctx, connect.NewRequest(&ermite.ListTokensRequest{PageToken: page}))
253 if err != nil {
254 return nil, "", c.Describe(err)
255 }
256 return res.Msg.GetTokens(), res.Msg.GetNextPageToken(), nil
257 })
258}
259
260func newTokenListCmd(env *cliEnv) *cobra.Command {
261 cmd := &cobra.Command{
262 Use: "list",
263 Short: "List stored tokens",
264 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.",
265 Args: cobra.NoArgs,
266 }
267 asJSON := addJSONFlag(cmd)
268 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
269 c, err := env.client(cmd)
270 if err != nil {
271 return err
272 }
273 tokens, err := listTokens(env.ctx(cmd), c)
274 if err != nil {
275 return err
276 }
277 if *asJSON {
278 out := make([]jsonToken, 0, len(tokens))
279 for _, t := range tokens {
280 out = append(out, toJSONToken(t))
281 }
282 return writeJSON(cmd.OutOrStdout(), out)
283 }
284 if len(tokens) == 0 {
285 noticef(cmd, "no tokens")
286 return nil
287 }
288 rows := make([][]string, 0, len(tokens))
289 for _, t := range tokens {
290 repos := "all"
291 if len(t.GetRepos()) > 0 {
292 repos = strings.Join(t.GetRepos(), ",")
293 }
294 branches := "all"
295 if len(t.GetBranchPrefixes()) > 0 {
296 branches = strings.Join(t.GetBranchPrefixes(), ",")
297 }
298 rows = append(rows, []string{
299 t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches,
300 shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
301 })
302 }
303 return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows)
304 }
305 return cmd
306}
307
308func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
309 return &cobra.Command{
310 Use: "revoke <id>",
311 Short: "Revoke a token",
312 Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" +
313 "A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" +
314 "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" +
315 "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.",
316 Args: cobra.ExactArgs(1),
317 ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
318 if len(args) > 0 {
319 return nil, cobra.ShellCompDirectiveNoFileComp
320 }
321 ids := complete(env, cmd, toComplete, listTokens, func(t *ermite.Token) (string, string) {
322 return t.GetId(), clean(t.GetLabel())
323 })
324 return ids, cobra.ShellCompDirectiveNoFileComp
325 },
326 RunE: func(cmd *cobra.Command, args []string) error {
327 c, err := env.client(cmd)
328 if err != nil {
329 return err
330 }
331 if _, err := c.Tokens().RevokeToken(env.ctx(cmd), connect.NewRequest(&ermite.RevokeTokenRequest{Id: args[0]})); err != nil {
332 return c.Describe(err)
333 }
334 _, err = fmt.Fprintf(cmd.OutOrStdout(), "revoked %s\n", args[0])
335 return err
336 },
337 }
338}