| 1 | package directorydata |
| 2 | |
| 3 | import ( |
| 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 |
| 19 | var 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. |
| 23 | var 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 | |
| 31 | var ( |
| 32 | ErrRosterUnauthorized = errors.New("roster unauthorized") |
| 33 | ErrRosterNotFound = errors.New("roster not found") |
| 34 | ErrRosterBadRequest = errors.New("invalid roster request") |
| 35 | ) |
| 36 | |
| 37 | type 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. |
| 44 | func 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 | |
| 55 | type RosterRequest struct{ Token, Team, Fetch, Cursor string } |
| 56 | |
| 57 | type 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 | |
| 70 | func (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. |
| 75 | func (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 | |