Skip to content
quarry Artifact [8a15c0889f]
Login

Artifact [8a15c0889f]

Artifact 8a15c0889fc4d719379d69a6168e3c2adcf85a72b106d6d9ecfb3a8a6d9813c7:


// token.go is RFC 0021's credential table: named, scoped, expiring
// tokens, any number per account, each revocable on its own.
//
// It replaces sync_token, which held one push credential per account
// and replaced it on every mint, so two sessions sharing an account
// broke each other's pushes whenever either minted. A token here is
// one thing a machine holds; minting adds a row and revoking ends one.
//
// The value is qat_<id>_<secret>. The prefix lets a secret scanner
// find one in a log; the id finds the row without trying every token.
// A token with sync scope is sealed rather than hashed, for the reason
// synctoken.go gives: the login card proves knowledge of the §6.3
// secret, and the server can only check that by deriving it from the
// token it holds.
package oltp

import (
	"crypto/rand"
	"crypto/subtle"
	"database/sql"
	"errors"
	"fmt"
	"slices"
	"sort"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/xfer"
)

const tokenSchema = `
CREATE TABLE IF NOT EXISTS token (
	id          TEXT PRIMARY KEY,
	account     TEXT NOT NULL DEFAULT '',
	run         TEXT NOT NULL DEFAULT '',
	name        TEXT NOT NULL,
	verifier    TEXT NOT NULL, -- sha256 hex, or the sealed value when sealed = 1
	sealed      INTEGER NOT NULL,
	sync_secret TEXT NOT NULL DEFAULT '',
	scope       TEXT NOT NULL, -- words, comma-separated: sync,api,web
	repos       TEXT NOT NULL DEFAULT '', -- '' = every repository; else comma-separated names
	ceiling     TEXT NOT NULL DEFAULT '', -- '' = no ceiling; else a cap string
	created     TEXT NOT NULL,
	expires     TEXT NOT NULL,
	last_used   TEXT NOT NULL DEFAULT '',
	revoked     TEXT NOT NULL DEFAULT '',
	migrated    INTEGER NOT NULL DEFAULT 0 -- 1: copied from sync_token, whose row it mirrors
);
CREATE INDEX IF NOT EXISTS token_by_account ON token (account);
CREATE UNIQUE INDEX IF NOT EXISTS token_by_verifier ON token (account, verifier);
CREATE INDEX IF NOT EXISTS token_by_run ON token (run);`

// The scope words. Words rather than letters, which would collide with
// the capability letters.
const (
	ScopeSync = "sync"
	ScopeAPI  = "api"
	ScopeWeb  = "web"
)

const (
	tokenPrefix = "qat_"
	// TokensPerAccount bounds the live tokens one account holds. It is
	// what bounds the login card's loop over sync tokens (Secrets), and
	// a machine reaching it is minting per session instead of per role.
	TokensPerAccount = 25
	TokenDefaultLife = 90 * 24 * time.Hour
	TokenMaxLife     = 365 * 24 * time.Hour
	// lastUsedEvery bounds the last_used write, as sessionSeenEvery
	// bounds a session's last_seen.
	lastUsedEvery = 5 * time.Minute
)

// TokenSpec is what a mint asks for. A zero Expires means the default
// life.
type TokenSpec struct {
	Name    string
	Scopes  []string
	Repos   []string
	Ceiling string
	Expires time.Time
}

// Token is a row, never its value.
type Token struct {
	ID      string `json:"id"`
	Account string `json:"account"`
	// Run is the CI run a run token was minted for; "" for an
	// account's token, whose Account is then set.
	Run      string   `json:"run,omitempty"`
	Name     string   `json:"name"`
	Scopes   []string `json:"scopes"`
	Repos    []string `json:"repos"`
	Ceiling  string   `json:"ceiling"`
	Sealed   bool     `json:"-"`
	Created  string   `json:"created"`
	Expires  string   `json:"expires"`
	LastUsed string   `json:"last_used"`
	Revoked  string   `json:"revoked"`
	verifier string
}

// Has reports whether the token carries scope.
func (t Token) Has(scope string) bool { return slices.Contains(t.Scopes, scope) }

