oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package directorydata
2
3import (
4 "context"
5 "crypto/sha256"
6 "database/sql"
7 "embed"
8 "encoding/base64"
9 "errors"
10 "io/fs"
11 "time"
12
13 "amadan.net/oficina/home/roster"
14 "amadan.net/rastrillo/rastrillo/migrate"
15 "github.com/go-json-experiment/json"
16)
17
18//go:embed roster_migrations/migrations/*.sql
19var rosterMigrations embed.FS
20
21// RosterSchema is directory-only and deliberately unmounted. Applying it leaves
22// generation and consumer authority empty. Apply it after Schema.
23var RosterSchema = func() *migrate.Set {
24 f, err := fs.Sub(rosterMigrations, "roster_migrations")
25 if err != nil {
26 panic(err)
27 }
28 return migrate.MustFromFS(f, "oficina_directory_roster")
29}()
30
31var (
32 ErrRosterUnauthorized = errors.New("roster unauthorized")
33 ErrRosterNotFound = errors.New("roster not found")
34 ErrRosterBadRequest = errors.New("invalid roster request")
35)
36
37type RosterSource struct {
38 store *Store
39 directory string
40}
41
42// NewRosterSource creates a read-only source. The caller authenticates operator
43// provisioning separately; this constructor never initializes authority.
44func NewRosterSource(db *sql.DB, sealer Sealer, cfg Config, directory string) (*RosterSource, error) {
45 if !ValidOrigin(directory) {
46 return nil, ErrInvalid
47 }
48 s, err := New(db, sealer, cfg)
49 if err != nil {
50 return nil, err
51 }
52 return &RosterSource{store: s, directory: directory}, nil
53}
54
55type RosterRequest struct{ Token, Team, Fetch, Cursor string }
56
57type rosterCursor struct {
58 Version int `json:"version"`
59 Consumer string `json:"consumer"`
60 Team string `json:"team"`
61 Generation string `json:"generation"`
62 Epoch int64 `json:"epoch"`
63 Fetch string `json:"fetch"`
64 Last string `json:"last"`
65 Next int `json:"next"`
66 Issued int64 `json:"issued"`
67 Expires int64 `json:"expires"`
68}
69
70func (s *RosterSource) cursorPurpose() string { return "directory.roster-cursor/" + s.directory }
71
72// Read observes authorization, generation, epoch and members in one transaction.
73// Its Page must be signed by roster.SignPage at the HTTP boundary. A 409 result
74// has no membership fields. No returned mapping grants item access.
75func (s *RosterSource) Read(ctx context.Context, req RosterRequest, now time.Time) (roster.Page, int, error) {
76 fail := func(status int, err error) (roster.Page, int, error) { return roster.Page{}, status, err }
77 if !roster.ValidChallenge(req.Token) {
78 return fail(401, ErrRosterUnauthorized)
79 }
80 if !alnum(req.Team, 64) || !roster.ValidChallenge(req.Fetch) || len(req.Cursor) > roster.CursorLimit || now.Unix() <= 0 {
81 return fail(400, ErrRosterBadRequest)
82 }
83 tx, err := s.store.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
84 if err != nil {
85 return fail(503, ErrUnavailable)
86 }
87 defer tx.Rollback()
88 hash := sha256.Sum256([]byte(req.Token))
89 var route Route
90 var enabled int
91 var state, slot string
92 var issued int64
93 var expires sql.NullInt64
94 err = tx.QueryRowContext(ctx, `SELECT c.consumer_id,c.team_id,c.app,c.origin,c.enabled,k.slot,k.issued_at,k.expires_at,t.sqid,t.label,i.state
95 FROM roster_tokens k JOIN roster_consumers c ON c.consumer_id=k.consumer_id
96 JOIN teams t ON t.id=c.team_id JOIN team_instances i ON i.team_id=c.team_id AND i.app=c.app AND i.consumer_id=c.consumer_id AND i.origin=c.origin
97 WHERE k.token_hash=?`, hash[:]).Scan(&route.ConsumerID, &route.TeamID, &route.App, &route.Origin, &enabled, &slot, &issued, &expires, &route.TeamSQID, &route.Label, &state)
98 if errors.Is(err, sql.ErrNoRows) {
99 return fail(401, ErrRosterUnauthorized)
100 }
101 if err != nil {
102 return fail(503, ErrUnavailable)
103 }
104 if enabled != 1 || issued > now.Unix() || issued <= 0 || (slot != "current" && slot != "previous") || slot == "current" && expires.Valid || slot == "previous" && (!expires.Valid || expires.Int64 <= now.Unix() || expires.Int64 <= issued || expires.Int64-issued > 300) {
105 return fail(401, ErrRosterUnauthorized)
106 }
107 if req.Team != route.TeamSQID {
108 return fail(404, ErrRosterNotFound)
109 }
110 if (state != "live" && state != "pending") || !s.store.apps[route.App] || !ValidOrigin(route.Origin) || !validConsumer(route.ConsumerID) || !validLabel(route.Label) || !s.store.validateRoute(route) {
111 return fail(503, ErrUnavailable)
112 }
113 p := roster.Page{Version: 1, Type: "page", Directory: s.directory, Consumer: route.ConsumerID, Team: route.TeamSQID, Audience: route.Origin, Fetch: req.Fetch, RequestCursorHash: roster.CursorHash(req.Cursor)}
114 err = tx.QueryRowContext(ctx, `SELECT g.generation,t.roster_epoch FROM roster_generation g JOIN teams t ON t.id=? WHERE g.singleton=1`, route.TeamID).Scan(&p.Generation, &p.Epoch)
115 if err != nil || !roster.ValidChallenge(p.Generation) || p.Epoch <= 0 {
116 return fail(503, ErrUnavailable)
117 }
118 if state == "pending" && !pendingRosterAllowed(ctx, tx, route, p.Generation, now.Unix()) {
119 return fail(503, ErrUnavailable)
120 }
121 index := 0
122 last := ""
123 if req.Cursor != "" {
124 raw, e := base64.RawURLEncoding.Strict().DecodeString(req.Cursor)
125 if e != nil || base64.RawURLEncoding.EncodeToString(raw) != req.Cursor {
126 return fail(400, ErrRosterBadRequest)
127 }
128 plain, e := s.store.sealer.Open(s.cursorPurpose(), raw)
129 if e != nil {
130 return fail(400, ErrRosterBadRequest)
131 }
132 var c rosterCursor
133 if json.Unmarshal(plain, &c, json.RejectUnknownMembers(true)) != nil || c.Version != 1 || c.Consumer != route.ConsumerID || c.Team != route.TeamSQID || c.Fetch != req.Fetch || !validSubject(c.Last) || !roster.ValidChallenge(c.Generation) || c.Epoch <= 0 || c.Next < 1 || c.Next >= 100 || c.Issued <= 0 || c.Issued > now.Unix() || c.Expires <= now.Unix() || c.Expires <= c.Issued || c.Expires-c.Issued > 10 {
134 return fail(400, ErrRosterBadRequest)
135 }
136 if c.Generation != p.Generation || c.Epoch != p.Epoch {
137 p.Type = "stale-cursor"
138 if tx.Commit() != nil {
139 return fail(503, ErrUnavailable)
140 }
141 return p, 409, nil
142 }
143 index = c.Next
144 last = c.Last
145 }
146 var count int
147 if tx.QueryRowContext(ctx, `SELECT count(*) FROM (SELECT 1 FROM memberships WHERE team_id=? LIMIT 10001)`, route.TeamID).Scan(&count) != nil || count > roster.MemberLimit {
148 return fail(503, ErrUnavailable)
149 }
150 rows, err := tx.QueryContext(ctx, `SELECT person_ref,role FROM memberships WHERE team_id=? AND person_ref COLLATE BINARY > ? ORDER BY person_ref COLLATE BINARY LIMIT 101`, route.TeamID, last)
151 if err != nil {
152 return fail(503, ErrUnavailable)
153 }
154 members := make([]roster.MemberEntry, 0, 101)
155 for rows.Next() {
156 var m roster.MemberEntry
157 if rows.Scan(&m.Subject, &m.Role) != nil || !validSubject(m.Subject) || (m.Role != "owner" && m.Role != "member") || m.Subject <= last {
158 rows.Close()
159 return fail(503, ErrUnavailable)
160 }
161 last = m.Subject
162 members = append(members, m)
163 }
164 err = rows.Err()
165 rows.Close()
166 if err != nil {
167 return fail(503, ErrUnavailable)
168 }
169 complete := len(members) <= roster.PageLimit
170 if !complete {
171 if index >= 99 {
172 return fail(503, ErrUnavailable)
173 }
174 members = members[:roster.PageLimit]
175 c := rosterCursor{Version: 1, Consumer: route.ConsumerID, Team: route.TeamSQID, Generation: p.Generation, Epoch: p.Epoch, Fetch: req.Fetch, Last: members[len(members)-1].Subject, Next: index + 1, Issued: now.Unix(), Expires: now.Unix() + 10}
176 b, e := json.Marshal(c)
177 if e != nil {
178 return fail(503, ErrUnavailable)
179 }
180 sealed, e := s.store.sealer.Seal(s.cursorPurpose(), b)
181 if e != nil {
182 return fail(503, ErrUnavailable)
183 }
184 cursor := base64.RawURLEncoding.EncodeToString(sealed)
185 if len(cursor) > roster.CursorLimit {
186 return fail(503, ErrUnavailable)
187 }
188 p.Cursor = &cursor
189 }
190 p.Index = &index
191 p.Members = &members
192 p.Complete = &complete
193 if tx.Commit() != nil {
194 return fail(503, ErrUnavailable)
195 }
196 return p, 200, nil
197}
198