~bigbes/sr-ht-spec

ref: 90eb06ec6f16e09cc7d1ab7558c9464c3764aca8 sr-ht-spec/cmd/specsrht/token.go -rw-r--r-- 3.9 KiB
90eb06ec — Eugene Blikh feat(cmd): agent tokens and host-side proposals get admin commands 13 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"strconv"
	"text/tabwriter"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-core/config"

	"sourcecraft.dev/bigbes/sr-ht-spec/db"
)

const tokenUsage = "usage: specsrht token create <name> | specsrht token list | specsrht token revoke <id>"

// runToken is the agent-token administration command:
//
//	specsrht token create <name>
//	specsrht token list
//	specsrht token revoke <id>
//
// Agent tokens have no other entry point, and without one a freshly deployed
// instance cannot be written to at all: both agent write surfaces — the REST
// PUT and mcpsrv's spec_propose — refuse an anonymous caller, and the human
// path (native receive-pack) is approval rather than proposal. The alternative
// to this command is an operator hand-writing an INSERT with a sha256 hash,
// which is exactly the shape of mistake that ends with an unusable credential
// and no way to tell why.
//
// It talks to [db.Store] directly rather than going through service.New. Tokens
// touch neither git nor the index, so pulling in the repo root, the hooks and
// bleve to mint a row would only add ways for the command to fail on an
// instance whose daemon is otherwise fine.
func runToken(args []string) error {
	if len(args) == 0 {
		return errors.New(tokenUsage)
	}

	conf := config.LoadConfig()
	cfg, err := validateConfig(conf)
	if err != nil {
		return err
	}
	pool, err := openDatabase(cfg.ConnectionString)
	if err != nil {
		return err
	}
	defer pool.Close()

	store := db.NewStore(pool)
	ctx := context.Background()

	switch args[0] {
	case "create":
		if len(args) != 2 {
			return errors.New("usage: specsrht token create <name>")
		}
		name := args[1]

		// The plaintext exists only here: it is generated, hashed, stored as a
		// hash, and printed once. Nothing writes it to the log, because a token
		// in a log file is a token in a backup.
		token, err := db.GenerateToken()
		if err != nil {
			return err
		}
		row, err := store.CreateAgentToken(ctx, name, db.HashToken(token))
		if err != nil {
			return err
		}
		fmt.Printf("created agent token %q (id %d)\n\n  %s\n\n"+
			"This is the only time the token is shown — only its hash is stored.\n"+
			"Agents present it as: Authorization: Bearer <token>\n",
			row.Name, row.ID, token)
		return nil

	case "list":
		tokens, err := store.ListAgentTokens(ctx)
		if err != nil {
			return err
		}
		w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
		fmt.Fprintln(w, "ID\tNAME\tCREATED\tSTATE")
		for _, t := range tokens {
			fmt.Fprintln(w, formatTokenRow(t))
		}
		return w.Flush()

	case "revoke":
		if len(args) != 2 {
			return errors.New("usage: specsrht token revoke <id>")
		}
		id, err := parseTokenID(args[1])
		if err != nil {
			return err
		}
		if err := store.RevokeAgentToken(ctx, id); err != nil {
			return err
		}
		fmt.Printf("revoked agent token %d\n", id)
		return nil

	default:
		return fmt.Errorf("unknown subcommand %q: want create, list or revoke", args[0])
	}
}

// formatTokenRow renders one token as a tab-separated line for `token list`.
// The hash is deliberately not shown: it identifies nothing an operator acts
// on, and printing a column of it would only invite treating it as the
// credential.
func formatTokenRow(t *db.AgentToken) string {
	state := "active"
	if !t.Active() {
		state = "revoked " + t.Revoked.Format(time.RFC3339)
	}
	return fmt.Sprintf("%d\t%s\t%s\t%s", t.ID, t.Name, t.Created.Format(time.RFC3339), state)
}

// parseTokenID reads the id argument of `token revoke`. It rejects anything
// that is not a positive integer here rather than letting a typo become an
// UPDATE that matches no row and reports "not found", which reads like the
// token is already gone.
func parseTokenID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil || id <= 0 {
		return 0, fmt.Errorf("token id %q is not a positive integer; `specsrht token list` shows the ids", s)
	}
	return id, nil
}