oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1// agents.go is the non-human half of §6.2's actors: the token a person
2// mints so a program can drive their decks, the middleware that turns
3// one back into a principal, and the screen that does both.
4//
5// The rule the whole file exists to hold, spec §7.1 and suite ruling 9:
6// an agent is its OWN principal, never a borrowed session. Three
7// consequences, each implemented here rather than only stated:
8//
9// - The token is a bearer credential and only a bearer credential.
10// The API never reads a session cookie and the HTML screens never
11// read a bearer, so neither surface can be reached with the other's
12// credential — and a page in a signed-in person's browser has
13// nothing that would drive the API.
14// - Every write it makes is recorded "agent:<id>", so a deck's
15// history names the agent and, through its operator, the person
16// who is answerable for it.
17// - It can see exactly what its operator can see, and not one deck
18// more: every API read and write runs through visibleDecks
19// evaluated for the operator (api.go).
20//
21// Only the SHA-256 of the token is stored — share.go's bargain, and
22// share.go's reason. So the token is shown once, at the moment of
23// minting, and the screen says so there rather than letting somebody
24// find out by coming back for it.
25package slides
26
27import (
28 "context"
29 "crypto/rand"
30 "crypto/sha256"
31 "crypto/subtle"
32 "encoding/hex"
33 "errors"
34 "fmt"
35 "net/http"
36 "strconv"
37 "strings"
38 "time"
39
40 "github.com/go-chi/chi/v5"
41 "gorm.io/gorm"
42
43 "amadan.net/rastrillo/rastrillo"
44 "amadan.net/rastrillo/rastrillo/form"
45)
46
47// --- the token ---
48
49// agentTokenPrefix marks a Slides agent token on sight. A secret that
50// leaks into a log or a paste is found by whoever is scanning for it,
51// and a prefix is what makes that scan possible; it is also what tells
52// a person which of the six tokens in their password manager this is.
53const agentTokenPrefix = "sl_"
54
55// agentTokenAlphabet is base62: what survives being copied out of a
56// terminal, pasted into a YAML file and typed into an environment
57// variable, with nothing to escape and nothing to trim.
58const agentTokenAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
59
60// agentTokenLength is how many of those characters follow the prefix.
61// Forty over sixty-two symbols is about 238 bits, which is not
62// guessable by anything, and short enough to fit on one line.
63const agentTokenLength = 40
64
65// newAgentToken mints one. len(agentTokenAlphabet) is 62, not a power
66// of two, so a plain `b % 62` would be very slightly biased — this
67// rejects the tail of the byte range instead, because a credential is
68// the one place in this app where "astronomically unlikely to matter"
69// is not the standard to hold a random number to.
70func newAgentToken() (string, error) {
71 // 248 is the largest multiple of 62 at or below 256; a byte at or
72 // above it is drawn again.
73 const limit = 248
74 out := make([]byte, 0, agentTokenLength)
75 buf := make([]byte, agentTokenLength)
76 for len(out) < agentTokenLength {
77 if _, err := rand.Read(buf); err != nil {
78 return "", fmt.Errorf("slides: generate agent token: %w", err)
79 }
80 for _, b := range buf {
81 if b >= limit {
82 continue
83 }
84 out = append(out, agentTokenAlphabet[int(b)%len(agentTokenAlphabet)])
85 if len(out) == agentTokenLength {
86 break
87 }
88 }
89 }
90 return agentTokenPrefix + string(out), nil
91}
92
93// hashAgentToken is the stored form: SHA-256 hex, unsalted. The token
94// is 238 bits of randomness, so there is no dictionary to defend
95// against and no salt to add; what an unsalted hash buys is that
96// resolving a bearer is one indexed read.
97//
98// Written out again rather than shared with hashShareToken and
99// hashRemoteToken for presentation.go's reason: the three are separate
100// protocol decisions that happen to agree today, and a change to how
101// share links are stored must not silently change what every live
102// agent token means.
103func hashAgentToken(token string) string {
104 sum := sha256.Sum256([]byte(token))
105 return hex.EncodeToString(sum[:])
106}
107
108// --- the principal ---
109
110// agentPrincipal is spec §7.1's `<name>~<operator email>`: what a
111// person reads in an activity list to know which program made a change
112// and on whose authority.
113//
114// The tilde is the separator because neither half can contain one — an
115// agent name is [a-z0-9-] (validAgentName) and an email address's
116// local part cannot hold a bare tilde in any address this app admits —
117// so the string parses back apart unambiguously.
118func agentPrincipal(name, operatorEmail string) string {
119 return name + "~" + operatorEmail
120}
121
122// agentActor is spec §6.2's actor string for an agent, the counterpart
123// of decks.go's actorFor. Every write over the API records one.
124func agentActor(id int64) string { return fmt.Sprintf("agent:%d", id) }
125
126// agentMark is the robot prefix an agent's principal is shown with,
127// amadan's own convention. It is a mark and not a colour: an agent's
128// write is a different kind of fact from a person's, and a tint alone
129// would say so only to people who can see it.
130const agentMark = "🤖 "
131
132// --- the request principal ---
133
134// principal is the resolved bearer: which agent is calling, and the
135// person whose visibility it borrows. Held together because every API
136// handler needs both — the actor to record, and the operator to scope.
137type principal struct {
138 agent Agent
139 operator User
140}
141
142// actor is what this principal's writes are recorded as.
143func (p principal) actor() string { return agentActor(p.agent.ID) }
144
145// principalKey is the context key the bearer middleware stashes a
146// resolved principal under. An unexported type, so nothing outside
147// this package can put one there.
148type principalKey struct{}
149
150// principalFrom answers the principal this request carries, if the
151// bearer gate resolved one. Only /api/v1 is behind that gate, so on
152// every HTML screen this answers false — which is what makes "the HTML
153// routes never read a bearer" a fact about the router rather than a
154// promise each handler has to keep.
155func principalFrom(r *http.Request) (principal, bool) {
156 p, ok := r.Context().Value(principalKey{}).(principal)
157 return p, ok
158}
159
160// bearerActor is the API's whole gate: an `Authorization: Bearer`
161// header, resolved to a live agent and its operator, or a JSON 401.
162//
163// It reads the header and nothing else. No cookie, no fallback to a
164// session, no "signed in OR bearer" — an API that accepted a cookie
165// would be an API every page in the person's browser could drive.
166//
167// The lookup is by hash, so it is one indexed read; the compare that
168// follows is constant-time over two hex strings of fixed length, which
169// the index alone would not be. Belt and braces: SQLite's comparison
170// is not constant-time, so the index narrows and this decides.
171func (a *app) bearerActor(next http.Handler) http.Handler {
172 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
173 // presentation.go's reader, which the phone remote already
174 // uses: one place decides what `Authorization: Bearer` means
175 // in this app.
176 token := bearerToken(r)
177 if token == "" {
178 apiFail(w, http.StatusUnauthorized, errUnauthorized)
179 return
180 }
181 hash := hashAgentToken(token)
182
183 var agent Agent
184 err := a.db.G.WithContext(r.Context()).
185 Where("token_hash = ? AND revoked_at IS NULL", hash).Take(&agent).Error
186 switch {
187 case err == nil:
188 case errors.Is(err, gorm.ErrRecordNotFound):
189 // A token that was never real and one that was revoked get
190 // the same answer, share.go's rule: anything else would let
191 // a holder of a guess learn that it had once worked.
192 apiFail(w, http.StatusUnauthorized, errUnauthorized)
193 return
194 default:
195 a.logger.Error("slides: resolve a bearer", "err", err)
196 apiFail(w, http.StatusInternalServerError, errInternal)
197 return
198 }
199 if subtle.ConstantTimeCompare([]byte(agent.TokenHash), []byte(hash)) != 1 {
200 apiFail(w, http.StatusUnauthorized, errUnauthorized)
201 return
202 }
203
204 var operator User
205 if err := a.db.G.WithContext(r.Context()).Take(&operator, agent.OperatorID).Error; err != nil {
206 // An agent whose operator is gone is an agent with nobody
207 // answerable for it, and it can see nothing: 401 rather
208 // than a principal with no visibility, so the answer says
209 // what is wrong.
210 a.logger.Error("slides: an agent's operator is missing",
211 "agent", agent.ID, "operator", agent.OperatorID, "err", err)
212 apiFail(w, http.StatusUnauthorized, errUnauthorized)
213 return
214 }
215 // And an agent whose operator is no longer admitted here is an
216 // agent with nobody answerable for it either — spec §6.1's
217 // offboarding rule (codex 6). An agent sees exactly what its
218 // operator sees; when the operator sees nothing, so does it.
219 //
220 // This is the recheck that makes taking an address off
221 // SLIDES_ALLOWED_ADDRESSES mean something. Without it, a token
222 // nobody revoked plus an operator row nobody deleted was
223 // enough to keep writing team decks indefinitely, which is the
224 // one path out of this instance that survives a browser being
225 // signed out. Fail closed, and the same 401 a revoked token
226 // gets: an agent learns nothing about why.
227 if !a.allowed.allows(operator.Email) {
228 a.logger.Warn("slides: an agent's operator is no longer admitted; refusing the token",
229 "agent", agent.ID, "operator", operator.ID)
230 apiFail(w, http.StatusUnauthorized, errUnauthorized)
231 return
232 }
233
234 ctx := context.WithValue(r.Context(), principalKey{}, principal{agent: agent, operator: operator})
235 next.ServeHTTP(w, r.WithContext(ctx))
236 })
237}
238
239// --- names ---
240
241// agentNameMin and agentNameMax bound the short half of a principal.
242// Two characters because one is not a name; thirty-two because the
243// principal has to stay readable in a list row beside an address.
244const (
245 agentNameMin = 2
246 agentNameMax = 32
247)
248
249// validAgentName holds the name to what a principal can carry:
250// lowercase letters, digits and hyphens. No dot, no at sign and no
251// tilde, so `<name>~<email>` splits at exactly one place and no name
252// can be made to look like an address.
253func validAgentName(name string) bool {
254 if len(name) < agentNameMin || len(name) > agentNameMax {
255 return false
256 }
257 for _, c := range name {
258 switch {
259 case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
260 default:
261 return false
262 }
263 }
264 return true
265}
266
267// --- actor labels ---
268
269// actorLabels resolves a set of actor strings to what a person reads:
270// an address for a person, the robot mark plus the principal for an
271// agent.
272//
273// Two queries for any number of actors, because the deck list calls it
274// once with every row's actor rather than once per row.
275//
276// An actor that resolves to nothing — a deleted person, an agent row
277// that is gone — is left out of the map rather than rendered as a raw
278// id. A list row simply says nothing about who last touched it, which
279// is true, where "person:7" would be noise.
280func (a *app) actorLabels(ctx context.Context, actors []string) map[string]string {
281 var personIDs, agentIDs []int64
282 for _, actor := range actors {
283 kind, raw, found := strings.Cut(actor, ":")
284 if !found {
285 continue
286 }
287 id, err := strconv.ParseInt(raw, 10, 64)
288 if err != nil {
289 continue
290 }
291 switch kind {
292 case "person":
293 personIDs = append(personIDs, id)
294 case "agent":
295 agentIDs = append(agentIDs, id)
296 }
297 }
298
299 out := map[string]string{}
300 if len(personIDs) > 0 {
301 var people []User
302 if err := a.db.G.WithContext(ctx).Where("id IN ?", personIDs).Find(&people).Error; err != nil {
303 a.logger.Error("slides: resolve person actors", "err", err)
304 }
305 for _, u := range people {
306 name := u.Name
307 if name == "" {
308 name = u.Email
309 }
310 out[actorFor(u)] = name
311 }
312 }
313 if len(agentIDs) > 0 {
314 var agents []Agent
315 if err := a.db.G.WithContext(ctx).Where("id IN ?", agentIDs).Find(&agents).Error; err != nil {
316 a.logger.Error("slides: resolve agent actors", "err", err)
317 }
318 // One query for the operators the agents name, so this stays
319 // two queries however many agents there are.
320 ids := make([]int64, 0, len(agents))
321 for _, ag := range agents {
322 ids = append(ids, ag.OperatorID)
323 }
324 emails := map[int64]string{}
325 var operators []User
326 if err := a.db.G.WithContext(ctx).Where("id IN ?", ids).Find(&operators).Error; err != nil {
327 a.logger.Error("slides: resolve agent operators", "err", err)
328 }
329 for _, u := range operators {
330 emails[u.ID] = u.Email
331 }
332 for _, ag := range agents {
333 out[agentActor(ag.ID)] = agentMark + agentPrincipal(ag.Name, emails[ag.OperatorID])
334 }
335 }
336 return out
337}
338
339// --- GET /agents ---
340
341type agentRow struct {
342 Principal string
343 Created string
344 StatusLabel string
345 StatusTone string
346 Revoked bool
347 RevokeLabel string
348 RevokeAria string
349 RevokeHref string
350}
351
352type agentsPage struct {
353 shell
354 Title string
355 Lead string
356 WarningTitle string
357 Warning string
358 Action string
359 NameLabel string
360 NameHint string
361 NameValue string
362 CreateLabel string
363 Errors map[string]string
364
365 // NewToken is the token this request minted, shown once and never
366 // again. Empty on every visit that did not just create one.
367 NewToken string
368 NewPrincipal string
369 NewTitle string
370 NewHint string
371 NewLabel string
372 NewUse string
373 CopyHint string
374
375 Agents []agentRow
376 EmptyTitle string
377 EmptyBody string
378}
379
380// agents serves GET /agents: this person's agents, what a token can
381// do, and the one form that mints another.
382func (a *app) agents(w http.ResponseWriter, r *http.Request) {
383 u, ok := a.viewer(w, r)
384 if !ok {
385 return
386 }
387 // No token from the query string, deliberately: the one that was
388 // just minted is written straight into the POST's own answer
389 // (createAgent), and a GET is a URL — history, Referer, every log
390 // that records a path — so there is nowhere here for a credential
391 // to arrive from that it should be honoured out of.
392 a.renderAgentsPage(w, r, u, "", "", nil)
393}
394
395func (a *app) renderAgentsPage(w http.ResponseWriter, r *http.Request, u User, newToken, name string, errs map[string]string) {
396 var rows []Agent
397 if err := a.db.G.WithContext(r.Context()).
398 Where("operator_id = ?", u.ID).
399 Order("created_at DESC, id DESC").Find(&rows).Error; err != nil {
400 a.fail(w, r, "list agents", err)
401 return
402 }
403
404 page := agentsPage{
405 // Appearance returns to the bare path: this page's query can be
406 // the one-time token (?new=), and reflecting a query into the
407 // page would print it again (TestAgentTokenShownOnceAndHashed).
408 shell: a.shellFor(r, u, frame{parts: []string{rastrillo.T(r, "slides.agents.title")}, ret: "/agents"}),
409 Title: rastrillo.T(r, "slides.agents.title"),
410 Lead: rastrillo.T(r, "slides.agents.lead"),
411 WarningTitle: rastrillo.T(r, "slides.agents.warning.title"),
412 Warning: rastrillo.T(r, "slides.agents.warning"),
413 Action: "/agents",
414 NameLabel: rastrillo.T(r, "slides.agents.name.label"),
415 NameHint: rastrillo.T(r, "slides.agents.name.hint"),
416 NameValue: name,
417 CreateLabel: rastrillo.T(r, "slides.agents.create"),
418 Errors: errs,
419 EmptyTitle: rastrillo.T(r, "slides.agents.empty.title"),
420 EmptyBody: rastrillo.T(r, "slides.agents.empty.body"),
421 Agents: make([]agentRow, 0, len(rows)),
422 }
423 if newToken != "" {
424 page.NewToken = newToken
425 page.NewTitle = rastrillo.T(r, "slides.agents.new.title")
426 page.NewHint = rastrillo.T(r, "slides.agents.new.body")
427 page.NewLabel = rastrillo.T(r, "slides.agents.new.label")
428 page.NewUse = rastrillo.Tf(r, "slides.agents.new.use", "origin", a.cfg.Origin)
429 page.CopyHint = rastrillo.T(r, "slides.agents.new.copy")
430 // Which agent the token belongs to: the row that carries this
431 // hash, so the page cannot name one agent and show another's
432 // credential.
433 var minted Agent
434 if err := a.db.G.WithContext(r.Context()).
435 Where("token_hash = ?", hashAgentToken(newToken)).Take(&minted).Error; err == nil {
436 page.NewPrincipal = agentPrincipal(minted.Name, u.Email)
437 }
438 }
439 for _, ag := range rows {
440 principal := agentPrincipal(ag.Name, u.Email)
441 row := agentRow{
442 Principal: principal,
443 Created: rastrillo.Tf(r, "slides.agents.row.created", "when", whenWords(r, ag.CreatedAt)),
444 Revoked: ag.RevokedAt != nil,
445 RevokeLabel: rastrillo.T(r, "slides.agents.revoke"),
446 RevokeAria: rastrillo.Tf(r, "slides.agents.revoke.aria", "principal", principal),
447 RevokeHref: fmt.Sprintf("/agents/%d/revoke", ag.ID),
448 }
449 if row.Revoked {
450 row.StatusLabel = rastrillo.T(r, "slides.agents.revoked")
451 row.StatusTone = "neutral"
452 } else {
453 row.StatusLabel = rastrillo.T(r, "slides.agents.active")
454 row.StatusTone = "positive"
455 }
456 page.Agents = append(page.Agents, row)
457 }
458 render(w, "agents", page)
459}
460
461// --- POST /agents ---
462
463// createAgent mints one agent and its token, and answers with the
464// agents page itself carrying the token, so it is shown once.
465//
466// The answer is rendered from the POST rather than redirected to,
467// which is the one place this app breaks POST-redirect-GET on purpose:
468// a redirect would have to carry the token in its URL, and a URL with
469// a live credential in it lands in the browser's history, in the
470// Referer of everything the page then loads, and in any log that
471// records paths. The body is the only place it can be shown that is
472// not one of those, so the token goes there and the response is marked
473// no-store. The cost is a re-POST prompt on refresh, which mints
474// another agent — visible, named and revocable, which a leaked token
475// is not.
476func (a *app) createAgent(w http.ResponseWriter, r *http.Request) {
477 u, ok := a.viewer(w, r)
478 if !ok {
479 return
480 }
481
482 p := form.Parse(r, form.Field{Name: "name", Required: true})
483 name := strings.TrimSpace(p.String("name"))
484 errs := map[string]string{}
485 switch {
486 case !p.OK() || !validAgentName(name):
487 // The framework's own message is English built from the field
488 // name; every visible string in this app is a catalog key, so
489 // the failure is kept and the sentence replaced.
490 errs["Name"] = rastrillo.T(r, "slides.agents.name.invalid")
491 default:
492 var taken int64
493 if err := a.db.G.WithContext(r.Context()).Model(&Agent{}).
494 Where("operator_id = ? AND name = ? AND revoked_at IS NULL", u.ID, name).
495 Count(&taken).Error; err != nil {
496 a.fail(w, r, "check an agent name", err)
497 return
498 }
499 if taken > 0 {
500 errs["Name"] = rastrillo.T(r, "slides.agents.name.taken")
501 }
502 }
503 if len(errs) > 0 {
504 w.WriteHeader(http.StatusUnprocessableEntity)
505 a.renderAgentsPage(w, r, u, "", p.Echo()["name"], errs)
506 return
507 }
508
509 token, err := newAgentToken()
510 if err != nil {
511 a.fail(w, r, "mint an agent token", err)
512 return
513 }
514 agent := Agent{
515 Name: name,
516 OperatorID: u.ID,
517 TokenHash: hashAgentToken(token),
518 CreatedAt: time.Now().UTC(),
519 }
520 if err := a.db.G.WithContext(r.Context()).Create(&agent).Error; err != nil {
521 a.fail(w, r, "create agent", err)
522 return
523 }
524 a.logger.Info("slides: agent minted",
525 "agent", agent.ID, "principal", agentPrincipal(agent.Name, u.Email))
526 // The one response that carries a credential, so no cache — shared
527 // or the browser's own back/forward store — may keep it.
528 w.Header().Set("Cache-Control", "no-store")
529 a.renderAgentsPage(w, r, u, token, "", nil)
530}
531
532// --- POST /agents/{id}/revoke ---
533
534// revokeAgent turns one agent off. Scoped by operator, so another
535// person's agent id is a 404 rather than a revocation nobody was
536// entitled to make; revoking twice is not an error, because the token
537// is dead either way and a double-clicked button should not look like
538// a failure.
539func (a *app) revokeAgent(w http.ResponseWriter, r *http.Request) {
540 u, ok := a.viewer(w, r)
541 if !ok {
542 return
543 }
544 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
545 if err != nil {
546 a.notFound(w, r)
547 return
548 }
549
550 // revoked_at IS NULL in the WHERE, so a second POST cannot move the
551 // timestamp: when an agent was turned off is a fact about it.
552 res := a.db.G.WithContext(r.Context()).Model(&Agent{}).
553 Where("id = ? AND operator_id = ? AND revoked_at IS NULL", id, u.ID).
554 Update("revoked_at", time.Now().UTC())
555 if res.Error != nil {
556 a.fail(w, r, "revoke agent", res.Error)
557 return
558 }
559 if res.RowsAffected == 0 {
560 var n int64
561 if err := a.db.G.WithContext(r.Context()).Model(&Agent{}).
562 Where("id = ? AND operator_id = ?", id, u.ID).Count(&n).Error; err != nil {
563 a.fail(w, r, "check an agent", err)
564 return
565 }
566 if n == 0 {
567 a.notFound(w, r)
568 return
569 }
570 } else {
571 a.logger.Info("slides: agent revoked", "agent", id, "actor", actorFor(u))
572 }
573 http.Redirect(w, r, "/agents", http.StatusSeeOther)
574}
575