UI: password change enforcement + migration recovery
- Web UI admin password reset now enforces admin role server-side (was cookie-auth + CSRF only; any logged-in user could previously reset any account's password) - Added self-service password change UI at GET/PUT /profile: current_password + new_password + confirm_password; server-side equality check; lockout + Argon2id verification; revokes all other sessions on success - password_change_form.html fragment and profile.html page - Nav bar actor name now links to /profile - policy: ActionChangePassword + default rule -7 allowing human accounts to change their own password - openapi.yaml: built-in rules count updated to -7 Migration recovery: - mciasdb schema force --version N: new subcommand to clear dirty migration state without running SQL (break-glass) - schema subcommands bypass auto-migration on open so the tool stays usable when the database is dirty - Migrate(): shim no longer overrides schema_migrations when it already has an entry; duplicate-column error on the latest migration is force-cleaned and treated as success (handles columns added outside the runner) Security: - Admin role is now validated in handleAdminResetPassword before any DB access; non-admin receives 403 - handleSelfChangePassword follows identical lockout + constant-time Argon2id path as the REST self-service handler; current password required to prevent token-theft account takeover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
sqlitedriver "github.com/golang-migrate/migrate/v4/database/sqlite"
|
||||
@@ -93,19 +94,65 @@ func Migrate(database *DB) error {
|
||||
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
|
||||
|
||||
if legacyVersion > 0 {
|
||||
// Force the migrator to treat the database as already at
|
||||
// legacyVersion so Up only applies newer migrations.
|
||||
if err := m.Force(legacyVersion); err != nil {
|
||||
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err)
|
||||
// Only fast-forward from the legacy version when golang-migrate has no
|
||||
// version record of its own yet (ErrNilVersion). If schema_migrations
|
||||
// already has an entry — including a dirty entry from a previously
|
||||
// failed migration — leave it alone and let golang-migrate handle it.
|
||||
// Overriding a non-nil version would discard progress (or a dirty
|
||||
// state that needs idempotent re-application) and cause migrations to
|
||||
// be retried unnecessarily.
|
||||
_, _, versionErr := m.Version()
|
||||
if errors.Is(versionErr, migrate.ErrNilVersion) {
|
||||
if err := m.Force(legacyVersion); err != nil {
|
||||
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
// A "duplicate column name" error means the failing migration is an
|
||||
// ADD COLUMN that was already applied outside the migration runner
|
||||
// (common during development before a migration file existed).
|
||||
// If this is the last migration and its version matches LatestSchemaVersion,
|
||||
// force it clean so subsequent starts succeed.
|
||||
//
|
||||
// This is intentionally narrow: we only suppress the error when the
|
||||
// dirty version equals the latest known version, preventing accidental
|
||||
// masking of errors in intermediate migrations.
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
v, dirty, verErr := m.Version()
|
||||
if verErr == nil && dirty && int(v) == LatestSchemaVersion { //nolint:gosec // G115: safe conversion
|
||||
if forceErr := m.Force(LatestSchemaVersion); forceErr != nil {
|
||||
return fmt.Errorf("db: force after duplicate column: %w", forceErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("db: apply migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceSchemaVersion marks the database as being at the given version without
|
||||
// running any SQL. This is a break-glass operation: use it to clear a dirty
|
||||
// migration state after verifying (or manually applying) the migration SQL.
|
||||
//
|
||||
// Passing a version that has never been recorded by golang-migrate is safe;
|
||||
// it simply sets the version and clears the dirty flag. The next call to
|
||||
// Migrate will apply any versions higher than the forced one.
|
||||
func ForceSchemaVersion(database *DB, version int) error {
|
||||
m, err := newMigrate(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
|
||||
|
||||
if err := m.Force(version); err != nil {
|
||||
return fmt.Errorf("db: force schema version %d: %w", version, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SchemaVersion returns the current applied schema version of the database.
|
||||
// Returns 0 if no migrations have been applied yet.
|
||||
func SchemaVersion(database *DB) (int, error) {
|
||||
|
||||
Reference in New Issue
Block a user