~bigbes/sr-ht-spec

ref: c74776077006e81af82c2fd53cb186271b42bee9 sr-ht-spec/authn/doc.go -rw-r--r-- 8.4 KiB
c7477607 — Eugene Blikh authn: accept tokens.sr.ht working tokens beside the agent token 10 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
// Package authn answers one question for spec.sr.ht — "who is making this
// request?" — and builds the git provenance that answers "who made this write?"
// forever after.
//
// The design has exactly two principals that carry authority:
//
//   - the human owner, recognised by the shared `sr.ht.unified-login.v1`
//     cookie carrying the instance's [sr.ht] owner-name;
//   - an agent, recognised by a bearer token.
//
// Everything else is anonymous. Single-user does not mean "no authorization";
// it relocates it onto agents, which is why Principal distinguishes those two
// and nothing finer. The boundary that bounds an agent's damage is the refs rule
// (agents may only write proposals/*), and that lives in gitx; nothing here
// replaces it.
//
// # Two agent credential planes
//
// An agent is recognised on either of two planes, and Principal.Plane says
// which:
//
//   - PlaneLocal — spec's own agent_token row: one instance-wide shared secret,
//     hashed at rest, with no owner, no expiry and no grants. This is v1's
//     credential and it keeps working exactly as it did.
//   - PlaneInstance — a tokens.sr.ht working token: signed by the instance,
//     expiring, owned by a meta.sr.ht account, and carrying a grant set
//     (ActionPropose, ActionRead). It is validated by sr-ht-ecore's bearer
//     package and is present only when the instance config has a
//     [tokens.sr.ht] section; where it does not, the plane is absent and the
//     local one is the only door, which is a supported configuration.
//
// The instance plane is tried first and falls back to the local one on exactly
// two refusals — see Resolver.resolveInstanceToken, where the reasoning lives.
//
// The two planes are not interchangeable, and the difference this package has to
// carry is that only one of them has an owner. The local token is a secret with
// no user behind it; an instance token names one. Principal.Owner therefore
// keeps meaning "the human this agent acts for", which on this single-owner
// instance is always [sr.ht] owner-name — a token belonging to anybody else is
// refused rather than admitted as a second identity, because every consumer of
// that field (the provenance committer, the refs rule's principal kind, the
// coreauth AuthContext) is written for one human.
//
// Grants are orthogonal to the refs rule and to provenance, and replace neither.
// A grant says what an instance token was minted for; the refs rule still says
// where an agent may point a ref, and provenance is still mandatory on every
// agent write, on both planes.
//
// The cookie and the bearer planes are deliberately asymmetric:
//
//   - A cookie that is missing, forged, expired or unreadable yields an
//     anonymous principal and never an error. Browsing must keep working.
//   - A bearer token that is present but unknown, revoked or corrupt is a hard
//     failure. An agent that presented an explicit credential must not be
//     silently downgraded to a reader; it would then fail confusingly at the
//     write instead of clearly at the door.
//
// Provenance is the other half. Agent identity and session ID are mandatory on
// every agent write, and are recorded in the commit itself so that a plain
// `git log` on any clone carries the audit trail:
//
//	Author:    claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>
//	Committer: bigbes <bigbes@gmail.com>
//
//	    Add storage model section
//
//	    X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921
//	    X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809
//
// A write missing either field is rejected rather than defaulted: a commit
// stamped with a guessed session is worse than no commit, because it launders
// unattributable output as attributed.
//
// This package owns no storage and opens no connections. The agent_token table
// lives in db/, injected through the TokenStore interface declared here; the
// "user" row an instance token's owner resolves to is reached through
// UserLookup, and the tokens.sr.ht validator through BearerValidator. authn
// never imports db and never calls core-go's auth.LookupUser itself, so the
// dependency arrow keeps pointing downward and the whole package stays testable
// with no Postgres and no daemon to talk to.
package authn