// Live is neither revoked nor expired at now.
func (t Token) Live(now time.Time) bool {
	if t.Revoked != "" {
		return false
	}
	exp, err := time.Parse(time.RFC3339, t.Expires)
	return err == nil && now.Before(exp)
}

// Reaches reports whether the token may act on repo. An empty list is
// every repository; a name in the list grants nothing, it narrows.
//
// A token with a list does not reach forge scope (repo ""): a list of
// repositories would mean nothing if the repository-less acts stayed
// open to it. The one exception, reading its own identity, is the
// API router's (ops.TokenAuth.Limited).
func (t Token) Reaches(repo string) bool {
	return len(t.Repos) == 0 || (repo != "" && slices.Contains(t.Repos, repo))
}

// LooksLikeToken says whether a presented password has a token's
// shape. A password without it takes the path it always took.
func LooksLikeToken(s string) bool {
	if _, _, ok := splitRunToken(s); ok {
		return true
	}
	id, secret, ok := splitToken(s)
	return ok && id != "" && secret != ""
}

func splitToken(s string) (id, secret string, ok bool) {
	rest, ok := strings.CutPrefix(s, tokenPrefix)
	if !ok {
		return "", "", false
	}
	return strings.Cut(rest, "_")
}

// newTokenID is a short public id: 50 bits in the sync token alphabet,
// enough that ids do not collide in one forge's lifetime.
func newTokenID() (string, error) {
	raw := make([]byte, 7)
	if _, err := rand.Read(raw); err != nil {
		return "", err
	}
	return syncTokenAlphabet.EncodeToString(raw)[:10], nil
}

func newTokenSecret() (string, error) {
	raw := make([]byte, 20)
	if _, err := rand.Read(raw); err != nil {
		return "", err
	}
	return syncTokenAlphabet.EncodeToString(raw), nil
}

