coverage~bigbes/sr-ht-spec64cae3afservice/bearer.go

Coverage
58.3% 14/24 statements
Δ
+0.0
Blob
541810a
1 package service
2
3 import (
4 "context"
5 "database/sql"
6 "fmt"
7 "os"
8
9 "github.com/vaughan0/go-ini"
10 "sourcecraft.dev/bigbes/sr-ht-core/auth"
11 "sourcecraft.dev/bigbes/sr-ht-core/config"
12 "sourcecraft.dev/bigbes/sr-ht-core/database"
13 "sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
14 "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
15
16 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
17 "sourcecraft.dev/bigbes/sr-ht-spec/db"
18 )
19
20 // TokensSection is tokens.sr.ht's config section, spelled literally because it
21 // is what the instance's config.ini says and what every other service on the
22 // instance looks the daemon up by.
23 const TokensSection = "tokens.sr.ht"
24
25 // Option configures a Service at construction.
26 type Option func(*options)
27
28 // options is what the Option functions accumulate.
29 type options struct {
30 // conf is the instance config.ini, present only when WithInstanceTokens was
31 // passed. It is held here rather than on Config because it carries the
32 // instance's secrets — [sr.ht] network-key and [webhooks] private-key — and
33 // Config is a value the daemon prints.
34 conf ini.File
35 haveConf bool
36 }
37
38 // WithInstanceTokens builds the tokens.sr.ht bearer plane from the instance
39 // config. It is the only plane an agent can authenticate on, so passing this
40 // option and having no [tokens.sr.ht] section is a startup failure — see
41 // instancePlane.
42 //
43 // It is an option rather than a parameter because `specsrht doc` also builds a
44 // Service, authenticates nobody, and has no use for a validator or the HTTP
45 // client behind it.
46 2 func WithInstanceTokens(conf ini.File) Option {
47 2 return func(o *options) {
48 2 o.conf = conf
49 2 o.haveConf = true
50 2 }
51 }
52
53 // instancePlane builds the tokens.sr.ht bearer plane from the instance config.
54 //
55 // A missing [tokens.sr.ht] origin used to be an answer rather than an error:
56 // spec minted its own agent token, so an instance without the daemon simply kept
57 // using the plane it shipped with. It no longer has one. A daemon that came up
58 // without this plane would serve reads and refuse every agent write on the
59 // instance — over HTTP and over `git push` alike — so the absence fails startup,
60 // where an operator is looking, instead of surfacing one request at a time as an
61 // unexplained 503.
62 //
63 // The origin is read in its internal form — instconf.InternalOrigin, which is
64 // named rather than a bool, so that the reading cannot be flipped invisibly —
65 // and so the revocation check of SPEC ch. 6 step 4 crosses the docker network
66 // directly instead of going out through the reverse proxy and back in.
67 6 func instancePlane(conf ini.File, q db.Querier) (authn.ResolverOption, error) {
68 6 origin := instconf.InternalOrigin(conf, TokensSection)
69 6 if origin == "" {
70 3 return nil, fmt.Errorf(
71 3 "service: no [%s] origin in config.ini; spec.sr.ht issues no agent credential of "+
72 3 "its own and cannot authenticate an agent without the daemon that does", TokensSection)
73 3 }
74
75 // The node id is what the daemon's internal guard logs the caller as. The
76 // hostname is the honest answer and needs no config key to be forgotten or
77 // to drift; a host that cannot name itself is a startup failure rather than
78 // a guessed label, because a fabricated node id is worse than none — it is
79 // the wrong answer to the only question the revocation log can be asked.
80 3 node, err := os.Hostname()
81 3 if err != nil {
82 0 return nil, fmt.Errorf("service: the tokens.sr.ht plane needs a node id and this host cannot name itself: %w", err)
83 0 }
84
85 3 v, err := bearer.New(bearer.Options{
86 3 Origin: origin,
87 3 ClientID: ConfigSection,
88 3 NodeID: node,
89 3 })
90 3 if err != nil {
91 1 return nil, fmt.Errorf("service: build the tokens.sr.ht validator: %w", err)
92 1 }
93
94 // auth.LookupUser opens its own transaction, so the pool itself is needed
95 // and not the Querier interface a *sql.Tx also satisfies. Refusing loudly
96 // beats silently leaving the plane out: an instance that configured
97 // tokens.sr.ht and got no instance plane would look identical to one that
98 // did not configure it, and the difference would only surface as every agent
99 // token being refused.
100 2 pool, ok := q.(*sql.DB)
101 2 if !ok {
102 0 return nil, fmt.Errorf(
103 0 "service: the tokens.sr.ht plane needs the *sql.DB pool (auth.LookupUser opens its own transaction), got %T", q)
104 0 }
105
106 2 return authn.WithInstancePlane(v, metaUserLookup{pool: pool, conf: conf}), nil
107 }
108
109 // metaUserLookup resolves the owner of an instance token to the local "user"
110 // row, through core-go's auth.LookupUser — the same function dolt, cover and
111 // bench resolve their token owners with.
112 //
113 // The two context values it installs are not optional and not defensive.
114 // auth.LookupUser reads config.ServiceName out of the context on every call and
115 // opens a read-only transaction through core-go's database context, and both of
116 // those panic when absent. spec's resolver middleware runs on the *anonymous*
117 // router, which core-go's WithDefaultMiddleware does not decorate — it installs
118 // the config, database and auth middleware on the authenticated router only —
119 // so nothing upstream has put either there. This adapter is what supplies them,
120 // and it is the reason authn declares a UserLookup interface instead of calling
121 // core-go itself.
122 type metaUserLookup struct {
123 pool *sql.DB
124 conf ini.File
125 }
126
127 // LookupUser implements authn.UserLookup.
128 //
129 // A user this instance has never seen is resolved by core-go against
130 // meta.sr.ht and written down. In practice that path is not reached: the
131 // resolver refuses an instance token whose owner is not [sr.ht] owner-name, and
132 // the daemon seeds that row with EnsureOwnerUser before it serves anything.
133 0 func (l metaUserLookup) LookupUser(ctx context.Context, username string) (authn.InstanceUser, error) {
134 0 ctx = database.Context(ctx, l.pool)
135 0 ctx = config.Context(ctx, l.conf, ConfigSection)
136 0
137 0 var ac auth.AuthContext
138 0 if err := auth.LookupUser(ctx, username, &ac); err != nil {
139 0 return authn.InstanceUser{}, fmt.Errorf("service: look up user %q: %w", username, err)
140 0 }
141 0 if ac.UserID == 0 {
142 0 // A resolved user with no row id would key nothing and authenticate
143 0 // everything; there is no sensible value to substitute.
144 0 return authn.InstanceUser{}, fmt.Errorf("service: user %q resolved to no row id", username)
145 0 }
146 0 return authn.InstanceUser{ID: ac.UserID, Username: ac.Username}, nil
147 }