~bigbes/sr-ht-spec

ref: 0bbb37594f116e34df1c52c412155974ac17e7ef sr-ht-spec/service/token.go -rw-r--r-- 7.6 KiB
0bbb3759 — Eugene Blikh ci(apk): build CSS against core 0.84.5 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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
package service

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"
	"unicode/utf8"

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

// MaxTokenNameLen bounds a token's label. It is a human-readable note about
// which agent holds the credential, rendered in a table and in `token list`;
// the cap keeps a pasted paragraph from becoming a row nobody can read.
const MaxTokenNameLen = 128

// AgentTokenLookup is the one db/ method the token adapter needs. It is an
// interface rather than a *db.Store so the error mapping below — the part that
// actually carries a contract — can be tested against a fake instead of a
// Postgres instance. *db.Store satisfies it.
type AgentTokenLookup interface {
	AgentTokenByHash(ctx context.Context, hash []byte) (*db.AgentToken, error)
}

// TokenStore adapts db/'s agent_token queries to authn.TokenStore. It exists
// because authn must not import db: authn declares the sliver of persistence it
// needs, and service/ — the layer that is allowed to know about both — wires
// them together.
//
// The whole of the adaptation is the error contract, and it is not cosmetic.
// authn's contract is that "no such token" is an error satisfying
// errors.Is(err, authn.ErrUnknownToken) and that everything else is transient.
// db/ spells the same condition ErrNotFound, which authn has never heard of, so
// an unmapped pass-through would make an unknown token look like a Postgres
// outage: a 503 telling an agent to retry a credential that will never work.
type TokenStore struct {
	lookup AgentTokenLookup
}

// NewTokenStore wires a db.Store in as authn's TokenStore.
func NewTokenStore(lookup AgentTokenLookup) *TokenStore {
	return &TokenStore{lookup: lookup}
}

// TokenStore returns the adapter the resolver authenticates agents through.
func (s *Service) TokenStore() *TokenStore { return s.tokens }

// LookupAgentToken implements authn.TokenStore.
//
// A revoked row is returned rather than refused: authn is what turns it into a
// refusal, so the refusal can say "revoked" instead of "unknown" and an
// operator can tell a token they deliberately killed from one that never
// existed. Any other failure is returned wrapped and unclassified, which authn
// reads as transient — the fail-closed direction, since a store outage must
// never read as a valid credential.
func (t *TokenStore) LookupAgentToken(ctx context.Context, hash []byte) (authn.AgentToken, error) {
	if t.lookup == nil {
		return authn.AgentToken{}, errors.New("service: TokenStore has no backing store")
	}
	row, err := t.lookup.AgentTokenByHash(ctx, hash)
	if err != nil {
		if errors.Is(err, db.ErrNotFound) {
			return authn.AgentToken{}, fmt.Errorf("%w: no agent_token row matches the presented token",
				authn.ErrUnknownToken)
		}
		return authn.AgentToken{}, fmt.Errorf("service: look up agent token: %w", err)
	}
	if row == nil {
		// db/ never returns (nil, nil); a store that did would otherwise
		// authenticate a nil row as a valid token.
		return authn.AgentToken{}, errors.New("service: agent token lookup returned no row and no error")
	}
	return authn.AgentToken{
		ID:      int64(row.ID),
		Name:    row.Name,
		Hash:    row.Hash,
		Created: row.Created,
		Revoked: row.Revoked,
	}, nil
}

// AgentToken is one credential as the surfaces above this layer need it: no
// hash, because nothing above service/ has any use for it, and no plaintext,
// because it exists only in the response to the call that minted it.
type AgentToken struct {
	ID      int
	Name    string
	Created time.Time
	Revoked *time.Time
}

// Active reports whether the token may still authenticate.
func (t AgentToken) Active() bool { return t.Revoked == nil }