// MintToken adds a token to account and returns its value the ONLY
// time it is readable.
func (d *DB) MintToken(account string, spec TokenSpec) (string, Token, error) {
	if account == "" {
		return "", Token{}, errors.New("oltp: empty account name")
	}
	name := strings.TrimSpace(spec.Name)
	if name == "" {
		return "", Token{}, Refusal{"a token needs a name"}
	}
	if len(spec.Scopes) == 0 {
		return "", Token{}, Refusal{"a token needs a scope: sync, api or web"}
	}
	for _, s := range spec.Scopes {
		if s != ScopeSync && s != ScopeAPI && s != ScopeWeb {
			return "", Token{}, Refusal{fmt.Sprintf("unknown token scope %q: use sync, api or web", s)}
		}
	}
	now := time.Now().UTC()
	expires := spec.Expires
	if expires.IsZero() {
		expires = now.Add(TokenDefaultLife)
	}
	if !expires.After(now) || expires.Sub(now) > TokenMaxLife+time.Minute {
		return "", Token{}, Refusal{"a token expires within a year"}
	}
	if strings.ContainsRune(spec.Ceiling, 's') {
		return "", Token{}, Refusal{"a token never carries s"}
	}
	live, err := d.liveTokenCount(account, now)
	if err != nil {
		return "", Token{}, err
	}
	if live >= TokensPerAccount {
		return "", Token{}, Refusal{fmt.Sprintf("%s already holds %d live tokens; revoke one first", account, TokensPerAccount)}
	}
	id, err := newTokenID()
	if err != nil {
		return "", Token{}, err
	}
	secret, err := newTokenSecret()
	if err != nil {
		return "", Token{}, err
	}
	value := tokenPrefix + id + "_" + secret
	tok := Token{ID: id, Account: account, Name: name, Scopes: dedupe(spec.Scopes), Repos: dedupe(spec.Repos),
		Ceiling: spec.Ceiling, Created: now.Format(time.RFC3339), Expires: expires.UTC().Format(time.RFC3339)}
	verifier := hashToken(value)
	if tok.Has(ScopeSync) {
		tok.Sealed = true
		if verifier, err = d.seal.seal(value); err != nil {
			return "", Token{}, err
		}
	}
	if _, err := d.w.Exec(`INSERT INTO token (id, account, name, verifier, sealed, scope, repos, ceiling, created, expires)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		tok.ID, account, tok.Name, verifier, boolInt(tok.Sealed), strings.Join(tok.Scopes, ","),
		strings.Join(tok.Repos, ","), tok.Ceiling, tok.Created, tok.Expires); err != nil {
		return "", Token{}, err
	}
	return value, tok, nil
}

func dedupe(in []string) []string {
	var out []string
	for _, s := range in {
		if s = strings.TrimSpace(s); s != "" && !slices.Contains(out, s) {
			out = append(out, s)
		}
	}
	return out
}

func (d *DB) liveTokenCount(account string, now time.Time) (int, error) {
	toks, err := d.Tokens(account)
	if err != nil {
		return 0, err
	}
	n := 0
	for _, t := range toks {
		if t.Live(now) {
			n++
		}
	}
	return n, nil
}

const tokenColumns = `id, account, run, name, verifier, sealed, scope, repos, ceiling, created, expires, last_used, revoked`

func scanToken(row interface{ Scan(...any) error }) (Token, error) {
	var t Token
	var sealed int
	var scope, repos string
	if err := row.Scan(&t.ID, &t.Account, &t.Run, &t.Name, &t.verifier, &sealed, &scope, &repos, &t.Ceiling,
		&t.Created, &t.Expires, &t.LastUsed, &t.Revoked); err != nil {
		return Token{}, err
	}
	t.Sealed = sealed == 1
	t.Scopes = dedupe(strings.Split(scope, ","))
	t.Repos = dedupe(strings.Split(repos, ","))
	return t, nil
}

// Tokens lists every row of account's, revoked and expired included,
// newest first. Never a value.
func (d *DB) Tokens(account string) ([]Token, error) {
	rows, err := d.db.Query(`SELECT `+tokenColumns+` FROM token WHERE account = ? ORDER BY created DESC, id`, account)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Token
	for rows.Next() {
		t, err := scanToken(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

// RevokeToken ends one of account's tokens. The row stays, marked, for
// the list and the audit. Nothing caches a token, so every door refuses
// it from the next request.
//
// A token copied from sync_token takes its old row with it, so that a
// rollback to a build that reads sync_token cannot bring it back.
func (d *DB) RevokeToken(account, id string) error {
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	var verifier string
	var migrated int
	if err := tx.QueryRow(`SELECT verifier, migrated FROM token WHERE id = ? AND account = ? AND revoked = ''`,
		id, account).Scan(&verifier, &migrated); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return fmt.Errorf("oltp: %s holds no live token %q: %w", account, id, sql.ErrNoRows)
		}
		return err
	}
	if _, err := tx.Exec(`UPDATE token SET revoked = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id); err != nil {
		return err
	}
	if migrated == 1 && syncTokenTableIn(tx) {
		if _, err := tx.Exec(`DELETE FROM sync_token WHERE user = ? AND sealed = ?`, account, verifier); err != nil {
			return err
		}
	}
	return tx.Commit()
}

// syncTokenTableIn reports whether the pre-RFC 0021 table is present.
func syncTokenTableIn(q interface {
	QueryRow(string, ...any) *sql.Row
}) bool {
	var n int
	err := q.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_token'`).Scan(&n)
	return err == nil && n > 0
}

// ResolveToken is the one resolver behind every door: it answers the
// live token of account's that value is, provided it carries scope.
//
// A value with a token's shape is found by its id and compared by hash,
// or unsealed and compared in constant time. A value without the shape
// is tried, for the sync door only, against the account's sealed sync
// tokens: the push credentials minted before tokens had ids are still
// the values their holders have, and the migration kept them as they
// were.
func (d *DB) ResolveToken(account, value, scope string) (Token, bool) {
	if account == "" || value == "" {
		return Token{}, false
	}
	if reservedRunName(account) {
		return d.resolveRunToken(account, value, scope)
	}
	now := time.Now()
	if id, _, ok := splitToken(value); ok {
		t, err := scanToken(d.db.QueryRow(`SELECT `+tokenColumns+` FROM token WHERE id = ?`, id))
		if err != nil {
			// Same work as a real check, so an unknown id is not told
			// apart from a wrong secret by the time it takes.
			subtle.ConstantTimeCompare([]byte(hashToken(value)), []byte(decoyTokenHash))
			return Token{}, false
		}
		if !d.tokenMatches(t, value) || t.Account != account || !t.Has(scope) || !t.Live(now) || !d.mayUseTokens(account) {
			return Token{}, false
		}
		d.touchToken(t, now)
		return t, true
	}
	if scope != ScopeSync || !d.mayUseTokens(account) {
		return Token{}, false
	}
	for _, t := range d.liveSyncTokens(account, now) {
		if d.tokenMatches(t, value) {
			d.touchToken(t, now)
			return t, true
		}
	}
	return Token{}, false
}

