package api
import (
"context"
"fmt"
"strings"
"time"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// RepoOwnership is the registry's record of who holds a repository and
// where it appears. Two facts, deliberately separate: the deed is
// always an account, while the namespace may be a team.
type RepoOwnership interface {
OwnerOf(repo string) (string, error)
SetOwner(repo, account string) error
NamespaceOf(repo string) (string, error)
SetNamespace(repo, ns, by string, at time.Time) error
}
// deedMover is the part of an ownership store that can say, before a
// deed moves, why it would put two repositories at one address (RFC
// 0025). Optional: a store without it moves deeds as before.
type deedMover interface {
DeedMoveRefusal(repo, account string) error
}
type repoClaimIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Account to hand the deed to. Empty means the caller. Naming
// somebody else is how a store an operator imported reaches the
// person whose project it actually is.
Account string `json:"account" validate:"max=64"`
}
type repoClaimOut struct {
Repo string `json:"repo"`
Owner string `json:"owner"`
Namespace string `json:"namespace"`
// SignOff: as repo.delete's, for an agent handing on its owner's
// repository.
SignOff *SignOffRequest `json:"signOff,omitempty"`
}
// registerRepoClaim is how a repository acquires an owner, or changes
// the one it has.
//
// Three cases, and the third is the compromise. Taking a repository
// nobody holds is a claim. Handing on one you hold yourself is a
// transfer between two people who have both already agreed, since the
// recipient has to administer the repository already. Taking one
// somebody else holds is refused, and the refusal says what the missing
// verb is: an offer the recipient could decline, which this build
// cannot present.
//
// The consent proxy is deliberate and it is weaker than an offer.
// Being made owner of something you already administer is a promotion,
// not a surprise. Being handed a repository you have never touched is
// exactly what an offer exists to let you refuse -- so that case stays
// refused rather than approximated.
//
// The quota deliberately does not apply. Claiming does not bring a new
// repository into being — the forge is already carrying this store —
// and the act is gated on already holding `a`, which somebody else had
// to grant. A quota here would refuse people the deed to work they are
// already administering.
func registerRepoClaim(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[repoClaimIn, repoClaimOut]{
Name: "repo.claim",
Caps: "a",
Doc: "Take the deed to a repository nobody holds, or hand one you hold to another of its admins. A site " +
"admin may hand any repository to one of its admins on request, without a role there and with a " +
"receipt to the owner, but never to themselves. The recipient must already administer the " +
"repository: that is the nearest thing to consent available until an offer they can decline exists.",
Kind: ops.Command,
Auth: ops.Token,
Operator: true,
Route: "POST /api/v1/r/{repo}/claim",
Run: func(ctx context.Context, in repoClaimIn) (repoClaimOut, error) {
// An agent may ask its owner to hand on a repository they
// hold; nothing else.
if strings.TrimSpace(in.Account) != "" {
if req, asked, err := d.askOwner(ctx, "repo.claim", in.Repo, in, "hand "+d.repoLabel(in.Repo)+" to "+strings.TrimSpace(in.Account)); asked {
return repoClaimOut{Repo: in.Repo, SignOff: req}, err
}
}
// An agent is refused before the deed is consulted, held
// or unheld (RFC 0021): it never holds one.
if err := d.refuseAgent(ctx, "claim"); err != nil {
return repoClaimOut{}, err
}
if d.Owners == nil {
return repoClaimOut{}, fmt.Errorf(
"this deployment has no ownership registry: %w", ops.ErrUnavailable)
}
owner, err := d.Owners.OwnerOf(in.Repo)
if err != nil {
return repoClaimOut{}, err
}
who := ops.User(ctx)
// Taking what nobody holds, or handing on what you hold
// yourself. Taking somebody ELSE's repository is refused,
// and says what the missing verb would be.
//
// A site admin may hand any repository on: a transfer on
// request is an operator act (#318), journalled for the
// owner by the witness.
if owner != "" && owner != who && !(d.isSiteAdmin(ctx, who) && d.siteReaches(ctx, in.Repo)) {
return repoClaimOut{}, fmt.Errorf(
"%w: %s is held by %s; taking a repository somebody holds is a transfer, and a transfer "+
"needs their agreement, which this build cannot ask for",
ops.ErrInvalid, in.Repo, owner)
}
// A forge agent's s does not reach a deed held by a site
// admin or by another forge agent (RFC 0021, Forge agents:
// Guards); one held by nobody or by a plain person, it may.
if owner != "" && owner != who {
if err := d.refuseForgeAgentOn(ctx, owner); err != nil {
return repoClaimOut{}, err
}
}
target := strings.TrimSpace(in.Account)
if target == "" {
target = who
}
if target == "" {
return repoClaimOut{}, fmt.Errorf(
"%w: a claim needs an account to hand the deed to", ops.ErrInvalid)
}
// The operator path hands a deed on; it never takes one.
// Holding it would make the operator a party to a
// repository they hold no role on (#318).
if target == who && d.isSiteAdmin(ctx, who) &&
(d.CapsFor == nil || !d.capsFor(ctx, who, in.Repo).HasAny("a")) {
return repoClaimOut{}, fmt.Errorf(
"%w: you hold no role on %s; a site admin hands a repository to one of its admins, "+
"and never to themselves", ops.ErrInvalid, in.Repo)
}
// Handing the deed to somebody else requires that they
// already administer the repository. It is the nearest
// thing to consent available without a pending-offer flow:
// being made owner of something you already run is a
// promotion, not a surprise, while being handed a
// repository you have never touched is exactly what the
// transfer flow exists to let you refuse.
if target != who {
if d.Accounts == nil {
return repoClaimOut{}, fmt.Errorf(
"this deployment cannot check the recipient's role: %w", ops.ErrUnavailable)
}
grants, gerr := d.Accounts.GrantsFor(target)
if gerr != nil {
return repoClaimOut{}, gerr
}
if !strings.Contains(grants[in.Repo], "admin") {
return repoClaimOut{}, fmt.Errorf(
"%w: %s does not administer %s, so the deed cannot be handed over without their agreement",
ops.ErrInvalid, target, in.Repo)
}
}
// Asked before anything moves, so a refusal leaves the deed
// where it was rather than half handed over.
if mv, ok := d.Owners.(deedMover); ok {
if err := mv.DeedMoveRefusal(in.Repo, target); err != nil {
return repoClaimOut{}, refusalAsInvalid(err)
}
}
if err := d.Owners.SetOwner(in.Repo, target); err != nil {
return repoClaimOut{}, err
}
// Where the repository APPEARS is a separate fact from who
// holds it -- but not an independent one.
//
// A repository under a TEAM keeps appearing there: the team
// holds the path, and changing who holds the deed says
// nothing about that. A repository in a PERSON's namespace
// is a different matter, because that namespace IS the
// previous owner's own handle: leaving it after the deed
// moves means the repository goes on appearing under
// somebody who no longer holds it.
//
// Found by using it rather than by reading it -- pkgsrc
// changed hands and stayed at the old owner's name.
//
// So a personal namespace follows the deed, a team's does
// not, and an empty one is filled in.
ns, err := d.Owners.NamespaceOf(in.Repo)
if err != nil {
return repoClaimOut{}, err
}
if ns == "" || ns == owner || ns == oltp.ForgeNamespace {
ns = target
if err := d.Owners.SetNamespace(in.Repo, ns, who, time.Now()); err != nil {
return repoClaimOut{}, err
}
}
return repoClaimOut{Repo: in.Repo, Owner: target, Namespace: ns}, nil
},
})
}
// refuseUnlessTheirs is the deed gating a destructive verb.
//
// The `a` letter says what somebody may do INSIDE a repository. It was
// also, until now, the whole gate on destroying one, which meant an
// admin collaborator could delete a repository they do not own — a
// grant made so somebody could help maintain a project let them end it.
// Destroying and un-serving are the two verbs that belong with the deed
// rather than with the role.
//
// A forge-held repository (no owner) keeps its previous gate: the
// letter alone. Somebody has to be able to retire a store nobody
// claimed, and eighteen of the ones served here are in that state.
//
// A site admin is still permitted: deleting and un-serving are operator
// acts (#318), the lifecycle the forge keeps over repositories it does
// not otherwise reach, and the witness leaves the owner a receipt.
//
// An agent is refused first, before the deed is read, held or unheld
// (RFC 0021): retiring a repository nobody claimed stays a person's act.
func (d Deps) refuseUnlessTheirs(ctx context.Context, repo, verb string) error {
if err := d.refuseAgent(ctx, verb); err != nil {
return err
}
// Before the deed is read, for a site token: a private repository
// its agent holds no grant on answers exactly as a name the forge
// does not serve, so the refusal names no owner, an unclaimed one is
// not acted on, and the two cannot be told apart. Any other caller
// is as before (a person clearing a failed import's leftover row
// still can; a person's token is turned away at the router).
if tok, byToken := ops.TokenOf(ctx); byToken && tok.SiteAdmin() {
if _, err := d.repo(repo); err != nil || !d.siteReaches(ctx, repo) {
return fmt.Errorf("no repository %q: %w", repo, ops.ErrNotFound)
}
}
if d.Owners == nil {
return nil
}
owner, err := d.Owners.OwnerOf(repo)
if err != nil {
return err
}
if owner == "" {
return nil
}
who := ops.User(ctx)
if who == owner {
return nil
}
// The same edge as repo.claim's: a forge agent's s stops at a deed
// a site admin or another forge agent holds.
if err := d.refuseForgeAgentOn(ctx, owner); err != nil {
return err
}
if d.isSiteAdmin(ctx, who) && d.siteReaches(ctx, repo) {
return nil
}
return fmt.Errorf("%s belongs to %s; administering a repository is not the same as being free to %s it: %w",
repo, owner, verb, ops.ErrForbidden)
}