Skip to content
quarry Artifact [c7e0fd19b2]
Login

Artifact [c7e0fd19b2]

Artifact c7e0fd19b2822ff5c5ae13b15a6e75ff99f08b60f8b607ab11d2269832c9543b:


// 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 (
	"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);`

// 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"
// does not work — Reader is u3bBC78 and the u expands through fossil's
// shipped reader category kptw, which is wiki editing and ticket
// editing. And 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 int `json:"members"`
	Repos   int `json:"repos"`
}

// 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)}
	}
	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.
func (d *DB) DeleteTeam(name string) error {
	for _, q := range []string{
		`DELETE FROM team_repo WHERE team = ?`,
		`DELETE FROM team_member WHERE team = ?`,
		`DELETE FROM team WHERE name = ?`,
	} {
		if _, err := d.db.Exec(q, name); err != nil {
			return err
		}
	}
	return nil
}

// 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),
			(SELECT count(*) FROM team_repo r WHERE r.team = t.name)
		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); 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),
			(SELECT count(*) FROM team_repo r WHERE r.team = team.name)
		FROM team WHERE name = ?`, name).
		Scan(&t.Name, &t.Title, &t.Descr, &t.Created, &t.CreatedBy, &t.Members, &t.Repos)
	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)}
	}
	_, err := d.db.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 {
	_, err := d.db.Exec(`DELETE FROM team_member WHERE team = ? AND account = ?`, team, account)
	return err
}

// TeamMembers lists a team's people, owners first then by name.
func (d *DB) TeamMembers(team string) ([]TeamMember, error) {
	rows, err := d.db.Query(`SELECT account, caps, owner, listed FROM team_member
		WHERE team = ? ORDER BY owner DESC, account`, team)
	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 := tx.Exec(
		`UPDATE repo SET namespace = ?, namespace_by = ?, namespace_at = ? WHERE name = ?`,
		team, by, now, repo); 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
	}
	if _, err := tx.Exec(
		`UPDATE repo SET namespace = owner WHERE name = ?`, repo); 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.
func (d *DB) TeamRepos(team string) ([]TeamRepo, error) {
	rows, err := d.db.Query(`SELECT repo, added_by, added FROM team_repo
		WHERE team = ? ORDER BY repo`, team)
	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.
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
}