~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/service/bearer.go -rw-r--r-- 6.2 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day 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
package service

import (
	"context"
	"database/sql"
	"fmt"
	"os"

	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/database"
	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
	"sourcecraft.dev/bigbes/sr-ht-ecore/instconf"

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

// TokensSection is tokens.sr.ht's config section, spelled literally because it
// is what the instance's config.ini says and what every other service on the
// instance looks the daemon up by.
const TokensSection = "tokens.sr.ht"

// Option configures a Service at construction.
type Option func(*options)

// options is what the Option functions accumulate.
type options struct {
	// conf is the instance config.ini, present only when WithInstanceTokens was
	// passed. It is held here rather than on Config because it carries the
	// instance's secrets — [sr.ht] network-key and [webhooks] private-key — and
	// Config is a value the daemon prints.
	conf     ini.File
	haveConf bool
}

// WithInstanceTokens builds the tokens.sr.ht bearer plane from the instance
// config. It is the only plane an agent can authenticate on, so passing this
// option and having no [tokens.sr.ht] section is a startup failure — see
// instancePlane.
//
// It is an option rather than a parameter because `specsrht doc` also builds a
// Service, authenticates nobody, and has no use for a validator or the HTTP
// client behind it.
func WithInstanceTokens(conf ini.File) Option {
	return func(o *options) {
		o.conf = conf
		o.haveConf = true
	}
}

// instancePlane builds the tokens.sr.ht bearer plane from the instance config.
//
// A missing [tokens.sr.ht] origin used to be an answer rather than an error:
// spec minted its own agent token, so an instance without the daemon simply kept
// using the plane it shipped with. It no longer has one. A daemon that came up
// without this plane would serve reads and refuse every agent write on the
// instance — over HTTP and over `git push` alike — so the absence fails startup,
// where an operator is looking, instead of surfacing one request at a time as an
// unexplained 503.
//
// The origin is read in its internal form — instconf.InternalOrigin, which is
// named rather than a bool, so that the reading cannot be flipped invisibly —
// and so the revocation check of SPEC ch. 6 step 4 crosses the docker network
// directly instead of going out through the reverse proxy and back in.
func instancePlane(conf ini.File, q db.Querier) (authn.ResolverOption, error) {
	origin := instconf.InternalOrigin(conf, TokensSection)
	if origin == "" {
		return nil, fmt.Errorf(
			"service: no [%s] origin in config.ini; spec.sr.ht issues no agent credential of "+
				"its own and cannot authenticate an agent without the daemon that does", TokensSection)
	}

	// The node id is what the daemon's internal guard logs the caller as. The
	// hostname is the honest answer and needs no config key to be forgotten or
	// to drift; a host that cannot name itself is a startup failure rather than
	// a guessed label, because a fabricated node id is worse than none — it is
	// the wrong answer to the only question the revocation log can be asked.
	node, err := os.Hostname()
	if err != nil {
		return nil, fmt.Errorf("service: the tokens.sr.ht plane needs a node id and this host cannot name itself: %w", err)
	}

	v, err := bearer.New(bearer.Options{
		Origin:   origin,
		ClientID: ConfigSection,
		NodeID:   node,
	})
	if err != nil {
		return nil, fmt.Errorf("service: build the tokens.sr.ht validator: %w", err)
	}

	// auth.LookupUser opens its own transaction, so the pool itself is needed
	// and not the Querier interface a *sql.Tx also satisfies. Refusing loudly
	// beats silently leaving the plane out: an instance that configured
	// tokens.sr.ht and got no instance plane would look identical to one that
	// did not configure it, and the difference would only surface as every agent
	// token being refused.
	pool, ok := q.(*sql.DB)
	if !ok {
		return nil, fmt.Errorf(
			"service: the tokens.sr.ht plane needs the *sql.DB pool (auth.LookupUser opens its own transaction), got %T", q)
	}

	return authn.WithInstancePlane(v, metaUserLookup{pool: pool, conf: conf}), nil
}

// metaUserLookup resolves the owner of an instance token to the local "user"
// row, through core-go's auth.LookupUser — the same function dolt, cover and
// bench resolve their token owners with.
//
// The two context values it installs are not optional and not defensive.
// auth.LookupUser reads config.ServiceName out of the context on every call and
// opens a read-only transaction through core-go's database context, and both of
// those panic when absent. spec's resolver middleware runs on the *anonymous*
// router, which core-go's WithDefaultMiddleware does not decorate — it installs
// the config, database and auth middleware on the authenticated router only —
// so nothing upstream has put either there. This adapter is what supplies them,
// and it is the reason authn declares a UserLookup interface instead of calling
// core-go itself.
type metaUserLookup struct {
	pool *sql.DB
	conf ini.File
}

// LookupUser implements authn.UserLookup.
//
// A user this instance has never seen is resolved by core-go against
// meta.sr.ht and written down. In practice that path is not reached: the
// resolver refuses an instance token whose owner is not [sr.ht] owner-name, and
// the daemon seeds that row with EnsureOwnerUser before it serves anything.
func (l metaUserLookup) LookupUser(ctx context.Context, username string) (authn.InstanceUser, error) {
	ctx = database.Context(ctx, l.pool)
	ctx = config.Context(ctx, l.conf, ConfigSection)

	var ac auth.AuthContext
	if err := auth.LookupUser(ctx, username, &ac); err != nil {
		return authn.InstanceUser{}, fmt.Errorf("service: look up user %q: %w", username, err)
	}
	if ac.UserID == 0 {
		// A resolved user with no row id would key nothing and authenticate
		// everything; there is no sensible value to substitute.
		return authn.InstanceUser{}, fmt.Errorf("service: user %q resolved to no row id", username)
	}
	return authn.InstanceUser{ID: ac.UserID, Username: ac.Username}, nil
}