// team.go is RFC 0018's structure: a user-owned place that holds
// repositories and carries grants.
//
// Two things here are load-bearing and easy to get wrong, so they are
// stated at the top.
//
// OWNERSHIP IS A MEMBERSHIP ROW, not a column. Every forge surveyed for
// 0018 does it this way — Forgejo auto-creates an Owners team, GitHub
// advises at least two owners — and a column would be bus-factor one by
// construction. It also keeps "who manages this" the same shape as "who
// can do what" rather than a special case beside it.
//
// A REPOSITORY IS UNDER EXACTLY ONE TEAM, and the schema says so: the
// primary key of team_repo is the repository, not the pair. Exclusivity
// enforced by the table cannot be forgotten by a caller, and it is what
// keeps the aggregations honest — a repository in two teams would have
// its chat in two rooms and its tickets on two boards.
package oltp
import (
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
const teamSchema = `
CREATE TABLE IF NOT EXISTS team (
name TEXT PRIMARY KEY, -- shares the ACCOUNT namespace
title TEXT NOT NULL DEFAULT '', -- display name
descr TEXT NOT NULL DEFAULT '', -- one sentence, public
created TEXT NOT NULL,
-- Provenance, not authority. Nothing resolves through this: the
-- creator holds nothing the moment they stop being an owner. It
-- exists so "who made this and why does it exist" has an answer a
-- bare timestamp cannot give.
created_by TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS team_member (
team TEXT NOT NULL,
account TEXT NOT NULL,
-- What this member gets on every repository under the team, ON TOP
-- of the membership floor. Empty is a real state: the floor and
-- nothing more.
caps TEXT NOT NULL DEFAULT '',
-- Manages the TEAM -- its members, its repositories, its title --
-- and nothing about any repository under it. A team that holds
-- admin somewhere must not become a way to mint repository admins
-- without a repository admin agreeing.
owner INTEGER NOT NULL DEFAULT 0,
-- Listed on the team's public page. Default yes, Forgejo's rule,
-- because the member list is the useful half of a public page --
-- with an opt-out, because a member did not choose the team's
-- visibility.
listed INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (team, account)
);
CREATE INDEX IF NOT EXISTS team_member_by_account ON team_member (account, team);
CREATE TABLE IF NOT EXISTS team_repo (
-- PRIMARY KEY on the repository, not the pair: RFC 0018's
-- exclusivity, enforced by the schema.
repo TEXT PRIMARY KEY,
team TEXT NOT NULL,
-- Who put it here. Recorded because the right to do so came from a
-- capability held at that moment, which can later change hands.
added_by TEXT NOT NULL DEFAULT '',
added TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS team_repo_by_team ON team_repo (team, repo);
-- An offer is a repository waiting on a team's consent (RFC 0018
-- §"The receiving half is an offer"). It carries no access: nothing
-- resolves through this table, and until an owner accepts, the
-- repository is exactly where it was.
CREATE TABLE IF NOT EXISTS team_repo_offer (
-- PRIMARY KEY on the repository for the same reason team_repo has
-- one: a repository may be offered to at most one team at a time,
-- so two teams can never be answering the same question. Offering
-- it elsewhere REPLACES the pending offer, which is what somebody
-- correcting a mistyped team name means.
repo TEXT PRIMARY KEY,
team TEXT NOT NULL,
offered_by TEXT NOT NULL,
offered TEXT NOT NULL,
expires TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS team_repo_offer_by_team ON team_repo_offer (team, repo);`
// TeamOfferTTL is how long a repository's offer to a team stands.
// Seven days, the span an account invite holds, for the reason RFC
// 0018 gives: a pending grant that never expires is a grant that lands
// on whoever inherits the team next.
const TeamOfferTTL = InviteTTL
// TeamOffer is one repository waiting on a team's consent.
type TeamOffer struct {
Repo string `json:"repo"`
Team string `json:"team"`
OfferedBy string `json:"offeredBy"`
Offered string `json:"offered"`
Expires string `json:"expires"`
}
// OfferTeamRepo records a repository's offer to a team, replacing any
// offer that repository already had. It grants nothing.
func (d *DB) OfferTeamRepo(team, repo, by string) error {
now := time.Now().UTC()
_, err := d.db.Exec(`INSERT INTO team_repo_offer (repo, team, offered_by, offered, expires)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(repo) DO UPDATE SET team = excluded.team,
offered_by = excluded.offered_by, offered = excluded.offered,
expires = excluded.expires`,
repo, team, by, now.Format(time.RFC3339), now.Add(TeamOfferTTL).Format(time.RFC3339))
return err
}
// TeamOfferFor returns a repository's pending offer. An offer past its
// expiry is not pending: it is reported as absent and swept when a
// listing next runs, so a stale row can never be accepted.
func (d *DB) TeamOfferFor(repo string) (TeamOffer, bool, error) {
var o TeamOffer
err := d.db.QueryRow(`SELECT repo, team, offered_by, offered, expires
FROM team_repo_offer WHERE repo = ?`, repo).
Scan(&o.Repo, &o.Team, &o.OfferedBy, &o.Offered, &o.Expires)
if err == sql.ErrNoRows {
return TeamOffer{}, false, nil
}
if err != nil {
return TeamOffer{}, false, err
}
if offerExpired(o.Expires) {
return TeamOffer{}, false, nil
}
return o, true, nil
}
// TeamOffers lists a team's pending offers, oldest first — the order
// they were asked in.
func (d *DB) TeamOffers(team string) ([]TeamOffer, error) {
if err := d.sweepTeamOffers(); err != nil {
return nil, err
}
rows, err := d.db.Query(`SELECT repo, team, offered_by, offered, expires
FROM team_repo_offer WHERE team = ? ORDER BY offered`, team)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TeamOffer
for rows.Next() {
var o TeamOffer
if err := rows.Scan(&o.Repo, &o.Team, &o.OfferedBy, &o.Offered, &o.Expires); err != nil {
return nil, err
}
if offerExpired(o.Expires) {
continue
}
out = append(out, o)
}
return out, rows.Err()
}
// DropTeamOffer removes a repository's offer — accepted, declined,
// withdrawn or expired, all the same row going away. RFC 0018 keeps no
// register of who declined whom.
func (d *DB) DropTeamOffer(repo string) error {
_, err := d.db.Exec(`DELETE FROM team_repo_offer WHERE repo = ?`, repo)
return err
}
// sweepTeamOffers deletes what has run out. Cheap, and on the listing
// path rather than a timer: the table holds one row per offered
// repository, and an offer nobody looks at harms nobody.
func (d *DB) sweepTeamOffers() error {
_, err := d.db.Exec(`DELETE FROM team_repo_offer WHERE expires <= ?`,
time.Now().UTC().Format(time.RFC3339))
return err
}
func offerExpired(expires string) bool {
t, err := time.Parse(time.RFC3339, expires)
return err == nil && !time.Now().UTC().Before(t)
}
// teamFloor is what membership alone carries on every repository under
// a team (RFC 0018): read everything, participate freely, pollute
// nothing.
//
// Spelled out in letters rather than routed through a preset or a
// category, for two reasons the RFC records. "Membership means Reader"
// did not work when this was written — Reader was u3bBC78 and the u
// expanded through fossil's shipped reader category kptw, which is
// wiki editing and ticket editing. Reader has since been fixed to
// spell its own letters out (ticket 085786dd870e), so the floor and
// the preset now agree by design; the floor still stands alone,
// because the SECOND reason never depended on the first. A category
// cannot carry it either, because a PRIVATE
// repository has both categories empty by definition and teams are
// mostly used on private repositories: the floor is the entire access
// story there, so it has to stand on its own.
//
// g h j o r z clone, links, wiki read, code read, ticket read, archives
// 2 read the forum -- and landings, which are forum threads
// 8 see CI runs, checks and logs (quarry's own letter)
// C chat -- quarry-local, so not a store write
//
// Absent on purpose: every letter that writes the repository's own
// store. The companion half of the floor (3, B, 4) arrives with RFC
// 0017 and is not here yet.
const teamFloor = "ghjorz28C"
// Team is one team as a page sees it.
type Team struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
Descr string `json:"descr,omitempty"`
Created string `json:"created"`
CreatedBy string `json:"createdBy,omitempty"`
// Members and Repos are counts, folded by the listing so a table
// does not need a query per row.
//
// Members counts the LISTED members only, because a count is read
// beside the list it describes and every surface that shows the
// list withholds an opt-out. Counting everybody made the number
// disagree with the list next to it, which told a stranger that
// somebody had opted out and how many had — on the public surface
// the opt-out exists to govern. The rule is that the count
// describes the list beside it.
Members int `json:"members"`
Repos int `json:"repos"`
// A team whose members have ALL opted out therefore reports zero
// members publicly and is indistinguishable from an empty team.
// That is required rather than merely tolerated: if "withheld"
// were distinguishable from "absent", the count would disclose the
// opt-out again by a longer route, which is the bug this comment
// exists because of.
//
// Unlisted is how many members chose not to appear. It is filled
// by the query and MUST be cleared for any caller who cannot
// manage the team — it is the withheld fact itself, in a number.
// Managers get it so their own page can say "4 members · 1
// unlisted" rather than making them subtract, and so the opt-out
// is visible to the people who administer it.
Unlisted int `json:"unlisted,omitempty"`
}
// TeamMember is one person's place in a team.
type TeamMember struct {
Account string `json:"account"`
Caps string `json:"caps,omitempty"`
Owner bool `json:"owner"`
Listed bool `json:"listed"`
}
// TeamRepo is one repository under a team.
type TeamRepo struct {
Repo string `json:"repo"`
Team string `json:"team"`
AddedBy string `json:"addedBy,omitempty"`
Added string `json:"added"`
}
// teamName holds the shape a handle may take. The same alphabet as a
// login, because teams and accounts SHARE that namespace: a grant row
// names a string, and if that string could be either kind the forge
// would have to guess on every capability resolution.
func teamName(name string) error {
switch {
case name == "":
return Refusal{"a team needs a name"}
case len(name) > 64:
return Refusal{fmt.Sprintf("team name is %d characters; the limit is 64", len(name))}
case !validLogin(name):
return Refusal{fmt.Sprintf("team name %q: use letters, digits, '.', '-' or '_', starting with a letter or digit", name)}
}
return nil
}
// AddTeam creates a team and makes its creator the first owner.
//
// Both halves in one call, because a team with no owner is a set of
// grants nobody can see or revoke — the worst thing an access model can
// contain — and only a site admin could repair it. A site admin making
// a team on somebody else's behalf hands it over afterwards, which is
// one deliberate step rather than a state the forge can be left in by
// accident.
func (d *DB) AddTeam(name, title, descr, by string) error {
name = strings.TrimSpace(name)
if err := teamName(name); err != nil {
return err
}
if _, taken := d.AccountByName(name); taken {
return Refusal{fmt.Sprintf("an account is already called %q", name)}
}
if _, taken := d.TeamByName(name); taken {
return Refusal{fmt.Sprintf("a team is already called %q", name)}
}
// And a repository's name, because a team name becomes the first
// segment of a namespaced path. Without this, creating a team is a
// way to change where somebody else's existing URL resolves: make
// a team called "quarry", put zit under it, and /r/quarry/zit —
// which meant the repository quarry with "zit" beneath it — starts
// naming zit instead. Team creation is an ordinary action for any
// signed-in account (RFC 0018 T1), so that would be user-creatable
// link re-pointing. The three pools have to be disjoint at the top
// segment for the routing rule to be arithmetic rather than clever.
if d.RepoNameTaken(name) {
return Refusal{fmt.Sprintf("a repository is already called %q", name)}
}
if _, err := d.db.Exec(`INSERT INTO team (name, title, descr, created, created_by)
VALUES (?, ?, ?, ?, ?)`,
name, strings.TrimSpace(title), strings.TrimSpace(descr),
time.Now().UTC().Format(time.RFC3339), by); err != nil {
return err
}
d.teamsCreated()
if by == "" {
return nil
}
if _, found := d.AccountByName(by); !found {
return nil
}
_, err := d.db.Exec(`INSERT INTO team_member (team, account, owner) VALUES (?, ?, 1)
ON CONFLICT (team, account) DO UPDATE SET owner = 1`, name, by)
return err
}
// TeamNameTaken reports whether a team holds this name — the account
// side of the shared-namespace check.
func (d *DB) TeamNameTaken(name string) bool {
_, found := d.TeamByName(strings.TrimSpace(name))
return found
}
// SetTeamText changes the display name and the public sentence. The
// handle never changes: it is what every grant row names.
func (d *DB) SetTeamText(name, title, descr string) error {
_, err := d.db.Exec(`UPDATE team SET title = ?, descr = ? WHERE name = ?`,
strings.TrimSpace(title), strings.TrimSpace(descr), name)
return err
}
// DeleteTeam removes a team, its membership and its repository
// bindings. The repositories themselves are untouched: a team manages
// access to them and does not own them (RFC 0018), so deleting one must
// never be able to delete anybody's code.
// DeleteTeam dissolves a team. Every repository it held falls back to
// appearing under its owner.
//
// The fallback is the whole reason dissolving a team is safe: the team
// held the PATH, never the deed, so the repositories survive it and
// simply appear somewhere else. Without this they kept a path segment
// naming a team that no longer exists, and nothing could recover it --
// the re-seat migration only looks at repositories still in a team, and
// these have just left.
//
// One transaction, so a half-dissolved team cannot exist.
func (d *DB) DeleteTeam(name string) error {
tx, err := d.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE repo SET namespace = owner, namespace_by = '', namespace_at = ?
WHERE name IN (SELECT repo FROM team_repo WHERE team = ?)`,
time.Now().UTC().Format(time.RFC3339), name); err != nil {
return err
}
for _, q := range []string{
`DELETE FROM team_repo WHERE team = ?`,
`DELETE FROM team_member WHERE team = ?`,
`DELETE FROM team WHERE name = ?`,
} {
if _, err := tx.Exec(q, name); err != nil {
return err
}
}
return tx.Commit()
}
// Teams lists every team with its member and repository counts.
func (d *DB) Teams() ([]Team, error) {
return d.teams(`ORDER BY t.name`)
}
// TeamsFor lists the teams an account belongs to.
func (d *DB) TeamsFor(account string) ([]Team, error) {
return d.teams(`WHERE EXISTS (SELECT 1 FROM team_member m
WHERE m.team = t.name AND m.account = ?) ORDER BY t.name`, account)
}
func (d *DB) teams(where string, args ...any) ([]Team, error) {
rows, err := d.db.Query(`SELECT t.name, t.title, t.descr, t.created, t.created_by,
(SELECT count(*) FROM team_member m WHERE m.team = t.name AND m.listed = 1),
(SELECT count(*) FROM team_repo r WHERE r.team = t.name),
(SELECT count(*) FROM team_member m WHERE m.team = t.name AND m.listed = 0)
FROM team t `+where, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Team
for rows.Next() {
var t Team
if err := rows.Scan(&t.Name, &t.Title, &t.Descr, &t.Created, &t.CreatedBy,
&t.Members, &t.Repos, &t.Unlisted); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// TeamByName reads one team, counts included.
//
// The counts are filled here and not left at zero, because Team carries
// them and an unfilled field renders as a FACT: team.get returned
// "members": 0 beside a list holding one member, which is the same bug
// as a template printing an absent count as nothing. Two subqueries on
// a single-row read cost nothing next to being wrong.
func (d *DB) TeamByName(name string) (Team, bool) {
var t Team
err := d.db.QueryRow(`SELECT name, title, descr, created, created_by,
(SELECT count(*) FROM team_member m WHERE m.team = team.name AND m.listed = 1),
(SELECT count(*) FROM team_repo r WHERE r.team = team.name),
(SELECT count(*) FROM team_member m WHERE m.team = team.name AND m.listed = 0)
FROM team WHERE name = ?`, name).
Scan(&t.Name, &t.Title, &t.Descr, &t.Created, &t.CreatedBy,
&t.Members, &t.Repos, &t.Unlisted)
if err != nil {
return Team{}, false
}
return t, true
}
// SetTeamMember adds a person to a team or changes what they hold
// there. Idempotent: the surfaces that call it are a form and a
// checkbox.
func (d *DB) SetTeamMember(team, account, caps string, owner, listed bool) error {
if _, found := d.TeamByName(team); !found {
return Refusal{fmt.Sprintf("no team named %q", team)}
}
if _, found := d.AccountByName(account); !found {
return Refusal{fmt.Sprintf("no account named %q", account)}
}
return setTeamMember(d.db, team, account, caps, owner, listed)
}
func setTeamMember(ex execer, team, account, caps string, owner, listed bool) error {
_, err := ex.Exec(`INSERT INTO team_member (team, account, caps, owner, listed)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (team, account) DO UPDATE SET
caps = excluded.caps, owner = excluded.owner, listed = excluded.listed`,
team, account, caps, boolInt(owner), boolInt(listed))
return err
}
// RemoveTeamMember takes somebody out of a team. Removing a person who
// is not in it is not an error: two owners on one page, or one
// double-click, must both end with them out.
func (d *DB) RemoveTeamMember(team, account string) error {
return removeTeamMember(d.db, team, account)
}
func removeTeamMember(ex execer, team, account string) error {
_, err := ex.Exec(`DELETE FROM team_member WHERE team = ? AND account = ?`, team, account)
return err
}
// SetTeamMemberGuarded is GuardTeamMember and SetTeamMember as one
// decision, for the reason SetCapsGuarded gives: two owners demoting
// each other at once would each count two owners and leave none.
func (d *DB) SetTeamMemberGuarded(actor, team, account, caps string, owner, listed bool) (warning string, err error) {
err = d.guarded(func(tx *sql.Tx) error {
if warning, err = d.GuardTeamMember(actor, team, account, owner, false); err != nil {
return err
}
if _, found := d.TeamByName(team); !found {
return Refusal{fmt.Sprintf("no team named %q", team)}
}
if _, found := d.AccountByName(account); !found {
return Refusal{fmt.Sprintf("no account named %q", account)}
}
return setTeamMember(tx, team, account, caps, owner, listed)
})
return warning, err
}
// RemoveTeamMemberGuarded is GuardTeamMember and RemoveTeamMember as
// one decision.
func (d *DB) RemoveTeamMemberGuarded(actor, team, account string) (warning string, err error) {
err = d.guarded(func(tx *sql.Tx) error {
if warning, err = d.GuardTeamMember(actor, team, account, false, true); err != nil {
return err
}
return removeTeamMember(tx, team, account)
})
return warning, err
}
// TeamMembers lists a team's people, owners first then by name.
// TeamMembers is every member. TeamMembersPage is the windowed read
// the overview uses; the People screen is where the whole roster lives.
func (d *DB) TeamMembers(team string) ([]TeamMember, error) {
return d.TeamMembersPage(team, -1, 0)
}
// TeamMembersPage is one window of them; a negative limit means all.
func (d *DB) TeamMembersPage(team string, limit, offset int) ([]TeamMember, error) {
if limit == 0 {
return []TeamMember{}, nil
}
rows, err := d.db.Query(`SELECT account, caps, owner, listed FROM team_member
WHERE team = ? ORDER BY owner DESC, account LIMIT ? OFFSET ?`, team, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TeamMember
for rows.Next() {
var m TeamMember
var owner, listed int
if err := rows.Scan(&m.Account, &m.Caps, &owner, &listed); err != nil {
return nil, err
}
m.Owner, m.Listed = owner == 1, listed == 1
out = append(out, m)
}
return out, rows.Err()
}
// TeamMemberOf reads one person's row, if they have one.
func (d *DB) TeamMemberOf(team, account string) (TeamMember, bool) {
var m TeamMember
var owner, listed int
err := d.db.QueryRow(`SELECT account, caps, owner, listed FROM team_member
WHERE team = ? AND account = ?`, team, account).
Scan(&m.Account, &m.Caps, &owner, &listed)
if err != nil {
return TeamMember{}, false
}
m.Owner, m.Listed = owner == 1, listed == 1
return m, true
}
// IsTeamOwner reports whether an account manages a team.
func (d *DB) IsTeamOwner(team, account string) bool {
m, found := d.TeamMemberOf(team, account)
return found && m.Owner
}
// teamOwners counts a team's owners.
func (d *DB) teamOwners(team string) (int, error) {
var n int
err := d.db.QueryRow(`SELECT count(*) FROM team_member
WHERE team = ? AND owner = 1`, team).Scan(&n)
return n, err
}
// GuardTeamMember judges a membership change before it is written, the
// same way GuardCaps judges a capability change: a change that would
// leave the team with no owner is refused, unless the actor is a site
// admin, who is TOLD instead.
//
// Same shape and same reason. A team nobody can manage holds grants
// nobody can revoke, and the surface that produced that state would
// have looked like it worked. The site-admin exception is GuardCaps'
// too: a forge admin can always reach in, so refusing them would only
// mean doing it in two steps.
//
// owner is the standing the account would have AFTER the change;
// leaving is expressed separately, which is why removal asks this too.
func (d *DB) GuardTeamMember(actor, team, account string, owner, leaving bool) (warning string, err error) {
before, err := d.teamOwners(team)
if err != nil {
return "", err
}
was := d.IsTeamOwner(team, account)
after := before
switch {
case leaving && was:
after--
case !leaving && was && !owner:
after--
case !leaving && !was && owner:
after++
}
if before == 0 || after > 0 {
return "", nil
}
if me, _ := d.AccountByName(actor); me.Admin {
return fmt.Sprintf("%s has no owner of its own now; only site admins can change it", team), nil
}
if actor == account {
return "", Refusal{fmt.Sprintf("that would leave %s with no owner; make somebody else an owner first", team)}
}
return "", Refusal{fmt.Sprintf("that would leave %s with no owner; name another owner first", team)}
}
// SetTeamRepo puts a repository under a team, moving it if it was under
// another. The caller's right to do so is checked above this, at the
// op: it needs `a` on the repository (RFC 0018), and by records who
// held it at the time.
func (d *DB) SetTeamRepo(team, repo, by string) error {
if _, found := d.TeamByName(team); !found {
return Refusal{fmt.Sprintf("no team named %q", team)}
}
tx, err := d.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now().UTC().Format(time.RFC3339)
if _, err := tx.Exec(`INSERT INTO team_repo (repo, team, added_by, added)
VALUES (?, ?, ?, ?)
ON CONFLICT (repo) DO UPDATE SET
team = excluded.team, added_by = excluded.added_by, added = excluded.added`,
repo, team, by, now); err != nil {
return err
}
// The path segment follows the team, in the SAME transaction.
//
// team_repo is the authority for which team holds a repository;
// repo.namespace is where the repository APPEARS, and a repository
// in a team appears under the team. They were written in different
// places, so putting a repository in a team moved the team row and
// left the path naming the previous holder — a URL that quietly
// stopped matching the truth beside it.
if err := placeRepo(tx, repo, team, by, time.Now()); err != nil {
return err
}
return tx.Commit()
}
// RemoveTeamRepo takes a repository out of whatever team holds it.
//
// The path segment falls back to the owner in the same transaction.
// This is the half RFC 0018 promised without saying so: a team can be
// dissolved without putting anybody's code at risk precisely because
// the deed never moved, and the repository simply appears under its
// owner again. A repository nobody holds falls back to no namespace,
// which is the forge-held state.
func (d *DB) RemoveTeamRepo(repo string) error {
tx, err := d.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM team_repo WHERE repo = ?`, repo); err != nil {
return err
}
// The placement record goes with it. namespace_by names who PUT a
// repository where it is; once the segment has fallen back to the
// owner nobody put it there, and leaving the previous placer's name
// beside it describes a decision that no longer applies.
// Back to its owner's namespace, through the one writer, so the
// team it leaves is remembered as a place it was.
var owner string
if err := tx.QueryRow(`SELECT owner FROM repo WHERE name = ?`, repo).Scan(&owner); err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err := placeRepo(tx, repo, owner, "", time.Now()); err != nil {
return err
}
return tx.Commit()
}
// TeamOfRepo answers which team holds a repository, or "" for none.
// The ordinary case is none.
func (d *DB) TeamOfRepo(repo string) string {
var team string
if d.db.QueryRow(`SELECT team FROM team_repo WHERE repo = ?`, repo).Scan(&team) != nil {
return ""
}
return team
}
// TeamRepos lists the repositories under a team.
// TeamRepos is every repository a team holds. Team.Repos already
// carries the count, so a surface wanting a window uses TeamReposPage
// and reads the total from there.
func (d *DB) TeamRepos(team string) ([]TeamRepo, error) {
return d.TeamReposPage(team, -1, 0)
}
// TeamReposPage is one window of them; a negative limit means all.
func (d *DB) TeamReposPage(team string, limit, offset int) ([]TeamRepo, error) {
if limit == 0 {
return []TeamRepo{}, nil
}
rows, err := d.db.Query(`SELECT repo, added_by, added FROM team_repo
WHERE team = ? ORDER BY repo LIMIT ? OFFSET ?`, team, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TeamRepo
for rows.Next() {
r := TeamRepo{Team: team}
if err := rows.Scan(&r.Repo, &r.AddedBy, &r.Added); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// anyTeams caches whether the forge has a single team.
//
// EffectiveCaps is the hottest path in the forge — every gate calls it,
// many times per page — and teamCaps would otherwise add a query to
// each call on every forge, including the overwhelming majority that
// have no teams at all. A cached flag makes the common case free
// instead of merely cheap.
//
// TRUE latches and is never re-checked: a forge that has a team does
// not go back to having none while the process lives, and DeleteTeam
// deliberately does not clear the flag — a redundant query on a forge
// that once had a team costs nothing.
//
// FALSE is cached for teamFlagTTL rather than for ever. Caching it
// permanently would be wrong on a multi-node forge, where another node
// can create the first team and this one would never notice; not
// caching it at all was the first version of this code, and the
// benchmark showed it re-querying on every single caps resolution,
// which is the cost the flag exists to remove. A few seconds of
// staleness on "does this forge have any teams yet" is the honest
// middle: the only consequence is that a brand-new team's grants take
// a moment to be seen on a node that did not create it.
type teamFlag struct {
mu sync.Mutex
any bool
checked time.Time
}
// teamFlagTTL bounds how long a negative answer is trusted.
const teamFlagTTL = 5 * time.Second
func (d *DB) hasAnyTeam() bool {
d.teamsExist.mu.Lock()
defer d.teamsExist.mu.Unlock()
if d.teamsExist.any {
return true
}
if !d.teamsExist.checked.IsZero() && time.Since(d.teamsExist.checked) < teamFlagTTL {
return false
}
var one int
err := d.db.QueryRow(`SELECT 1 FROM team LIMIT 1`).Scan(&one)
d.teamsExist.any = err == nil
d.teamsExist.checked = time.Now()
return d.teamsExist.any
}
// teamsCreated is called after a team is made, so this node's next caps
// resolution stops short-circuiting without waiting out the TTL.
func (d *DB) teamsCreated() {
d.teamsExist.mu.Lock()
d.teamsExist.any = true
d.teamsExist.mu.Unlock()
}
// teamCaps is what a team gives one account on one repository: the
// membership floor plus their own team row, or "" when the repository
// is under no team or the account is in no team.
//
// One query, because EffectiveCaps runs on every request — and none at
// all on a forge with no teams, see hasAnyTeam. The floor is unioned
// for MEMBERSHIP ALONE: a member with an empty caps string still gets
// it, which is the point of RFC 0018's floor and the reason a page must
// be able to say "by membership" as a route in its own right.
func (d *DB) teamCaps(account, repo string) string {
if account == "" || repo == "" || !d.hasAnyTeam() {
return ""
}
var caps string
err := d.db.QueryRow(`SELECT m.caps FROM team_repo r
JOIN team_member m ON m.team = r.team
WHERE r.repo = ? AND m.account = ?`, repo, account).Scan(&caps)
if err != nil {
return ""
}
return capUnion(teamFloor, caps)
}
// TeamPerson is one row of a team's People screen: somebody who
// reaches at least one repository under the team, and how.
type TeamPerson struct {
Account string `json:"account"`
// Member, Owner and Caps describe their team row, when they have
// one. Caps empty with Member true is the membership floor alone.
Member bool `json:"member"`
Owner bool `json:"owner"`
Caps string `json:"caps,omitempty"`
// Direct names the repositories under this team where they hold a
// string of their own, which the team cannot change — that is
// edited on the repository. Sorted.
Direct []TeamDirect `json:"direct,omitempty"`
}
// TeamDirect is one repository-specific grant somebody holds under the
// team, independent of the team.
type TeamDirect struct {
Repo string `json:"repo"`
Caps string `json:"caps"`
}
// Reaches reports whether this person reaches anything at all here.
func (p TeamPerson) Reaches() bool { return p.Member || len(p.Direct) > 0 }
// TeamPeople answers RFC 0018's one screen: everyone who reaches any
// repository under this team, with the route each of them came by.
//
// TWO SCOPED QUERIES rather than CapHolders per repository. CapHolders
// returns every account on the forge by design — the settings editor
// needs the ones who could hold a string as well as the ones who do —
// so looping it over a team's repositories would fold accounts times
// repositories rows to find the handful that matter. On this forge
// that is 3,100 accounts against a 20-repository team.
//
// So: the member rows, then only the repo_caps rows that exist for
// repositories under the team. Both are index reads over small sets.
// peopleUnion is the one population this screen is about: everybody who
// reaches any repository under the team, by either route. It is a
// subquery rather than two round trips so the ORDER and the LIMIT
// happen in SQLite — which is the whole point of paging this screen.
//
// A member appears with their flags; somebody holding only a direct
// string on one of the team's repositories appears with none. GROUP BY
// collapses the two routes for a person who has both, and MAX keeps the
// flags: a member with a direct grant is still a member.
const peopleUnion = `SELECT account, MAX(is_member) AS member, MAX(is_owner) AS owner FROM (
SELECT account, 1 AS is_member, owner AS is_owner
FROM team_member WHERE team = ?
UNION ALL
SELECT c.account, 0, 0
FROM repo_caps c JOIN team_repo r ON r.repo = c.repo
WHERE r.team = ? AND c.caps != ''
) GROUP BY account`
// TeamPeopleCount is how many people the screen has to show, for the
// pager. Counting is not free, but it is one aggregate over the same
// subquery rather than materialising every row to call len() on it.
func (d *DB) TeamPeopleCount(team string) (int, error) {
var n int
err := d.db.QueryRow(`SELECT COUNT(*) FROM (`+peopleUnion+`)`, team, team).Scan(&n)
return n, err
}
// TeamPeoplePage is one page of the People screen, ordered owners then
// members then everybody else, each by name.
//
// Paged in SQL, not in Go. The unpaged version below built the whole
// population in a map, sorted it and handed it over — which is one row
// per account that reaches the team, each carrying a sub-list per
// repository, so the cost grew with accounts AND repositories at once.
// A pager over that would have bounded what was RENDERED and not what
// was computed, which is the trap people.list documents in its own
// input struct. Here the order and the limit are the database's, and
// the two follow-up queries ask only about the accounts on this page.
func (d *DB) TeamPeoplePage(team string, limit, offset int) ([]TeamPerson, error) {
if limit <= 0 {
return []TeamPerson{}, nil
}
rows, err := d.db.Query(`SELECT account, member, owner FROM (`+peopleUnion+`)
ORDER BY owner DESC, member DESC, account ASC
LIMIT ? OFFSET ?`, team, team, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TeamPerson
order := map[string]int{}
for rows.Next() {
var p TeamPerson
if err := rows.Scan(&p.Account, &p.Member, &p.Owner); err != nil {
return nil, err
}
order[p.Account] = len(out)
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) == 0 {
return []TeamPerson{}, nil
}
// The two detail reads, scoped to this page. A placeholder list
// rather than a join back into the union: the accounts are already
// in hand, and at most `limit` of them.
marks := make([]string, len(out))
args := make([]any, 0, len(out)+1)
args = append(args, team)
for i, p := range out {
marks[i] = "?"
args = append(args, p.Account)
}
in := "(" + strings.Join(marks, ",") + ")"
capRows, err := d.db.Query(`SELECT account, caps FROM team_member
WHERE team = ? AND account IN `+in, args...)
if err != nil {
return nil, err
}
defer capRows.Close()
for capRows.Next() {
var account, caps string
if err := capRows.Scan(&account, &caps); err != nil {
return nil, err
}
if i, ok := order[account]; ok {
out[i].Caps = caps
}
}
if err := capRows.Err(); err != nil {
return nil, err
}
dirRows, err := d.db.Query(`SELECT c.account, c.repo, c.caps
FROM repo_caps c JOIN team_repo r ON r.repo = c.repo
WHERE r.team = ? AND c.caps != '' AND c.account IN `+in+`
ORDER BY c.account, c.repo`, args...)
if err != nil {
return nil, err
}
defer dirRows.Close()
for dirRows.Next() {
var account, repo, caps string
if err := dirRows.Scan(&account, &repo, &caps); err != nil {
return nil, err
}
if i, ok := order[account]; ok {
out[i].Direct = append(out[i].Direct, TeamDirect{Repo: repo, Caps: caps})
}
}
return out, dirRows.Err()
}
// RepoNameTaken reports whether a repository of this name is
// registered. It exists so the account and team guards can ask without
// importing the repository half's error handling: a read that fails is
// answered as "taken", which refuses a creation rather than allowing
// one the routing layer could not then disambiguate.
func (d *DB) RepoNameTaken(name string) bool {
var one int
err := d.db.QueryRow(`SELECT 1 FROM repo WHERE name = ?`, name).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false
}
return true
}
// TeamCount is how many teams exist, for the explore tab's badge and
// the directory's pager. Counted, never len() of a paged listing — the
// badge and the list are different questions and a windowed list
// answers the wrong one.
func (d *DB) TeamCount() (int, error) {
var n int
err := d.db.QueryRow(`SELECT COUNT(*) FROM team`).Scan(&n)
return n, err
}
// TeamOwnerCount is how many owners a team has.
//
// A page must not count them from its own rows. The People screen
// withholds the last owner's Remove because the guard would refuse it,
// and owners sort first — so page one happens to hold them all and a
// count taken from the page happens to be right. That is a coincidence
// of the sort order, not a fact about the team, and a safety control
// should not rest on one.
func (d *DB) TeamOwnerCount(team string) (int, error) {
var n int
err := d.db.QueryRow(`SELECT COUNT(*) FROM team_member
WHERE team = ? AND owner = 1`, team).Scan(&n)
return n, err
}
// TeamListed answers "did these people opt out", for one page of them.
//
// One query rather than a TeamMemberOf per row: the People screen has
// to withhold an opt-out from anybody who cannot manage the team, and
// asking per row would put the work back in proportion to the page
// while the page exists to bound it. Accounts not in the team are
// simply absent from the result, which reads as "no membership to
// withhold" — the caller is asking about a mixed population.
//
// Listed is deliberately NOT a field on TeamPerson. It would then be
// serialised by every op that returns one, and the opt-out would leak
// to exactly the readers it is kept from unless every one of them
// remembered to clear it.
func (d *DB) TeamListed(team string, accounts []string) (map[string]bool, error) {
out := map[string]bool{}
if len(accounts) == 0 {
return out, nil
}
marks := make([]string, len(accounts))
args := make([]any, 0, len(accounts)+1)
args = append(args, team)
for i, a := range accounts {
marks[i] = "?"
args = append(args, a)
}
rows, err := d.db.Query(`SELECT account, listed FROM team_member
WHERE team = ? AND account IN (`+strings.Join(marks, ",")+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var account string
var listed bool
if err := rows.Scan(&account, &listed); err != nil {
return nil, err
}
out[account] = listed
}
return out, rows.Err()
}
// TeamPeople is the whole population, unpaged. Kept for callers that
// genuinely want all of it; every SURFACE should use TeamPeoplePage.
func (d *DB) TeamPeople(team string) ([]TeamPerson, error) {
by := map[string]*TeamPerson{}
get := func(account string) *TeamPerson {
if p, ok := by[account]; ok {
return p
}
p := &TeamPerson{Account: account}
by[account] = p
return p
}
members, err := d.TeamMembers(team)
if err != nil {
return nil, err
}
for _, m := range members {
p := get(m.Account)
p.Member, p.Owner, p.Caps = true, m.Owner, m.Caps
}
// Direct grants on the team's repositories. A non-member with a
// string on one of them belongs on this screen — the page asks
// "who reaches these repositories", and an answer that hid them
// would be answering a different question.
rows, err := d.db.Query(`SELECT c.account, c.repo, c.caps
FROM repo_caps c
JOIN team_repo r ON r.repo = c.repo
WHERE r.team = ? AND c.caps != ''
ORDER BY c.account, c.repo`, team)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var account, repo, caps string
if err := rows.Scan(&account, &repo, &caps); err != nil {
return nil, err
}
p := get(account)
p.Direct = append(p.Direct, TeamDirect{Repo: repo, Caps: caps})
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]TeamPerson, 0, len(by))
for _, p := range by {
out = append(out, *p)
}
// Owners first, then members, then everybody else, each by name:
// the order somebody scanning for "who runs this" needs.
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.Owner != b.Owner {
return a.Owner
}
if a.Member != b.Member {
return a.Member
}
return a.Account < b.Account
})
return out, nil
}