// mayUseTokens is the account behind a token still standing: a disabled
// or deleted account's tokens are refused as its password is.
func (d *DB) mayUseTokens(account string) bool {
	acct, ok := d.AccountByName(account)
	return ok && acct.Disabled == ""
}

// LiveToken is account's live token with id, for a door that has
// already proved the holder some other way: the xfer front, whose login
// card verified against the secret this token derives.
func (d *DB) LiveToken(account, id string) (Token, bool) {
	if reservedRunName(account) {
		t, ok := d.runToken(account)
		if !ok || t.ID != id || !t.Live(time.Now()) {
			return Token{}, false
		}
		return t, true
	}
	t, err := scanToken(d.db.QueryRow(`SELECT `+tokenColumns+` FROM token WHERE id = ? AND account = ?`, id, account))
	if err != nil || !t.Live(time.Now()) || !d.mayUseTokens(account) {
		return Token{}, false
	}
	return t, true
}

// decoyTokenHash is compared against when no row answers, for timing.
var decoyTokenHash = hashToken("qat_decoy_decoy")

func (d *DB) tokenMatches(t Token, value string) bool {
	if !t.Sealed {
		return subtle.ConstantTimeCompare([]byte(t.verifier), []byte(hashToken(value))) == 1
	}
	plain, err := d.seal.open(t.verifier)
	if err != nil {
		return false
	}
	return subtle.ConstantTimeCompare([]byte(plain), []byte(value)) == 1
}

// touchToken records use, at most once per lastUsedEvery. Best effort:
// a request is never refused because its bookkeeping failed.
func (d *DB) touchToken(t Token, now time.Time) {
	if last, err := time.Parse(time.RFC3339, t.LastUsed); err == nil && now.Sub(last) < lastUsedEvery {
		return
	}
	d.w.Exec(`UPDATE token SET last_used = ? WHERE id = ?`, now.UTC().Format(time.RFC3339), t.ID)
}

// liveSyncTokens is the account's live sealed sync tokens, bounded by
// the per-account cap.
func (d *DB) liveSyncTokens(account string, now time.Time) []Token {
	// Expiry is in the WHERE, not after the LIMIT: expired rows that
	// nobody revoked must not fill the window and hide live ones.
	// RFC3339 in UTC compares as text.
	rows, err := d.db.Query(`SELECT `+tokenColumns+` FROM token
		WHERE account = ? AND sealed = 1 AND revoked = '' AND expires > ? ORDER BY created, id LIMIT ?`,
		account, now.UTC().Format(time.RFC3339), TokensPerAccount)
	if err != nil {
		return nil
	}
	defer rows.Close()
	var out []Token
	for rows.Next() {
		t, err := scanToken(rows)
		if err != nil {
			return nil
		}
		if t.Has(ScopeSync) && t.Live(now) {
			out = append(out, t)
		}
	}
	return out
}

// TokenCaps is the letters token t resolves to on repo (RFC 0021, The
// letters a token resolves to): the public set, unioned with the
// account's effective set cut to the ceiling. A repository the token
// does not name is reached as the public reaches it. s never flows.
//
// Nothing is stored: it runs on every call, so a letter the account
// loses is gone from every token at once.
func (d *DB) TokenCaps(t Token, repo string) CapSet {
	if t.Run != "" {
		return d.runTokenCaps(t, repo)
	}
	public := CapSet(categoryFor(d.db, repo, "nobody"))
	if !t.Reaches(repo) {
		return public
	}
	eff := d.EffectiveCaps(t.Account, repo)
	if t.Ceiling != "" {
		eff = capIntersect(eff, d.expandCeiling(repo, t.Ceiling))
	}
	return CapSet(strings.ReplaceAll(capUnion(string(public), string(eff)), "s", ""))
}

