oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
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
18var agentNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
19
20var 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.
24var 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.
31const maxLiveAgents = 20
32
33var 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.
39var 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).
44func 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
53func 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.
61func principal(ag Agent, operator Person) string { return ag.Name + "~" + operator.Email }
62
63func 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.
70func (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.
118func agentsOwnedBy(g *gorm.DB, operator Person) *gorm.DB {
119 return scope.OwnedBy(g, "operator_person_id", operator.ID)
120}
121
122func (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.
130func (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.
150func (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).
161func (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.
179func (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.
189func (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.
215func (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