// 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"
"time"
)
// 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)
IsTeamOwner(team, account string) bool
// The last-owner guard and the write as one decision, the only
// membership writes here: a guard asked in one call and a write made
// in another let two owners removing each other at once leave a team
// with none.
SetTeamMemberGuarded(actor, team, account, caps string, owner, listed bool) (string, error)
RemoveTeamMemberGuarded(actor, team, account string) (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)
}
}
// offerTold is the inbox half of a repository offer (ticket 6d176a8c).
//
// The offer machinery was complete except for this: the row was
// recorded, both sides could read it through team.get with offeredBy
// and an expiry, and neither side was ever TOLD. The only way to learn
// an offer was waiting was to open the team's settings page, which is
// the one place somebody with nothing pending has no reason to visit.
//
// Attention rather than notify, for AccessChanged's reason: an offer
// changes who may reach a repository, and that is account mail. It
// does not ride the preference matrix and cannot be switched off,
// because a decision waiting on you is not content you subscribed to.
//
// Deliberately silent to the actor. Whoever pressed the button already
// knows; a row telling you what you just did is noise in the one place
// that must stay worth reading.
func (d Deps) tellTeamOwners(ctx context.Context, team, summary, link string) {
if d.Teams == nil {
return
}
members, err := d.Teams.TeamMembers(team)
if err != nil {
return
}
actor := author(ctx)
now := time.Now().UTC().Format(time.RFC3339Nano)
for _, m := range members {
if !m.Owner || m.Account == actor {
continue
}
d.attend(m.Account, AttentionEvent{
Kind: "team", From: actor, Summary: summary, Link: link, At: now,
})
}
}
// tellOfferer answers the person who asked. An offer they made is a
// question, and accept and decline are both its answer.
func (d Deps) tellOfferer(ctx context.Context, who, summary, link string) {
if who == "" || who == author(ctx) {
return
}
d.attend(who, AttentionEvent{
Kind: "team", From: author(ctx), Summary: summary, Link: link,
At: time.Now().UTC().Format(time.RFC3339Nano),
})
}
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, err := d.Teams.SetTeamMemberGuarded(author(ctx), in.Name, in.Account, in.Caps, owner, listed)
if err != nil {
return teamOkOut{}, refusalAsInvalid(err)
}
d.raiseTeam(ctx, evt.TeamMember, in.Name, in.Account+" joined or changed role")
// 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, err := d.Teams.RemoveTeamMemberGuarded(author(ctx), in.Name, in.Account)
if err != nil {
return teamOkOut{}, refusalAsInvalid(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
}
// An offer is a team.repo and not a noun of its own.
// Nothing has moved — that is what an offer MEANS —
// but it is the team's repository relationship that
// changed, and the vocabulary is deliberately small
// (RFC 0005). The summary says which half it is.
d.raiseTeam(ctx, evt.TeamRepo, in.Name,
in.Repo+" was offered to this team")
d.tellTeamOwners(ctx, in.Name,
author(ctx)+" offered "+in.Repo+" to "+in.Name,
d.link("/t/"+in.Name+"/settings"))
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.tellOfferer(ctx, offer.OfferedBy,
author(ctx)+" accepted "+in.Repo+" into "+in.Name,
d.link("/t/"+in.Name))
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
}
d.raiseTeam(ctx, evt.TeamRepo, in.Name,
in.Repo+"'s offer was ended")
// "ended" rather than "declined": either side may end an
// offer, so an offerer withdrawing their own reaches this
// path too — and tellOfferer stays silent when the actor IS
// the offerer, so nobody hears about their own withdrawal.
d.tellOfferer(ctx, offer.OfferedBy,
author(ctx)+" declined "+in.Repo+" for "+in.Name,
d.link("/r/"+in.Repo))
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
}