// 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
}