~bigbes/sr-ht-spec

ref: c74776077006e81af82c2fd53cb186271b42bee9 sr-ht-spec/service/bearer.go -rw-r--r-- 6.0 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
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-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 offers the instance config to the tokens.sr.ht bearer
// plane. Whether a plane is actually built depends on what is in it: see
// instancePlane, which treats a missing [tokens.sr.ht] section as "there is no
// such daemon on this instance" rather than as a misconfiguration.
//
// It is an option rather than a parameter because the two CLI paths that build a
// Service — `specsrht token` and `specsrht doc` — authenticate nobody and have
// 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,
// or reports that there is none to build (a nil option, nil error).
//
// The absence is the case worth spelling out. An instance whose config.ini has
// no [tokens.sr.ht] section has no such daemon, and spec must start anyway and
// keep accepting its own agent token: the local plane is not a fallback for a
// broken instance plane, it is the plane this service shipped with. So a missing
// origin is an answer, not an error — while an origin that is present and
// unusable is an error, and fails startup where an operator is looking rather
// than one request at a time as an unexplained 503.
//
// The origin is read in its internal form (GetOrigin's external=false), 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 := config.GetOrigin(conf, TokensSection, false)
	if origin == "" {
		return nil, nil
	}

	// 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
}