Skip to content
quarry Artifact [7c1780ba6a]
Login

Artifact [7c1780ba6a]

Artifact 7c1780ba6a1ee0580cc252c4e8f181d81c721e9b583c907d60cdcf5a8889d20c:


package api

import (
	"context"
	"errors"
	"fmt"
	"slices"
	"strings"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// takedown.go is RFC 0023's operator acts: disable a repository on a
// notice, put one on legal hold, and end either. A site admin's, at the
// forge, since a site admin holds no letters on other people's
// repositories (#318). A site token may disable and restore, because a
// host complaint needs an answer in minutes; placing or lifting a hold
// is a judgment about evidence and stays with a person.

// TakedownStore is the takedown table (oltp/takedown.go).
type TakedownStore interface {
	TakedownOf(repo string) (oltp.Takedown, bool, error)
	SetTakedown(oltp.Takedown) error
	EndTakedown(repo, strength, actor, via, reason string) error
	TakedownLog(repo string, limit int) ([]oltp.TakedownAct, error)
	Takedowns() (map[string]oltp.Takedown, error)
}

type takedownIn struct {
	Repo     string `json:"repo" validate:"required,max=200"`
	Category string `json:"category" validate:"required,max=40"`
	Notice   string `json:"notice" validate:"max=200"`
	Reason   string `json:"reason" validate:"max=4000"`
}

type takedownEndIn struct {
	Repo   string `json:"repo" validate:"required,max=200"`
	Reason string `json:"reason" validate:"max=4000"`
}

type takedownOut struct {
	Repo     string `json:"repo"`
	Strength string `json:"strength,omitempty"`
}

type takedownLogIn struct {
	Repo  string `json:"repo" validate:"max=200"`
	Limit int    `json:"limit"`
}

type takedownActOut struct {
	Repo     string `json:"repo"`
	Owner    string `json:"owner"`
	Act      string `json:"act"`
	Strength string `json:"strength"`
	Category string `json:"category"`
	Notice   string `json:"notice"`
	Reason   string `json:"reason"`
	Actor    string `json:"actor"`
	Via      string `json:"via"`
	At       string `json:"at"`
}

type takedownCurOut struct {
	Repo     string `json:"repo"`
	Strength string `json:"strength"`
	Category string `json:"category"`
	Notice   string `json:"notice"`
	At       string `json:"at"`
}

type takedownLogOut struct {
	Acts []takedownActOut `json:"acts"`
	// Current is every takedown in force, for the fleet's rows.
	Current []takedownCurOut `json:"current"`
}

// takedownReach is the line every takedown act holds before it writes:
// a token reaches only a repository it can read, so a private one it
// holds no grant on answers as a name nobody serves; and a forge
// agent's s stops at a deed a site admin or another forge agent holds.
func (d Deps) takedownReach(ctx context.Context, repo string) error {
	if d.Takedowns == nil {
		return fmt.Errorf("this deployment keeps no takedowns: %w", ops.ErrUnavailable)
	}
	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 {
		owner, err := d.Owners.OwnerOf(repo)
		if err != nil {
			return err
		}
		if owner != "" {
			if err := d.refuseForgeAgentOn(ctx, owner); err != nil {
				return err
			}
		}
	}
	return nil
}

// refuseHeldRepo is the router's takedown gate for an op that names its
// repository in the body rather than the path, so the router never sees
// it: moving a repository between teams, accepting an offer, granting an
// agent. Silent for a repository the caller cannot read, which the op's
// own not-found answers.
func (d Deps) refuseHeldRepo(ctx context.Context, repo string) error {
	if d.Held == nil || repo == "" || (d.CanRead != nil && !d.CanRead(ctx, repo)) {
		return nil
	}
	if why := d.Held(ctx, repo); why != "" {
		return fmt.Errorf("%s: %w", why, ops.ErrForbidden)
	}
	return nil
}

func refuseTokenHold(ctx context.Context) error {
	if _, byToken := ops.TokenOf(ctx); byToken {
		return fmt.Errorf("a legal hold is a judgment about evidence and reporting; placing or lifting one needs a person signed in, not a token: %w", ops.ErrForbidden)
	}
	return nil
}

func takedownErr(err error) error {
	if errors.Is(err, oltp.ErrTakedownState) {
		return fmt.Errorf("%v: %w", err, ops.ErrInvalid)
	}
	return err
}

func (d Deps) takedownChanged() {
	if d.TakedownsChanged != nil {
		d.TakedownsChanged()
	}
}

func (d Deps) registerTakedowns(r *ops.Registry) error {
	set := func(name, strength, route, doc string) error {
		return ops.Register(r, ops.Op[takedownIn, takedownOut]{
			Name: name, Caps: "s", Scope: ops.ScopeFleet, Kind: ops.Command, Auth: ops.Admin,
			Route: route, Doc: doc,
			Run: func(ctx context.Context, in takedownIn) (takedownOut, error) {
				if strength == oltp.LegalHold {
					if err := refuseTokenHold(ctx); err != nil {
						return takedownOut{}, err
					}
				}
				if err := d.takedownReach(ctx, in.Repo); err != nil {
					return takedownOut{}, err
				}
				if err := d.Takedowns.SetTakedown(oltp.Takedown{Repo: in.Repo, Strength: strength,
					Category: in.Category, Notice: in.Notice, Reason: in.Reason,
					Actor: ops.User(ctx), Via: ops.Via(ctx)}); err != nil {
					return takedownOut{}, takedownErr(err)
				}
				d.takedownChanged()
				return takedownOut{Repo: in.Repo, Strength: strength}, nil
			},
		})
	}
	end := func(name, strength, route, doc string) error {
		return ops.Register(r, ops.Op[takedownEndIn, takedownOut]{
			Name: name, Caps: "s", Scope: ops.ScopeFleet, Kind: ops.Command, Auth: ops.Admin,
			Route: route, Doc: doc,
			Run: func(ctx context.Context, in takedownEndIn) (takedownOut, error) {
				if strength == oltp.LegalHold {
					if err := refuseTokenHold(ctx); err != nil {
						return takedownOut{}, err
					}
				}
				if err := d.takedownReach(ctx, in.Repo); err != nil {
					return takedownOut{}, err
				}
				if err := d.Takedowns.EndTakedown(in.Repo, strength, ops.User(ctx), ops.Via(ctx), in.Reason); err != nil {
					return takedownOut{}, takedownErr(err)
				}
				d.takedownChanged()
				return takedownOut{Repo: in.Repo}, nil
			},
		})
	}
	if err := set("repo.disable", oltp.Disabled, "POST /api/v1/repos/disable",
		"Disable a repository on a notice (RFC 0023): every surface answers 451 to everybody but its own admins, "+
			"who keep access to remove what was reported. The store and settings are kept; delete and stop serving "+
			"are refused until it is restored. `category` is one of copyright, host, unlawful, terms, terrorism, "+
			"child-abuse, preservation, law-enforcement."); err != nil {
		return err
	}
	if err := set("repo.hold", oltp.LegalHold, "POST /api/v1/repos/hold",
		"Put a repository on legal hold (RFC 0023): 451 to everybody, its owner included, and no writes, so what "+
			"is held stays as it was. May follow a disable. A person's act; a token is refused."); err != nil {
		return err
	}
	if err := end("repo.restore", oltp.Disabled, "POST /api/v1/repos/restore",
		"Restore a disabled repository: every surface answers as before. Does not lift a legal hold."); err != nil {
		return err
	}
	if err := end("repo.lift", oltp.LegalHold, "POST /api/v1/repos/lift",
		"Lift a legal hold. A person's act; a token is refused."); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[takedownLogIn, takedownLogOut]{
		Name: "repo.takedowns", Caps: "s", Scope: ops.ScopeFleet, Kind: ops.Query, Auth: ops.Admin,
		Route: "GET /api/v1/fleet/takedowns",
		Doc:   "The takedowns in force, and the takedown log newest first: every disable, hold, restore and lift, for one repository or the whole forge.",
		Run: func(ctx context.Context, in takedownLogIn) (takedownLogOut, error) {
			if d.Takedowns == nil {
				return takedownLogOut{}, fmt.Errorf("this deployment keeps no takedowns: %w", ops.ErrUnavailable)
			}
			limit := in.Limit
			if limit <= 0 || limit > 500 {
				limit = 100
			}
			acts, err := d.Takedowns.TakedownLog(in.Repo, limit)
			if err != nil {
				return takedownLogOut{}, err
			}
			cur, err := d.Takedowns.Takedowns()
			if err != nil {
				return takedownLogOut{}, err
			}
			out := takedownLogOut{Acts: []takedownActOut{}, Current: []takedownCurOut{}}
			for _, t := range cur {
				if (in.Repo == "" || t.Repo == in.Repo) && d.siteReaches(ctx, t.Repo) {
					out.Current = append(out.Current, takedownCurOut{Repo: t.Repo, Strength: t.Strength,
						Category: t.Category, Notice: t.Notice, At: t.At})
				}
			}
			slices.SortFunc(out.Current, func(a, b takedownCurOut) int { return strings.Compare(a.Repo, b.Repo) })
			for _, a := range acts {
				// A token sees only the rows of repositories it can read,
				// the private line every other site-token read holds.
				if !d.siteReaches(ctx, a.Repo) {
					continue
				}
				out.Acts = append(out.Acts, takedownActOut{Repo: a.Repo, Owner: a.Owner, Act: a.Act,
					Strength: a.Strength, Category: a.Category, Notice: a.Notice, Reason: a.Reason,
					Actor: a.Actor, Via: a.Via, At: a.At})
			}
			return out, nil
		},
	})
}