// expandCeiling resolves a ceiling's category letters the way
// effectiveIn resolves an account's, so a ceiling of u means what u
// means on that repository.
func (d *DB) expandCeiling(repo, ceiling string) CapSet {
	c := noSetup(ceiling)
	if strings.ContainsRune(c, 'u') {
		c = capUnion(c, categoryFor(d.db, repo, "reader"))
	}
	if strings.ContainsRune(c, 'v') {
		c = capUnion(c, categoryFor(d.db, repo, "developer"))
	}
	return CapSet(c)
}

// capLetters is every letter a set can answer for.
const capLetters = "abcdefghijklmnopqrtuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"

// capIntersect is the letters both sets answer for, spelled out one by
// one: the umbrella a and the implies table make a set's string a poor
// guide to what it holds, so the intersection asks HasAny per letter.
// a survives only when both hold it, and then every letter it implies
// is held by both anyway.
func capIntersect(a, b CapSet) CapSet {
	var out strings.Builder
	for _, l := range capLetters {
		if a.HasAny(string(l)) && b.HasAny(string(l)) {
			out.WriteRune(l)
		}
	}
	return CapSet(out.String())
}

// Secrets is every credential a login card for user on repo may be
// signed with, in the order they are tried.
//
// A hand-issued sync_user row comes first, as it always has: somebody
// is relying on one. Then each live sync token that reaches repo and
// resolves there to i or y, its §6.3 secret derived on the spot so a
// repository made after the mint needs no re-issue. A token resolving
// to y without i signs only unversioned pushes, and says so.
func (d *DB) Secrets(repo, user string) []xfer.Credential {
	if reservedRunName(user) {
		return d.runSecrets(repo, user)
	}
	// A disabled account signs nothing, hand-issued rows included. A
	// sync_user row with no account behind it (a forge without the
	// accounts plane) still answers, as it always has.
	if acct, ok := d.AccountByName(user); ok && acct.Disabled != "" {
		return nil
	}
	var out []xfer.Credential
	var s string
	if err := d.db.QueryRow(`SELECT secret FROM sync_user WHERE repo = ? AND user = ?`,
		repo, user).Scan(&s); err == nil {
		out = append(out, xfer.Credential{Secret: s, CheckIn: true, Publish: true})
	}
	if d.SyncProjectCode == nil || user == "" {
		return out
	}
	toks := d.liveSyncTokens(user, time.Now())
	if len(toks) == 0 {
		return out
	}
	code, ok := d.SyncProjectCode(repo)
	if !ok || code == "" {
		return out
	}
	for _, t := range toks {
		caps := d.TokenCaps(t, repo)
		checkIn, publish := caps.HasAny("i"), caps.HasAny("y")
		if !t.Reaches(repo) || (!checkIn && !publish) {
			continue
		}
		value, err := d.seal.open(t.verifier)
		if err != nil {
			continue
		}
		out = append(out, xfer.Credential{Secret: syncSecret(code, user, value), Token: t.ID, CheckIn: checkIn, Publish: publish})
	}
	return out
}

// LegacySyncUsers lists the repositories where account holds a
// hand-issued sync_user row, for the Push credential list.
func (d *DB) LegacySyncUsers(account string) ([]string, error) {
	rows, err := d.db.Query(`SELECT repo FROM sync_user WHERE user = ? ORDER BY repo`, account)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []string
	for rows.Next() {
		var r string
		if err := rows.Scan(&r); err != nil {
			return nil, err
		}
		out = append(out, r)
	}
	return out, rows.Err()
}

