Skip to content
quarry Artifact [d6a821c47a]
Login

Artifact [d6a821c47a]

Artifact d6a821c47aeb15a1da346cbce9168f15bc155db96248298734d02e6bc77fe366:


package oltp

import (
	"database/sql"
	"errors"
	"fmt"
	"strings"
)

// Refusal is a save the guard would not make, with the reason in the
// words the page shows. It is the error type callers match on.
type Refusal struct{ Reason string }

func (r Refusal) Error() string { return r.Reason }

// GuardCaps judges a change to one account's string on a repository
// (RFC 0008, bulletproofing): a save that would remove the actor's own
// admin here is refused, and so is a save that would leave the
// repository with no admin. A site admin is held to both like anyone:
// the flag reaches the forge, not the repository (#318), so an admin
// they removed is one nobody could put back from inside.
//
// This is the judgement alone, for a page previewing a save. A save
// goes through SetCapsGuarded, which makes the same judgement under
// the lock it writes with; judging here and writing elsewhere is the
// race that left repositories with no admin.
func (d *DB) GuardCaps(actor, account, repo, caps string) (warning string, err error) {
	return d.guardCaps(d.db, actor, account, repo, caps)
}

func (d *DB) guardCaps(q querier, actor, account, repo, caps string) (warning string, err error) {
	if act, ok := accountByName(q, actor); ok && act.Operator != "" {
		if err := d.agentGuard(q, act, account, repo, caps); err != nil {
			return "", err
		}
	}
	if actor == account {
		own, _ := capsOf(q, actor, repo)
		if d.effectiveIn(q, actor, repo, own).HasAny("a") && !d.effectiveIn(q, actor, repo, caps).HasAny("a") {
			return "", Refusal{fmt.Sprintf("that would remove your own admin on %s; have another admin make the change", repo)}
		}
	}
	before, after, err := d.adminCountIn(q, repo, account, caps)
	if err != nil {
		return "", err
	}
	if before > 0 && after == 0 {
		return "", Refusal{fmt.Sprintf("that would leave %s with no admin; make someone else an Admin first", repo)}
	}
	return "", nil
}

// agentGuard is the two refusals for an agent holding a (RFC 0021,
// What an agent may hold), keyed on the actor being an agent and on
// nothing else: it can neither lock its operator out nor escalate
// anyone.
//
//   - It cannot lower or remove the letters of its own operator or of
//     the repository's deed holder: no letter they hold now may be gone
//     after the write.
//   - It cannot grant anyone a letter it does not itself resolve to
//     here: an agent holding a through a developer ceiling writes
//     developer at most.
//
// Both compare EFFECTIVE sets letter by letter, as the intersection
// does, so an umbrella letter is judged by what it reaches.
//
// A forge agent is held to both, with its answerable admin in the
// operator's place for the first (RFC 0021, Forge agents: Guards), and
// the first reaches further for it: a machine never reduces a site
// admin's reach or another forge agent's, so no letter of theirs may
// be gone after its write either. Raising them is not lowering.
func (d *DB) agentGuard(q querier, agent Account, account, repo, caps string) error {
	own, _ := capsOf(q, account, repo)
	before := d.effectiveIn(q, account, repo, own)
	after := d.effectiveIn(q, account, repo, caps)
	var owner string
	q.QueryRow(`SELECT owner FROM repo WHERE name = ?`, repo).Scan(&owner)
	operator, role := agent.Operator, "its operator"
	shielded := []struct{ name, role string }{{owner, "the deed holder"}}
	if agent.Operator == forgeOperator {
		// Resolved, as the badge resolves it: an admin who no longer
		// answers (disabled, deleted, unflagged) is not shielded here
		// either, so the guard and the page say the same thing.
		operator, role = answerableIn(q, agent.Name), "the admin answerable for it"
		if target, ok := accountByName(q, account); ok && target.Name != agent.Name {
			switch {
			case target.Operator == forgeOperator:
				shielded = append(shielded, struct{ name, role string }{target.Name, "another forge agent"})
			case target.Admin && target.Operator == "":
				// Shut or open: a disabled admin's letters are not
				// lowered while they cannot see it happen. (Who ANSWERS
				// for the agent, above, resolves as the badge does; this
				// is a different question.)
				shielded = append(shielded, struct{ name, role string }{target.Name, "a site admin"})
			}
		}
	}
	shielded = append([]struct{ name, role string }{{operator, role}}, shielded...)
	for _, who := range shielded {
		if who.name == "" || who.name != account {
			continue
		}
		for _, l := range capLetters {
			if before.HasAny(string(l)) && !after.HasAny(string(l)) {
				return Forbidden{Refusal{fmt.Sprintf("%s is %s on %s; an agent cannot lower or remove their letters there", account, who.role, repo)}}
			}
		}
	}
	agentOwn, _ := capsOf(q, agent.Name, repo)
	mine := d.effectiveIn(q, agent.Name, repo, agentOwn)
	// Only what the write ADDS is judged against the agent's own set:
	// a letter the target held already, through a team or the
	// categories, is not the agent's gift.
	for _, l := range capLetters {
		if after.HasAny(string(l)) && !before.HasAny(string(l)) && !mine.HasAny(string(l)) {
			return Forbidden{Refusal{fmt.Sprintf("%s does not hold %c on %s itself; an agent cannot grant a letter it does not resolve to", agent.Name, l, repo)}}
		}
	}
	return nil
}

