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 "context"
5 "errors"
6 "net/http"
7
8 "amadan.net/oficina/home/roster"
9 "amadan.net/rastrillo/rastrillo"
10 "gorm.io/gorm"
11)
12
13type accessContextKey struct{}
14type requestAccess struct {
15 uncertain bool
16 responseStore *Store
17 responseDocs map[int64]permission
18 runtime *teamRuntime
19 actor Actor
20 subject string
21 decision roster.Decision
22}
23type permission uint8
24
25const (
26 permissionRead permission = iota
27 permissionWrite
28 permissionOwner
29)
30
31var errTeamNotMember = errors.New("docs: team membership required")
32
33func sameActor(a, b Actor) bool {
34 if a.PersonID != b.PersonID || (a.VerifiedAddress == nil) != (b.VerifiedAddress == nil) {
35 return false
36 }
37 return a.VerifiedAddress == nil || *a.VerifiedAddress == *b.VerifiedAddress
38}
39func (a *requestAccess) state() roster.State {
40 if a == nil {
41 return roster.Unknown
42 }
43 if a.uncertain {
44 return roster.Unknown
45 }
46 if a.decision.State == roster.Member && !a.decision.Lease.Valid() {
47 a.uncertain = true
48 return roster.Unknown
49 }
50 return a.decision.State
51}
52func (s *Store) access(ctx context.Context, actor Actor) (*requestAccess, bool) {
53 a, ok := ctx.Value(accessContextKey{}).(*requestAccess)
54 return a, ok && a != nil && a.runtime == s.team && sameActor(a.actor, actor)
55}
56func (t *teamRuntime) resolveAccess(ctx context.Context, id requestIdentity) *requestAccess {
57 a := &requestAccess{runtime: t, actor: id.Actor, subject: id.HomeSubject, decision: roster.Decision{State: roster.NotMember}}
58 if id.HomeSubject != "" {
59 a.decision = t.cache.Check(ctx, t.bindings.Issuer, t.bindings.Team, id.HomeSubject)
60 }
61 return a
62}
63func (t *teamRuntime) admission(next http.Handler) http.Handler {
64 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65 id, ok := currentIdentity(r)
66 if !ok {
67 http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
68 return
69 }
70 a := t.resolveAccess(r.Context(), id)
71 next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), accessContextKey{}, a)))
72 })
73}
74
75// permissionScope is also usable under the SQLite writer. It never refreshes
76// the roster, and checks a lease without waiting for a cache publisher.
77func (s *Store) permissionScope(ctx context.Context, g *gorm.DB, actor Actor, p permission) *gorm.DB {
78 owner := s.team == nil
79 if s.team != nil {
80 a, ok := s.access(ctx, actor)
81 if !ok {
82 return g.Where("1 = 0")
83 }
84 owner = a.state() == roster.Member
85 }
86 if actor.PersonID <= 0 {
87 return g.Where("1 = 0")
88 }
89 if p == permissionOwner {
90 if !owner {
91 return g.Where("1 = 0")
92 }
93 return owned(g, actor.PersonID)
94 }
95 grant := actor.VerifiedAddress != nil && *actor.VerifiedAddress != ""
96 if !owner && !grant {
97 return g.Where("1 = 0")
98 }
99 clause := "documents.owner_id > 0 AND ("
100 args := []any{}
101 if owner {
102 clause += "documents.owner_id = ?"
103 args = append(args, actor.PersonID)
104 }
105 if grant {
106 if owner {
107 clause += " OR "
108 }
109 clause += "EXISTS (SELECT 1 FROM grants WHERE grants.doc_id = documents.id AND grants.subject = ?"
110 args = append(args, subjectOf(*actor.VerifiedAddress))
111 if p == permissionWrite {
112 clause += " AND grants.role = ?"
113 args = append(args, RoleWriter)
114 }
115 clause += ")"
116 }
117 return g.Where(clause+")", args...)
118}
119func (s *Store) permissionError(ctx context.Context, actor Actor, err error) error {
120 if s.team == nil || err == nil {
121 return err
122 }
123 if !errors.Is(err, gorm.ErrRecordNotFound) {
124 return errTeamUnavailable
125 }
126 a, ok := s.access(ctx, actor)
127 if !ok || a.state() == roster.Unknown {
128 return errTeamUnavailable
129 }
130 return gorm.ErrRecordNotFound
131}
132func (s *Store) requireMember(ctx context.Context, actor Actor) error {
133 if s.team == nil {
134 return nil
135 }
136 a, ok := s.access(ctx, actor)
137 if !ok {
138 return errTeamUnavailable
139 }
140 switch a.state() {
141 case roster.Member:
142 return nil
143 case roster.NotMember:
144 return errTeamNotMember
145 default:
146 return errTeamUnavailable
147 }
148}
149
150func (s *Store) ownerActor(ctx context.Context, owner int64) Actor {
151 if s.team == nil {
152 return Actor{PersonID: owner}
153 }
154 a, ok := ctx.Value(accessContextKey{}).(*requestAccess)
155 if !ok || a == nil || a.actor.PersonID != owner {
156 return Actor{PersonID: owner}
157 }
158 return a.actor
159}
160
161func (s *Store) checkPermission(ctx context.Context, db *gorm.DB, actor Actor, docID int64, p permission) error {
162 var doc Document
163 err := s.permissionScope(ctx, db, actor, p).Where("documents.id = ? AND documents.deleted_at IS NULL", docID).First(&doc).Error
164 err = s.permissionError(ctx, actor, err)
165 if p == permissionWrite && errors.Is(err, gorm.ErrRecordNotFound) {
166 return ErrNotWritable
167 }
168 return err
169}
170func (s *Store) snapshotPermission(ctx context.Context, db *gorm.DB, docID int64) error {
171 if s.team == nil {
172 return nil
173 }
174 a, ok := ctx.Value(accessContextKey{}).(*requestAccess)
175 if !ok || a == nil {
176 return errTeamUnavailable
177 }
178 return s.checkPermission(ctx, db, a.actor, docID, permissionWrite)
179}
180
181func (s *Store) ownerMutation(ctx context.Context, owner, docID int64, mutate func(*gorm.DB) error) error {
182 actor := s.ownerActor(ctx, owner)
183 return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
184 if err := s.checkPermission(ctx, tx, actor, docID, permissionOwner); err != nil {
185 return err
186 }
187 if err := mutate(tx); err != nil {
188 return s.permissionError(ctx, actor, err)
189 }
190 return s.checkPermission(ctx, tx, actor, docID, permissionOwner)
191 })
192}
193
194func (h *handlers) internalFailureStatus() int {
195 if h.store.team != nil {
196 return http.StatusServiceUnavailable
197 }
198 return http.StatusInternalServerError
199}
200func (h *handlers) requireMemberRequest(w http.ResponseWriter, r *http.Request, item bool) bool {
201 if h.store.team == nil {
202 if h.member(r) {
203 return true
204 }
205 h.fail(w, r, http.StatusForbidden, failStatus)
206 return false
207 }
208 actor, e := h.actor(r)
209 if e == nil {
210 e = h.store.requireMember(r.Context(), actor)
211 }
212 if e == nil {
213 return true
214 }
215 code, kind := http.StatusServiceUnavailable, failStatus
216 if errors.Is(e, errTeamNotMember) {
217 code = http.StatusForbidden
218 if item {
219 code, kind = http.StatusNotFound, failDocument
220 }
221 }
222 h.fail(w, r, code, kind)
223 return false
224}
225
226func (h *handlers) partialCatalog(r *http.Request) string {
227 if h.store.team == nil {
228 return ""
229 }
230 a, ok := r.Context().Value(accessContextKey{}).(*requestAccess)
231 if !ok || a == nil || a.runtime != h.store.team || a.state() == roster.Unknown {
232 return rastrillo.T(r, "docs.team.partial")
233 }
234 return ""
235}
236
237// The account group contains only local reauthentication and explicit linking.
238// A known addressless Home account must be able to start that local gesture.
239func requireTeamAccountAdmission(list allowlist) func(http.Handler) http.Handler {
240 return func(next http.Handler) http.Handler {
241 local := requireAdmission(list, "/signin/local")(next)
242 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
243 id, ok := currentIdentity(r)
244 if ok && id.Actor.VerifiedAddress == nil && id.Session.Method == homeAssertionMethod {
245 next.ServeHTTP(w, r)
246 return
247 }
248 local.ServeHTTP(w, r)
249 })
250 }
251}
252