Skip to content
quarry Artifact [5b8765ee5c]
Login

Artifact [5b8765ee5c]

Artifact 5b8765ee5cfc20982dc549f9f958a23a2edbe4c7cf68dbff9f532a0e4fec261e:


package api

import (
	"context"
	"fmt"
	"strings"
	"time"

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

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"`
}

// registerRepoClaim is how a forge-held repository acquires an owner.
//
// It claims only what nobody holds. Moving a repository that already
// has an owner is a transfer, which needs the recipient's consent and
// is not built here; refusing with that said plainly is better than
// quietly doing half of it.
//
// 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 it to another of its admins. " +
			"Refuses a repository that already has an owner: changing hands is a transfer, not a claim.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/claim",
		Run: func(ctx context.Context, in repoClaimIn) (repoClaimOut, error) {
			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
			}
			if owner != "" {
				return repoClaimOut{}, fmt.Errorf(
					"%w: %s is already held by %s; changing hands is a transfer, which this build does not have yet",
					ops.ErrInvalid, in.Repo, owner)
			}
			who := ops.User(ctx)
			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)
			}
			// 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)
				}
			}
			if err := d.Owners.SetOwner(in.Repo, target); err != nil {
				return repoClaimOut{}, err
			}
			// A repository already under a team keeps appearing there:
			// the deed and the appearance are separate, and claiming
			// one must not silently move the other.
			ns, err := d.Owners.NamespaceOf(in.Repo)
			if err != nil {
				return repoClaimOut{}, err
			}
			if ns == "" {
				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, and that is a KNOWN GAP rather than
// a decision. The ownership model says the operator administers the
// forge and not the repositories under it, but enforcing that here
// would be the first place it is enforced anywhere, while the operator
// still passes every other capability gate by holding the setup letter.
// Closing it is a separate change that has to close it everywhere at
// once, or it is a rule with one door locked and the rest open.
func (d Deps) refuseUnlessTheirs(ctx context.Context, repo, verb string) error {
	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 || d.isSiteAdmin(who) {
		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)
}