internal/store/orgs.go
292 lines · 8085 bytes
1package store
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8)
9
10type Org struct {
11 ID int64
12 Name string
13}
14
15type OrgMember struct {
16 Username string
17 Role string // member | admin
18}
19
20// ownerNameTaken reports whether name is claimed by any user or org. Users
21// and orgs share one namespace: /<owner>/<repo> must be unambiguous.
22func ownerNameTaken(q interface {
23 QueryRow(string, ...any) *sql.Row
24}, name string) (bool, error) {
25 var n int
26 err := q.QueryRow(
27 "SELECT (SELECT COUNT(*) FROM users WHERE username = ?) + (SELECT COUNT(*) FROM orgs WHERE name = ?)",
28 name, name).Scan(&n)
29 return n > 0, err
30}
31
32// CreateOrg makes an organization with the creator as its first admin.
33func (s *Store) CreateOrg(name string, creatorID int64) (int64, error) {
34 tx, err := s.DB.Begin()
35 if err != nil {
36 return 0, err
37 }
38 defer tx.Rollback()
39 taken, err := ownerNameTaken(tx, name)
40 if err != nil {
41 return 0, err
42 }
43 if taken {
44 return 0, fmt.Errorf("the name %q is taken", name)
45 }
46 res, err := tx.Exec("INSERT INTO orgs (name) VALUES (?)", name)
47 if err != nil {
48 return 0, err
49 }
50 id, err := res.LastInsertId()
51 if err != nil {
52 return 0, err
53 }
54 if _, err := tx.Exec(
55 "INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, 'admin')", id, creatorID); err != nil {
56 return 0, err
57 }
58 return id, tx.Commit()
59}
60
61func (s *Store) OrgByName(name string) (Org, error) {
62 var o Org
63 err := s.DB.QueryRow("SELECT id, name FROM orgs WHERE name = ?", name).Scan(&o.ID, &o.Name)
64 if errors.Is(err, sql.ErrNoRows) {
65 return o, ErrNotFound
66 }
67 return o, err
68}
69
70// OrgRole returns the user's role in the org ("" for non-members).
71func (s *Store) OrgRole(orgID, userID int64) (string, error) {
72 var role string
73 err := s.DB.QueryRow(
74 "SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID).Scan(&role)
75 if errors.Is(err, sql.ErrNoRows) {
76 return "", nil
77 }
78 return role, err
79}
80
81func (s *Store) OrgMembers(orgID int64) ([]OrgMember, error) {
82 rows, err := s.DB.Query(`
83 SELECT u.username, m.role FROM org_members m
84 JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY u.username`, orgID)
85 if err != nil {
86 return nil, err
87 }
88 defer rows.Close()
89 var out []OrgMember
90 for rows.Next() {
91 var m OrgMember
92 if err := rows.Scan(&m.Username, &m.Role); err != nil {
93 return nil, err
94 }
95 out = append(out, m)
96 }
97 return out, rows.Err()
98}
99
100// ListOrgsForUser returns the orgs the user belongs to, with their role.
101func (s *Store) ListOrgsForUser(userID int64) ([]OrgMember, error) {
102 rows, err := s.DB.Query(`
103 SELECT o.name, m.role FROM org_members m
104 JOIN orgs o ON o.id = m.org_id WHERE m.user_id = ? ORDER BY o.name`, userID)
105 if err != nil {
106 return nil, err
107 }
108 defer rows.Close()
109 var out []OrgMember
110 for rows.Next() {
111 var m OrgMember
112 if err := rows.Scan(&m.Username, &m.Role); err != nil {
113 return nil, err
114 }
115 out = append(out, m)
116 }
117 return out, rows.Err()
118}
119
120// SetOrgMember adds a member or updates their role. Demoting the last admin
121// is refused: an org must always have one.
122func (s *Store) SetOrgMember(orgID, userID int64, role string) error {
123 tx, err := s.DB.Begin()
124 if err != nil {
125 return err
126 }
127 defer tx.Rollback()
128 if role == "member" {
129 ok, err := wouldKeepAdmin(tx, orgID, userID)
130 if err != nil {
131 return err
132 }
133 if !ok {
134 return errors.New("an organization needs at least one admin")
135 }
136 }
137 if _, err := tx.Exec(`
138 INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, ?)
139 ON CONFLICT (org_id, user_id) DO UPDATE SET role = excluded.role`,
140 orgID, userID, role); err != nil {
141 return err
142 }
143 return tx.Commit()
144}
145
146// RemoveOrgMember drops a member, refusing to remove the last admin. The
147// account's team memberships in the org go with it: team add requires
148// membership, so a row left behind would grant access to a non-member.
149func (s *Store) RemoveOrgMember(orgID, userID int64) error {
150 tx, err := s.DB.Begin()
151 if err != nil {
152 return err
153 }
154 defer tx.Rollback()
155 ok, err := wouldKeepAdmin(tx, orgID, userID)
156 if err != nil {
157 return err
158 }
159 if !ok {
160 return errors.New("an organization needs at least one admin")
161 }
162 res, err := tx.Exec("DELETE FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID)
163 if err != nil {
164 return err
165 }
166 if n, _ := res.RowsAffected(); n == 0 {
167 return ErrNotFound
168 }
169 if _, err := tx.Exec(
170 "DELETE FROM team_members WHERE user_id = ? AND team_id IN (SELECT id FROM teams WHERE org_id = ?)",
171 userID, orgID); err != nil {
172 return err
173 }
174 return tx.Commit()
175}
176
177// wouldKeepAdmin reports whether the org keeps at least one admin after
178// userID stops being one.
179func wouldKeepAdmin(tx *sql.Tx, orgID, userID int64) (bool, error) {
180 var n int
181 err := tx.QueryRow(
182 "SELECT COUNT(*) FROM org_members WHERE org_id = ? AND role = 'admin' AND user_id <> ?",
183 orgID, userID).Scan(&n)
184 return n > 0, err
185}
186
187// DeleteOrg removes an empty organization; orgs still owning repositories
188// are refused.
189func (s *Store) DeleteOrg(orgID int64) error {
190 var n int
191 if err := s.DB.QueryRow(
192 "SELECT COUNT(*) FROM repos WHERE owner_kind = 'org' AND owner_id = ?", orgID).Scan(&n); err != nil {
193 return err
194 }
195 if n > 0 {
196 return fmt.Errorf("the organization still owns %d repositories; delete or transfer them first", n)
197 }
198 // Grants and a parked about text name the org by id with no foreign
199 // key (#306).
200 tx, err := s.DB.Begin()
201 if err != nil {
202 return err
203 }
204 defer tx.Rollback()
205 if _, err := tx.Exec("DELETE FROM repo_access WHERE subject_kind = 'org' AND subject_id = ?", orgID); err != nil {
206 return err
207 }
208 if _, err := tx.Exec("DELETE FROM profile_about_backfill WHERE owner_kind = 'org' AND owner_id = ?", orgID); err != nil {
209 return err
210 }
211 if _, err := tx.Exec("DELETE FROM orgs WHERE id = ?", orgID); err != nil {
212 return err
213 }
214 return tx.Commit()
215}
216
217// RenameOrg changes an org's name, holding the shared owner-namespace
218// invariant. The caller moves the on-disk repos directory afterward.
219func (s *Store) RenameOrg(orgID int64, newName string) error {
220 tx, err := s.DB.Begin()
221 if err != nil {
222 return err
223 }
224 defer tx.Rollback()
225 taken, err := ownerNameTaken(tx, newName)
226 if err != nil {
227 return err
228 }
229 if taken {
230 return fmt.Errorf("the name %q is taken", newName)
231 }
232 res, err := tx.Exec("UPDATE orgs SET name = ? WHERE id = ?", newName, orgID)
233 if err != nil {
234 return err
235 }
236 if n, _ := res.RowsAffected(); n == 0 {
237 return ErrNotFound
238 }
239 return tx.Commit()
240}
241
242// Profile is the presentational half of a user or org. The about text is
243// not here: it is a file in <owner>/.gitbay, read through the control
244// layer.
245type Profile struct {
246 Description string `json:"description,omitempty"`
247 Website string `json:"website,omitempty"`
248 Links []ProfileLink `json:"links,omitempty"`
249}
250
251// ProfileLink is one free-form link. The label is optional; a link
252// without one renders as its URL.
253type ProfileLink struct {
254 Label string `json:"label,omitempty"`
255 URL string `json:"url"`
256}
257
258// OwnerProfile reads the profile for kind "user" or "org".
259func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) {
260 table := map[string]string{"user": "users", "org": "orgs"}[kind]
261 var p Profile
262 var linksJSON string
263 err := s.DB.QueryRow(
264 "SELECT description, website, links FROM "+table+" WHERE id = ?", id).
265 Scan(&p.Description, &p.Website, &linksJSON)
266 if err != nil {
267 return p, err
268 }
269 if linksJSON != "" {
270 if err := json.Unmarshal([]byte(linksJSON), &p.Links); err != nil {
271 return p, fmt.Errorf("%s %d links: %w", kind, id, err)
272 }
273 }
274 return p, nil
275}
276
277// SetOwnerProfile updates the profile for kind "user" or "org".
278func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error {
279 table := map[string]string{"user": "users", "org": "orgs"}[kind]
280 links := ""
281 if len(p.Links) > 0 {
282 raw, err := json.Marshal(p.Links)
283 if err != nil {
284 return err
285 }
286 links = string(raw)
287 }
288 _, err := s.DB.Exec(
289 "UPDATE "+table+" SET description = ?, website = ?, links = ? WHERE id = ?",
290 p.Description, p.Website, links, id)
291 return err
292}