Files
mcias/cmd/mciasdb/schema.go
Kyle Isom f262ca7b4e 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>
2026-03-12 15:33:19 -07:00

87 lines
2.4 KiB
Go

package main
import (
"flag"
"fmt"
"git.wntrmute.dev/kyle/mcias/internal/db"
)
func (t *tool) runSchema(args []string) {
if len(args) == 0 {
fatalf("schema requires a subcommand: verify, migrate, force")
}
switch args[0] {
case "verify":
t.schemaVerify()
case "migrate":
t.schemaMigrate()
case "force":
t.schemaForce(args[1:])
default:
fatalf("unknown schema subcommand %q", args[0])
}
}
// schemaVerify reports the current schema version and exits 1 if migrations
// are pending, 0 if the database is up-to-date.
func (t *tool) schemaVerify() {
version, err := db.SchemaVersion(t.db)
if err != nil {
fatalf("get schema version: %v", err)
}
latest := db.LatestSchemaVersion
fmt.Printf("schema version: %d (latest: %d)\n", version, latest)
if version < latest {
fmt.Printf("%d migration(s) pending\n", latest-version)
// Exit 1 to signal that migrations are needed (useful in scripts).
// We call os.Exit directly rather than fatalf to avoid printing "mciasdb: ".
fmt.Println("run 'mciasdb schema migrate' to apply pending migrations")
exitCode1()
}
fmt.Println("schema is up-to-date")
}
// schemaForce marks the database as being at a specific migration version
// without running any SQL. Use this to clear a dirty migration state after
// you have verified that the schema already reflects the target version.
//
// Example: mciasdb schema force --version 6
func (t *tool) schemaForce(args []string) {
fs := flag.NewFlagSet("schema force", flag.ExitOnError)
version := fs.Int("version", 0, "schema version to force (required)")
_ = fs.Parse(args)
if *version <= 0 {
fatalf("--version must be a positive integer")
}
if err := db.ForceSchemaVersion(t.db, *version); err != nil {
fatalf("force schema version: %v", err)
}
fmt.Printf("schema version forced to %d; run 'schema migrate' to apply any remaining migrations\n", *version)
}
// schemaMigrate applies any pending migrations and reports each one.
func (t *tool) schemaMigrate() {
before, err := db.SchemaVersion(t.db)
if err != nil {
fatalf("get schema version: %v", err)
}
if err := db.Migrate(t.db); err != nil {
fatalf("migrate: %v", err)
}
after, err := db.SchemaVersion(t.db)
if err != nil {
fatalf("get schema version after migrate: %v", err)
}
if before == after {
fmt.Println("no migrations needed; schema is already up-to-date")
return
}
fmt.Printf("migrated schema from version %d to %d\n", before, after)
}