| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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. |
| 20 | var 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. |
| 36 | func (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. |
| 81 | func (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. |
| 87 | func (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. |
| 95 | func (a *App) mayShare(sheetID uint64, m Member) bool { |
| 96 | role, ok := a.roleFor(sheetID, m) |
| 97 | return ok && role.Allows(SheetOwner) |
| 98 | } |
| 99 | |
| 100 | func (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. |
| 121 | func (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. |
| 151 | type sharedWith struct { |
| 152 | Member Member |
| 153 | Role SheetRole |
| 154 | } |
| 155 | |
| 156 | func (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. |
| 182 | func (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. |
| 228 | func (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". |
| 268 | func (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 | |