Checkpoint: password reset, rule expiry, migrations
- Self-service and admin password-change endpoints
(PUT /v1/auth/password, PUT /v1/accounts/{id}/password)
- Policy rule time-scoped expiry (not_before / expires_at)
with migration 000006 and engine filtering
- golang-migrate integration; embedded SQL migrations
- PolicyRecord fieldalignment lint fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -128,14 +128,23 @@ func (db *DB) UpdateAccountStatus(accountID int64, status model.AccountStatus) e
|
||||
}
|
||||
|
||||
// UpdatePasswordHash updates the Argon2id password hash for an account.
|
||||
// Returns ErrNotFound if no active account with the given ID exists, consistent
|
||||
// with the RowsAffected checks in RevokeToken and RenewToken.
|
||||
func (db *DB) UpdatePasswordHash(accountID int64, hash string) error {
|
||||
_, err := db.sql.Exec(`
|
||||
result, err := db.sql.Exec(`
|
||||
UPDATE accounts SET password_hash = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, hash, now(), accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update password hash: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update password hash rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -640,6 +649,23 @@ func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeAllUserTokensExcept revokes all non-expired, non-revoked tokens for an
|
||||
// account except for the token identified by exceptJTI. Used by the
|
||||
// self-service password change flow to invalidate all other sessions while
|
||||
// keeping the caller's current session active.
|
||||
func (db *DB) RevokeAllUserTokensExcept(accountID int64, exceptJTI, reason string) error {
|
||||
n := now()
|
||||
_, err := db.sql.Exec(`
|
||||
UPDATE token_revocation
|
||||
SET revoked_at = ?, revoke_reason = ?
|
||||
WHERE account_id = ? AND jti != ? AND revoked_at IS NULL AND expires_at > ?
|
||||
`, n, nullString(reason), accountID, exceptJTI, n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: revoke all tokens except %q for account %d: %w", exceptJTI, accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneExpiredTokens removes token_revocation rows that are past their expiry.
|
||||
// Returns the number of rows deleted.
|
||||
func (db *DB) PruneExpiredTokens() (int64, error) {
|
||||
|
||||
@@ -21,7 +21,7 @@ var migrationsFS embed.FS
|
||||
// LatestSchemaVersion is the highest migration version defined in the
|
||||
// migrations/ directory. Update this constant whenever a new migration file
|
||||
// is added.
|
||||
const LatestSchemaVersion = 5
|
||||
const LatestSchemaVersion = 6
|
||||
|
||||
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
|
||||
// files. It opens a dedicated *sql.DB using the same DSN as the main
|
||||
|
||||
6
internal/db/migrations/000006_policy_rule_expiry.up.sql
Normal file
6
internal/db/migrations/000006_policy_rule_expiry.up.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Add optional time-scoped validity window to policy rules.
|
||||
-- NULL means "no constraint" (rule is always active / never expires).
|
||||
-- The policy engine skips rules where not_before > now() or expires_at <= now()
|
||||
-- at cache-load time (SetRules), not at query time.
|
||||
ALTER TABLE policy_rules ADD COLUMN not_before TEXT DEFAULT NULL;
|
||||
ALTER TABLE policy_rules ADD COLUMN expires_at TEXT DEFAULT NULL;
|
||||
@@ -4,18 +4,23 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
// policyRuleCols is the column list for all policy rule SELECT queries.
|
||||
const policyRuleCols = `id, priority, description, rule_json, enabled, created_by, created_at, updated_at, not_before, expires_at`
|
||||
|
||||
// CreatePolicyRule inserts a new policy rule record. The returned record
|
||||
// includes the database-assigned ID and timestamps.
|
||||
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64) (*model.PolicyRuleRecord, error) {
|
||||
// notBefore and expiresAt are optional; nil means no constraint.
|
||||
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64, notBefore, expiresAt *time.Time) (*model.PolicyRuleRecord, error) {
|
||||
n := now()
|
||||
result, err := db.sql.Exec(`
|
||||
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?)
|
||||
`, priority, description, ruleJSON, createdBy, n, n)
|
||||
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at, not_before, expires_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)
|
||||
`, priority, description, ruleJSON, createdBy, n, n, formatNullableTime(notBefore), formatNullableTime(expiresAt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: create policy rule: %w", err)
|
||||
}
|
||||
@@ -39,6 +44,8 @@ func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
NotBefore: notBefore,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -46,7 +53,7 @@ func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string
|
||||
// Returns ErrNotFound if no such rule exists.
|
||||
func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
|
||||
return db.scanPolicyRule(db.sql.QueryRow(`
|
||||
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
|
||||
SELECT `+policyRuleCols+`
|
||||
FROM policy_rules WHERE id = ?
|
||||
`, id))
|
||||
}
|
||||
@@ -55,7 +62,7 @@ func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
|
||||
// When enabledOnly is true, only rules with enabled=1 are returned.
|
||||
func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, error) {
|
||||
query := `
|
||||
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
|
||||
SELECT ` + policyRuleCols + `
|
||||
FROM policy_rules`
|
||||
if enabledOnly {
|
||||
query += ` WHERE enabled = 1`
|
||||
@@ -80,8 +87,12 @@ func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, erro
|
||||
}
|
||||
|
||||
// UpdatePolicyRule updates the mutable fields of a policy rule.
|
||||
// Only the fields in the update map are changed; other fields are untouched.
|
||||
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string) error {
|
||||
// Only non-nil fields are changed; nil fields are left untouched.
|
||||
// For notBefore and expiresAt, use a non-nil pointer-to-pointer:
|
||||
// - nil (outer) → don't change
|
||||
// - non-nil → nil → set column to NULL
|
||||
// - non-nil → non-nil → set column to the time value
|
||||
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string, notBefore, expiresAt **time.Time) error {
|
||||
n := now()
|
||||
|
||||
// Build SET clause dynamically to only update provided fields.
|
||||
@@ -102,6 +113,14 @@ func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, rul
|
||||
setClauses += ", rule_json = ?"
|
||||
args = append(args, *ruleJSON)
|
||||
}
|
||||
if notBefore != nil {
|
||||
setClauses += ", not_before = ?"
|
||||
args = append(args, formatNullableTime(*notBefore))
|
||||
}
|
||||
if expiresAt != nil {
|
||||
setClauses += ", expires_at = ?"
|
||||
args = append(args, formatNullableTime(*expiresAt))
|
||||
}
|
||||
args = append(args, id)
|
||||
|
||||
_, err := db.sql.Exec(`UPDATE policy_rules SET `+setClauses+` WHERE id = ?`, args...)
|
||||
@@ -141,10 +160,12 @@ func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
|
||||
var enabledInt int
|
||||
var createdAtStr, updatedAtStr string
|
||||
var createdBy *int64
|
||||
var notBeforeStr, expiresAtStr *string
|
||||
|
||||
err := row.Scan(
|
||||
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
||||
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
||||
¬BeforeStr, &expiresAtStr,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -153,7 +174,7 @@ func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
|
||||
return nil, fmt.Errorf("db: scan policy rule: %w", err)
|
||||
}
|
||||
|
||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
|
||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr, notBeforeStr, expiresAtStr)
|
||||
}
|
||||
|
||||
// scanPolicyRuleRow scans a single policy rule from *sql.Rows.
|
||||
@@ -162,19 +183,21 @@ func (db *DB) scanPolicyRuleRow(rows *sql.Rows) (*model.PolicyRuleRecord, error)
|
||||
var enabledInt int
|
||||
var createdAtStr, updatedAtStr string
|
||||
var createdBy *int64
|
||||
var notBeforeStr, expiresAtStr *string
|
||||
|
||||
err := rows.Scan(
|
||||
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
||||
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
||||
¬BeforeStr, &expiresAtStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: scan policy rule row: %w", err)
|
||||
}
|
||||
|
||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
|
||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr, notBeforeStr, expiresAtStr)
|
||||
}
|
||||
|
||||
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string) (*model.PolicyRuleRecord, error) {
|
||||
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string, notBeforeStr, expiresAtStr *string) (*model.PolicyRuleRecord, error) {
|
||||
r.Enabled = enabledInt == 1
|
||||
r.CreatedBy = createdBy
|
||||
|
||||
@@ -187,5 +210,23 @@ func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.NotBefore, err = nullableTime(notBeforeStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.ExpiresAt, err = nullableTime(expiresAtStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// formatNullableTime converts a *time.Time to a *string suitable for SQLite.
|
||||
// Returns nil if the input is nil (stores NULL).
|
||||
func formatNullableTime(t *time.Time) *string {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
s := t.UTC().Format(time.RFC3339)
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
@@ -11,7 +12,7 @@ func TestCreateAndGetPolicyRule(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
ruleJSON := `{"actions":["pgcreds:read"],"resource_type":"pgcreds","effect":"allow"}`
|
||||
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil)
|
||||
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule: %v", err)
|
||||
}
|
||||
@@ -49,9 +50,9 @@ func TestGetPolicyRule_NotFound(t *testing.T) {
|
||||
func TestListPolicyRules(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil)
|
||||
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil)
|
||||
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil)
|
||||
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil, nil, nil)
|
||||
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil, nil, nil)
|
||||
|
||||
rules, err := db.ListPolicyRules(false)
|
||||
if err != nil {
|
||||
@@ -70,8 +71,8 @@ func TestListPolicyRules(t *testing.T) {
|
||||
func TestListPolicyRules_EnabledOnly(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil)
|
||||
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil)
|
||||
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil, nil, nil)
|
||||
|
||||
if err := db.SetPolicyRuleEnabled(r2.ID, false); err != nil {
|
||||
t.Fatalf("SetPolicyRuleEnabled: %v", err)
|
||||
@@ -100,11 +101,11 @@ func TestListPolicyRules_EnabledOnly(t *testing.T) {
|
||||
func TestUpdatePolicyRule(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil)
|
||||
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
|
||||
newDesc := "updated description"
|
||||
newPriority := 25
|
||||
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil); err != nil {
|
||||
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil, nil, nil); err != nil {
|
||||
t.Fatalf("UpdatePolicyRule: %v", err)
|
||||
}
|
||||
|
||||
@@ -127,10 +128,10 @@ func TestUpdatePolicyRule(t *testing.T) {
|
||||
func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil)
|
||||
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
|
||||
newJSON := `{"effect":"deny","roles":["auditor"]}`
|
||||
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON); err != nil {
|
||||
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON, nil, nil); err != nil {
|
||||
t.Fatalf("UpdatePolicyRule (json only): %v", err)
|
||||
}
|
||||
|
||||
@@ -150,7 +151,7 @@ func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
|
||||
func TestSetPolicyRuleEnabled(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil)
|
||||
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
if !rec.Enabled {
|
||||
t.Fatal("new rule should be enabled")
|
||||
}
|
||||
@@ -175,7 +176,7 @@ func TestSetPolicyRuleEnabled(t *testing.T) {
|
||||
func TestDeletePolicyRule(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil)
|
||||
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
|
||||
if err := db.DeletePolicyRule(rec.ID); err != nil {
|
||||
t.Fatalf("DeletePolicyRule: %v", err)
|
||||
@@ -200,7 +201,7 @@ func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
acct, _ := db.CreateAccount("policy-creator", model.AccountTypeHuman, "hash")
|
||||
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID)
|
||||
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule with createdBy: %v", err)
|
||||
}
|
||||
@@ -210,3 +211,111 @@ func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
|
||||
t.Errorf("expected CreatedBy=%d, got %v", acct.ID, got.CreatedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePolicyRule_WithExpiresAt(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
rec, err := db.CreatePolicyRule("expiring rule", 100, `{"effect":"allow"}`, nil, nil, &exp)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule with expiresAt: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetPolicyRule(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyRule: %v", err)
|
||||
}
|
||||
if got.ExpiresAt == nil {
|
||||
t.Fatal("expected ExpiresAt to be set")
|
||||
}
|
||||
if !got.ExpiresAt.Equal(exp) {
|
||||
t.Errorf("expected ExpiresAt=%v, got %v", exp, *got.ExpiresAt)
|
||||
}
|
||||
if got.NotBefore != nil {
|
||||
t.Errorf("expected NotBefore=nil, got %v", *got.NotBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePolicyRule_WithNotBefore(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
nb := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
rec, err := db.CreatePolicyRule("scheduled rule", 100, `{"effect":"allow"}`, nil, &nb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule with notBefore: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetPolicyRule(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyRule: %v", err)
|
||||
}
|
||||
if got.NotBefore == nil {
|
||||
t.Fatal("expected NotBefore to be set")
|
||||
}
|
||||
if !got.NotBefore.Equal(nb) {
|
||||
t.Errorf("expected NotBefore=%v, got %v", nb, *got.NotBefore)
|
||||
}
|
||||
if got.ExpiresAt != nil {
|
||||
t.Errorf("expected ExpiresAt=nil, got %v", *got.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePolicyRule_WithBothTimes(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
nb := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
rec, err := db.CreatePolicyRule("windowed rule", 100, `{"effect":"allow"}`, nil, &nb, &exp)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule with both times: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetPolicyRule(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyRule: %v", err)
|
||||
}
|
||||
if got.NotBefore == nil || !got.NotBefore.Equal(nb) {
|
||||
t.Errorf("NotBefore mismatch: got %v", got.NotBefore)
|
||||
}
|
||||
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(exp) {
|
||||
t.Errorf("ExpiresAt mismatch: got %v", got.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePolicyRule_SetExpiresAt(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("no expiry", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
|
||||
exp := time.Date(2030, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
expPtr := &exp
|
||||
if err := db.UpdatePolicyRule(rec.ID, nil, nil, nil, nil, &expPtr); err != nil {
|
||||
t.Fatalf("UpdatePolicyRule (set expires_at): %v", err)
|
||||
}
|
||||
|
||||
got, _ := db.GetPolicyRule(rec.ID)
|
||||
if got.ExpiresAt == nil {
|
||||
t.Fatal("expected ExpiresAt to be set after update")
|
||||
}
|
||||
if !got.ExpiresAt.Equal(exp) {
|
||||
t.Errorf("expected ExpiresAt=%v, got %v", exp, *got.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePolicyRule_ClearExpiresAt(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
rec, _ := db.CreatePolicyRule("will clear", 100, `{"effect":"allow"}`, nil, nil, &exp)
|
||||
|
||||
// Clear expires_at by passing non-nil outer, nil inner.
|
||||
var nilTime *time.Time
|
||||
if err := db.UpdatePolicyRule(rec.ID, nil, nil, nil, nil, &nilTime); err != nil {
|
||||
t.Fatalf("UpdatePolicyRule (clear expires_at): %v", err)
|
||||
}
|
||||
|
||||
got, _ := db.GetPolicyRule(rec.ID)
|
||||
if got.ExpiresAt != nil {
|
||||
t.Errorf("expected ExpiresAt=nil after clear, got %v", *got.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,18 +167,24 @@ type PGCredAccessGrant struct {
|
||||
const (
|
||||
EventPGCredAccessGranted = "pgcred_access_granted" //nolint:gosec // G101: audit event type, not a credential
|
||||
EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential
|
||||
|
||||
EventPasswordChanged = "password_changed"
|
||||
)
|
||||
|
||||
// PolicyRuleRecord is the database representation of a policy rule.
|
||||
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
||||
// The ID, Priority, and Description are stored as dedicated columns.
|
||||
// NotBefore and ExpiresAt define an optional validity window; nil means no
|
||||
// constraint (always active / never expires).
|
||||
type PolicyRuleRecord struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy *int64 `json:"-"`
|
||||
Description string `json:"description"`
|
||||
RuleJSON string `json:"rule_json"`
|
||||
ID int64 `json:"id"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedBy *int64 `json:"-"`
|
||||
Description string `json:"description"`
|
||||
RuleJSON string `json:"rule_json"`
|
||||
ID int64 `json:"id"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// adminInput is a convenience helper for building admin PolicyInputs.
|
||||
@@ -378,3 +379,131 @@ func TestEvaluate_AccountTypeGating(t *testing.T) {
|
||||
t.Error("human account should not match system-only rule")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Engine.SetRules time-filtering tests ----
|
||||
|
||||
func TestSetRules_SkipsExpiredRule(t *testing.T) {
|
||||
engine := NewEngine()
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
err := engine.SetRules([]PolicyRecord{
|
||||
{
|
||||
ID: 1,
|
||||
Description: "expired",
|
||||
Priority: 100,
|
||||
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||
Enabled: true,
|
||||
ExpiresAt: &past,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRules: %v", err)
|
||||
}
|
||||
|
||||
// The expired rule should not be in the cache; evaluation should deny.
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionListAccounts,
|
||||
Resource: Resource{Type: ResourceAccount},
|
||||
}
|
||||
effect, _ := engine.Evaluate(input)
|
||||
if effect != Deny {
|
||||
t.Error("expired rule should not match; expected Deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRules_SkipsNotYetActiveRule(t *testing.T) {
|
||||
engine := NewEngine()
|
||||
future := time.Now().Add(1 * time.Hour)
|
||||
|
||||
err := engine.SetRules([]PolicyRecord{
|
||||
{
|
||||
ID: 2,
|
||||
Description: "not yet active",
|
||||
Priority: 100,
|
||||
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||
Enabled: true,
|
||||
NotBefore: &future,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRules: %v", err)
|
||||
}
|
||||
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionListAccounts,
|
||||
Resource: Resource{Type: ResourceAccount},
|
||||
}
|
||||
effect, _ := engine.Evaluate(input)
|
||||
if effect != Deny {
|
||||
t.Error("future not_before rule should not match; expected Deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRules_IncludesActiveWindowRule(t *testing.T) {
|
||||
engine := NewEngine()
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
future := time.Now().Add(1 * time.Hour)
|
||||
|
||||
err := engine.SetRules([]PolicyRecord{
|
||||
{
|
||||
ID: 3,
|
||||
Description: "currently active",
|
||||
Priority: 100,
|
||||
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||
Enabled: true,
|
||||
NotBefore: &past,
|
||||
ExpiresAt: &future,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRules: %v", err)
|
||||
}
|
||||
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionListAccounts,
|
||||
Resource: Resource{Type: ResourceAccount},
|
||||
}
|
||||
effect, _ := engine.Evaluate(input)
|
||||
if effect != Allow {
|
||||
t.Error("rule within its active window should match; expected Allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRules_NilTimesAlwaysActive(t *testing.T) {
|
||||
engine := NewEngine()
|
||||
|
||||
err := engine.SetRules([]PolicyRecord{
|
||||
{
|
||||
ID: 4,
|
||||
Description: "no time constraints",
|
||||
Priority: 100,
|
||||
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||
Enabled: true,
|
||||
// NotBefore and ExpiresAt are both nil.
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRules: %v", err)
|
||||
}
|
||||
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionListAccounts,
|
||||
Resource: Resource{Type: ResourceAccount},
|
||||
}
|
||||
effect, _ := engine.Evaluate(input)
|
||||
if effect != Allow {
|
||||
t.Error("nil time fields mean always active; expected Allow")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Engine wraps the stateless Evaluate function with an in-memory cache of
|
||||
@@ -31,11 +32,19 @@ func NewEngine() *Engine {
|
||||
// into a Rule. This prevents the database from injecting values into the ID or
|
||||
// Description fields that are stored as dedicated columns.
|
||||
func (e *Engine) SetRules(records []PolicyRecord) error {
|
||||
now := time.Now()
|
||||
rules := make([]Rule, 0, len(records))
|
||||
for _, rec := range records {
|
||||
if !rec.Enabled {
|
||||
continue
|
||||
}
|
||||
// Skip rules outside their validity window.
|
||||
if rec.NotBefore != nil && now.Before(*rec.NotBefore) {
|
||||
continue
|
||||
}
|
||||
if rec.ExpiresAt != nil && now.After(*rec.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
var body RuleBody
|
||||
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
||||
return fmt.Errorf("policy: decode rule %d %q: %w", rec.ID, rec.Description, err)
|
||||
@@ -75,6 +84,8 @@ func (e *Engine) Evaluate(input PolicyInput) (Effect, *Rule) {
|
||||
// Using a local struct avoids importing the db or model packages from policy,
|
||||
// which would create a dependency cycle.
|
||||
type PolicyRecord struct {
|
||||
NotBefore *time.Time
|
||||
ExpiresAt *time.Time
|
||||
Description string
|
||||
RuleJSON string
|
||||
ID int64
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
@@ -90,6 +91,8 @@ func (s *Server) handleSetTags(w http.ResponseWriter, r *http.Request) {
|
||||
type policyRuleResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
NotBefore *string `json:"not_before,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
Description string `json:"description"`
|
||||
RuleBody policy.RuleBody `json:"rule"`
|
||||
ID int64 `json:"id"`
|
||||
@@ -102,15 +105,24 @@ func policyRuleToResponse(rec *model.PolicyRuleRecord) (policyRuleResponse, erro
|
||||
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
||||
return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err)
|
||||
}
|
||||
return policyRuleResponse{
|
||||
resp := policyRuleResponse{
|
||||
ID: rec.ID,
|
||||
Priority: rec.Priority,
|
||||
Description: rec.Description,
|
||||
RuleBody: body,
|
||||
Enabled: rec.Enabled,
|
||||
CreatedAt: rec.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: rec.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}, nil
|
||||
CreatedAt: rec.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: rec.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if rec.NotBefore != nil {
|
||||
s := rec.NotBefore.UTC().Format(time.RFC3339)
|
||||
resp.NotBefore = &s
|
||||
}
|
||||
if rec.ExpiresAt != nil {
|
||||
s := rec.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
resp.ExpiresAt = &s
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -133,6 +145,8 @@ func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
type createPolicyRuleRequest struct {
|
||||
Description string `json:"description"`
|
||||
NotBefore *string `json:"not_before,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
Rule policy.RuleBody `json:"rule"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
@@ -157,6 +171,29 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
|
||||
priority = 100 // default
|
||||
}
|
||||
|
||||
// Parse optional time-scoped validity window.
|
||||
var notBefore, expiresAt *time.Time
|
||||
if req.NotBefore != nil {
|
||||
t, err := time.Parse(time.RFC3339, *req.NotBefore)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "not_before must be RFC3339", "bad_request")
|
||||
return
|
||||
}
|
||||
notBefore = &t
|
||||
}
|
||||
if req.ExpiresAt != nil {
|
||||
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be RFC3339", "bad_request")
|
||||
return
|
||||
}
|
||||
expiresAt = &t
|
||||
}
|
||||
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be after not_before", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
ruleJSON, err := json.Marshal(req.Rule)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
@@ -171,7 +208,7 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy)
|
||||
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy, notBefore, expiresAt)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
@@ -202,10 +239,14 @@ func (s *Server) handleGetPolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type updatePolicyRuleRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
Rule *policy.RuleBody `json:"rule,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
NotBefore *string `json:"not_before,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
Rule *policy.RuleBody `json:"rule,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
ClearNotBefore *bool `json:"clear_not_before,omitempty"`
|
||||
ClearExpiresAt *bool `json:"clear_expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -230,11 +271,39 @@ func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
s := string(b)
|
||||
ruleJSON = &s
|
||||
js := string(b)
|
||||
ruleJSON = &js
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON); err != nil {
|
||||
// Parse optional time-scoped validity window updates.
|
||||
// Double-pointer semantics: nil = no change, non-nil→nil = clear, non-nil→non-nil = set.
|
||||
var notBefore, expiresAt **time.Time
|
||||
if req.ClearNotBefore != nil && *req.ClearNotBefore {
|
||||
var nilTime *time.Time
|
||||
notBefore = &nilTime // non-nil outer, nil inner → set to NULL
|
||||
} else if req.NotBefore != nil {
|
||||
t, err := time.Parse(time.RFC3339, *req.NotBefore)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "not_before must be RFC3339", "bad_request")
|
||||
return
|
||||
}
|
||||
tp := &t
|
||||
notBefore = &tp
|
||||
}
|
||||
if req.ClearExpiresAt != nil && *req.ClearExpiresAt {
|
||||
var nilTime *time.Time
|
||||
expiresAt = &nilTime // non-nil outer, nil inner → set to NULL
|
||||
} else if req.ExpiresAt != nil {
|
||||
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be RFC3339", "bad_request")
|
||||
return
|
||||
}
|
||||
tp := &t
|
||||
expiresAt = &tp
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON, notBefore, expiresAt); err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
||||
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword)))
|
||||
|
||||
// Self-service password change (requires valid token; actor must match target account).
|
||||
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
|
||||
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
|
||||
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
|
||||
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
|
||||
@@ -801,6 +805,183 @@ func (s *Server) handleTOTPRemove(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- Password change endpoints ----
|
||||
|
||||
// adminSetPasswordRequest is the request body for PUT /v1/accounts/{id}/password.
|
||||
// Used by admins to reset any human account's password without requiring the
|
||||
// current password.
|
||||
type adminSetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// handleAdminSetPassword allows an admin to reset any human account's password.
|
||||
// No current-password verification is required because the admin role already
|
||||
// represents a higher trust level, matching the break-glass recovery pattern.
|
||||
//
|
||||
// Security: new password is validated (minimum length) and hashed with Argon2id
|
||||
// before storage. The plaintext is never logged. All active tokens for the
|
||||
// target account are revoked so that a compromised-account recovery fully
|
||||
// invalidates any outstanding sessions.
|
||||
func (s *Server) handleAdminSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
acct, ok := s.loadAccount(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeHuman {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "password can only be set on human accounts", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
var req adminSetPasswordRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Security (F-13): enforce minimum length before hashing.
|
||||
if err := validate.Password(req.NewPassword); err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
|
||||
Time: s.cfg.Argon2.Time,
|
||||
Memory: s.cfg.Argon2.Memory,
|
||||
Threads: s.cfg.Argon2.Threads,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("hash password (admin reset)", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
||||
s.logger.Error("update password hash", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: revoke all active sessions so a compromised account cannot
|
||||
// continue to use old tokens after a password reset. Failure here means
|
||||
// the API's documented guarantee ("all active sessions revoked") cannot be
|
||||
// upheld, so we return 500 rather than silently succeeding.
|
||||
if err := s.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
|
||||
s.logger.Error("revoke tokens on password reset", "error", err, "account_id", acct.ID)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
actor := middleware.ClaimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if actor != nil {
|
||||
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
||||
actorID = &a.ID
|
||||
}
|
||||
}
|
||||
s.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// changePasswordRequest is the request body for PUT /v1/auth/password.
|
||||
// The current_password is required to prevent token-theft attacks: an attacker
|
||||
// who steals a valid JWT cannot change the password without also knowing the
|
||||
// existing one.
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// handleChangePassword allows an authenticated user to change their own password.
|
||||
// The current password must be verified before the new hash is written.
|
||||
//
|
||||
// Security: current password is verified with Argon2id (constant-time).
|
||||
// Lockout is checked and failures are recorded to prevent the endpoint from
|
||||
// being used as an oracle for the current password. On success, all other
|
||||
// active sessions (other JTIs) are revoked so stale tokens cannot be used
|
||||
// after a credential rotation.
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeHuman {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "password change is only available for human accounts", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
var req changePasswordRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.CurrentPassword == "" || req.NewPassword == "" {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "current_password and new_password are required", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check lockout before verifying (same as login flow) so an
|
||||
// attacker cannot use this endpoint to brute-force the current password.
|
||||
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
s.logger.Error("lockout check (password change)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
||||
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: verify the current password with the same constant-time
|
||||
// Argon2id path used at login to prevent timing oracles.
|
||||
ok, verifyErr := auth.VerifyPassword(req.CurrentPassword, acct.PasswordHash)
|
||||
if verifyErr != nil || !ok {
|
||||
_ = s.db.RecordLoginFailure(acct.ID)
|
||||
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "current password is incorrect", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Security (F-13): enforce minimum length on the new password before hashing.
|
||||
if err := validate.Password(req.NewPassword); err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
|
||||
Time: s.cfg.Argon2.Time,
|
||||
Memory: s.cfg.Argon2.Memory,
|
||||
Threads: s.cfg.Argon2.Threads,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("hash password (self-service change)", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
||||
s.logger.Error("update password hash", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: clear the failure counter since the user proved knowledge of
|
||||
// the current password, then revoke all tokens *except* the current one so
|
||||
// the caller retains their active session but any other stolen sessions are
|
||||
// invalidated. Revocation failure breaks the documented guarantee so we
|
||||
// return 500 rather than silently succeeding.
|
||||
_ = s.db.ClearLoginFailures(acct.ID)
|
||||
if err := s.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
|
||||
s.logger.Error("revoke other tokens on password change", "error", err, "account_id", acct.ID)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"self_service"}`)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- Postgres credential endpoints ----
|
||||
|
||||
type pgCredRequest struct {
|
||||
|
||||
@@ -896,6 +896,97 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleAdminResetPassword allows an admin to set a new password for any human
|
||||
// account without requiring the current password. On success all active tokens
|
||||
// for the target account are revoked so a compromised account is fully
|
||||
// invalidated.
|
||||
//
|
||||
// Security: new password is validated (minimum 12 chars) and hashed with
|
||||
// Argon2id before storage. The plaintext is never logged or included in any
|
||||
// response. Audit event EventPasswordChanged is recorded on success.
|
||||
func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(id)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeHuman {
|
||||
u.renderError(w, r, http.StatusBadRequest, "password can only be reset for human accounts")
|
||||
return
|
||||
}
|
||||
|
||||
newPassword := r.FormValue("new_password")
|
||||
confirmPassword := r.FormValue("confirm_password")
|
||||
if newPassword == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "new password is required")
|
||||
return
|
||||
}
|
||||
// Server-side equality check mirrors the client-side guard; defends against
|
||||
// direct POST requests that bypass the JavaScript confirmation.
|
||||
if newPassword != confirmPassword {
|
||||
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
|
||||
return
|
||||
}
|
||||
// Security (F-13): enforce minimum length before hashing.
|
||||
if err := validate.Password(newPassword); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(newPassword, auth.ArgonParams{
|
||||
Time: u.cfg.Argon2.Time,
|
||||
Memory: u.cfg.Argon2.Memory,
|
||||
Threads: u.cfg.Argon2.Threads,
|
||||
})
|
||||
if err != nil {
|
||||
u.logger.Error("hash password (admin reset)", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
||||
u.logger.Error("update password hash", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to update password")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: revoke all active sessions for the target account so an
|
||||
// attacker who held a valid token cannot continue to use it after reset.
|
||||
// Render an error fragment rather than silently claiming success if
|
||||
// revocation fails.
|
||||
if err := u.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
|
||||
u.logger.Error("revoke tokens on admin password reset", "account_id", acct.ID, "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
|
||||
|
||||
// Return a success fragment so HTMX can display confirmation inline.
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "password_reset_result", AccountDetailData{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
Flash: "Password updated and all active sessions revoked.",
|
||||
},
|
||||
Account: acct,
|
||||
})
|
||||
}
|
||||
|
||||
// handleIssueSystemToken issues a long-lived service token for a system account.
|
||||
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
@@ -70,7 +71,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
|
||||
// policyRuleToView converts a DB record to a template-friendly view.
|
||||
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
|
||||
pretty := prettyJSONStr(rec.RuleJSON)
|
||||
return &PolicyRuleView{
|
||||
v := &PolicyRuleView{
|
||||
ID: rec.ID,
|
||||
Priority: rec.Priority,
|
||||
Description: rec.Description,
|
||||
@@ -79,6 +80,16 @@ func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
|
||||
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
|
||||
UpdatedAt: rec.UpdatedAt.Format("2006-01-02 15:04 UTC"),
|
||||
}
|
||||
now := time.Now()
|
||||
if rec.NotBefore != nil {
|
||||
v.NotBefore = rec.NotBefore.UTC().Format("2006-01-02 15:04 UTC")
|
||||
v.IsPending = now.Before(*rec.NotBefore)
|
||||
}
|
||||
if rec.ExpiresAt != nil {
|
||||
v.ExpiresAt = rec.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
|
||||
v.IsExpired = now.After(*rec.ExpiresAt)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func prettyJSONStr(s string) string {
|
||||
@@ -160,6 +171,29 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional time-scoped validity window from datetime-local inputs.
|
||||
var notBefore, expiresAt *time.Time
|
||||
if nbStr := strings.TrimSpace(r.FormValue("not_before")); nbStr != "" {
|
||||
t, err := time.Parse("2006-01-02T15:04", nbStr)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid not_before time format")
|
||||
return
|
||||
}
|
||||
notBefore = &t
|
||||
}
|
||||
if eaStr := strings.TrimSpace(r.FormValue("expires_at")); eaStr != "" {
|
||||
t, err := time.Parse("2006-01-02T15:04", eaStr)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid expires_at time format")
|
||||
return
|
||||
}
|
||||
expiresAt = &t
|
||||
}
|
||||
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
|
||||
u.renderError(w, r, http.StatusBadRequest, "expires_at must be after not_before")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
@@ -168,7 +202,7 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID)
|
||||
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID, notBefore, expiresAt)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
|
||||
return
|
||||
|
||||
@@ -190,6 +190,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
"templates/fragments/tags_editor.html",
|
||||
"templates/fragments/policy_row.html",
|
||||
"templates/fragments/policy_form.html",
|
||||
"templates/fragments/password_reset_form.html",
|
||||
}
|
||||
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||
if err != nil {
|
||||
@@ -293,6 +294,7 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
||||
uiMux.Handle("PATCH /policies/{id}/enabled", admin(u.handleTogglePolicyRule))
|
||||
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
|
||||
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
|
||||
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
|
||||
|
||||
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
|
||||
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
|
||||
@@ -593,9 +595,13 @@ type PolicyRuleView struct {
|
||||
RuleJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
NotBefore string // empty if not set
|
||||
ExpiresAt string // empty if not set
|
||||
ID int64
|
||||
Priority int
|
||||
Enabled bool
|
||||
IsExpired bool // true if expires_at is in the past
|
||||
IsPending bool // true if not_before is in the future
|
||||
}
|
||||
|
||||
// PoliciesData is the view model for the policies list page.
|
||||
|
||||
Reference in New Issue
Block a user