// 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"
"log"
"sort"
"craftdesign.group/quarry/internal/evt"
"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)
TeamPeoplePage(team string, limit, offset int) ([]oltp.TeamPerson, error)
TeamPeopleCount(team string) (int, error)
TeamListed(team string, accounts []string) (map[string]bool, error)
TeamOwnerCount(team string) (int, error)
TeamCount() (int, error)
TeamReposPage(team string, limit, offset int) ([]oltp.TeamRepo, error)
TeamMembersPage(team string, limit, offset int) ([]oltp.TeamMember, error)
TeamOfRepo(repo string) string
SetTeamRepo(team, repo, by string) error
RemoveTeamRepo(repo string) error
// Offers: a repository waiting on a team's consent, holding no
// access of its own (RFC 0018 §"The receiving half is an offer").
OfferTeamRepo(team, repo, by string) error
TeamOfferFor(repo string) (oltp.TeamOffer, bool, error)
TeamOffers(team string) ([]oltp.TeamOffer, error)
DropTeamOffer(repo string) error
}
// raiseTeam announces a change to a team.
//
// Called AFTER the write and never allowed to fail it: the change has
// already happened by the time this runs, so an error here would tell
// a caller their save did not when it did. raiseAccountChange's rule,
// for the same reason.
//
// The team's name goes in the repository slot (RFC 0005 §team): a
// subject is quarry.evt.<repo>.<noun>.<action>, and a team borrows
// that slot so one subject shape serves both.
func (d Deps) raiseTeam(ctx context.Context, kind evt.Kind, team, summary string) {
if d.Events == nil {
return
}
if err := d.Events.Raise(kind, team, map[string]any{
"team": team, "actor": author(ctx), "summary": summary,
}); err != nil {
log.Printf("events: raise %s: %v", kind, err)
}
}
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")
}
// mayOfferStillStands asks whether the person who made an offer could
// still make it today.
//
// An offer is consent from the TEAM's side; it is not a standing grant
// from the repository's. Somebody who held `a` when they offered may
// have lost it in the seven days since — handed the repository on,
// been removed as an admin, left. Accepting on the strength of a
// capability that has expired would move a repository on the authority
// of somebody who no longer has any, which is exactly the escalation
// §Authority exists to prevent, arriving a week late.
//
// A site admin's offer always stands: their ability to act is not a
// grant on the repository and cannot lapse with one.
func (d Deps) mayOfferStillStands(offer oltp.TeamOffer) bool {
if offer.OfferedBy == "" {
return false
}
if d.isSiteAdmin(offer.OfferedBy) {
return true
}
return d.CapsFor != nil && d.CapsFor(offer.OfferedBy, offer.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"`
// Limit and Offset window the listing. A forge-wide directory is
// unbounded by nature, and the badge reads Total rather than this.
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
Offset int `json:"offset" validate:"min=0,max=1000000"`
}
type teamListOut struct {
Teams []oltp.Team `json:"teams"`
// Total is every team on the forge, whatever this window holds. The
// explore strip's badge reads this: len(Teams) would silently
// become the page size the moment the listing was paged, which is
// the count-disagrees-with-its-list bug this area has produced
// twice already.
Total int `json:"total"`
}
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".
// MembersShown and ReposShown say how much of each count is in
// these arrays. The overview is a SUMMARY: it shows a screenful of
// each and points at the roster for the rest, rather than
// rendering a thousand rows nobody scrolls.
MembersShown int `json:"membersShown"`
ReposShown int `json:"reposShown"`
Donatable []string `json:"donatable,omitempty"`
// Offered is what is WAITING on this team's answer (RFC 0018
// §"The receiving half is an offer"). Shown to anybody who can see
// the team: a pending offer is not private — it names a repository
// whose admin has already chosen to make the connection public by
// asking — and hiding it from members would mean only owners knew
// what the team was about to take on. Only an owner gets the
// controls.
Offered []oltp.TeamOffer `json:"offered,omitempty"`
// Losing names, per repository, who would lose access if it left
// the team — the people whose only route to it is the membership
// floor. RFC 0017 requires this BEFORE the act, not after: "a
// confirmation that lists them is the difference between a decision
// and an accident". The op's Warning says the same thing afterwards,
// which is the wrong moment for a control on a page.
//
// Empty for a public repository, and correctly so: the categories
// already grant what the floor does, so nobody's access moves.
Losing map[string][]string `json:"losing,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"`
}
// teamPageIn is teamGetIn plus a window. A separate type because
// team.get and team.people page different things.
type teamPageIn struct {
Name string `json:"name" validate:"required,max=64"`
// Limit and Offset bound the WORK, not only what is rendered: the
// order and the window are the database's (oltp.TeamPeoplePage).
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
Offset int `json:"offset" validate:"min=0,max=1000000"`
}
type teamPeopleOut struct {
Team oltp.Team `json:"team"`
People []oltp.TeamPerson `json:"people"`
// Total is the whole population this window is cut from, so a pager
// knows there is a next page and a heading can say how many people
// reach these repositories without listing them.
Total int `json:"total"`
// Owners is the team's owner count, from the store and not from
// this page: the screen hides the last owner's Remove, and that
// control must not depend on which rows happen to be in view.
Owners int `json:"owners"`
// 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"`
// InRoom, as on team.get, so the nav is the same on both pages.
InRoom bool `json:"inRoom"`
}
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
// overviewRows is how much of each list the team's overview carries.
// The page is a summary and the People screen is the roster, so this is
// "a screenful" and not a page size: past it the overview says how many
// more there are and links to the list that pages properly.
const overviewRows = 12
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"`
// Offered says the call recorded a QUESTION rather than a change:
// the repository is where it was until one of the team's owners
// accepts (RFC 0018). OK is still true — the call did what it
// could — so a caller that ignores this field must not be able to
// read the result as "it moved".
Offered bool `json:"offered,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
var total int
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{}
}
// Total describes the population THIS listing is a window
// on, not the forge. A filtered listing (mine, or one
// account's) has already been narrowed and had opt-outs
// withheld, so its total is what survived that; only the
// unfiltered directory can take the cheap count.
if who == "" {
if n, cerr := d.Teams.TeamCount(); cerr == nil {
total = n
} else {
total = len(teams)
}
} else {
total = len(teams)
}
// The window is applied after the opt-out filter, so a
// withheld membership cannot leave a short page behind and
// tell a stranger that something was removed.
if in.Offset < len(teams) {
teams = teams[in.Offset:]
} else {
teams = nil
}
if in.Limit > 0 && len(teams) > in.Limit {
teams = teams[:in.Limit]
}
// 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, Total: total}, 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.TeamMembersPage(in.Name, overviewRows, 0)
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.TeamReposPage(in.Name, overviewRows, 0); err != nil {
return teamGetOut{}, err
}
out.MembersShown, out.ReposShown = len(out.Members), len(out.Repos)
// Only for somebody who can act on it: this is a list of
// accounts, and a page that cannot offer the control has no
// reason to carry one.
if out.CanManage || len(out.MayRemove) > 0 {
for _, tr := range out.Repos {
if who := d.wouldLose(in.Name, tr.Repo); len(who) > 0 {
if out.Losing == nil {
out.Losing = map[string][]string{}
}
out.Losing[tr.Repo] = who
}
}
}
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 offers, err := d.Teams.TeamOffers(in.Name); err == nil {
// A repository the caller may not see is withheld: an
// offer must not become a way to learn a private
// repository exists (RFC 0017's rule that visibility
// never relaxes).
for _, o := range offers {
if _, err := d.repoFor(ctx, o.Repo); err != nil {
continue
}
out.Offered = append(out.Offered, o)
}
}
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
}
// A repository already waiting on an answer is
// not something to offer again: the control
// would invite the same question a second
// time, and the page says elsewhere that it is
// pending.
if _, pending, err := d.Teams.TeamOfferFor(name); err == nil && pending {
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[teamPageIn, 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 teamPageIn) (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),
InRoom: d.inTeamRoom(ctx, in.Name)}
if !out.CanManage {
out.Team.Unlisted = 0
}
total, err := d.Teams.TeamPeopleCount(in.Name)
if err != nil {
return teamPeopleOut{}, err
}
out.Total = total
if out.Owners, err = d.Teams.TeamOwnerCount(in.Name); err != nil {
return teamPeopleOut{}, err
}
people, err := d.Teams.TeamPeoplePage(in.Name, in.Limit, in.Offset)
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.
// Who opted out, asked one page at a time rather than by
// reading every member row: the page is bounded now, so
// this lookup has to be too or the screen still does work
// proportional to the team.
// Who opted out, for THIS PAGE, in one query. Asking per
// row would put the work back in proportion to the page
// the window exists to bound.
listed := map[string]bool{}
if !out.CanManage {
names := make([]string, 0, len(people))
for _, p := range people {
names = append(names, p.Account)
}
listed, err = d.Teams.TeamListed(in.Name, names)
if err != nil {
return teamPeopleOut{}, err
}
}
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)
}
d.raiseTeam(ctx, evt.TeamCreated, in.Name, author(ctx)+" made this team")
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
}
d.raiseTeam(ctx, evt.TeamSettings, in.Name, author(ctx)+" changed the name or description")
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
}
// Dissolving a team takes every repository it held out of
// it, so the same two obligations apply: name who loses
// access, and tell them afterwards.
dissolving, _ := d.Teams.TeamRepos(in.Name)
watches := make([]handsWatch, 0, len(dissolving))
for _, tr := range dissolving {
watches = append(watches, d.watchHands(tr.Repo, in.Name))
}
d.raiseTeam(ctx, evt.TeamRemoved, in.Name, author(ctx)+" deleted this team")
if err := d.Teams.DeleteTeam(in.Name); err != nil {
return teamOkOut{}, err
}
for _, w := range watches {
d.settled(ctx, w, author(ctx))
}
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)
}
d.raiseTeam(ctx, evt.TeamMember, in.Name, in.Account+" joined or changed role")
if err := d.Teams.SetTeamMember(in.Name, in.Account, in.Caps, owner, listed); err != nil {
return teamOkOut{}, refusalAsInvalid(err)
}
// Only on arrival: `existed` was read before the write, so
// this is the one call that turned a non-member into a
// member. An owner editing caps or listing on somebody
// already here changes nothing they need telling about.
if !existed {
d.TeamMembership(ctx, author(ctx), in.Account, in.Name)
}
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
}
d.raiseTeam(ctx, evt.TeamMember, in.Name, in.Account+" left")
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)
}
// A repository is under exactly one team, so adding it to a
// second MOVES it — which revokes for one population and
// grants to another in a single operation. Both halves are
// watched, and the losing team's members are named in the
// answer rather than left to discover it.
//
// Not refused: whoever may add already holds `a` on the
// repository, and team.repo.remove accepts that too, so the
// move grants no authority a remove-then-add would not.
// What it must not do is happen quietly.
from := d.Teams.TeamOfRepo(in.Repo)
// The receiving half (RFC 0018). Holding `a` on the
// repository says nobody can take it from you; it does not
// say you may hand a team work it never asked for. Where
// the actor does not own the team, this is a question, and
// the answer is an owner's. Where they do, there is nobody
// to consult.
if !d.mayManageTeam(ctx, in.Name) {
if err := d.Teams.OfferTeamRepo(in.Name, in.Repo, author(ctx)); err != nil {
return teamOkOut{}, err
}
return teamOkOut{Name: in.Name, OK: true, Offered: true,
Warning: fmt.Sprintf("offered to %s; nothing moves until one of its owners accepts", in.Name)}, nil
}
out := teamOkOut{Name: in.Name, OK: true}
if from != "" && from != in.Name {
out.Warning = d.handsWarning(from, in.Repo)
}
watch := d.watchHands(in.Repo, from, in.Name)
if err := d.Teams.SetTeamRepo(in.Name, in.Repo, author(ctx)); err != nil {
return teamOkOut{}, refusalAsInvalid(err)
}
// An offer this supersedes is answered by the act.
_ = d.Teams.DropTeamOffer(in.Repo)
d.raiseTeam(ctx, evt.TeamRepo, in.Name, in.Repo+" joined this team")
d.settled(ctx, watch, author(ctx))
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[teamRepoIn, teamOkOut]{
Name: "team.repo.offer.accept",
Doc: "Accept a repository offered to this team, moving it under the team (RFC 0018). An owner's decision: the offer carries no access until this call, and a repository that has moved elsewhere since is refused rather than taken back.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams/{name}/repo/accept",
Run: func(ctx context.Context, in teamRepoIn) (teamOkOut, error) {
if d.Teams == nil {
return teamOkOut{}, errNoTeams()
}
if !d.mayManageTeam(ctx, in.Name) {
return teamOkOut{}, fmt.Errorf("only an owner of %s may accept what is offered to it: %w",
in.Name, ops.ErrForbidden)
}
offer, ok, err := d.Teams.TeamOfferFor(in.Repo)
if err != nil {
return teamOkOut{}, err
}
// Expired reads as absent, which is what TeamOfferFor
// already answers: an offer nobody answered in seven days
// is a question withdrawn.
if !ok || offer.Team != in.Name {
return teamOkOut{}, fmt.Errorf("%s is not offered to %s: %w",
in.Repo, in.Name, ops.ErrNotFound)
}
// The offer is consent from the team's side. It is NOT a
// standing grant from the repository's: the person who
// offered held `a` when they offered, and may not now. Ask
// again, at the moment it takes effect.
if !d.mayOfferStillStands(offer) {
_ = d.Teams.DropTeamOffer(in.Repo)
return teamOkOut{}, fmt.Errorf("%s no longer administers %s, so the offer has lapsed: %w",
offer.OfferedBy, in.Repo, ops.ErrInvalid)
}
from := d.Teams.TeamOfRepo(in.Repo)
out := teamOkOut{Name: in.Name, OK: true}
if from != "" && from != in.Name {
out.Warning = d.handsWarning(from, in.Repo)
}
// The change of hands is reported HERE and not at the
// offer, because this is when anybody actually gains or
// loses access.
watch := d.watchHands(in.Repo, from, in.Name)
if err := d.Teams.SetTeamRepo(in.Name, in.Repo, offer.OfferedBy); err != nil {
return teamOkOut{}, refusalAsInvalid(err)
}
if err := d.Teams.DropTeamOffer(in.Repo); err != nil {
return teamOkOut{}, err
}
d.raiseTeam(ctx, evt.TeamRepo, in.Name, in.Repo+" joined this team")
d.settled(ctx, watch, author(ctx))
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[teamRepoIn, teamOkOut]{
Name: "team.repo.offer.decline",
Doc: "Decline a repository offered to this team, or withdraw an offer you made. Leaves no state: RFC 0018 keeps no register of who declined whom, and the offer may be made again.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams/{name}/repo/decline",
Run: func(ctx context.Context, in teamRepoIn) (teamOkOut, error) {
if d.Teams == nil {
return teamOkOut{}, errNoTeams()
}
offer, ok, err := d.Teams.TeamOfferFor(in.Repo)
if err != nil {
return teamOkOut{}, err
}
if !ok || offer.Team != in.Name {
return teamOkOut{}, fmt.Errorf("%s is not offered to %s: %w",
in.Repo, in.Name, ops.ErrNotFound)
}
// Two people may end an offer: the team it was made to, and
// whoever made it. A repository admin who changed their
// mind must not have to ask the team to decline for them.
if !d.mayManageTeam(ctx, in.Name) && !d.maySetRepoTeam(ctx, in.Repo) {
return teamOkOut{}, fmt.Errorf("only an owner of %s or an admin of %s may end this offer: %w",
in.Name, in.Repo, ops.ErrForbidden)
}
if err := d.Teams.DropTeamOffer(in.Repo); err != nil {
return teamOkOut{}, 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)
}
out := teamOkOut{Name: in.Name, OK: true,
Warning: d.handsWarning(in.Name, in.Repo)}
watch := d.watchHands(in.Repo, in.Name)
d.raiseTeam(ctx, evt.TeamRepo, in.Name, in.Repo+" left this team")
if err := d.Teams.RemoveTeamRepo(in.Repo); err != nil {
return teamOkOut{}, err
}
d.settled(ctx, watch, author(ctx))
return out, 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
}