~bigbes/sr-ht-spec

ref: 8219ede1804c144de2a2f2e42f3f476bff4b622a sr-ht-spec/cmd/specsrht/token.go -rw-r--r-- 4.2 KiB
8219ede1 — Eugene Blikh feat(web,service): the owner mints and revokes agent tokens in a browser 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
134
135
136
package main

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

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

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

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 goes through service/ rather than db/ directly, and constructs the owner
// principal to do so. The owner-only rule on minting is the one that makes
// revocation mean anything — an agent that could mint would survive having its
// credential revoked — so it is spelled once, in service/, and this command is
// held to it exactly as the /tokens page is. A process on this host could of
// course write the row itself; the point is not to confine it but to keep one
// implementation of what issuing a token *is*.
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()

	svc, err := service.New(cfg, pool)
	if err != nil {
		return err
	}
	owner := authn.Principal{Kind: authn.KindOwner, Owner: cfg.Instance.OwnerName}
	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 is returned by the mint and printed once. Nothing writes
		// it to the log, because a token in a log file is a token in a backup.
		token, row, err := svc.IssueAgentToken(ctx, owner, name)
		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 := svc.ListAgentTokens(ctx, owner)
		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 := svc.RevokeAgentToken(ctx, owner, 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 service.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
}