oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "amadan.net/oficina/home/roster"
5 "context"
6 "errors"
7 "fmt"
8 "strings"
9 "time"
10
11 "gorm.io/gorm"
12 "gorm.io/gorm/clause"
13)
14
15// Role is what a grant lets its holder do. Two values, spelled out, because a
16// role read out of a database during an incident should say what it is.
17type Role string
18
19const (
20 // RoleReader may read the document and nothing else.
21 RoleReader Role = "reader"
22 // RoleWriter may read it and apply updates to it.
23 RoleWriter Role = "writer"
24)
25
26func (r Role) valid() bool { return r == RoleReader || r == RoleWriter }
27
28// Grant is one person's access to one document.
29//
30// Subject is a lowercased, trimmed address rather than a people.id — see
31// 0005_grants.sql for why, which is that EnsurePerson's create branch seeds a
32// sample library, so minting a Person row to hold a grant would hand an
33// invitee six documents they never asked for before they had accepted.
34type Grant struct {
35 ID int64 `gorm:"column:id;primaryKey"`
36 DocID int64 `gorm:"column:doc_id;index"`
37 Subject string `gorm:"column:subject;index"`
38 Role Role `gorm:"column:role"`
39 // CreatedBy is the person who made this grant — the spec's §4 data
40 // model has it and this table shipped without it. It is what lets an
41 // operator answer "who let this address in", which is a question with
42 // no other source once a grant exists.
43 CreatedBy int64 `gorm:"column:created_by"`
44 CreatedAt time.Time `gorm:"column:created_at"`
45}
46
47// TableName pins the table, since GORM would otherwise pluralise Grant to
48// "grants" by a rule rather than by a decision.
49func (Grant) TableName() string { return "grants" }
50
51// ErrNotOwner is returned when somebody who is not a document's owner tries to
52// change who may reach it. A grantee — even a writer — cannot re-share.
53var ErrNotOwner = errors.New("docs: only a document's owner may share it")
54
55// ErrNotWritable is returned when the acting person may not write a document.
56// It does not distinguish a reader from a stranger: the write path answers
57// both the same way, for the reason §4.2 gives about reads.
58var ErrNotWritable = errors.New("docs: not writable by this person")
59
60// errEmptyAddress is a share form submitted with nothing in the address
61// field. Its own error rather than a generic one because the share handler
62// re-renders the page with a message beside the field, and a person mistyping
63// should not get a 500.
64var errEmptyAddress = errors.New("docs: cannot grant to an empty address")
65
66// Actor is who is acting on a request: this app's numeric person id, and the
67// address their grants are keyed on. Both are needed because ownership is
68// keyed on the id and grants are keyed on the address — see 0005_grants.sql
69// for why grants are not keyed on the id.
70type Actor struct {
71 PersonID int64
72 VerifiedAddress *string
73}
74
75// localActor keeps a verified local address distinct from an opaque Home subject.
76func localActor(id int64, address string) Actor {
77 address = subjectOf(address)
78 if address == "" {
79 return Actor{PersonID: id}
80 }
81 return Actor{PersonID: id, VerifiedAddress: &address}
82}
83
84// writable scopes a query to the documents one person may change: the ones
85// they own, plus the ones they hold a writer grant on. A reader grant is
86// absent from this query on purpose, and that absence is the role's whole
87// meaning.
88func writable(g *gorm.DB, a Actor) *gorm.DB {
89 return (&Store{}).permissionScope(context.Background(), g, a, permissionWrite)
90}
91
92// subjectOf normalises an address the way EnsurePerson stores one, so a grant
93// made to "Bob@Example.com " is the grant Bob holds when he signs in.
94//
95// It defers to normalizeAddress rather than repeating it. The two were the
96// same two lines in two files — the two halves of one key, free to drift
97// into comparing differently-normalised forms of the same address, which
98// would have shown up as a grant that silently admits nobody.
99func subjectOf(address string) string {
100 return normalizeAddress(address)
101}
102
103// addressOf maps a person id back to the address they signed in with, so
104// Grant can refuse a grant somebody makes to themselves.
105func (s *Store) addressOf(ctx context.Context, person int64) (*string, error) {
106 if s.team != nil {
107 p, err := s.team.principals.Person(ctx, person)
108 return p.VerifiedAddress, err
109 }
110 var p Person
111 if err := s.db.WithContext(ctx).First(&p, person).Error; err != nil {
112 return nil, err
113 }
114 address := subjectOf(p.Email)
115 if address == "" {
116 return nil, errIdentityRefused
117 }
118 return &address, nil
119}
120
121// looksLikeAnAddress is the cheapest check that keeps nonsense out of the
122// front door.
123//
124// Not a validator: RFC 5322 is not worth implementing here, and rauth owns
125// the real parsing downstream. This exists because Grant accepted any
126// non-empty string, so "not-an-email" became a row, appeared on the share
127// page, and made HasGrant answer true — widening admission to a string
128// that can never sign in, and leaving the owner a share list with a typo
129// on it they will read as a person. (Branch review, finding 10.)
130func looksLikeAnAddress(subject string) bool {
131 at := strings.Index(subject, "@")
132 return at > 0 && at < len(subject)-1 && !strings.ContainsAny(subject, " \t")
133}
134
135// readable scopes a query to the documents one person may read: the ones they
136// own, plus the ones they hold a grant on.
137//
138// This is the seam sharing adds beside owned. owned still answers "whose
139// document is this" and still means exactly what it meant — no call site that
140// genuinely wants ownership silently starts accepting grantees, which is what
141// widening owned in place would have done.
142//
143// The grant test is an EXISTS subquery rather than a join, and deliberately:
144// callers chain .Where("sqid = ?"), .Order and .Find onto this, and a join
145// would hand them duplicate rows and an ambiguous `id` in clauses written
146// before sharing existed.
147//
148// owner_id > 0 leads, so an orphan stays invisible even if a grant points at
149// it. 0002_owners.sql wrote owner_id 0 onto every pre-existing row, and three
150// tests pin that 0 matches nothing; a grants clause must not become the way
151// around them.
152func readable(g *gorm.DB, a Actor) *gorm.DB {
153 return (&Store{}).permissionScope(context.Background(), g, a, permissionRead)
154}
155
156// Grant gives an address access to a document. Only the owner may call it:
157// re-sharing is not something a grant carries, so a writer cannot widen who
158// else reaches the document.
159//
160// Granting twice to the same address changes the role rather than stacking a
161// second row, because two rows whose precedence nobody decided is a question
162// that gets answered differently by whichever query runs first.
163func (s *Store) Grant(ctx context.Context, owner int64, sqid, address string, role Role) error {
164 if !role.valid() {
165 return fmt.Errorf("docs: unknown role %q", role)
166 }
167 subject := subjectOf(address)
168 if subject == "" || !looksLikeAnAddress(subject) {
169 return errEmptyAddress
170 }
171
172 doc, err := s.Get(ctx, owner, sqid)
173 if err != nil {
174 // Get is owner-scoped, so this is "not yours" and "no such document"
175 // answering identically — spec §4.2, kept here rather than leaked into
176 // a distinguishable share error.
177 return err
178 }
179
180 // Granting to yourself is refused: you already reach it by owning it,
181 // and the row would put the owner on their own share list as though
182 // somebody had been invited.
183 ownerAddress, err := s.addressOf(ctx, owner)
184 if err != nil {
185 return fmt.Errorf("docs: resolve owner address: %w", err)
186 }
187 if ownerAddress != nil && *ownerAddress == subject {
188 return errEmptyAddress
189 }
190
191 grant := Grant{
192 DocID: doc.ID,
193 Subject: subject,
194 Role: role,
195 CreatedBy: owner,
196 CreatedAt: time.Now().UTC(),
197 }
198 return s.ownerMutation(ctx, owner, doc.ID, func(tx *gorm.DB) error {
199 return tx.Clauses(clause.OnConflict{
200 Columns: []clause.Column{{Name: "doc_id"}, {Name: "subject"}},
201 DoUpdates: clause.AssignmentColumns([]string{"role", "created_by"}),
202 }).Create(&grant).Error
203 })
204}
205
206// HasGrant reports whether an address holds a grant on any document here.
207//
208// The allowlist asks this so a granted address can sign in — see allowlist's
209// `granted` field for why an unredeemable invitation is the worst available
210// failure shape. It answers on the address alone, never on which document,
211// because admission is not a per-document question.
212//
213// A database error is REPORTED rather than folded into false, and that
214// distinction costs a guest their work if it is lost.
215//
216// requireAdmission runs on every request. Answering false on a failed
217// check ejects a guest mid-session, and their editor's next POST gets a
218// 403 — which save-queue.mjs reads as "the bytes are the problem",
219// dropping the batch permanently and, because Yjs updates depend on their
220// predecessors, poisoning everything after it. One two-second database
221// stall would cost a guest their paragraph. So the caller gets the error
222// and can answer 503, which the queue retries. (Re-review 3, finding 1.)
223func (s *Store) HasGrant(ctx context.Context, address string) (bool, error) {
224 subject := subjectOf(address)
225 if subject == "" {
226 return false, nil
227 }
228 // Scoped to grants on documents that still exist. A grant on a
229 // soft-deleted or orphaned (owner_id = 0) document opens nothing —
230 // readable hides those rows — so counting it here would hold the front
231 // door open for somebody who can reach nothing at all, and would keep
232 // holding it after every document they were given had been deleted.
233 // (Branch review, finding 12.)
234 var count int64
235 if err := s.db.WithContext(ctx).Model(&Grant{}).
236 Joins("JOIN documents ON documents.id = grants.doc_id").
237 Where("grants.subject = ? AND documents.deleted_at IS NULL AND documents.owner_id > 0",
238 subject).
239 Count(&count).Error; err != nil {
240 s.logger.Error("docs: checking grants for admission", "err", err)
241 return false, err
242 }
243 return count > 0, nil
244}
245
246// ListGrants returns who currently reaches a document, oldest first, for its
247// owner. Owner-scoped through Get, so a grantee asking gets the same answer a
248// stranger does — the share page is the owner's alone (§4.2).
249func (s *Store) ListGrants(ctx context.Context, owner int64, sqid string) ([]*Grant, error) {
250 doc, err := s.Get(ctx, owner, sqid)
251 if err != nil {
252 return nil, err
253 }
254 var grants []*Grant
255 err = s.db.WithContext(ctx).
256 Where("doc_id = ?", doc.ID).
257 Order("created_at ASC, id ASC").
258 Find(&grants).Error
259 return grants, err
260}
261
262// Revoke takes an address's access away.
263//
264// Revoking something that is not there succeeds. The owner asked for an
265// outcome — that this address does not reach this document — and that outcome
266// holds either way; reporting an error would only tell them about a race with
267// their own second click.
268func (s *Store) Revoke(ctx context.Context, owner int64, sqid, address string) error {
269 doc, err := s.Get(ctx, owner, sqid)
270 if err != nil {
271 return err
272 }
273 return s.ownerMutation(ctx, owner, doc.ID, func(tx *gorm.DB) error {
274 return tx.
275 Where("doc_id = ? AND subject = ?", doc.ID, subjectOf(address)).
276 Delete(&Grant{}).Error
277 })
278}
279
280// GetFor fetches a document the acting person may read: theirs, or one they
281// hold a grant on.
282//
283// Deliberately a second method rather than a widening of Get. Get is
284// owner-scoped and several paths want exactly that — sharing, revoking and
285// listing grants are the owner's alone — so widening Get in place would have
286// handed every one of them grantees as well, silently. The two names say
287// which question is being asked.
288//
289// A document the person may not read answers gorm.ErrRecordNotFound, the same
290// as one that does not exist, and the handler turns both into 404 (§4.2).
291func (s *Store) GetFor(ctx context.Context, a Actor, sqid string) (*Document, error) {
292 var doc Document
293 err := s.permissionScope(ctx, s.db.WithContext(ctx), a, permissionRead).
294 Where("documents.sqid = ? AND documents.deleted_at IS NULL", sqid).
295 First(&doc).Error
296 if err != nil {
297 return nil, s.permissionError(ctx, a, err)
298 }
299 s.rememberResponse(ctx, permissionRead, &doc)
300 return &doc, nil
301}
302
303// CanWrite reports whether the acting person may change a document.
304//
305// The same EXISTS clause writable builds, asked as a question rather than
306// used as a scope, because the document page needs the answer before it
307// renders: a reader must not be handed an editor (branch review, #2). The
308// one predicate, asked twice, so the page and the update route cannot
309// disagree about who may write.
310//
311// A database error answers false. Rendering a read-only page to somebody
312// who may write costs them a reload; rendering an editor to somebody who
313// may not costs them their work.
314func (s *Store) CanWrite(ctx context.Context, a Actor, docID int64) bool {
315 var count int64
316 if err := s.permissionScope(ctx, s.db.WithContext(ctx).Model(&Document{}), a, permissionWrite).
317 Where("documents.id = ?", docID).
318 Count(&count).Error; err != nil {
319 s.logger.Error("docs: checking write access", "doc", docID, "err", err)
320 return false
321 }
322 return count > 0
323}
324
325// ListFor returns every non-deleted document the acting person may read —
326// the ones they own, plus the ones they hold a grant on — newest first, on
327// the same UpdatedAt clock List uses so this list and handlers.go's recency
328// buckets cannot disagree.
329//
330// A second method beside List rather than a widening of it, for the reason
331// GetFor gives: List is owner-scoped and callers exist that want exactly
332// that, and widening in place hands every one of them grantees silently.
333//
334// The order deliberately does not separate "mine" from "shared with me".
335// Recency is the sort the list page already teaches, and a shared document
336// somebody is working in today belongs above one of yours you have not
337// opened since spring. Which are yours the row answers itself, through
338// docRow.SharedNote, without reordering the list.
339func (s *Store) ListFor(ctx context.Context, a Actor) ([]*Document, error) {
340 var docs []*Document
341 err := s.permissionScope(ctx, s.db.WithContext(ctx), a, permissionRead).
342 Where("documents.deleted_at IS NULL").
343 Order("documents.updated_at DESC, documents.id DESC").
344 Find(&docs).Error
345 if err != nil {
346 return nil, s.permissionError(ctx, a, fmt.Errorf("docs: list readable documents: %w", err))
347 }
348 if s.team != nil && len(docs) == 0 {
349 a, ok := s.access(ctx, a)
350 if !ok || a.state() == roster.Unknown {
351 return nil, errTeamUnavailable
352 }
353 }
354 s.rememberResponse(ctx, permissionRead, docs...)
355 return docs, nil
356}
357