a73x

internal/server/store/evolve.go

Ref:   Size: 6.7 KiB   History

package store

import (
	"database/sql"
	"fmt"
	"log/slog"
)

// ensureColumn adds a column to an existing table if it is not already
// present. The schema const only creates tables; columns added after a
// table shipped must go through here so old databases pick them up on Open.
//
// table, column, and decl are interpolated verbatim into the ALTER TABLE
// statement (SQLite cannot bind identifiers): pass trusted compile-time
// constants only, never caller- or request-derived strings.
func ensureColumn(db *sql.DB, table, column, decl string) error {
	var n int
	err := db.QueryRow(
		`SELECT count(*) FROM pragma_table_info(?) WHERE name = ?`, table, column,
	).Scan(&n)
	if err != nil {
		return fmt.Errorf("ensure %s.%s: %w", table, column, err)
	}
	if n > 0 {
		return nil
	}
	if _, err := db.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, column, decl)); err != nil {
		return fmt.Errorf("add %s.%s: %w", table, column, err)
	}
	return nil
}

// dropTable removes a table that is no longer part of the schema, so an
// existing database stops carrying it. The schema const only creates tables;
// removing one has to go through here for the same reason adding a column does.
//
// table is interpolated verbatim (SQLite cannot bind identifiers): pass trusted
// compile-time constants only, never caller- or request-derived strings — the
// same rule ensureColumn states, and for the same reason.
func dropTable(db *sql.DB, table string) error {
	if _, err := db.Exec(fmt.Sprintf(`DROP TABLE IF EXISTS %s`, table)); err != nil {
		return fmt.Errorf("drop %s: %w", table, err)
	}
	return nil
}

// dropIndex removes an index the schema no longer declares. An index whose
// columns change is a new index under a new name plus this: SQLite's CREATE
// UNIQUE INDEX IF NOT EXISTS leaves an existing index of that name exactly as
// it was, so a redefinition under the old name would be silently ignored on
// every database that already had one.
//
// index is interpolated verbatim (SQLite cannot bind identifiers): pass trusted
// compile-time constants only, the same rule ensureColumn states.
func dropIndex(db *sql.DB, index string) error {
	if _, err := db.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS %s`, index)); err != nil {
		return fmt.Errorf("drop index %s: %w", index, err)
	}
	return nil
}

// dropColumn removes a column that is no longer part of the schema, so an
// existing database stops carrying it — and stops carrying whatever was in it.
// Idempotent: a database that never had the column, or has already dropped it,
// is left alone.
//
// SQLite has dropped columns in place since 3.35, which is why this is one
// statement rather than the rebuild-and-swap dance. That matters for more than
// brevity: vms is referenced by exposures ON DELETE CASCADE, so a rebuild that
// dropped the old table would take every exposure with it, and it would have to
// restate every column added by ensureColumn or silently lose those too.
//
// table and column are interpolated verbatim (SQLite cannot bind identifiers):
// pass trusted compile-time constants only, the same rule ensureColumn states.
func dropColumn(db *sql.DB, table, column string) error {
	var n int
	err := db.QueryRow(
		`SELECT count(*) FROM pragma_table_info(?) WHERE name = ?`, table, column,
	).Scan(&n)
	if err != nil {
		return fmt.Errorf("check %s.%s: %w", table, column, err)
	}
	if n == 0 {
		return nil
	}
	if _, err := db.Exec(fmt.Sprintf(`ALTER TABLE %s DROP COLUMN %s`, table, column)); err != nil {
		return fmt.Errorf("drop %s.%s: %w", table, column, err)
	}
	slog.Info("dropped retired column", "table", table, "column", column)
	return nil
}

// rekeyRevokedSSHCerts rebuilds revoked_ssh_certs with a PRIMARY KEY over
// (tenant, serial) instead of serial alone.
//
// The table shipped keyed on serial because revocation was enforced fleet-wide:
// one row denied a serial for everyone. Scoping enforcement to the revoking
// tenant makes that key wrong in a way that FAILS OPEN — with one row per
// serial globally, the first tenant to revoke a serial takes the row, and a
// second tenant revoking the same serial is silently dropped by the ON CONFLICT
// and its certificate keeps working. Per-tenant rows remove that possibility
// rather than making it unlikely.
//
// Idempotent: it inspects the existing key and returns immediately once the
// table is already in the new shape. Every row is carried across.
func rekeyRevokedSSHCerts(db *sql.DB) error {
	keyed, err := pkColumns(db, "revoked_ssh_certs")
	if err != nil {
		return err
	}
	if len(keyed) == 0 || (len(keyed) == 2 && keyed[0] == "tenant" && keyed[1] == "serial") {
		return nil // absent (fresh schema builds it right) or already rekeyed
	}
	// Rows a pre-tenant-column binary wrote were backfilled to 'default', and the
	// default tenant is retired: no CA resolves to it, so once enforcement is
	// scoped those rows deny nothing. Say so at the moment of the migration,
	// where an operator rolling the release will see it, rather than leaving it
	// to be discovered as a certificate that started working again.
	var orphaned int
	if err := db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs WHERE tenant='default'`).Scan(&orphaned); err == nil && orphaned > 0 {
		slog.Warn("revocations filed under the retired 'default' tenant will no longer deny anything "+
			"now that revocation is tenant-scoped; re-revoke them under the owning tenant",
			"rows", orphaned)
	}

	tx, err := db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	for _, stmt := range []string{
		`CREATE TABLE revoked_ssh_certs_new (
			tenant     TEXT NOT NULL DEFAULT 'default',
			serial     INTEGER NOT NULL,
			revoked_at DATETIME NOT NULL,
			reason     TEXT NOT NULL DEFAULT '',
			PRIMARY KEY (tenant, serial)
		)`,
		`INSERT INTO revoked_ssh_certs_new(tenant, serial, revoked_at, reason)
		   SELECT tenant, serial, revoked_at, reason FROM revoked_ssh_certs`,
		`DROP TABLE revoked_ssh_certs`,
		`ALTER TABLE revoked_ssh_certs_new RENAME TO revoked_ssh_certs`,
	} {
		if _, err := tx.Exec(stmt); err != nil {
			return fmt.Errorf("rekey revoked_ssh_certs: %w", err)
		}
	}
	return tx.Commit()
}

// pkColumns returns the table's primary-key columns in key order, or nil when
// the table does not exist. table is a compile-time constant, the same rule
// ensureColumn states.
func pkColumns(db *sql.DB, table string) ([]string, error) {
	rows, err := db.Query(`SELECT name, pk FROM pragma_table_info(?) WHERE pk > 0 ORDER BY pk`, table)
	if err != nil {
		return nil, fmt.Errorf("read primary key of %s: %w", table, err)
	}
	defer rows.Close()
	var out []string
	for rows.Next() {
		var name string
		var pk int
		if err := rows.Scan(&name, &pk); err != nil {
			return nil, err
		}
		out = append(out, name)
	}
	return out, rows.Err()
}