// RevokeLegacySyncUser drops one hand-issued row of account's.
func (d *DB) RevokeLegacySyncUser(account, repo string) error {
	res, err := d.w.Exec(`DELETE FROM sync_user WHERE user = ? AND repo = ?`, account, repo)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return fmt.Errorf("oltp: %s holds no legacy push credential on %q: %w", account, repo, sql.ErrNoRows)
	}
	return nil
}

// runPrefix is reserved for run tokens' logins (RFC 0021, While the run
// lives): no account or team may take it, so the resolver can tell a
// run from a person by the name alone.
const runPrefix = "run-"

func reservedRunName(name string) bool {
	return strings.HasPrefix(strings.ToLower(name), runPrefix)
}

func runNameRefusal(name string) error {
	return Refusal{fmt.Sprintf("%q: names starting with %q are reserved for workflow runs", name, runPrefix)}
}

// ReservedRunNames lists the accounts and teams that took a run- name
// before it was reserved. They are reported, never renamed.
func (d *DB) ReservedRunNames() []string {
	var out []string
	for _, q := range []string{
		`SELECT name FROM account WHERE lower(name) LIKE 'run-%'`,
		`SELECT name FROM team WHERE lower(name) LIKE 'run-%'`,
	} {
		rows, err := d.db.Query(q)
		if err != nil {
			continue
		}
		var names []string
		for rows.Next() {
			var n string
			if rows.Scan(&n) == nil {
				names = append(names, n)
			}
		}
		rows.Close()
		sort.Strings(names)
		out = append(out, names...)
	}
	return out
}

// migrateSyncTokens copies every sync_token row into a token row named
// push: sync scope, every repository, no ceiling, sealed as it was,
// created as it was, expiring a year from the copy so a credential
// minted before expiry existed is not the one that never expires.
//
// sync_token is kept, not dropped: OLTP migrations are additive so an
// older build rolled back onto this database (deploy.sh --rollback, RFC
// 0020) still finds the push credentials it knows. It can be dropped
// once no release older than this one can be rolled back to. Until then
// the two are kept in step from this side, on every open, in one
// transaction:
//
//   - a copy whose sync_token row changed or vanished (an older build,
//     rolled back to, minted again and replaced it) is retired, since
//     the value it holds is one that build already retired;
//   - a row not yet copied is copied, once: (account, verifier) is
//     unique, and a revoked copy still counts as copied.
//
// Revoking a copy deletes its sync_token row (RevokeToken), so a
// rollback cannot revive it either.
func (d *DB) migrateSyncTokens() error {
	if !syncTokenTableIn(d.w) {
		return nil
	}
	// d.w's transactions are BEGIN IMMEDIATE (writerDSN), so a second
	// process opening at the same moment waits here for this one to
	// commit and then finds every row copied; OR IGNORE keeps a copy
	// that raced in some other way from failing the open.
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	now := time.Now().UTC()
	if _, err := tx.Exec(`UPDATE token SET revoked = ? WHERE migrated = 1 AND revoked = ''
		AND NOT EXISTS (SELECT 1 FROM sync_token s WHERE s.user = token.account AND s.sealed = token.verifier)`,
		now.Format(time.RFC3339)); err != nil {
		return err
	}
	type old struct{ user, sealed, created string }
	rows, err := tx.Query(`SELECT s.user, s.sealed, s.created FROM sync_token s
		WHERE NOT EXISTS (SELECT 1 FROM token t WHERE t.account = s.user AND t.verifier = s.sealed)`)
	if err != nil {
		return err
	}
	var olds []old
	for rows.Next() {
		var o old
		if err := rows.Scan(&o.user, &o.sealed, &o.created); err != nil {
			rows.Close()
			return err
		}
		olds = append(olds, o)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return err
	}
	expires := now.Add(TokenMaxLife).Format(time.RFC3339)
	for _, o := range olds {
		id, err := newTokenID()
		if err != nil {
			return err
		}
		if _, err := tx.Exec(`INSERT OR IGNORE INTO token (id, account, name, verifier, sealed, scope, created, expires, migrated)
			VALUES (?, ?, 'push', ?, 1, ?, ?, ?, 1)`, id, o.user, o.sealed, ScopeSync, o.created, expires); err != nil {
			return err
		}
	}
	return tx.Commit()
}