| 1 | package calls |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "crypto/sha256" |
| 7 | "encoding/base64" |
| 8 | "encoding/hex" |
| 9 | "errors" |
| 10 | "regexp" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | |
| 14 | "amadan.net/rastrillo/rastrillo/scope" |
| 15 | "gorm.io/gorm" |
| 16 | ) |
| 17 | |
| 18 | var agentNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) |
| 19 | |
| 20 | var errAgentName = errors.New("calls: an agent name is 1 to 64 letters, digits, - or _") |
| 21 | |
| 22 | // errAgentOperator guards against a caller that forgot to load a real |
| 23 | // Person before minting: a zero Person is never a legitimate operator. |
| 24 | var errAgentOperator = errors.New("calls: an agent needs a real operator") |
| 25 | |
| 26 | // maxLiveAgents is the operator-level ceiling (fix round 1, item 4): |
| 27 | // an operator who can mint agents without limit can mint without |
| 28 | // limit, and twenty is generous for the handful-of-automations-per- |
| 29 | // person shape this slice is aimed at. Revoked agents do not count |
| 30 | // against it — only live ones do. |
| 31 | const maxLiveAgents = 20 |
| 32 | |
| 33 | var errAgentLimit = errors.New("calls: an operator may hold at most 20 live agents") |
| 34 | |
| 35 | // errAgentDuplicate is a Go-side check (fix round 1, item 5), not a |
| 36 | // unique index: a name is free to reuse the moment its old row is |
| 37 | // revoked, which a partial unique index would not express as cleanly |
| 38 | // as a plain query does. |
| 39 | var errAgentDuplicate = errors.New("calls: an operator may not have two live agents with the same name") |
| 40 | |
| 41 | // newAgentToken mints "mt_" + 32 random bytes, base64url without |
| 42 | // padding, and its SHA-256 hex. The token exists in plaintext only on |
| 43 | // its way to the one page that shows it (§8.1). |
| 44 | func newAgentToken() (token, hash string, err error) { |
| 45 | var b [32]byte |
| 46 | if _, err := rand.Read(b[:]); err != nil { |
| 47 | return "", "", err |
| 48 | } |
| 49 | token = "mt_" + base64.RawURLEncoding.EncodeToString(b[:]) |
| 50 | return token, hashAgentToken(token), nil |
| 51 | } |
| 52 | |
| 53 | func hashAgentToken(token string) string { |
| 54 | sum := sha256.Sum256([]byte(token)) |
| 55 | return hex.EncodeToString(sum[:]) |
| 56 | } |
| 57 | |
| 58 | // principal is the identity a meeting's creator string is rendered as |
| 59 | // on the list (§8.1): the agent's own name, then the operator whose |
| 60 | // reach it borrows. |
| 61 | func principal(ag Agent, operator Person) string { return ag.Name + "~" + operator.Email } |
| 62 | |
| 63 | func actorForAgent(ag Agent) string { return "agent:" + strconv.FormatInt(ag.ID, 10) } |
| 64 | |
| 65 | // createAgent validates the name, refuses a made-up operator, and — |
| 66 | // inside one transaction, so a concurrent create from the same |
| 67 | // operator cannot slip past either check — refuses past the live cap |
| 68 | // or a live duplicate name before minting and storing the token's |
| 69 | // hash. |
| 70 | func (s *store) createAgent(ctx context.Context, name string, operator Person) (Agent, string, error) { |
| 71 | name = strings.TrimSpace(name) |
| 72 | if !agentNameRe.MatchString(name) { |
| 73 | return Agent{}, "", errAgentName |
| 74 | } |
| 75 | if operator.ID == 0 { |
| 76 | return Agent{}, "", errAgentOperator |
| 77 | } |
| 78 | token, hash, err := newAgentToken() |
| 79 | if err != nil { |
| 80 | return Agent{}, "", err |
| 81 | } |
| 82 | var ag Agent |
| 83 | err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| 84 | var live int64 |
| 85 | if err := agentsOwnedBy(tx.Model(&Agent{}), operator). |
| 86 | Where("revoked_at IS NULL"). |
| 87 | Count(&live).Error; err != nil { |
| 88 | return err |
| 89 | } |
| 90 | if live >= maxLiveAgents { |
| 91 | return errAgentLimit |
| 92 | } |
| 93 | var dup int64 |
| 94 | if err := agentsOwnedBy(tx.Model(&Agent{}), operator). |
| 95 | Where("name = ? AND revoked_at IS NULL", name). |
| 96 | Count(&dup).Error; err != nil { |
| 97 | return err |
| 98 | } |
| 99 | if dup > 0 { |
| 100 | return errAgentDuplicate |
| 101 | } |
| 102 | ag = Agent{Name: name, OperatorPersonID: operator.ID, TokenHash: hash, CreatedAt: s.now().UTC()} |
| 103 | return tx.Create(&ag).Error |
| 104 | }) |
| 105 | if err != nil { |
| 106 | return Agent{}, "", err |
| 107 | } |
| 108 | return ag, token, nil |
| 109 | } |
| 110 | |
| 111 | // agentsOwnedBy is the one ownership filter every operator-side agent |
| 112 | // query runs through (review S5-5, AGENTS.md's scoped-query rule): an |
| 113 | // agent is owned by the person who minted it, and its owner column is |
| 114 | // operator_person_id rather than the convention's user_id, so this is |
| 115 | // scope.OwnedBy rather than scope.Owned. Written once so no caller |
| 116 | // hand-writes the predicate — including the ones inside createAgent's |
| 117 | // transaction, which take tx rather than s.g. |
| 118 | func agentsOwnedBy(g *gorm.DB, operator Person) *gorm.DB { |
| 119 | return scope.OwnedBy(g, "operator_person_id", operator.ID) |
| 120 | } |
| 121 | |
| 122 | func (s *store) agentsFor(ctx context.Context, operator Person) ([]Agent, error) { |
| 123 | var out []Agent |
| 124 | err := agentsOwnedBy(s.g.WithContext(ctx), operator).Order("created_at DESC, id DESC").Find(&out).Error |
| 125 | return out, err |
| 126 | } |
| 127 | |
| 128 | // revokeAgent revokes one of the operator's own agents. False when the |
| 129 | // row is not theirs or is already revoked: a 404 shape, never a 403. |
| 130 | func (s *store) revokeAgent(ctx context.Context, id int64, operator Person) (bool, error) { |
| 131 | res := agentsOwnedBy(s.g.WithContext(ctx).Model(&Agent{}), operator). |
| 132 | Where("id = ? AND revoked_at IS NULL", id). |
| 133 | Update("revoked_at", s.now().UTC()) |
| 134 | return res.RowsAffected == 1, res.Error |
| 135 | } |
| 136 | |
| 137 | // agentByToken resolves a bearer to its agent and operator. Unknown, |
| 138 | // revoked, and an agent whose operator row no longer exists all |
| 139 | // answer false with no error — the caller must not tell them apart |
| 140 | // (fix round 1, item 1: fail closed, not a bare-ID Person). Whether |
| 141 | // the operator currently holds member standing is a separate question |
| 142 | // the caller re-derives (spec §8.1, §13). |
| 143 | // |
| 144 | // Not an ownership query, and so not agentsOwnedBy's (review S5-5): |
| 145 | // there is no operator on this path to scope to. The bearer is what |
| 146 | // establishes who the caller is, and the row it resolves to is what |
| 147 | // names the operator — scoping the lookup by an operator would mean |
| 148 | // already knowing the answer it exists to find. The token hash is the |
| 149 | // filter, and it is a secret the caller had to present. |
| 150 | func (s *store) agentByToken(ctx context.Context, token string) (Agent, Person, bool, error) { |
| 151 | var ag Agent |
| 152 | err := s.g.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", hashAgentToken(token)).Take(&ag).Error |
| 153 | return s.withOperator(ctx, ag, err) |
| 154 | } |
| 155 | |
| 156 | // agentByID resolves an agent scoped to its operator (fix round 1, |
| 157 | // item 2): a foreign or unknown id both answer false, the same 404 |
| 158 | // shape scope.Owned gives every other owned row — through |
| 159 | // agentsOwnedBy, so the predicate is the framework's and not this |
| 160 | // file's (review S5-5). |
| 161 | func (s *store) agentByID(ctx context.Context, id int64, operator Person) (Agent, Person, bool, error) { |
| 162 | var ag Agent |
| 163 | err := agentsOwnedBy(s.g.WithContext(ctx), operator).Where("id = ?", id).Take(&ag).Error |
| 164 | return s.withOperator(ctx, ag, err) |
| 165 | } |
| 166 | |
| 167 | // agentAnyByID resolves an agent by id alone, with no operator scope, |
| 168 | // and exists for exactly one purpose: rendering the principal of a |
| 169 | // write an agent made (§8.1, "robot badge and operator name on every |
| 170 | // write"). It is not an ownership check and must never stand in for |
| 171 | // one — agentByID is that — but every member already sees every |
| 172 | // meeting on the list, so naming which agent created one tells them |
| 173 | // nothing the list did not. |
| 174 | // |
| 175 | // Deliberately outside agentsOwnedBy (review S5-5): the reader of a |
| 176 | // list is not the operator of the agents on it, and scoping this |
| 177 | // lookup to the reader would blank the principal on every meeting made |
| 178 | // by somebody else's agent. Nothing is decided on what it returns. |
| 179 | func (s *store) agentAnyByID(ctx context.Context, id int64) (Agent, Person, bool, error) { |
| 180 | var ag Agent |
| 181 | err := s.g.WithContext(ctx).Where("id = ?", id).Take(&ag).Error |
| 182 | return s.withOperator(ctx, ag, err) |
| 183 | } |
| 184 | |
| 185 | // withOperator finishes every agent lookup the same way (fix round 1, |
| 186 | // item 1): a missing agent row, a driver error, and an agent whose |
| 187 | // operator row is gone are all one fail-closed answer, so no caller can |
| 188 | // tell them apart and none is ever handed a bare-ID Person. |
| 189 | func (s *store) withOperator(ctx context.Context, ag Agent, err error) (Agent, Person, bool, error) { |
| 190 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 191 | return Agent{}, Person{}, false, nil |
| 192 | } |
| 193 | if err != nil { |
| 194 | return Agent{}, Person{}, false, err |
| 195 | } |
| 196 | op, found, err := s.personByID(ctx, ag.OperatorPersonID) |
| 197 | if err != nil { |
| 198 | return Agent{}, Person{}, false, err |
| 199 | } |
| 200 | if !found { |
| 201 | return Agent{}, Person{}, false, nil |
| 202 | } |
| 203 | return ag, op, true, nil |
| 204 | } |
| 205 | |
| 206 | // creatorLabel renders a stored actor (§7.2) as the identity a reader |
| 207 | // is shown: an agent's principal, <name>~<operator email>, or a |
| 208 | // person's display name. It is a label and not an authority — nothing |
| 209 | // is decided on it, which is also why neither lookup it makes is a |
| 210 | // scoped one (review S5-5): the row was already shown to this reader |
| 211 | // by the list, and all this adds is the name of who made it — so an |
| 212 | // actor that no longer resolves comes back as |
| 213 | // the raw actor string rather than failing the whole list, logged the |
| 214 | // way store.title logs an unreadable title. |
| 215 | func (s *store) creatorLabel(ctx context.Context, actor string) string { |
| 216 | kind, rest, found := strings.Cut(actor, ":") |
| 217 | if !found { |
| 218 | return actor |
| 219 | } |
| 220 | id, err := strconv.ParseInt(rest, 10, 64) |
| 221 | if err != nil { |
| 222 | return actor |
| 223 | } |
| 224 | switch kind { |
| 225 | case "agent": |
| 226 | ag, op, ok, err := s.agentAnyByID(ctx, id) |
| 227 | if err != nil { |
| 228 | s.logger.Error("calls: resolve creator agent", "agent_id", id, "err", err) |
| 229 | return actor |
| 230 | } |
| 231 | if !ok { |
| 232 | return actor |
| 233 | } |
| 234 | return principal(ag, op) |
| 235 | case "person": |
| 236 | p, ok, err := s.personByID(ctx, id) |
| 237 | if err != nil { |
| 238 | s.logger.Error("calls: resolve creator person", "person_id", id, "err", err) |
| 239 | return actor |
| 240 | } |
| 241 | if !ok { |
| 242 | return actor |
| 243 | } |
| 244 | return p.Name |
| 245 | } |
| 246 | return actor |
| 247 | } |
| 248 | |