// IssueAgentToken mints a credential for the agent write plane and returns the
// plaintext exactly once, alongside the stored row.
//
// Owner-only, and that is the interesting half of the ACL: an agent holding a
// valid token may not mint another. Were it allowed to, revoking a compromised
// credential would not end the compromise — the holder would simply have issued
// itself a second one — and "revoke the token" is the entire incident response
// this design has.
//
// The plaintext is returned rather than stored. There is no second chance to
// read it, which is what makes a leaked database dump unreplayable, so a caller
// that drops the value has to mint a new token.
func (s *Service) IssueAgentToken(ctx context.Context, p authn.Principal, name string) (string, AgentToken, error) {
	if !p.IsOwner() {
		return "", AgentToken{}, fmt.Errorf("%w: %s may not issue agent tokens; only the instance owner may",
			ErrForbidden, p)
	}
	name, err := validateTokenName(name)
	if err != nil {
		return "", AgentToken{}, err
	}

	token, err := db.GenerateToken()
	if err != nil {
		return "", AgentToken{}, fmt.Errorf("service: %w", err)
	}
	row, err := s.store.CreateAgentToken(ctx, name, db.HashToken(token))
	if err != nil {
		return "", AgentToken{}, fmt.Errorf("service: issue agent token %q: %w", name, err)
	}
	return token, tokenView(row), nil
}

// ListAgentTokens returns every token, newest first, so the owner can see what
// exists and pick one to revoke. Owner-only for the same reason minting is: the
// list is the inventory of who can write, and an agent has no business reading
// it.
func (s *Service) ListAgentTokens(ctx context.Context, p authn.Principal) ([]AgentToken, error) {
	if !p.IsOwner() {
		return nil, fmt.Errorf("%w: %s may not list agent tokens; only the instance owner may", ErrForbidden, p)
	}
	rows, err := s.store.ListAgentTokens(ctx)
	if err != nil {
		return nil, fmt.Errorf("service: list agent tokens: %w", err)
	}
	out := make([]AgentToken, 0, len(rows))
	for _, row := range rows {
		out = append(out, tokenView(row))
	}
	return out, nil
}

// RevokeAgentToken stamps a token revoked. Owner-only. Revoking is a stamp
// rather than a delete so the audit trail keeps naming the token that made past
// proposals; re-revoking is a no-op, because an operator killing a credential
// twice is not an error worth failing.
func (s *Service) RevokeAgentToken(ctx context.Context, p authn.Principal, id int) error {
	if !p.IsOwner() {
		return fmt.Errorf("%w: %s may not revoke agent tokens; only the instance owner may", ErrForbidden, p)
	}
	if id <= 0 {
		return fmt.Errorf("%w: %d is not an agent token id", ErrInvalid, id)
	}
	if err := s.store.RevokeAgentToken(ctx, id); err != nil {
		if errors.Is(err, db.ErrNotFound) {
			return fmt.Errorf("%w: agent token %d", ErrNotFound, id)
		}
		return fmt.Errorf("service: revoke agent token %d: %w", id, err)
	}
	return nil
}

// validateTokenName normalizes and checks a token label. The rules are the
// weakest ones that keep the listing readable and unambiguous: trimmed,
// non-empty, valid UTF-8, no control characters, and bounded. A name grants
// nothing, so nothing stricter would be buying anything.
func validateTokenName(name string) (string, error) {
	name = strings.TrimSpace(name)
	if name == "" {
		return "", fmt.Errorf("%w: a token needs a name saying which agent holds it", ErrInvalid)
	}
	if len(name) > MaxTokenNameLen {
		return "", fmt.Errorf("%w: token name is %d bytes, over the %d-byte limit",
			ErrInvalid, len(name), MaxTokenNameLen)
	}
	if !utf8.ValidString(name) {
		return "", fmt.Errorf("%w: token name is not valid UTF-8", ErrInvalid)
	}
	for _, r := range name {
		if r < 0x20 || r == 0x7f {
			return "", fmt.Errorf("%w: token name contains a control character %U", ErrInvalid, r)
		}
	}
	return name, nil
}

// tokenView maps a stored token onto the surface shape, dropping the hash.
func tokenView(row *db.AgentToken) AgentToken {
	return AgentToken{ID: row.ID, Name: row.Name, Created: row.Created, Revoked: row.Revoked}
}