| 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. |
| 25 | package slides |
| 26 | |
| 27 | import ( |
| 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. |
| 53 | const 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. |
| 58 | const 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. |
| 63 | const 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. |
| 70 | func 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. |
| 103 | func 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. |
| 118 | func 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. |
| 124 | func 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. |
| 130 | const 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. |
| 137 | type principal struct { |
| 138 | agent Agent |
| 139 | operator User |
| 140 | } |
| 141 | |
| 142 | // actor is what this principal's writes are recorded as. |
| 143 | func (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. |
| 148 | type 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. |
| 155 | func 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. |
| 171 | func (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. |
| 244 | const ( |
| 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. |
| 253 | func 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. |
| 280 | func (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 | |
| 341 | type 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 | |
| 352 | type 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. |
| 382 | func (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 | |
| 395 | func (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. |
| 476 | func (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. |
| 539 | func (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 | |