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:
2026-03-12 14:38:38 -07:00
parent d7b69ed983
commit 22158824bd
25 changed files with 1574 additions and 137 deletions

View File

@@ -4,6 +4,156 @@ Source of truth for current development state.
---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
### 2026-03-12 — Password change: self-service and admin reset
Added the ability for users to change their own password and for admins to
reset any human account's password.
**Two new REST endpoints:**
- `PUT /v1/auth/password` — self-service: authenticated user changes their own
password; requires `current_password` for verification; revokes all tokens
except the caller's current session on success.
- `PUT /v1/accounts/{id}/password` — admin reset: no current password needed;
revokes all active sessions for the target account.
**internal/model/model.go**
- Added `EventPasswordChanged = "password_changed"` audit event constant.
**internal/db/accounts.go**
- Added `RevokeAllUserTokensExcept(accountID, exceptJTI, reason)`: revokes all
non-expired tokens for an account except one specific JTI (used by the
self-service flow to preserve the caller's session).
**internal/server/server.go**
- `handleAdminSetPassword`: admin password reset handler; validates new
password, hashes with Argon2id, revokes all target tokens, writes audit event.
- `handleChangePassword`: self-service handler; verifies current password with
Argon2id (same lockout/timing path as login), hashes new password, revokes
all other tokens, clears failure counter.
- Both routes registered in `Handler()`.
**internal/ui/handlers_accounts.go**
- `handleAdminResetPassword`: web UI counterpart to the admin REST handler;
renders `password_reset_result` fragment on success.
**internal/ui/ui.go**
- `PUT /accounts/{id}/password` route registered with admin+CSRF middleware.
- `templates/fragments/password_reset_form.html` added to shared template list.
**web/templates/fragments/password_reset_form.html** (new)
- HTMX form fragment for the admin password reset UI.
- `password_reset_result` template shows a success flash message followed by
the reset form.
**web/templates/account_detail.html**
- Added "Reset Password" card (human accounts only) using the new fragment.
**cmd/mciasctl/main.go**
- `auth change-password`: self-service password change; both passwords always
prompted interactively (no flag form — prevents shell-history exposure).
- `account set-password -id UUID`: admin reset; new password always prompted
interactively (no flag form).
- `auth login`: `-password` flag removed; password always prompted.
- `account create`: `-password` flag removed; password always prompted for
human accounts.
- All passwords read via `term.ReadPassword` (terminal echo disabled); raw
byte slices zeroed after use.
**openapi.yaml + web/static/openapi.yaml**
- `PUT /v1/auth/password`: self-service endpoint documented (Auth tag).
- `PUT /v1/accounts/{id}/password`: admin reset documented (Admin — Accounts
tag).
**ARCHITECTURE.md**
- API endpoint tables updated with both new endpoints.
- New "Password Change Flows" section in §6 (Session Management) documents the
self-service and admin flows, their security properties, and differences.
All tests pass; golangci-lint clean.
### 2026-03-12 — Checkpoint: fix fieldalignment lint warning
**internal/policy/engine_wrapper.go**
- Reordered `PolicyRecord` fields: `*time.Time` pointer fields moved before
string fields, shrinking the GC pointer-scan bitmap from 56 to 40 bytes
(govet fieldalignment)
All tests pass; `golangci-lint run ./...` clean.
### 2026-03-12 — Add time-scoped policy rule expiry
Policy rules now support optional `not_before` and `expires_at` fields for
time-limited validity windows. Rules outside their validity window are
automatically excluded at cache-load time (`Engine.SetRules`).
**internal/db/migrations/000006_policy_rule_expiry.up.sql** (new)
- `ALTER TABLE policy_rules ADD COLUMN not_before TEXT DEFAULT NULL`
- `ALTER TABLE policy_rules ADD COLUMN expires_at TEXT DEFAULT NULL`
**internal/db/migrate.go**
- `LatestSchemaVersion` bumped from 5 to 6
**internal/model/model.go**
- Added `NotBefore *time.Time` and `ExpiresAt *time.Time` to `PolicyRuleRecord`
**internal/db/policy.go**
- `policyRuleCols` updated with `not_before, expires_at`
- `CreatePolicyRule`: new params `notBefore, expiresAt *time.Time`
- `UpdatePolicyRule`: new params `notBefore, expiresAt **time.Time` (double-pointer
for three-state semantics: nil=no change, non-nil→nil=clear, non-nil→value=set)
- `finishPolicyRuleScan`: extended to populate `NotBefore`/`ExpiresAt` via
`nullableTime()`
- Added `formatNullableTime(*time.Time) *string` helper
**internal/policy/engine_wrapper.go**
- Added `NotBefore *time.Time` and `ExpiresAt *time.Time` to `PolicyRecord`
- `SetRules`: filters out rules where `not_before > now()` or `expires_at <= now()`
after the existing `Enabled` check
**internal/server/handlers_policy.go**
- `policyRuleResponse`: added `not_before` and `expires_at` (RFC3339, omitempty)
- `createPolicyRuleRequest`: added `not_before` and `expires_at`
- `updatePolicyRuleRequest`: added `not_before`, `expires_at`,
`clear_not_before`, `clear_expires_at`
- `handleCreatePolicyRule`: parses/validates RFC3339 times; rejects
`expires_at <= not_before`
- `handleUpdatePolicyRule`: parses times, handles clear booleans via
double-pointer pattern
**internal/ui/**
- `PolicyRuleView`: added `NotBefore`, `ExpiresAt`, `IsExpired`, `IsPending`
- `policyRuleToView`: populates time fields and computes expired/pending status
- `handleCreatePolicyRule`: parses `datetime-local` form inputs for time fields
**web/templates/fragments/**
- `policy_form.html`: added `datetime-local` inputs for not_before and expires_at
- `policy_row.html`: shows time info and expired/scheduled badges
**cmd/mciasctl/main.go**
- `policyCreate`: added `-not-before` and `-expires-at` flags (RFC3339)
- `policyUpdate`: added `-not-before`, `-expires-at`, `-clear-not-before`,
`-clear-expires-at` flags
**openapi.yaml**
- `PolicyRule` schema: added `not_before` and `expires_at` (nullable date-time)
- Create request: added `not_before` and `expires_at`
- Update request: added `not_before`, `expires_at`, `clear_not_before`,
`clear_expires_at`
**Tests**
- `internal/db/policy_test.go`: 5 new tests — `WithExpiresAt`, `WithNotBefore`,
`WithBothTimes`, `SetExpiresAt`, `ClearExpiresAt`; all existing tests updated
with new `CreatePolicyRule`/`UpdatePolicyRule` signatures
- `internal/policy/engine_test.go`: 4 new tests — `SkipsExpiredRule`,
`SkipsNotYetActiveRule`, `IncludesActiveWindowRule`, `NilTimesAlwaysActive`
**ARCHITECTURE.md**
- Schema: added `not_before` and `expires_at` columns to `policy_rules` DDL
- Added Scenario D (time-scoped access) to §20
All new and existing policy tests pass; no new lint warnings.
### 2026-03-12 — Integrate golang-migrate for database migrations
**internal/db/migrations/** (new directory — 5 embedded SQL files)
@@ -232,7 +382,7 @@ All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.
- [x] Phase 6: mciasdb — direct SQLite maintenance tool
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST)
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
- [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
- [ ] Phase 9: Client libraries (Go, Rust, Common Lisp, Python) — designed in ARCHITECTURE.md §19 but not yet implemented; `clients/` directory does not exist
- [x] Phase 10: Policy engine — ABAC with machine/service gating
---
### 2026-03-11 — Phase 10: Policy engine (ABAC + machine/service gating)
@@ -336,44 +486,15 @@ All tests pass; `go test ./...` clean; `golangci-lint run ./...` clean.
All 5 packages pass `go test ./...`; `golangci-lint run ./...` clean.
### 2026-03-11 — Phase 9: Client libraries
### 2026-03-11 — Phase 9: Client libraries (DESIGNED, NOT IMPLEMENTED)
**clients/testdata/** — shared JSON fixtures
- login_response.json, account_response.json, accounts_list_response.json
- validate_token_response.json, public_key_response.json, pgcreds_response.json
- error_response.json, roles_response.json
**clients/go/** — Go client library
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`; package `mciasgoclient`
- Typed errors: `MciasAuthError`, `MciasForbiddenError`, `MciasNotFoundError`,
`MciasInputError`, `MciasConflictError`, `MciasServerError`
- TLS 1.2+ enforced via `tls.Config{MinVersion: tls.VersionTLS12}`
- Token state guarded by `sync.RWMutex` for concurrent safety
- JSON decoded with `DisallowUnknownFields` on all responses
- 25 tests in `client_test.go`; all pass with `go test -race`
**clients/rust/** — Rust async client library
- Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep)
- `MciasError` enum via `thiserror`; `Arc<RwLock<Option<String>>>` for token
- 23 integration tests using `wiremock`; `cargo clippy -- -D warnings` clean
**clients/lisp/** — Common Lisp client library
- ASDF system `mcias-client`; HTTP via dexador, JSON via yason
- CLOS class `mcias-client`; plain functions for all operations
- Conditions: `mcias-error` base + 6 typed subclasses
- Mock server: Hunchentoot `mock-dispatcher` subclass (port 0, random per test)
- 37 fiveam checks; all pass on SBCL 2.6.1
- Fixed: yason decodes JSON `false` as `:false`; `validate-token` normalises
to `t`/`nil` before returning
**clients/python/** — Python 3.11+ client library
- Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27`
- `Client` context manager; `py.typed` marker; all symbols fully annotated
- Dataclasses: `Account`, `PublicKey`, `PGCreds`
- 32 pytest tests using `respx` mock transport; `mypy --strict` clean; `ruff` clean
**NOTE:** The client libraries described in ARCHITECTURE.md §19 were designed
but never committed to the repository. The `clients/` directory does not exist.
Only `test/mock/mockserver.go` was implemented. The designs remain in
ARCHITECTURE.md for future implementation.
**test/mock/mockserver.go** — Go in-memory mock server
- `Server` struct with `sync.RWMutex`; used by Go client integration test
- `Server` struct with `sync.RWMutex`; used for Go integration tests
- `NewServer()`, `AddAccount()`, `ServeHTTP()` for httptest.Server use
---