package oltp
import (
"database/sql"
"errors"
"fmt"
"log"
)
// sameCaps compares two strings as fossil reads them, setup as admin.
func sameCaps(a, b string) bool {
return Closure(noSetup(a)) == Closure(noSetup(b))
}
// A fossil repository's user table, kept as a reference rather than as
// accounts. An admin syncs it from the fossil server; each login is a
// row an admin may link to a quarry account, and only a link hands out
// capabilities. Fossil logins are per repository and quarry accounts
// span the forge, so a matching name is a suggestion and never a grant.
// No password hash is stored: nobody signs in as a row.
const fossilUserSchema = `
CREATE TABLE IF NOT EXISTS fossil_user (
repo TEXT NOT NULL,
login TEXT NOT NULL,
caps TEXT NOT NULL DEFAULT '',
info TEXT NOT NULL DEFAULT '',
linked TEXT NOT NULL DEFAULT '', -- the quarry account it was linked to
synced_at TEXT NOT NULL,
PRIMARY KEY (repo, login)
);`
// FossilUser is one row of a repository's synced fossil user table.
type FossilUser struct {
Login string
Caps string
Info string
Linked string
SyncedAt string
// Suggest is true when an unlinked row's login names an existing
// quarry account — offered to the admin, never acted on.
Suggest bool
// Held is what the linked account holds here, and Pinned is true
// when that differs from Caps: an owner set it by hand, and a sync
// leaves it alone.
Held string
Pinned bool
}
// ErrNoFossilUser and ErrNoAccount are a link's two refusals.
var (
ErrNoFossilUser = errors.New("no such fossil user on this repository")
ErrNoAccount = errors.New("no such account")
)
var fossilCategories = map[string]bool{
"nobody": true, "anonymous": true, "reader": true, "developer": true,
}
// ReplaceFossilUsers records a fresh sync of repo's fossil user table.
//
// The four category rows are applied as this repository's categories —
// that is what lets a fossil repository need no further setup — and a
// category the peer did not send keeps its current string. Every other
// row replaces the list. A login that is still present keeps its link,
// and its linked account is given the fresh capabilities, because that
// is what syncing means — unless the account's string no longer equals
// what the last sync gave it, which means an owner set it by hand. A login that has gone loses its row and link;
// the account it was linked to keeps what it holds, since nothing here
// revokes access an admin has not looked at.
func (d *DB) ReplaceFossilUsers(repo string, users []FossilUser) (rows, categories int, err error) {
tx, err := d.w.Begin()
if err != nil {
return 0, 0, err
}
defer tx.Rollback()
linked := map[string]string{}
prior := map[string]string{} // caps the last sync gave each linked login
q, err := tx.Query(`SELECT login, linked, caps FROM fossil_user WHERE repo = ? AND linked != ''`, repo)
if err != nil {
return 0, 0, err
}
for q.Next() {
var login, acct, caps string
if err := q.Scan(&login, &acct, &caps); err != nil {
q.Close()
return 0, 0, err
}
linked[login] = acct
prior[login] = caps
}
q.Close()
if _, err := tx.Exec(`DELETE FROM fossil_user WHERE repo = ?`, repo); err != nil {
return 0, 0, err
}
at := now()
var cats [][2]string
for _, u := range users {
if u.Login == "" {
continue
}
if fossilCategories[u.Login] {
cats = append(cats, [2]string{u.Login, u.Caps})
continue
}
acct := linked[u.Login]
if _, err := tx.Exec(`INSERT INTO fossil_user (repo, login, caps, info, linked, synced_at)
VALUES (?, ?, ?, ?, ?, ?)`, repo, u.Login, u.Caps, u.Info, acct, at); err != nil {
return 0, 0, err
}
rows++
// An account follows upstream only while it still holds what the
// last sync gave it: a string an owner set by hand since then is
// theirs, and a sync must not downgrade it.
if acct != "" {
if cur, err := capsOf(tx, acct, repo); err != nil {
return 0, 0, err
} else if sameCaps(cur, prior[u.Login]) && !sameCaps(cur, u.Caps) {
// The guard a person's change takes, applied to the sync:
// an upstream downgrade must not leave the repository
// without an admin. The admin stays, and the log says so.
before, after, err := d.adminCountIn(tx, repo, acct, noSetup(u.Caps))
if err != nil {
return 0, 0, err
}
if before > 0 && after == 0 {
log.Printf("oltp: fossil sync of %s kept %s as admin: upstream %s now holds %q, and nobody else administers it", repo, acct, u.Login, u.Caps)
} else if err := setCaps(tx, acct, repo, u.Caps); err != nil {
return 0, 0, err
}
}
}
}
if err := tx.Commit(); err != nil {
return 0, 0, err
}
for _, c := range cats {
if err := d.SetCategoryCaps(repo, c[0], c[1]); err != nil {
return rows, categories, err
}
categories++
}
return rows, categories, nil
}
// FossilUsers lists repo's synced fossil users by login.
func (d *DB) FossilUsers(repo string) ([]FossilUser, error) {
q, err := d.db.Query(`SELECT f.login, f.caps, f.info, f.linked, f.synced_at,
f.linked = '' AND EXISTS (SELECT 1 FROM account a WHERE a.name = f.login),
COALESCE(c.caps, '')
FROM fossil_user f LEFT JOIN repo_caps c ON c.account = f.linked AND c.repo = f.repo
WHERE f.repo = ? ORDER BY f.login`, repo)
if err != nil {
return nil, err
}
defer q.Close()
var out []FossilUser
for q.Next() {
var u FossilUser
if err := q.Scan(&u.Login, &u.Caps, &u.Info, &u.Linked, &u.SyncedAt, &u.Suggest, &u.Held); err != nil {
return nil, err
}
u.Held = noSetup(u.Held)
u.Pinned = u.Linked != "" && !sameCaps(u.Held, u.Caps)
out = append(out, u)
}
return out, q.Err()
}
// LinkFossilUser links a fossil login to a quarry account, which then
// holds that login's capabilities on repo. An empty account unlinks,
// leaving the account's capabilities as they are.
//
// Linking replaces the account's string with the login's, so it is a
// caps write like any other and takes the same guard, for the actor,
// under the lock it writes with: linking a repository's last admin, or
// oneself, to a login without a would otherwise leave it with none.
func (d *DB) LinkFossilUser(actor, repo, login, account string) (warning string, err error) {
err = d.guarded(func(tx *sql.Tx) error {
var caps string
if err := tx.QueryRow(`SELECT caps FROM fossil_user WHERE repo = ? AND login = ?`,
repo, login).Scan(&caps); err != nil {
return fmt.Errorf("%s: %w", login, ErrNoFossilUser)
}
if account != "" {
if _, ok := accountByName(tx, account); !ok {
return fmt.Errorf("%s: %w", account, ErrNoAccount)
}
if warning, err = d.guardCaps(tx, actor, account, repo, caps); err != nil {
return err
}
}
if _, err := tx.Exec(`UPDATE fossil_user SET linked = ? WHERE repo = ? AND login = ?`,
account, repo, login); err != nil {
return err
}
if account == "" {
return nil
}
return setCaps(tx, account, repo, caps)
})
return warning, err
}