// adminCount counts the repository's admins — accounts whose effective
// set holds a — before and after one account's string becomes caps.
func (d *DB) adminCount(repo, account, caps string) (before, after int, err error) {
	return d.adminCountIn(d.db, repo, account, caps)
}

func (d *DB) adminCountIn(q querier, repo, account, caps string) (before, after int, err error) {
	holders, err := capHolders(q, repo)
	if err != nil {
		return 0, 0, err
	}
	for _, h := range holders {
		// A person's agent never counts: its a is its operator's, who
		// is counted in their own right, and an inheriting agent's
		// would be read against the operator's string before the change
		// (RFC 0026), letting the last admin go.
		if a, ok := accountByName(q, h.Account); ok && a.Agent() && a.Operator != forgeOperator {
			continue
		}
		if d.effectiveIn(q, h.Account, repo, h.Caps).HasAny("a") {
			before++
		}
		own := h.Caps
		if h.Account == account {
			own = caps
		}
		if d.effectiveIn(q, h.Account, repo, own).HasAny("a") {
			after++
		}
	}
	return before, after, nil
}

// IsRefusal reports whether err is the guard saying no, including
// when an op has wrapped it on the way out.
func IsRefusal(err error) bool {
	var r Refusal
	return errors.As(err, &r)
}

// Forbidden is a Refusal the caller may never make, whoever asks and
// however they phrase it: an agent's edge (agentGuard), as against a
// save that was merely wrong this time. The API answers it as
// forbidden where a plain Refusal is an invalid request. It unwraps
// to its Refusal, so IsRefusal matches it too.
type Forbidden struct{ Refusal }

func (f Forbidden) Unwrap() error { return f.Refusal }

// IsForbidden reports whether err is, or wraps, a Forbidden refusal.
func IsForbidden(err error) bool {
	var f Forbidden
	return errors.As(err, &f)
}

// RefusalReason is the guard's own words from err, without whatever an
// op wrapped around them; "" when err is not a refusal.
func RefusalReason(err error) string {
	var r Refusal
	if errors.As(err, &r) {
		return r.Reason
	}
	return ""
}

// Minimal drops every letter the others already imply, so a string
// assembled from checkboxes is stored the way a person would type it.
func Minimal(caps string) string {
	// In the order given: the presets are hand-ordered strings, and a
	// string that round-trips through the editor should come back as
	// it went in.
	full := Closure(caps)
	var out []rune
	for _, r := range capUnion(caps) {
		rest := strings.ReplaceAll(full, string(r), "")
		if !strings.ContainsRune(Closure(rest), r) {
			out = append(out, r)
		}
	}
	return string(out)
}

