oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "errors"
5 "strings"
6
7 "gorm.io/gorm"
8 "gorm.io/gorm/clause"
9
10 "amadan.net/oficina/sheets/internal/sheets/store"
11)
12
13// errNoAccess is what every read answers when the viewer may not see a
14// sheet.
15//
16// Callers turn it into a 404, never a 403 (AGENTS.md). A 403 confirms
17// the row exists, which is a fact about somebody else's data — and on
18// an instance where guests hold single-sheet grants, the set of ids
19// that 403 is a map of the team's sheets.
20var errNoAccess = errors.New("sheets: no grant")
21
22// roleFor returns a member's effective role on a sheet, and whether
23// they have any access at all.
24//
25// Two sources, in order:
26//
27// 1. An explicit Grant row. This is the authority for everyone, and
28// the only source for a guest.
29// 2. Team standing. An owner or member of the team can reach every
30// sheet on the instance — that is what "one instance per team"
31// means, and it is why the previous instance-wide list was right
32// for a single-team instance and wrong the moment guests existed.
33//
34// A guest gets NOTHING from step 2. That is the whole distinction in
35// §8.2: a guest sees exactly the sheets granted to them.
36func (a *App) roleFor(sheetID uint64, m Member) (SheetRole, bool) {
37 if a.db == nil {
38 return "", false
39 }
40 // FIRST, covering both sources below. This check used to sit in the
41 // team-standing branch alone, which made an explicit Grant row a
42 // way round it: a grant holder kept their role on a soft-deleted
43 // sheet, and — once import existed — on one still being filled.
44 // openTab and the stream gate on roleFor and nothing else, so that
45 // was read and WRITE access to a sheet no list would show, for the
46 // one person every import gives an owner grant to.
47 if !a.sheetExists(sheetID) {
48 return "", false
49 }
50 var g Grant
51 err := a.db.Where("sheet_id = ? AND member_id = ?", sheetID, m.ID).First(&g).Error
52 if err == nil {
53 if role := SheetRole(g.Role); role.Valid() {
54 return role, true
55 }
56 // A row whose role this build does not recognise grants
57 // nothing. Falling through to team standing would be worse: it
58 // would silently upgrade an unreadable grant to full access.
59 return "", false
60 }
61 if !errors.Is(err, gorm.ErrRecordNotFound) {
62 return "", false
63 }
64
65 switch TeamRole(m.TeamRole) {
66 case TeamOwner, TeamMember:
67 // Team standing reaches every sheet, as an editor. Not as
68 // owner: sheet ownership is about who may change sharing, and
69 // handing that to every colleague by default makes the grant
70 // table decorative.
71 //
72 // The sheet is already known to exist — that is checked at the
73 // top, for both sources. It was checked here instead, once, and
74 // the grant path above went unguarded as a result.
75 return SheetEditor, true
76 }
77 return "", false
78}
79
80// mayRead is the gate every read route uses.
81func (a *App) mayRead(sheetID uint64, m Member) bool {
82 role, ok := a.roleFor(sheetID, m)
83 return ok && role.Allows(SheetViewer)
84}
85
86// mayWrite is the gate every cell-writing route uses.
87func (a *App) mayWrite(sheetID uint64, m Member) bool {
88 role, ok := a.roleFor(sheetID, m)
89 return ok && role.Allows(SheetEditor)
90}
91
92// mayShare is the gate the share routes use. Sheet owners only: a
93// person who can change who else can read a sheet is exercising the
94// one power that cannot be undone by the people it affects.
95func (a *App) mayShare(sheetID uint64, m Member) bool {
96 role, ok := a.roleFor(sheetID, m)
97 return ok && role.Allows(SheetOwner)
98}
99
100func (a *App) sheetExists(id uint64) bool {
101 var n int64
102 if err := a.db.Model(&store.Sheet{}).
103 Where("id = ? AND deleted_at IS NULL AND filling_since IS NULL", id).Count(&n).Error; err != nil {
104 return false
105 }
106 return n == 1
107}
108
109// visibleSheets returns the sheets a member may see.
110//
111// This is the hand-written grants join AGENTS.md requires, and the
112// reason it is hand-written rather than scope.Owned: scope.Owned is
113// single-column equality on user_id, our sheets are team-shared, and
114// applied here it would return only the sheets a person created — which
115// fails as a plausible short list rather than as an error.
116//
117// The query is deliberately one statement rather than "list all, then
118// filter in Go". A filter in Go reads every sheet on the instance into
119// memory before discarding most of them, which is both wasteful and the
120// shape where somebody later forgets the discard.
121func (a *App) visibleSheets(m Member) ([]store.Sheet, error) {
122 if a.db == nil {
123 return nil, errNoDB
124 }
125 // filling_since alongside deleted_at, everywhere the other is
126 // checked: a sheet still being filled from an imported file is not
127 // a thing anybody should see yet, and on this instance every team
128 // member sees every sheet, so "not visible" has to mean not in this
129 // query rather than not linked to.
130 q := a.db.Model(&store.Sheet{}).
131 Where("sheets.deleted_at IS NULL AND sheets.filling_since IS NULL")
132
133 switch TeamRole(m.TeamRole) {
134 case TeamOwner, TeamMember:
135 // The whole instance. One team, one set of sheets.
136 default:
137 // A guest sees exactly what they were granted. The join is an
138 // inner join precisely so a guest with no grants gets an empty
139 // list rather than everything.
140 q = q.Joins("JOIN grants ON grants.sheet_id = sheets.id AND grants.member_id = ?", m.ID)
141 }
142
143 var rows []store.Sheet
144 if err := q.Order("sheets.updated_at DESC, sheets.id DESC").Find(&rows).Error; err != nil {
145 return nil, err
146 }
147 return rows, nil
148}
149
150// grantsOn lists who can reach a sheet, for the share screen.
151type sharedWith struct {
152 Member Member
153 Role SheetRole
154}
155
156func (a *App) grantsOn(sheetID uint64) ([]sharedWith, error) {
157 if a.db == nil {
158 return nil, errNoDB
159 }
160 var rows []Grant
161 if err := a.db.Where("sheet_id = ?", sheetID).Order("id ASC").Find(&rows).Error; err != nil {
162 return nil, err
163 }
164 out := make([]sharedWith, 0, len(rows))
165 for _, g := range rows {
166 var m Member
167 if err := a.db.Where("id = ?", g.MemberID).First(&m).Error; err != nil {
168 continue // a grant whose member vanished names nobody
169 }
170 out = append(out, sharedWith{Member: m, Role: SheetRole(g.Role)})
171 }
172 return out, nil
173}
174
175// grant gives an address a role on a sheet, creating the member as a
176// guest if this is the first time the instance has seen them.
177//
178// Guest is the default for a new person, and the direction matters:
179// a guest wrongly made a member sees every sheet on the instance, while
180// a member wrongly left a guest sees one sheet too few and says so.
181// Only SHEETS_OWNERS promotes beyond guest, at sign-in.
182func (a *App) grant(sheetID uint64, address string, role SheetRole, by Member) (Member, error) {
183 if a.db == nil {
184 return Member{}, errNoDB
185 }
186 if !role.Valid() {
187 return Member{}, errors.New("sheets: unknown role")
188 }
189 addr := strings.ToLower(strings.TrimSpace(address))
190 if addr == "" {
191 return Member{}, errors.New("sheets: a share needs an address")
192 }
193 if !strings.Contains(addr, "@") {
194 return Member{}, errors.New("sheets: that does not look like an email address")
195 }
196 m, err := a.memberFor(addr)
197 if err != nil {
198 return Member{}, err
199 }
200 // Upsert: re-sharing with somebody who already has access changes
201 // their role rather than failing on the unique index, which is what
202 // a person doing it expects.
203 g := Grant{SheetID: sheetID, MemberID: m.ID, Role: string(role), GrantedBy: by.ID}
204 // One transaction, because the check and the write are the same
205 // decision. Scoped to tx throughout, never the outer handle
206 // (AGENTS.md) — the one-connection writer pool deadlocks otherwise.
207 if err := a.db.Transaction(func(tx *gorm.DB) error {
208 going, err := lastOwnerGoing(tx, sheetID, m.ID, role)
209 if err != nil {
210 return err
211 }
212 if going {
213 return errLastOwner
214 }
215 return tx.Clauses(clause.OnConflict{
216 Columns: []clause.Column{{Name: "sheet_id"}, {Name: "member_id"}},
217 DoUpdates: clause.AssignmentColumns([]string{"role", "granted_by", "updated_at"}),
218 }).Create(&g).Error
219 }); err != nil {
220 return Member{}, err
221 }
222 return m, nil
223}
224
225// revoke removes a grant. Removing one that is not there is not an
226// error: the caller wanted that person not to have access, and they do
227// not.
228func (a *App) revoke(sheetID, memberID uint64) error {
229 if a.db == nil {
230 return errNoDB
231 }
232 // The orphan check lives in here, inside the transaction that does
233 // the delete, rather than in the handler above it. A handler-side
234 // check is a read separated from its write by a request's worth of
235 // time: two owners revoking each other at once both saw two owners
236 // and both deleted.
237 return a.db.Transaction(func(tx *gorm.DB) error {
238 going, err := lastOwnerGoing(tx, sheetID, memberID, SheetViewer)
239 if err != nil {
240 return err
241 }
242 if going {
243 return errLastOwner
244 }
245 return tx.Where("sheet_id = ? AND member_id = ?", sheetID, memberID).
246 Delete(&Grant{}).Error
247 })
248}
249
250// Admits is the instance's admission gate: who may hold a session here.
251//
252// Two ways in, and the second is what makes sharing mean anything:
253//
254// 1. The SHEETS_OWNERS allowlist — the team.
255// 2. Holding at least one grant — a guest somebody shared a sheet
256// with.
257//
258// Without the second, `grant` would happily create a Grant row and a
259// guest Member for somebody who could then never sign in to use it:
260// auth refuses an address its Authorize does not admit, so the share
261// would be a row nobody could ever exercise. That is not a
262// hypothetical — it is what this app did between the grants table
263// landing and this function existing.
264//
265// It stays fail-closed. An address with no allowlist entry and no grant
266// is refused, and a database error is refused rather than admitted:
267// "we could not check" is not "yes".
268func (a *App) Admits(address string) bool {
269 addr := strings.ToLower(strings.TrimSpace(address))
270 if addr == "" {
271 return false
272 }
273 if a.admit != nil && a.admit(addr) {
274 return true
275 }
276 if a.db == nil {
277 return false
278 }
279 // The sheets join is load-bearing, not decoration. Without it this
280 // counts grants to sheets that no longer exist, so a guest whose
281 // every live grant has been revoked still signs in on the strength
282 // of a deleted one — and a sheet still being filled by an import
283 // would admit somebody before the import it belongs to had even
284 // succeeded. Admission has to rest on a sheet they could actually
285 // open.
286 var n int64
287 if err := a.db.Model(&Grant{}).
288 Joins("JOIN members ON members.id = grants.member_id").
289 Joins("JOIN sheets ON sheets.id = grants.sheet_id").
290 Where("members.address = ? AND sheets.deleted_at IS NULL AND sheets.filling_since IS NULL", addr).
291 Count(&n).Error; err != nil {
292 return false
293 }
294 return n > 0
295}
296