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 if slices.Contains(req.Scopes, ermite.Scope_SCOPE_RELEASE_WRITE) {
149 if err := requireReleases(env.ctx(cmd), c); err != nil {
150 if !errors.Is(err, errNoReleases) || explicitScope(scopes, ermite.Scope_SCOPE_RELEASE_WRITE) {
151 return fmt.Errorf("%w (needed for the release:write scope; nothing was created)", err)
152 }
153 req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_RELEASE_WRITE })
154 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no releases feature, so the token has no release:write scope")
155 }
156 }
157 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
158 if err != nil {
159 return c.Describe(err)
160 }
161 if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil {
162 return err
163 }
164 return printCreatedToken(cmd, res.Msg, *asJSON)
165 }
166 return cmd
167}
168
169// explicitScope reports whether the --scope flags name want.
170func explicitScope(flags []string, want ermite.Scope) bool {
171 return slices.ContainsFunc(flags, func(f string) bool {
172 s, err := client.ParseScope(f)
173 return err == nil && s == want
174 })
175}
176
177// requireBranchPrefixes refuses, before anything is created, to ask a server
178// for a branch-limited token when it does not list token.branch_prefixes: such
179// a server ignores the field. A server whose features cannot be read is refused
180// too (fail closed).
181func requireBranchPrefixes(ctx context.Context, c *client.Client) error {
182 res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
183 if err != nil {
184 return c.Describe(err)
185 }
186 if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) {
187 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)
188 }
189 return nil
190}
191
192// checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than
193// asked for. A server that predates branch prefixes ignores the field and
194// creates an unlimited token: the secret is then not printed.
195func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error {
196 if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) {
197 return nil
198 }
199 return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+
200 "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId())
201}
202
203// buildCreateToken maps the flags of `token create` to the request.
204func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
205 label = strings.TrimSpace(label)
206 if label == "" {
207 return nil, errors.New("the label must not be empty")
208 }
209 scopes, err := client.MergeScopes(preset, scopeFlags)
210 if err != nil {
211 return nil, err
212 }
213 req := &ermite.CreateTokenRequest{Label: label, Scopes: scopes}
214 for _, r := range repoFlags {
215 if r = strings.TrimSpace(r); r != "" && !slices.Contains(req.Repos, r) {
216 req.Repos = append(req.Repos, r)
217 }
218 }
219 for _, bp := range prefixFlags {
220 if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) {
221 req.BranchPrefixes = append(req.BranchPrefixes, bp)
222 }
223 }
224 if expires != "" {
225 at, err := client.ParseExpiry(expires, now)
226 if err != nil {
227 return nil, err
228 }
229 req.ExpiresAt = timestamppb.New(at)
230 }
231 return req, nil
232}
233
234func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJSON bool) error {
235 if asJSON {
236 return writeJSON(cmd.OutOrStdout(), jsonCreatedToken{jsonToken: toJSONToken(res.GetToken()), Secret: res.GetSecret()})
237 }
238 t := res.GetToken()
239 repos := "all"
240 if len(t.GetRepos()) > 0 {
241 repos = strings.Join(t.GetRepos(), ", ")
242 }
243 branches := "all"
244 if len(t.GetBranchPrefixes()) > 0 {
245 branches = strings.Join(t.GetBranchPrefixes(), ", ")
246 }
247 noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
248 _ = fields(cmd.ErrOrStderr(), [][2]string{
249 {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
250 {" repos", repos},
251 {" branches", branches},
252 {" expires", shortTime(t.GetExpiresAt(), "never")},
253 })
254 noticef(cmd, "the secret below is shown once and cannot be retrieved later")
255 _, err := fmt.Fprintln(cmd.OutOrStdout(), res.GetSecret())
256 return err
257}
258
259func listTokens(ctx context.Context, c *client.Client) ([]*ermite.Token, error) {
260 return collect(ctx, func(page string) ([]*ermite.Token, string, error) {
261 res, err := c.Tokens().ListTokens(ctx, connect.NewRequest(&ermite.ListTokensRequest{PageToken: page}))
262 if err != nil {
263 return nil, "", c.Describe(err)
264 }
265 return res.Msg.GetTokens(), res.Msg.GetNextPageToken(), nil
266 })
267}
268
269func newTokenListCmd(env *cliEnv) *cobra.Command {
270 cmd := &cobra.Command{
271 Use: "list",
272 Short: "List stored tokens",
273 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.",
274 Args: cobra.NoArgs,
275 }
276 asJSON := addJSONFlag(cmd)
277 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
278 c, err := env.client(cmd)
279 if err != nil {
280 return err
281 }
282 tokens, err := listTokens(env.ctx(cmd), c)
283 if err != nil {
284 return err
285 }
286 if *asJSON {
287 out := make([]jsonToken, 0, len(tokens))
288 for _, t := range tokens {
289 out = append(out, toJSONToken(t))
290 }
291 return writeJSON(cmd.OutOrStdout(), out)
292 }
293 if len(tokens) == 0 {
294 noticef(cmd, "no tokens")
295 return nil
296 }
297 rows := make([][]string, 0, len(tokens))
298 for _, t := range tokens {
299 repos := "all"
300 if len(t.GetRepos()) > 0 {
301 repos = strings.Join(t.GetRepos(), ",")
302 }
303 branches := "all"
304 if len(t.GetBranchPrefixes()) > 0 {
305 branches = strings.Join(t.GetBranchPrefixes(), ",")
306 }
307 rows = append(rows, []string{
308 t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches,
309 shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
310 })
311 }
312 return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows)
313 }
314 return cmd
315}
316
317func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
318 return &cobra.Command{
319 Use: "revoke <id>",
320 Short: "Revoke a token",
321 Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" +
322 "A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" +
323 "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" +
324 "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.",
325 Args: cobra.ExactArgs(1),
326 ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
327 if len(args) > 0 {
328 return nil, cobra.ShellCompDirectiveNoFileComp
329 }
330 ids := complete(env, cmd, toComplete, listTokens, func(t *ermite.Token) (string, string) {
331 return t.GetId(), clean(t.GetLabel())
332 })
333 return ids, cobra.ShellCompDirectiveNoFileComp
334 },
335 RunE: func(cmd *cobra.Command, args []string) error {
336 c, err := env.client(cmd)
337 if err != nil {
338 return err
339 }
340 if _, err := c.Tokens().RevokeToken(env.ctx(cmd), connect.NewRequest(&ermite.RevokeTokenRequest{Id: args[0]})); err != nil {
341 return c.Describe(err)
342 }
343 _, err = fmt.Fprintf(cmd.OutOrStdout(), "revoked %s\n", args[0])
344 return err
345 },
346 }
347}