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:
@@ -15,6 +15,7 @@
|
||||
//
|
||||
// schema verify
|
||||
// schema migrate
|
||||
// schema force --version N
|
||||
//
|
||||
// account list
|
||||
// account get --id UUID
|
||||
@@ -62,7 +63,22 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
database, masterKey, err := openDB(*configPath)
|
||||
command := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
// schema subcommands manage migrations themselves and must not trigger
|
||||
// auto-migration on open (a dirty database would prevent the tool from
|
||||
// opening at all, blocking recovery operations like "schema force").
|
||||
var (
|
||||
database *db.DB
|
||||
masterKey []byte
|
||||
err error
|
||||
)
|
||||
if command == "schema" {
|
||||
database, masterKey, err = openDBRaw(*configPath)
|
||||
} else {
|
||||
database, masterKey, err = openDB(*configPath)
|
||||
}
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
@@ -76,9 +92,6 @@ func main() {
|
||||
|
||||
tool := &tool{db: database, masterKey: masterKey}
|
||||
|
||||
command := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
switch command {
|
||||
case "schema":
|
||||
tool.runSchema(subArgs)
|
||||
@@ -111,6 +124,21 @@ type tool struct {
|
||||
// the same passphrase always yields the same key and encrypted secrets remain
|
||||
// readable. The passphrase env var is unset immediately after reading.
|
||||
func openDB(configPath string) (*db.DB, []byte, error) {
|
||||
database, masterKey, err := openDBRaw(configPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
_ = database.Close()
|
||||
return nil, nil, fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
return database, masterKey, nil
|
||||
}
|
||||
|
||||
// openDBRaw opens the database without running migrations. Used by schema
|
||||
// subcommands so they remain operational even when the database is in a dirty
|
||||
// migration state (e.g. to allow "schema force" to clear a dirty flag).
|
||||
func openDBRaw(configPath string) (*db.DB, []byte, error) {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load config: %w", err)
|
||||
@@ -121,11 +149,6 @@ func openDB(configPath string) (*db.DB, []byte, error) {
|
||||
return nil, nil, fmt.Errorf("open database %q: %w", cfg.Database.Path, err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(database); err != nil {
|
||||
_ = database.Close()
|
||||
return nil, nil, fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
|
||||
masterKey, err := deriveMasterKey(cfg, database)
|
||||
if err != nil {
|
||||
_ = database.Close()
|
||||
@@ -210,6 +233,7 @@ Global flags:
|
||||
Commands:
|
||||
schema verify Check schema version; exit 1 if migrations pending
|
||||
schema migrate Apply any pending schema migrations
|
||||
schema force --version N Force schema version (clears dirty state)
|
||||
|
||||
account list List all accounts
|
||||
account get --id UUID
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
@@ -8,13 +9,15 @@ import (
|
||||
|
||||
func (t *tool) runSchema(args []string) {
|
||||
if len(args) == 0 {
|
||||
fatalf("schema requires a subcommand: verify, migrate")
|
||||
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])
|
||||
}
|
||||
@@ -39,6 +42,26 @@ func (t *tool) schemaVerify() {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user