import (
	"errors"

	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
)

// Sentinel errors. Callers compare with errors.Is. The split that matters is
// permanent (the credential is bad — 401/403) versus transient (the store could
// not answer — 503); IsAuthFailure draws it.
var (
	// ErrNoToken is returned when a bearer credential was expected but the
	// request carried no Authorization header, or one in another scheme.
	ErrNoToken = errors.New("no agent token presented")

	// ErrUnknownToken is the contract a TokenStore must honour: it is what
	// LookupAgentToken returns (possibly wrapped) when no row matches the
	// presented hash. Any other error is treated as transient, so a Postgres
	// outage reads as "try again", never as "your token is bad".
	ErrUnknownToken = errors.New("unknown agent token")

	// ErrInvalidToken marks a presented credential that is malformed, or a
	// stored row whose hash does not actually match what was presented.
	ErrInvalidToken = errors.New("invalid agent token")

	// ErrRevokedToken marks a token that resolved to a real row which has been
	// revoked. Distinct from ErrUnknownToken so operators can tell "you are
	// using a token I deliberately killed" from "that token never existed".
	ErrRevokedToken = errors.New("revoked agent token")

	// ErrNotAgent is returned when agent provenance is demanded of a principal
	// that is not an agent — the human push path builds no trailers.
	ErrNotAgent = errors.New("principal is not an agent")

	// ErrMissingProvenance marks an agent write that omits the agent identity,
	// the session ID, or the base revision. Mandatory on every agent write; the
	// design is explicit that these are not defaultable.
	ErrMissingProvenance = errors.New("missing agent provenance")

	// ErrInvalidProvenance marks provenance whose values are present but
	// unusable: control characters or angle brackets that would forge a git
	// signature line or inject an extra trailer, an over-long field, or a base
	// revision that is not a hex object name.
	ErrInvalidProvenance = errors.New("invalid agent provenance")

	// ErrMissingConfig is returned by InstanceFromConfig when the instance
	// config lacks a key the provenance identities are built from. It is a
	// startup failure, not a request failure.
	ErrMissingConfig = errors.New("missing instance config key")

	// ErrNotInstanceOwner marks a valid tokens.sr.ht working token whose owner is
	// somebody other than the instance owner. 403: the credential verifies and
	// the holder is who they say they are, there is simply nothing on this
	// single-owner instance to grant them. Deliberately not an IsAuthFailure —
	// presenting it again will not help and neither will logging in.
	ErrNotInstanceOwner = errors.New("token owner is not the instance owner")

	// ErrMissingGrant marks an instance token that authenticated fine but does
	// not carry the action being attempted. 403, for the reason
	// bearer.ErrForbidden is: what the holder needs is a wider grant, not another
	// login.
	//
	// It is raised by Principal.Authorize, at the layer that knows the action —
	// never by the resolver, which runs before the router and so knows none.
	ErrMissingGrant = errors.New("token does not grant this action")
)

// IsAuthFailure reports whether err is a permanent credential failure — the
// caller should answer 401/403 — as opposed to a transient backend failure,
// which should answer 503 and be retried. Everything not in this set is
// transient by definition, which is the fail-closed direction: a store outage
// never reads as a valid credential.
//
// The instance plane's two permanent refusals are in the set for the same
// reason the local plane's are: a signature that does not verify and a token
// tokens.sr.ht has withdrawn are both "this credential is bad", whichever door
// it was presented at. bearer.ErrUnavailable is pointedly absent — see
// StatusFor, which is what surfaces should map with.
func IsAuthFailure(err error) bool {
	return errors.Is(err, ErrNoToken) ||
		errors.Is(err, ErrUnknownToken) ||
		errors.Is(err, ErrInvalidToken) ||
		errors.Is(err, ErrRevokedToken) ||
		errors.Is(err, bearer.ErrInvalid) ||
		errors.Is(err, bearer.ErrRevoked)
}