// team.go is RFC 0018's ops surface.
//
// Three authorities, deliberately apart, and the last row of the table
// is the security argument for the whole file:
//
// create a team ......... any signed-in account
// manage a team ......... its owners, or a site admin
// put a repo under it ... somebody holding `a` on THAT repository
//
// Creation is an ordinary user action, like registering a repository —
// `repo.add` sets the precedent in its own doc string: "Any signed-in
// account may call this — the creator becomes the repository's admin in
// the same operation, which is where ownership starts."
//
// The last one is what stops team creation being an escalation
// primitive. Without it: make a team, put somebody else's repository
// under it, grant yourself admin. The check is the capability that
// already exists, asked at the moment the repository joins.
package api
import (
"context"
"fmt"
"sort"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// TeamStore is RFC 0018's roster, in this package's own vocabulary the
// way AccountStore and CIRuns are.
type TeamStore interface {
Teams() ([]oltp.Team, error)
TeamsFor(account string) ([]oltp.Team, error)
TeamByName(name string) (oltp.Team, bool)
AddTeam(name, title, descr, by string) error
SetTeamText(name, title, descr string) error
DeleteTeam(name string) error
TeamMembers(team string) ([]oltp.TeamMember, error)
TeamMemberOf(team, account string) (oltp.TeamMember, bool)
SetTeamMember(team, account, caps string, owner, listed bool) error
RemoveTeamMember(team, account string) error
IsTeamOwner(team, account string) bool
GuardTeamMember(actor, team, account string, owner, leaving bool) (string, error)
TeamRepos(team string) ([]oltp.TeamRepo, error)
TeamPeople(team string) ([]oltp.TeamPerson, error)
TeamOfRepo(repo string) string
SetTeamRepo(team, repo, by string) error
RemoveTeamRepo(repo string) error
}
func errNoTeams() error {
return fmt.Errorf("this forge keeps no teams: %w", ops.ErrUnavailable)
}
// mayManageTeam reports whether the caller may change a team: one of
// its owners, or a site admin.
func (d Deps) mayManageTeam(ctx context.Context, team string) bool {
user := author(ctx)
if user == "" || d.Teams == nil {
return false
}
if d.Teams.IsTeamOwner(team, user) {
return true
}
return d.isSiteAdmin(user)
}
// isSiteAdmin answers from the accounts plane, or false without one.
func (d Deps) isSiteAdmin(user string) bool {
if d.Accounts == nil || user == "" {
return false
}
accts, err := d.Accounts.Accounts()
if err != nil {
return false
}
for _, a := range accts {
if a.Name == user {
return a.Admin
}
}
return false
}
// maySetRepoTeam reports whether the caller may move a repository into
// or out of a team: they must hold `a` on the REPOSITORY.
//
// Never the team's authority. A team that already holds admin
// somewhere must not become a way to reach another repository without
// that repository's admin agreeing.
func (d Deps) maySetRepoTeam(ctx context.Context, repo string) bool {
user := author(ctx)
if user == "" {
return false
}
if d.isSiteAdmin(user) {
return true
}
return d.CapsFor != nil && d.CapsFor(user, repo).HasAny("a")
}
type teamListIn struct {
// Mine limits the listing to the caller's own teams. Absent, the
// listing is every team, which is public (RFC 0018 §public
// presentation).
Mine bool `json:"mine"`
// Account limits it to one person's teams. A membership somebody
// opted out of is withheld unless the caller is that person or can
// manage the team — the opt-out is the member's choice, so it has
// to hold on every surface that could reveal it, not only the
// team's own page.
Account string `json:"account" validate:"max=64"`
}
type teamListOut struct {
Teams []oltp.Team `json:"teams"`
}
type teamGetIn struct {
Name string `json:"name" validate:"required,max=64"`
}
type teamGetOut struct {
Team oltp.Team `json:"team"`
Members []oltp.TeamMember `json:"members"`
Repos []oltp.TeamRepo `json:"repos"`
// CanManage says whether the caller may change any of this, so a
// page does not have to work it out a second way.
CanManage bool `json:"canManage"`
// Donatable is what THIS CALLER could put under the team: the
// repositories they administer that no team holds yet. The page
// cannot work it out — it would need the caller's letters on every
// repository, which is a query per row — and the gate is not team
// management but `a` on each repository, so the only honest source
// is the op that already answers authority. CanManage's precedent.
//
// A repository already under ANOTHER team is left out on purpose.
// team.repo.add would move it, and a control that silently takes a
// repository out of somebody else's team is not an "add".
Donatable []string `json:"donatable,omitempty"`
// MayRemove lists which of Repos this caller may take out — an
// admin of that repository, or an owner of this team, which is why
// it is not the same answer as CanManage for every row.
MayRemove []string `json:"mayRemove,omitempty"`
}
type teamPeopleOut struct {
Team oltp.Team `json:"team"`
People []oltp.TeamPerson `json:"people"`
// Repos is how many repositories the team holds, so a page can say
// what "reaches these repositories" is counting.
Repos int `json:"repos"`
CanManage bool `json:"canManage"`
}
type teamCreateIn struct {
Name string `json:"name" validate:"required,max=64"`
Title string `json:"title" validate:"max=120"`
Descr string `json:"descr" validate:"max=300"`
}
type teamTextIn struct {
Name string `json:"name" validate:"required,max=64"`
Title string `json:"title" validate:"max=120"`
Descr string `json:"descr" validate:"max=300"`
}
type teamMemberIn struct {
Name string `json:"name" validate:"required,max=64"`
Account string `json:"account" validate:"required,max=64"`
// Caps is what they get on every repository under the team, ON TOP
// of the membership floor. Empty is a real state: the floor alone.
Caps string `json:"caps" validate:"max=64"`
// Owner makes them a manager OF THE TEAM — its members, its
// repositories, its text — and nothing about any repository under
// it. ABSENT leaves an existing member's ownership alone, and makes
// a new member an ordinary one.
//
// A pointer for the same reason as Listed, and here the zero value
// was worse than confusing: this op is an upsert, so a caller
// changing an owner's caps without mentioning `owner` DEMOTED
// them. GuardTeamMember refuses the change that would leave a team
// with no owner at all, so the last owner was safe — but a team
// with two lost one silently, and the response said ok.
Owner *bool `json:"owner"`
// Listed shows them on the team's public page. ABSENT means listed;
// send false to opt out.
//
// A pointer, not a bool with `default=1`, because that default was
// unreachable in the path that matters: defaults are applied by the
// form and query codec only, a JSON body goes straight through
// json.Unmarshal, and applyDefault had no bool case in either
// event. Two members added over the API without the field landed
// silently unlisted — withheld from the team's page and absent from
// its count, which made the opt-out the default for everybody not
// using the web form. nil is the only way a body can say "I did not
// mention this", and the opt-out has to stay the member's own
// choice rather than an artefact of how they were added.
Listed *bool `json:"listed"`
}
// donatableLimit bounds the add control's options. It is a menu on a
// page, not a listing: past a screenful nobody scrolls it, and a person
// with more repositories than this needs a search rather than a longer
// select. Stated as a constant so the page and the op agree on what
// "the options" means.
const donatableLimit = 200
type teamRepoIn struct {
Name string `json:"name" validate:"required,max=64"`
Repo string `json:"repo" validate:"required,max=64"`
}
type teamOkOut struct {
Name string `json:"name"`
OK bool `json:"ok"`
// Warning is the guard's remark on a change it allowed — a site
// admin is told a team now has no owner rather than refused, the
// way GuardCaps treats a repository.
Warning string `json:"warning,omitempty"`
}
func (d Deps) registerTeams(r *ops.Registry) error {
if err := ops.Register(r, ops.Op[teamListIn, teamListOut]{
Name: "team.list",
Doc: "Every team on the forge, with its member and repository counts. Public: a quarry team sits at the level other forges call an organisation, and that level is publicly listable everywhere (RFC 0018). " +
"`mine` narrows it to the caller's own.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/teams",
Run: func(ctx context.Context, in teamListIn) (teamListOut, error) {
if d.Teams == nil {
return teamListOut{}, errNoTeams()
}
var teams []oltp.Team
var err error
who := in.Account
if in.Mine {
who = author(ctx)
}
switch {
case in.Mine && who == "":
return teamListOut{Teams: []oltp.Team{}}, nil
case who != "":
teams, err = d.Teams.TeamsFor(who)
default:
teams, err = d.Teams.Teams()
}
if err != nil {
return teamListOut{}, err
}
// Somebody else's listing honours their opt-out. Their own,
// and a manager's view of them, does not: the person knows
// what they chose, and a manager is who has to act on it.
if who != "" && who != author(ctx) {
kept := teams[:0]
for _, t := range teams {
if m, ok := d.Teams.TeamMemberOf(t.Name, who); ok && !m.Listed &&
!d.mayManageTeam(ctx, t.Name) {
continue
}
kept = append(kept, t)
}
teams = kept
}
if teams == nil {
teams = []oltp.Team{}
}
// The directory shows a count with no list beside it, and
// answering "may this caller manage it" per row is the
// query-per-row the folded counts exist to avoid. So the
// listing is uniformly public: nobody learns an opt-out
// from it, and a manager reads the true figure on the
// team's own page.
for i := range teams {
teams[i].Unlisted = 0
}
return teamListOut{Teams: teams}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[teamGetIn, teamGetOut]{
Name: "team.get",
Doc: "One team: its text, its repositories, and its members. Members who opted out of being listed are withheld from callers who cannot manage the team; a manager sees everyone, because they are the person who has to.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/teams/{name}",
Run: func(ctx context.Context, in teamGetIn) (teamGetOut, error) {
if d.Teams == nil {
return teamGetOut{}, errNoTeams()
}
t, found := d.Teams.TeamByName(in.Name)
if !found {
return teamGetOut{}, fmt.Errorf("no team named %q: %w", in.Name, ops.ErrNotFound)
}
out := teamGetOut{Team: t, CanManage: d.mayManageTeam(ctx, in.Name)}
if !out.CanManage {
out.Team.Unlisted = 0
}
members, err := d.Teams.TeamMembers(in.Name)
if err != nil {
return teamGetOut{}, err
}
out.Members = []oltp.TeamMember{}
for _, m := range members {
// An opt-out is withheld from everyone who cannot
// manage the team, including the member themselves —
// a page that showed you your own hidden row would
// read as though the opt-out had not taken.
if !m.Listed && !out.CanManage {
continue
}
out.Members = append(out.Members, m)
}
if out.Repos, err = d.Teams.TeamRepos(in.Name); err != nil {
return teamGetOut{}, err
}
if out.Repos == nil {
out.Repos = []oltp.TeamRepo{}
}
// Who may take each of these out. Not the same answer for
// every row: an owner of the team may remove any of them,
// but a repository admin who is not an owner may remove
// only their own — and they must be able to, or they have
// lost control of their own access list (team.repo.remove).
for _, tr := range out.Repos {
if out.CanManage || d.maySetRepoTeam(ctx, tr.Repo) {
out.MayRemove = append(out.MayRemove, tr.Repo)
}
}
// And what they could add — from the caller's OWN GRANTS,
// which is one query, and never by walking the forge.
//
// The first version of this asked maySetRepoTeam for every
// repository quarry serves. It was correct and it was
// linear in the size of the forge: measured at 3.2ms for 20
// repositories, 19ms for 200 and 174ms for 2,000, on a page
// render. Ticket fbe8dbfcc230 is about exactly that shape.
// A person administers few repositories however large the
// forge grows, so the honest scale is the caller's grants.
//
// myRepos also carries the site-admin case (an admin with
// nothing granted falls back to the fleet) and a limit, so
// this inherits both rather than restating them.
if author(ctx) != "" && d.Repos != nil && d.Accounts != nil {
_, mine, err := d.myRepos(ctx, donatableLimit)
if err == nil {
for _, name := range mine {
// Already under a team is left out: add would
// MOVE it, and a control that quietly takes a
// repository out of somebody else's team is not
// an add.
if d.Teams.TeamOfRepo(name) != "" {
continue
}
if !d.maySetRepoTeam(ctx, name) {
continue
}
out.Donatable = append(out.Donatable, name)
}
sort.Strings(out.Donatable)
}
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[teamGetIn, teamPeopleOut]{
Name: "team.people",
Doc: "Everyone who reaches any repository under this team, and the route each came by: a team role, the membership floor alone, or a string of their own on one of the repositories. " +
"A non-member holding a grant on one of them is included — the question is who reaches these repositories, and an answer that hid them would be answering a different one. " +
"Unlisted members are withheld from callers who cannot manage the team.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/teams/{name}/people",
Run: func(ctx context.Context, in teamGetIn) (teamPeopleOut, error) {
if d.Teams == nil {
return teamPeopleOut{}, errNoTeams()
}
t, found := d.Teams.TeamByName(in.Name)
if !found {
return teamPeopleOut{}, fmt.Errorf("no team named %q: %w", in.Name, ops.ErrNotFound)
}
out := teamPeopleOut{Team: t, Repos: t.Repos,
CanManage: d.mayManageTeam(ctx, in.Name)}
if !out.CanManage {
out.Team.Unlisted = 0
}
people, err := d.Teams.TeamPeople(in.Name)
if err != nil {
return teamPeopleOut{}, err
}
// The same withholding team.get does, for the same reason:
// a member who opted out is not shown to the public, and IS
// shown to whoever has to manage them.
listed := map[string]bool{}
if !out.CanManage {
members, err := d.Teams.TeamMembers(in.Name)
if err != nil {
return teamPeopleOut{}, err
}
for _, m := range members {
listed[m.Account] = m.Listed
}
}
out.People = []oltp.TeamPerson{}
for _, p := range people {
// Only a MEMBERSHIP is hidden by the opt-out. Somebody
// with a direct grant on one of the repositories is
// visible on that repository's own page regardless, so
// hiding them here would be a privacy promise this
// screen cannot keep.
if !out.CanManage && p.Member && !listed[p.Account] {
if len(p.Direct) == 0 {
continue
}
p.Member, p.Owner, p.Caps = false, false, ""
}
out.People = append(out.People, p)
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[teamCreateIn, teamOkOut]{
Name: "team.create",
Doc: "Make a team. Any signed-in account may call this — the creator becomes its first owner in the same operation, which is where ownership starts, and is what stops a team existing that nobody can manage. " +
"The name comes from the SAME namespace as accounts and is refused if an account holds it: a grant row names a string, and the forge must never have to guess which kind it is.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams",
Run: func(ctx context.Context, in teamCreateIn) (teamOkOut, error) {
if d.Teams == nil {
return teamOkOut{}, errNoTeams()
}
if err := d.Teams.AddTeam(in.Name, in.Title, in.Descr, author(ctx)); err != nil {
return teamOkOut{}, refusalAsInvalid(err)
}
return teamOkOut{Name: in.Name, OK: true}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[teamTextIn, teamOkOut]{
Name: "team.text",
Doc: "Set a team's display name and its public sentence. The handle never changes: it is what every grant row names.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams/{name}/text",
Run: func(ctx context.Context, in teamTextIn) (teamOkOut, error) {
if err := d.requireTeamManager(ctx, in.Name); err != nil {
return teamOkOut{}, err
}
if err := d.Teams.SetTeamText(in.Name, in.Title, in.Descr); err != nil {
return teamOkOut{}, err
}
return teamOkOut{Name: in.Name, OK: true}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[teamGetIn, teamOkOut]{
Name: "team.delete",
Doc: "Delete a team, its membership and its repository bindings. The repositories themselves are untouched: a team manages access to them and does not own them, so deleting one must never be able to delete anybody's code.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams/{name}/delete",
Run: func(ctx context.Context, in teamGetIn) (teamOkOut, error) {
if err := d.requireTeamManager(ctx, in.Name); err != nil {
return teamOkOut{}, err
}
if err := d.Teams.DeleteTeam(in.Name); err != nil {
return teamOkOut{}, err
}
return teamOkOut{Name: in.Name, OK: true}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[teamMemberIn, teamOkOut]{
Name: "team.member",
Doc: "Put an account in a team, or change what it holds there. `caps` is a capability string granted on every repository under the team, ON TOP of the membership floor every member carries; empty means the floor alone. " +
"`owner` manages the TEAM and nothing about any repository under it. A change that would leave the team with no owner is refused; a site admin is told instead. " +
"Omitting `owner` or `listed` keeps what the member already had — it is not the same as sending false — so a change to one field cannot quietly undo another.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams/{name}/member",
Run: func(ctx context.Context, in teamMemberIn) (teamOkOut, error) {
if err := d.requireTeamManager(ctx, in.Name); err != nil {
return teamOkOut{}, err
}
// An absent field means "I did not mention this", so it
// keeps whatever the member already had, and takes the
// documented default only when there is nothing to keep.
// Anything else makes this upsert destructive: omitting
// `listed` would undo somebody's opt-out, and omitting
// `owner` would take their ownership away.
cur, existed := d.Teams.TeamMemberOf(in.Name, in.Account)
listed, owner := true, false
if existed {
listed, owner = cur.Listed, cur.Owner
}
if in.Listed != nil {
listed = *in.Listed
}
if in.Owner != nil {
owner = *in.Owner
}
warn, gerr := d.Teams.GuardTeamMember(author(ctx), in.Name, in.Account, owner, false)
if gerr != nil {
return teamOkOut{}, refusalAsInvalid(gerr)
}
if err := d.Teams.SetTeamMember(in.Name, in.Account, in.Caps, owner, listed); err != nil {
return teamOkOut{}, refusalAsInvalid(err)
}
return teamOkOut{Name: in.Name, OK: true, Warning: warn}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[teamMemberIn, teamOkOut]{
Name: "team.unmember",
Doc: "Take an account out of a team. Removing somebody who is not in it is not an error. NOTE that on a private repository this removes ALL of their access: there is nothing beneath the membership floor.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams/{name}/unmember",
Run: func(ctx context.Context, in teamMemberIn) (teamOkOut, error) {
if err := d.requireTeamManager(ctx, in.Name); err != nil {
return teamOkOut{}, err
}
warn, gerr := d.Teams.GuardTeamMember(author(ctx), in.Name, in.Account, false, true)
if gerr != nil {
return teamOkOut{}, refusalAsInvalid(gerr)
}
if err := d.Teams.RemoveTeamMember(in.Name, in.Account); err != nil {
return teamOkOut{}, err
}
return teamOkOut{Name: in.Name, OK: true, Warning: warn}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[teamRepoIn, teamOkOut]{
Name: "team.repo.add",
Doc: "Put a repository under a team, moving it if another team held it — a repository is under exactly one. " +
"Gated on `a` on THAT REPOSITORY, never on the team: otherwise making a team would be a way to take somebody else's repository and grant yourself admin of it.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams/{name}/repo",
Run: func(ctx context.Context, in teamRepoIn) (teamOkOut, error) {
if d.Teams == nil {
return teamOkOut{}, errNoTeams()
}
if _, found := d.Teams.TeamByName(in.Name); !found {
return teamOkOut{}, fmt.Errorf("no team named %q: %w", in.Name, ops.ErrNotFound)
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return teamOkOut{}, err
}
if !d.maySetRepoTeam(ctx, in.Repo) {
return teamOkOut{}, fmt.Errorf("only an admin of %s may put it under a team: %w",
in.Repo, ops.ErrForbidden)
}
if err := d.Teams.SetTeamRepo(in.Name, in.Repo, author(ctx)); err != nil {
return teamOkOut{}, refusalAsInvalid(err)
}
return teamOkOut{Name: in.Name, OK: true}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[teamRepoIn, teamOkOut]{
Name: "team.repo.remove",
Doc: "Take a repository out of its team. EITHER side may do this — an admin of the repository or an owner of the team — because whoever can grant must be able to revoke, or a repository admin loses control of their own access list.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams/{name}/repo/remove",
Run: func(ctx context.Context, in teamRepoIn) (teamOkOut, error) {
if d.Teams == nil {
return teamOkOut{}, errNoTeams()
}
if !d.maySetRepoTeam(ctx, in.Repo) && !d.mayManageTeam(ctx, in.Name) {
return teamOkOut{}, fmt.Errorf("only an admin of %s or an owner of %s may take it out: %w",
in.Repo, in.Name, ops.ErrForbidden)
}
if err := d.Teams.RemoveTeamRepo(in.Repo); err != nil {
return teamOkOut{}, err
}
return teamOkOut{Name: in.Name, OK: true}, nil
},
})
}
// requireTeamManager is the gate every management op shares.
func (d Deps) requireTeamManager(ctx context.Context, team string) error {
if d.Teams == nil {
return errNoTeams()
}
if _, found := d.Teams.TeamByName(team); !found {
return fmt.Errorf("no team named %q: %w", team, ops.ErrNotFound)
}
if !d.mayManageTeam(ctx, team) {
return fmt.Errorf("only an owner of %s or a site admin may change it: %w",
team, ops.ErrForbidden)
}
return nil
}
// refusalAsInvalid carries the guard's own words across the ops
// boundary as a client error rather than a server one.
func refusalAsInvalid(err error) error {
if err == nil {
return nil
}
if oltp.IsRefusal(err) {
return fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
return err
}