~bigbes/sr-ht-spec

ref: 6d7dbdb5560f52652c1ce20f153a964b5d6fc88d sr-ht-spec/authn/doc.go -rw-r--r-- 8.1 KiB
6d7dbdb5 — Eugene Blikh ci: publish this build's own coverage and benchmarks 2 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
// 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.
//
// # One agent credential plane
//
// An agent is recognised by a tokens.sr.ht working token — PlaneInstance:
// 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 it is the only credential this service authenticates an
// agent with.
//
// spec used to mint its own as well — the agent_token row: one instance-wide
// shared secret, hashed at rest, with no owner, no expiry and no grants. That
// plane is gone. Issuance is centralised in tokens.sr.ht, so there is one door
// and nothing behind it: a credential this plane refuses is refused, rather than
// being offered to a second store that might say yes. A well-formed token from
// another issuer (a meta.sr.ht PAT — bearer.ErrNotOurs) used to fall through to
// that store and now fails at the door, which is the same answer one hash lookup
// later, said honestly.
//
// The instance plane names an owner where the local secret had none.
// Principal.Owner means "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.
//
// 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 "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 backend
// could not answer — 503); IsAuthFailure draws it.
//
// Everything the credential itself can be wrong about is now spelled by
// sr-ht-ecore's bearer package — ErrInvalid, ErrNotOurs, ErrRevoked — because
// there is one issuer and one validator. The sentinels below are what this
// service adds on top of that answer.
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")

	// ErrNoAgentPlane is returned when a bearer credential is presented to a
	// resolver that was built without the tokens.sr.ht plane — an instance whose
	// config.ini has no [tokens.sr.ht] origin. It is a wiring failure and not a
	// credential failure, so it is deliberately not an IsAuthFailure: telling an
	// agent its token is bad when the truth is that this service cannot check
	// any token would send it off to re-provision a perfectly good credential.
	ErrNoAgentPlane = errors.New("no agent credential plane is configured")

	// 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 backend outage
// never reads as a valid credential.
//
// bearer.ErrNotOurs joined the set when the local plane left it. A meta.sr.ht
// PAT used to fall through to spec's own store, where it missed; with one door
// there is nothing to fall through to, and "that credential was issued by
// somebody whose tokens this service does not take" is as permanent a refusal as
// a signature that does not verify. 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, bearer.ErrInvalid) ||
		errors.Is(err, bearer.ErrNotOurs) ||
		errors.Is(err, bearer.ErrRevoked)
}