| 1 | package docs |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | type Role string |
| 18 | |
| 19 | const ( |
| 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 | |
| 26 | func (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. |
| 34 | type 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. |
| 49 | func (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. |
| 53 | var 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. |
| 58 | var 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. |
| 64 | var 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. |
| 70 | type Actor struct { |
| 71 | PersonID int64 |
| 72 | VerifiedAddress *string |
| 73 | } |
| 74 | |
| 75 | // localActor keeps a verified local address distinct from an opaque Home subject. |
| 76 | func 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. |
| 88 | func 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. |
| 99 | func 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. |
| 105 | func (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.) |
| 130 | func 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. |
| 152 | func 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. |
| 163 | func (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.) |
| 223 | func (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). |
| 249 | func (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. |
| 268 | func (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). |
| 291 | func (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. |
| 314 | func (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. |
| 339 | func (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 | |