// Change says what a save did, in words: the role it moved between and
// the abilities it gained and lost. "" when nothing changed.
func Change(before, after string) string {
	if Closure(before) == Closure(after) {
		return ""
	}
	var parts []string
	b, a := Infer(before), Infer(after)
	switch {
	case before == "":
		parts = append(parts, "added as "+a.Role)
	case after == "":
		parts = append(parts, "removed from the repository")
	case b.Role != a.Role:
		parts = append(parts, b.Role+" → "+a.Role)
	}
	was := map[string]bool{}
	for _, s := range Decode(before) {
		was[s] = true
	}
	now := map[string]bool{}
	var gained, lost []string
	for _, s := range Decode(after) {
		now[s] = true
		if !was[s] {
			gained = append(gained, s)
		}
	}
	for _, s := range Decode(before) {
		if !now[s] {
			lost = append(lost, s)
		}
	}
	if len(gained) > 0 && after != "" {
		parts = append(parts, "gains: "+strings.Join(gained, ", "))
	}
	if len(lost) > 0 && before != "" && after != "" {
		parts = append(parts, "loses: "+strings.Join(lost, ", "))
	}
	return strings.Join(parts, "; ")
}

// SetCapsGuarded is the one way a person's string on a repository is
// changed: GuardCaps and the write as one decision. The guard counts
// admins and the write changes them; run apart, two admins demoting
// each other at once each count two, each pass, and the repository is
// left with none. Here the write lock is taken before the count, so
// the second save counts after the first has landed and is refused.
func (d *DB) SetCapsGuarded(actor, account, repo, caps string) (warning string, err error) {
	err = d.guarded(func(tx *sql.Tx) error {
		// A string for a name that is not an account would be a row that
		// looks like access and grants none. An empty one only removes.
		if noSetup(caps) != "" {
			if _, found := accountByName(tx, account); !found {
				return noSuchAccount(account)
			}
		}
		if warning, err = d.guardCaps(tx, actor, account, repo, caps); err != nil {
			return err
		}
		return handEdit(tx, account, repo, caps)
	})
	if err == nil {
		d.reconcileFollowersOn(repo)
	}
	return warning, err
}

// GrantGuarded is Grant under the same guard: a role's string, or no
// string for NoRole, written as SetCapsGuarded writes it.
func (d *DB) GrantGuarded(actor, account, repo string, role Role) (warning string, err error) {
	if role == Custom {
		return "", errors.New("oltp: custom is a reading, not a role to grant")
	}
	caps := ""
	if role != NoRole {
		caps = role.Caps()
	}
	err = d.guarded(func(tx *sql.Tx) error {
		if role != NoRole {
			if _, found := accountByName(tx, account); !found {
				return noSuchAccount(account)
			}
		}
		if warning, err = d.guardCaps(tx, actor, account, repo, caps); err != nil {
			return err
		}
		return handEdit(tx, account, repo, caps)
	})
	if err == nil {
		d.reconcileFollowersOn(repo)
	}
	return warning, err
}

// guarded runs a guard and its write as one transaction on the writer,
// which takes SQLite's write lock at BEGIN (the DSN's _txlock), so no
// other writer can change what the guard counts before the write
// lands. Everything inside fn goes through tx: a read through d.db
// would miss the transaction's own rows, and a write through d.w
// would wait forever on the one connection this transaction holds.
func (d *DB) guarded(fn func(tx *sql.Tx) error) error {
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if err := fn(tx); err != nil {
		return err
	}
	return tx.Commit()
}

// querier is the read half of a *sql.DB or a *sql.Tx, so a helper can
// read through a guard's transaction as well as the pool.
type querier interface {
	QueryRow(query string, args ...any) *sql.Row
	Query(query string, args ...any) (*sql.Rows, error)
}