diff --git a/.junie/memory/language.json b/.junie/memory/language.json index 0637a08..27e664b 100644 --- a/.junie/memory/language.json +++ b/.junie/memory/language.json @@ -1 +1 @@ -[] \ No newline at end of file +[{"lang":"en","usageCount":1}] \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..11b0f26 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,83 @@ +# CLAUDE.md + +## Project Overview + +MCIAS (Metacircular Identity and Access System) is a single-sign-on (SSO) and Identity & Access Management (IAM) system for personal projects. The target audience is a single developer building personal apps, with support for onboarding friends onto those apps. + +**Priorities (in order):** security, robustness, correctness. Performance is secondary. + +## Tech Stack + +- **Language:** Go +- **Database:** SQLite +- **Logging/Utilities:** git.wntrmute.dev/kyle/goutils +- **Crypto:** Ed25519 (signatures), Argon2 (password hashing) +- **Tokens:** JWT signed with Ed25519 (algorithm: EdDSA); always validate the `alg` header on receipt — never accept `none` or symmetric algorithms +- **Auth:** Username/password + optional TOTP; future FIDO/Yubikey support + +## Binaries + +- `mciassrv` — authentication server (REST + gRPC over HTTPS/TLS, with HTMX web UI) +- `mciasctl` — admin CLI for account/token/credential/policy management (REST) +- `mciasdb` — offline SQLite maintenance tool (schema, accounts, tokens, audit, pgcreds) +- `mciasgrpcctl` — admin CLI for gRPC interface + +## Development Workflow + +If PROGRESS.md does not yet exist, create it before proceeding. It is the source of truth for current state. + +1. Check PROGRESS.md for current state and next steps +2. Define discrete next steps with actionable acceptance criteria +3. Implement, adversarially verify correctness, write tests +4. Commit to git, update PROGRESS.md +5. Repeat + +When instructed to checkpoint: +- Verify that the project lints cleanly. +- Verify that the project unit tests complete successfully. +- Ensure that all integration and end-to-end tests complete successfully. +- Commit to git and update PROGRESS.md. + +## Security Constraints + +This is a security-critical project. The following rules are non-negotiable: + +- Never implement custom crypto. Use standard library (`crypto/...`) or well-audited packages only. +- Always validate the `alg` header in JWTs before processing; reject `none` and any non-EdDSA algorithm. +- Argon2id parameters must meet current OWASP recommendations; never reduce them for convenience. +- Credential storage (passwords, tokens, secrets) must never appear in logs, error messages, or API responses. +- Any code touching authentication flows, token issuance/validation, or credential storage must include a comment citing the rationale for each security decision. +- When in doubt about a crypto or auth decision, halt and ask rather than guess. +- Review all crypto primitives against current best practices before use; flag any deviation in the commit body. + +## Testing Requirements + +- Tests live alongside source in the same package, using the `_test.go` suffix +- Run with `go test ./...`; CI must pass with zero failures +- Unit tests for all exported functions and security-critical internal functions +- Integration tests for all subsystems (database layer, token issuance, auth flows) +- End-to-end tests for complete login, token renewal, and revocation flows +- Adversarially verify all outputs: test invalid inputs, boundary conditions, and known attack patterns (e.g., JWT `alg` confusion, timing attacks on credential comparison) +- Use `crypto/subtle.ConstantTimeCompare` wherever token or credential equality is checked + +## Git Commit Style + +- First line: single line, max 55 characters +- Body (optional): bullet points describing work done +- Security-sensitive changes (crypto primitives, auth flows, token handling, credential storage, session management) must be explicitly flagged in the commit body with a `Security:` line describing what changed and why it is safe + +## Go Conventions + +- Format all code with `goimports` before committing +- Lint with `golangci-lint`; resolve all warnings unless explicitly justified. This must be done after every step. +- Wrap errors with `fmt.Errorf("context: %w", err)` to preserve stack context +- Prefer explicit error handling over panics; never silently discard errors +- Use `log/slog` (or goutils equivalents) for structured logging; never `fmt.Println` in production paths + +## Key Documents + +- `PROJECT.md` — Project specifications and requirements +- `ARCHITECTURE.md` — **Required before any implementation.** Covers token lifecycle, session management, multi-app trust boundaries, and database schema. Do not generate code until this document exists. +- `PROJECT_PLAN.md` — Discrete implementation steps (to be written) +- `PROGRESS.md` — Development progress tracking (to be written) +- `openapi.yaml` - Must be kept in sync with any API changes. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 32e2051..59c3ddd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -245,6 +245,61 @@ Key properties: - Admin can revoke all tokens for a user (e.g., on account suspension) - Token expiry is enforced at validation time, regardless of revocation table +### Password Change Flows + +Two distinct flows exist for changing a password, with different trust assumptions: + +#### Self-Service Password Change (`PUT /v1/auth/password`) + +Used by a human account holder to change their own password. + +1. Caller presents a valid JWT and supplies both `current_password` and + `new_password` in the request body. +2. The server looks up the account by the JWT subject. +3. **Lockout check** — same policy as login (10 failures in 15 min → 15 min + lockout). An attacker with a stolen token cannot use this endpoint to + brute-force the current password without hitting the lockout. +4. **Current password verified** with `auth.VerifyPassword` (Argon2id, + constant-time via `crypto/subtle.ConstantTimeCompare`). On failure a login + failure is recorded and HTTP 401 is returned. +5. New password is validated (minimum 12 characters) and hashed with Argon2id + using the server's configured parameters. +6. The new hash is written atomically to the `accounts` table. +7. **All tokens except the caller's current JTI are revoked** (reason: + `password_changed`). The caller keeps their active session; all other + concurrent sessions are invalidated. This limits the blast radius of a + credential compromise without logging the user out mid-operation. +8. Login failure counter is cleared (successful proof of knowledge). +9. Audit event `password_changed` is written with `{"via":"self_service"}`. + +#### Admin Password Reset (`PUT /v1/accounts/{id}/password`) + +Used by an administrator to reset a human account's password for recovery +purposes (e.g. user forgot their password, account handover). + +1. Caller presents an admin JWT. +2. Only `new_password` is required; no `current_password` verification is + performed. The admin role represents a higher trust level. +3. New password is validated (minimum 12 characters) and hashed with Argon2id. +4. The new hash is written to the `accounts` table. +5. **All active tokens for the target account are revoked** (reason: + `password_reset`). Unlike the self-service flow, the admin cannot preserve + the user's session because the reset is typically done during an outage of + the user's access. +6. Audit event `password_changed` is written with `{"via":"admin_reset"}`. + +#### Security Notes + +- The current password requirement on the self-service path prevents an + attacker who steals a JWT from changing credentials. A stolen token grants + access to resources for its remaining lifetime but cannot be used to + permanently take over the account. +- Admin resets are always audited with both actor and target IDs so the log + shows which admin performed the reset. +- Plaintext passwords are never logged, stored, or included in any response. +- Both flows use the same Argon2id parameters (OWASP 2023: time=3, memory=64 MB, + threads=4, hash length=32 bytes). + --- ## 7. Multi-App Trust Boundaries @@ -285,6 +340,7 @@ All endpoints use JSON request/response bodies. All responses include a | POST | `/v1/auth/login` | none | Username/password (+TOTP) login → JWT | | POST | `/v1/auth/logout` | bearer JWT | Revoke current token | | POST | `/v1/auth/renew` | bearer JWT | Exchange token for new token | +| PUT | `/v1/auth/password` | bearer JWT | Self-service password change (requires current password) | ### Token Endpoints @@ -304,6 +360,13 @@ All endpoints use JSON request/response bodies. All responses include a | PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) | | DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account | +### Password Endpoints + +| Method | Path | Auth required | Description | +|---|---|---|---| +| PUT | `/v1/auth/password` | bearer JWT | Self-service: change own password (current password required) | +| PUT | `/v1/accounts/{id}/password` | admin JWT | Admin reset: set any human account's password | + ### Role Endpoints (admin only) | Method | Path | Auth required | Description | @@ -356,6 +419,38 @@ All endpoints use JSON request/response bodies. All responses include a | GET | `/v1/health` | none | Health check | | GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) | +### Web Management UI + +mciassrv embeds an HTMX-based web management interface served alongside the +REST API. The UI is an admin-only interface providing a visual alternative to +`mciasctl` for day-to-day management. + +**Package:** `internal/ui/` — UI handlers call internal Go functions directly; +no internal HTTP round-trips to the REST API. + +**Template engine:** Go `html/template` with templates embedded at compile time +via `web/` (`embed.FS`). Templates are parsed once at startup. + +**Session management:** JWT stored as `HttpOnly; Secure; SameSite=Strict` +cookie (`mcias_session`). CSRF protection uses HMAC-signed double-submit +cookie pattern (`mcias_csrf`). + +**Pages and features:** + +| Path | Description | +|---|---| +| `/login` | Username/password login with optional TOTP step | +| `/` | Dashboard (account summary) | +| `/accounts` | Account list | +| `/accounts/{id}` | Account detail — status, roles, tags, PG credentials (system accounts) | +| `/pgcreds` | Postgres credentials list (owned + granted) with create form | +| `/policies` | Policy rules management — create, enable/disable, delete | +| `/audit` | Audit log viewer | + +**HTMX fragments:** Mutating operations (role updates, tag edits, credential +saves, policy toggles, access grants) use HTMX partial-page updates for a +responsive experience without full-page reloads. + --- ## 9. Database Schema @@ -445,10 +540,22 @@ CREATE TABLE system_tokens ( created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) ); +-- Per-account failed login attempts for brute-force lockout enforcement. +-- One row per account; window_start resets when the window expires or on +-- a successful login. +CREATE TABLE failed_logins ( + account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE, + window_start TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 1 +); + -- Postgres credentials for system accounts, encrypted at rest. CREATE TABLE pg_credentials ( id INTEGER PRIMARY KEY, account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE, + -- owner_id: account that administers the credentials and may grant/revoke + -- access. Nullable for backwards compatibility with pre-migration-5 rows. + owner_id INTEGER REFERENCES accounts(id), pg_host TEXT NOT NULL, pg_port INTEGER NOT NULL DEFAULT 5432, pg_database TEXT NOT NULL, @@ -459,6 +566,21 @@ CREATE TABLE pg_credentials ( updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) ); +-- Explicit read-access grants from a credential owner to another account. +-- Grantees may view connection metadata but the password is never decrypted +-- for them in the UI. Only the owner may update or delete the credential set. +CREATE TABLE pg_credential_access ( + id INTEGER PRIMARY KEY, + credential_id INTEGER NOT NULL REFERENCES pg_credentials(id) ON DELETE CASCADE, + grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + granted_by INTEGER REFERENCES accounts(id), + granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE (credential_id, grantee_id) +); + +CREATE INDEX idx_pgcred_access_cred ON pg_credential_access (credential_id); +CREATE INDEX idx_pgcred_access_grantee ON pg_credential_access (grantee_id); + -- Audit log — append-only. Never contains credentials or secret material. CREATE TABLE audit_log ( id INTEGER PRIMARY KEY, @@ -496,7 +618,9 @@ CREATE TABLE policy_rules ( enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)), created_by INTEGER REFERENCES accounts(id), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339) + expires_at TEXT DEFAULT NULL -- optional: expiry time (RFC3339) ); ``` @@ -1440,6 +1564,26 @@ For belt-and-suspenders, an explicit deny for production tags: No `ServiceNames` or `RequiredTags` field means this matches any service account. +**Scenario D — Time-scoped access:** + +The `deploy-agent` needs temporary access to production pgcreds for a 4-hour +maintenance window. Instead of creating a rule and remembering to delete it, +the operator sets `not_before` and `expires_at`: + +``` +mciasctl policy create \ + -description "deploy-agent: temp production access" \ + -json rule.json \ + -not-before 2026-03-12T02:00:00Z \ + -expires-at 2026-03-12T06:00:00Z +``` + +The policy engine filters rules at cache-load time (`Engine.SetRules`): rules +where `not_before > now()` or `expires_at <= now()` are excluded from the +cached rule set. No changes to the `Evaluate()` or `matches()` functions are +needed. Both fields are optional and nullable; `NULL` means no constraint +(always active / never expires). + ### Middleware Integration `internal/middleware.RequirePolicy(engine, action, resourceType)` is a drop-in diff --git a/CLAUDE.md b/CLAUDE.md index f8bc3b9..11b0f26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,10 @@ MCIAS (Metacircular Identity and Access System) is a single-sign-on (SSO) and Id ## Binaries -- `mciassrv` — authentication server (REST API over HTTPS/TLS) -- `mciasctl` — admin CLI for account/token/credential management +- `mciassrv` — authentication server (REST + gRPC over HTTPS/TLS, with HTMX web UI) +- `mciasctl` — admin CLI for account/token/credential/policy management (REST) +- `mciasdb` — offline SQLite maintenance tool (schema, accounts, tokens, audit, pgcreds) +- `mciasgrpcctl` — admin CLI for gRPC interface ## Development Workflow diff --git a/PROGRESS.md b/PROGRESS.md index 0b869ef..90629a0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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>>` 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 --- diff --git a/README.md b/README.md index 03a7ba8..4c64405 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ MCIAS is a self-hosted SSO and IAM service for personal projects. It provides authentication (JWT/Ed25519), account management, TOTP, and -Postgres credential storage over a REST API (HTTPS) and a gRPC API (TLS). +Postgres credential storage over a REST API (HTTPS), a gRPC API (TLS), +and an HTMX-based web management UI. See [ARCHITECTURE.md](ARCHITECTURE.md) for the technical design and [PROJECT_PLAN.md](PROJECT_PLAN.md) for the implementation roadmap. @@ -177,7 +178,7 @@ TOKEN=$(curl -sk https://localhost:8443/v1/auth/login \ export MCIAS_TOKEN=$TOKEN mciasctl -server https://localhost:8443 account list -mciasctl account create -username alice -password s3cr3t +mciasctl account create -username alice # password prompted interactively mciasctl role set -id $UUID -roles admin mciasctl token issue -id $SYSTEM_UUID mciasctl pgcreds set -id $UUID -host db.example.com -port 5432 \ @@ -241,6 +242,24 @@ See `man mciasgrpcctl` and [ARCHITECTURE.md](ARCHITECTURE.md) §17. --- +## Web Management UI + +mciassrv includes a built-in web interface for day-to-day administration. +After starting the server, navigate to `https://localhost:8443/login` and +log in with an admin account. + +The UI provides: +- **Dashboard** — account summary overview +- **Accounts** — list, create, update, delete accounts; manage roles and tags +- **PG Credentials** — view, create, and manage Postgres credential access grants +- **Policies** — create and manage ABAC policy rules +- **Audit** — browse the audit log + +Sessions use `HttpOnly; Secure; SameSite=Strict` cookies with CSRF protection. +See [ARCHITECTURE.md](ARCHITECTURE.md) §8 (Web Management UI) for design details. + +--- + ## Deploying with Docker ```sh diff --git a/cmd/mciasctl/main.go b/cmd/mciasctl/main.go index 772a6d0..dd0eb60 100644 --- a/cmd/mciasctl/main.go +++ b/cmd/mciasctl/main.go @@ -16,13 +16,15 @@ // // Commands: // -// auth login -username NAME [-password PASS] [-totp CODE] +// auth login -username NAME [-totp CODE] +// auth change-password (passwords always prompted interactively) // // account list -// account create -username NAME [-password PASS] [-type human|system] -// account get -id UUID -// account update -id UUID [-status active|inactive] -// account delete -id UUID +// account create -username NAME [-type human|system] +// account get -id UUID +// account update -id UUID [-status active|inactive] +// account delete -id UUID +// account set-password -id UUID // // role list -id UUID // role set -id UUID -roles role1,role2,... @@ -34,9 +36,9 @@ // pgcreds get -id UUID // // policy list -// policy create -description STR -json FILE [-priority N] +// policy create -description STR -json FILE [-priority N] [-not-before RFC3339] [-expires-at RFC3339] // policy get -id ID -// policy update -id ID [-priority N] [-enabled true|false] +// policy update -id ID [-priority N] [-enabled true|false] [-not-before RFC3339] [-expires-at RFC3339] [-clear-not-before] [-clear-expires-at] // policy delete -id ID // // tag list -id UUID @@ -123,28 +125,28 @@ type controller struct { func (c *controller) runAuth(args []string) { if len(args) == 0 { - fatalf("auth requires a subcommand: login") + fatalf("auth requires a subcommand: login, change-password") } switch args[0] { case "login": c.authLogin(args[1:]) + case "change-password": + c.authChangePassword(args[1:]) default: fatalf("unknown auth subcommand %q", args[0]) } } // authLogin authenticates with the server using username and password, then -// prints the resulting bearer token to stdout. If -password is not supplied on -// the command line, the user is prompted interactively (input is hidden so the -// password does not appear in shell history or terminal output). +// prints the resulting bearer token to stdout. The password is always prompted +// interactively; it is never accepted as a command-line flag to prevent it from +// appearing in shell history, ps output, and process argument lists. // -// Security: passwords are never stored by this process beyond the lifetime of -// the HTTP request. Interactive reads use golang.org/x/term.ReadPassword so -// that terminal echo is disabled; the byte slice is zeroed after use. +// Security: terminal echo is disabled during password entry +// (golang.org/x/term.ReadPassword); the raw byte slice is zeroed after use. func (c *controller) authLogin(args []string) { fs := flag.NewFlagSet("auth login", flag.ExitOnError) username := fs.String("username", "", "username (required)") - password := fs.String("password", "", "password (reads from stdin if omitted)") totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)") _ = fs.Parse(args) @@ -152,21 +154,19 @@ func (c *controller) authLogin(args []string) { fatalf("auth login: -username is required") } - // If no password flag was provided, prompt interactively so it does not - // appear in process arguments or shell history. - passwd := *password - if passwd == "" { - fmt.Fprint(os.Stderr, "Password: ") - raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms - fmt.Fprintln(os.Stderr) // newline after hidden input - if err != nil { - fatalf("read password: %v", err) - } - passwd = string(raw) - // Zero the raw byte slice once copied into the string. - for i := range raw { - raw[i] = 0 - } + // Security: always prompt interactively; never accept password as a flag. + // This prevents the credential from appearing in shell history, ps output, + // and /proc/PID/cmdline. + fmt.Fprint(os.Stderr, "Password: ") + raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms + fmt.Fprintln(os.Stderr) // newline after hidden input + if err != nil { + fatalf("read password: %v", err) + } + passwd := string(raw) + // Zero the raw byte slice once copied into the string. + for i := range raw { + raw[i] = 0 } body := map[string]string{ @@ -191,11 +191,53 @@ func (c *controller) authLogin(args []string) { } } +// authChangePassword allows an authenticated user to change their own password. +// A valid bearer token must be set (via -token flag or MCIAS_TOKEN env var). +// Both passwords are always prompted interactively; they are never accepted as +// command-line flags to prevent them from appearing in shell history, ps +// output, and process argument lists. +// +// Security: terminal echo is disabled during entry (golang.org/x/term); +// raw byte slices are zeroed after use. The server requires the current +// password to prevent token-theft attacks. On success all other active +// sessions are revoked server-side. +func (c *controller) authChangePassword(_ []string) { + // Security: always prompt interactively; never accept passwords as flags. + fmt.Fprint(os.Stderr, "Current password: ") + rawCurrent, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms + fmt.Fprintln(os.Stderr) + if err != nil { + fatalf("read current password: %v", err) + } + currentPasswd := string(rawCurrent) + for i := range rawCurrent { + rawCurrent[i] = 0 + } + + fmt.Fprint(os.Stderr, "New password: ") + rawNew, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms + fmt.Fprintln(os.Stderr) + if err != nil { + fatalf("read new password: %v", err) + } + newPasswd := string(rawNew) + for i := range rawNew { + rawNew[i] = 0 + } + + body := map[string]string{ + "current_password": currentPasswd, + "new_password": newPasswd, + } + c.doRequest("PUT", "/v1/auth/password", body, nil) + fmt.Println("password changed; other active sessions revoked") +} + // ---- account subcommands ---- func (c *controller) runAccount(args []string) { if len(args) == 0 { - fatalf("account requires a subcommand: list, create, get, update, delete") + fatalf("account requires a subcommand: list, create, get, update, delete, set-password") } switch args[0] { case "list": @@ -208,6 +250,8 @@ func (c *controller) runAccount(args []string) { c.accountUpdate(args[1:]) case "delete": c.accountDelete(args[1:]) + case "set-password": + c.accountSetPassword(args[1:]) default: fatalf("unknown account subcommand %q", args[0]) } @@ -222,7 +266,6 @@ func (c *controller) accountList() { func (c *controller) accountCreate(args []string) { fs := flag.NewFlagSet("account create", flag.ExitOnError) username := fs.String("username", "", "username (required)") - password := fs.String("password", "", "password for human accounts (prompted if omitted)") accountType := fs.String("type", "human", "account type: human or system") _ = fs.Parse(args) @@ -230,12 +273,11 @@ func (c *controller) accountCreate(args []string) { fatalf("account create: -username is required") } - // For human accounts, prompt for a password interactively if one was not - // supplied on the command line so it stays out of shell history. - // Security: terminal echo is disabled during entry; the raw byte slice is - // zeroed after conversion to string. System accounts have no password. - passwd := *password - if passwd == "" && *accountType == "human" { + // Security: always prompt interactively for human-account passwords; never + // accept them as a flag. Terminal echo is disabled; the raw byte slice is + // zeroed after conversion to string. System accounts have no password. + var passwd string + if *accountType == "human" { fmt.Fprint(os.Stderr, "Password: ") raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms fmt.Fprintln(os.Stderr) @@ -306,6 +348,40 @@ func (c *controller) accountDelete(args []string) { fmt.Println("account deleted") } +// accountSetPassword resets a human account's password (admin operation). +// No current password is required. All active sessions for the target account +// are revoked by the server on success. +// +// Security: the new password is always prompted interactively; it is never +// accepted as a command-line flag to prevent it from appearing in shell +// history, ps output, and process argument lists. Terminal echo is disabled +// (golang.org/x/term); the raw byte slice is zeroed after use. +func (c *controller) accountSetPassword(args []string) { + fs := flag.NewFlagSet("account set-password", flag.ExitOnError) + id := fs.String("id", "", "account UUID (required)") + _ = fs.Parse(args) + + if *id == "" { + fatalf("account set-password: -id is required") + } + + // Security: always prompt interactively; never accept password as a flag. + fmt.Fprint(os.Stderr, "New password: ") + raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms + fmt.Fprintln(os.Stderr) + if err != nil { + fatalf("read password: %v", err) + } + passwd := string(raw) + for i := range raw { + raw[i] = 0 + } + + body := map[string]string{"new_password": passwd} + c.doRequest("PUT", "/v1/accounts/"+*id+"/password", body, nil) + fmt.Println("password updated; all active sessions revoked") +} + // ---- role subcommands ---- func (c *controller) runRole(args []string) { @@ -511,6 +587,8 @@ func (c *controller) policyCreate(args []string) { description := fs.String("description", "", "rule description (required)") jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)") priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)") + notBefore := fs.String("not-before", "", "earliest activation time (RFC3339, optional)") + expiresAt := fs.String("expires-at", "", "expiry time (RFC3339, optional)") _ = fs.Parse(args) if *description == "" { @@ -537,6 +615,18 @@ func (c *controller) policyCreate(args []string) { "priority": *priority, "rule": ruleBody, } + if *notBefore != "" { + if _, err := time.Parse(time.RFC3339, *notBefore); err != nil { + fatalf("policy create: -not-before must be RFC3339: %v", err) + } + body["not_before"] = *notBefore + } + if *expiresAt != "" { + if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil { + fatalf("policy create: -expires-at must be RFC3339: %v", err) + } + body["expires_at"] = *expiresAt + } var result json.RawMessage c.doRequest("POST", "/v1/policy/rules", body, &result) @@ -562,6 +652,10 @@ func (c *controller) policyUpdate(args []string) { id := fs.String("id", "", "rule ID (required)") priority := fs.Int("priority", -1, "new priority (-1 = no change)") enabled := fs.String("enabled", "", "true or false") + notBefore := fs.String("not-before", "", "earliest activation time (RFC3339)") + expiresAt := fs.String("expires-at", "", "expiry time (RFC3339)") + clearNotBefore := fs.Bool("clear-not-before", false, "remove not_before constraint") + clearExpiresAt := fs.Bool("clear-expires-at", false, "remove expires_at constraint") _ = fs.Parse(args) if *id == "" { @@ -584,8 +678,24 @@ func (c *controller) policyUpdate(args []string) { fatalf("policy update: -enabled must be true or false") } } + if *clearNotBefore { + body["clear_not_before"] = true + } else if *notBefore != "" { + if _, err := time.Parse(time.RFC3339, *notBefore); err != nil { + fatalf("policy update: -not-before must be RFC3339: %v", err) + } + body["not_before"] = *notBefore + } + if *clearExpiresAt { + body["clear_expires_at"] = true + } else if *expiresAt != "" { + if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil { + fatalf("policy update: -expires-at must be RFC3339: %v", err) + } + body["expires_at"] = *expiresAt + } if len(body) == 0 { - fatalf("policy update: at least one of -priority or -enabled is required") + fatalf("policy update: at least one flag is required") } var result json.RawMessage @@ -766,16 +876,25 @@ Global flags: -cacert Path to CA certificate for TLS verification Commands: - auth login -username NAME [-password PASS] [-totp CODE] - Obtain a bearer token. Password is prompted if -password is - omitted. Token is written to stdout; expiry to stderr. + auth login -username NAME [-totp CODE] + Obtain a bearer token. Password is always prompted interactively + (never accepted as a flag) to avoid shell-history exposure. + Token is written to stdout; expiry to stderr. Example: export MCIAS_TOKEN=$(mciasctl auth login -username alice) + auth change-password + Change the current user's own password. Requires a valid bearer + token. Current and new passwords are always prompted interactively. + Revokes all other active sessions on success. account list - account create -username NAME [-password PASS] [-type human|system] - account get -id UUID - account update -id UUID -status active|inactive - account delete -id UUID + account create -username NAME [-type human|system] + account get -id UUID + account update -id UUID -status active|inactive + account delete -id UUID + account set-password -id UUID + Admin: reset a human account's password without requiring the + current password. New password is always prompted interactively. + Revokes all active sessions for the account. role list -id UUID role set -id UUID -roles role1,role2,... @@ -788,10 +907,13 @@ Commands: policy list policy create -description STR -json FILE [-priority N] + [-not-before RFC3339] [-expires-at RFC3339] FILE must contain a JSON rule body, e.g.: {"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true} policy get -id ID policy update -id ID [-priority N] [-enabled true|false] + [-not-before RFC3339] [-expires-at RFC3339] + [-clear-not-before] [-clear-expires-at] policy delete -id ID tag list -id UUID diff --git a/internal/db/accounts.go b/internal/db/accounts.go index e7324cf..41d1cb8 100644 --- a/internal/db/accounts.go +++ b/internal/db/accounts.go @@ -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) { diff --git a/internal/db/migrate.go b/internal/db/migrate.go index b102a5d..ecdb665 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -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 diff --git a/internal/db/migrations/000006_policy_rule_expiry.up.sql b/internal/db/migrations/000006_policy_rule_expiry.up.sql new file mode 100644 index 0000000..f2602e1 --- /dev/null +++ b/internal/db/migrations/000006_policy_rule_expiry.up.sql @@ -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; diff --git a/internal/db/policy.go b/internal/db/policy.go index 1b7589e..82906b6 100644 --- a/internal/db/policy.go +++ b/internal/db/policy.go @@ -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 +} diff --git a/internal/db/policy_test.go b/internal/db/policy_test.go index 9e35d33..1fb7dea 100644 --- a/internal/db/policy_test.go +++ b/internal/db/policy_test.go @@ -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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 17f823c..841a9fb 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -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"` } diff --git a/internal/policy/engine_test.go b/internal/policy/engine_test.go index b5936b3..b87fb65 100644 --- a/internal/policy/engine_test.go +++ b/internal/policy/engine_test.go @@ -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") + } +} diff --git a/internal/policy/engine_wrapper.go b/internal/policy/engine_wrapper.go index bb8d478..a44aca4 100644 --- a/internal/policy/engine_wrapper.go +++ b/internal/policy/engine_wrapper.go @@ -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 diff --git a/internal/server/handlers_policy.go b/internal/server/handlers_policy.go index bbf1766..1857856 100644 --- a/internal/server/handlers_policy.go +++ b/internal/server/handlers_policy.go @@ -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 } diff --git a/internal/server/server.go b/internal/server/server.go index be0266c..149949b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 { diff --git a/internal/ui/handlers_accounts.go b/internal/ui/handlers_accounts.go index bc346fd..e56509a 100644 --- a/internal/ui/handlers_accounts.go +++ b/internal/ui/handlers_accounts.go @@ -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") diff --git a/internal/ui/handlers_policy.go b/internal/ui/handlers_policy.go index 951e2ff..2298924 100644 --- a/internal/ui/handlers_policy.go +++ b/internal/ui/handlers_policy.go @@ -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 diff --git a/internal/ui/ui.go b/internal/ui/ui.go index e74a81b..1674a87 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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. diff --git a/openapi.yaml b/openapi.yaml index fda25e7..d908ac7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -206,6 +206,24 @@ components: enabled: type: boolean example: true + not_before: + type: string + format: date-time + nullable: true + description: | + Earliest time the rule becomes active. NULL means no constraint + (always active). Rules where `not_before > now()` are skipped + during evaluation. + example: "2026-04-01T00:00:00Z" + expires_at: + type: string + format: date-time + nullable: true + description: | + Time after which the rule is no longer active. NULL means no + constraint (never expires). Rules where `expires_at <= now()` are + skipped during evaluation. + example: "2026-06-01T00:00:00Z" created_at: type: string format: date-time @@ -582,6 +600,68 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + /v1/auth/password: + put: + summary: Change own password (self-service) + description: | + Change the password of the currently authenticated human account. + The caller must supply the correct `current_password` to prevent + token-theft attacks: possession of a valid JWT alone is not sufficient. + + On success: + - The stored Argon2id hash is replaced with the new password hash. + - All active sessions *except* the caller's current token are revoked. + - The lockout failure counter is cleared. + + On failure (wrong current password): + - A login failure is recorded against the account, subject to the + same lockout rules as `POST /v1/auth/login`. + + Only applies to human accounts. System accounts have no password. + operationId: changePassword + tags: [Auth] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [current_password, new_password] + properties: + current_password: + type: string + description: The account's current password (required for verification). + example: old-s3cr3t + new_password: + type: string + description: The new password. Minimum 12 characters. + example: new-s3cr3t-long + responses: + "204": + description: Password changed. Other active sessions revoked. + "400": + $ref: "#/components/responses/BadRequest" + "401": + description: Current password is incorrect. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: current password is incorrect + code: unauthorized + "429": + description: Account temporarily locked due to too many failed attempts. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: account temporarily locked + code: account_locked + # ── Admin ────────────────────────────────────────────────────────────────── /v1/auth/totp: @@ -984,7 +1064,10 @@ paths: `token_issued`, `token_renewed`, `token_revoked`, `token_expired`, `account_created`, `account_updated`, `account_deleted`, `role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`, - `pgcred_accessed`, `pgcred_updated`. + `pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`, + `pgcred_access_revoked`, `tag_added`, `tag_removed`, + `policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`, + `policy_deny`. operationId: listAudit tags: [Admin — Audit] security: @@ -1118,6 +1201,57 @@ paths: "404": $ref: "#/components/responses/NotFound" + /v1/accounts/{id}/password: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + + put: + summary: Admin password reset (admin) + description: | + Reset the password for a human account without requiring the current + password. This is intended for account recovery (e.g. a user forgot + their password). + + On success: + - The stored Argon2id hash is replaced with the new password hash. + - All active sessions for the target account are revoked. + + Only applies to human accounts. The new password must be at least + 12 characters. + operationId: adminSetPassword + tags: [Admin — Accounts] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [new_password] + properties: + new_password: + type: string + description: The new password. Minimum 12 characters. + example: new-s3cr3t-long + responses: + "204": + description: Password reset. All active sessions for the account revoked. + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /v1/policy/rules: get: summary: List policy rules (admin) @@ -1169,6 +1303,16 @@ paths: example: 50 rule: $ref: "#/components/schemas/RuleBody" + not_before: + type: string + format: date-time + description: Earliest activation time (RFC3339, optional). + example: "2026-04-01T00:00:00Z" + expires_at: + type: string + format: date-time + description: Expiry time (RFC3339, optional). + example: "2026-06-01T00:00:00Z" responses: "201": description: Rule created. @@ -1239,6 +1383,22 @@ paths: example: false rule: $ref: "#/components/schemas/RuleBody" + not_before: + type: string + format: date-time + description: Set earliest activation time (RFC3339). + example: "2026-04-01T00:00:00Z" + expires_at: + type: string + format: date-time + description: Set expiry time (RFC3339). + example: "2026-06-01T00:00:00Z" + clear_not_before: + type: boolean + description: Set to true to remove not_before constraint. + clear_expires_at: + type: boolean + description: Set to true to remove expires_at constraint. responses: "200": description: Updated rule. diff --git a/web/templates/account_detail.html b/web/templates/account_detail.html index c2e1da4..9f04935 100644 --- a/web/templates/account_detail.html +++ b/web/templates/account_detail.html @@ -44,4 +44,15 @@

Tags

{{template "tags_editor" .}}
+{{if eq (string .Account.AccountType) "human"}} +
+

Reset Password

+

+ Set a new password for this account. All active sessions will be revoked. +

+
+ {{template "password_reset_form" .}} +
+
+{{end}} {{end}} diff --git a/web/templates/fragments/password_reset_form.html b/web/templates/fragments/password_reset_form.html new file mode 100644 index 0000000..806c93b --- /dev/null +++ b/web/templates/fragments/password_reset_form.html @@ -0,0 +1,47 @@ +{{define "password_reset_form"}} +
+
+ + +
+
+ + +
+ + +
+ +{{end}} + +{{define "password_reset_result"}} +{{if .Flash}} + +{{end}} +{{template "password_reset_form" .}} +{{end}} diff --git a/web/templates/fragments/policy_form.html b/web/templates/fragments/policy_form.html index a6ffb53..848de9d 100644 --- a/web/templates/fragments/policy_form.html +++ b/web/templates/fragments/policy_form.html @@ -72,6 +72,16 @@ Owner must match subject (self-service rules only) +
+
+ + +
+
+ + +
+
{{end}} diff --git a/web/templates/fragments/policy_row.html b/web/templates/fragments/policy_row.html index fdab018..d7f80bf 100644 --- a/web/templates/fragments/policy_row.html +++ b/web/templates/fragments/policy_row.html @@ -4,6 +4,15 @@ {{.Priority}} {{.Description}} + {{if .IsExpired}}expired{{end}} + {{if .IsPending}}scheduled{{end}} + {{if or .NotBefore .ExpiresAt}} +
+ {{if .NotBefore}}Not before: {{.NotBefore}}{{end}} + {{if and .NotBefore .ExpiresAt}} · {{end}} + {{if .ExpiresAt}}Expires: {{.ExpiresAt}}{{end}} +
+ {{end}}
Show rule JSON
{{.RuleJSON}}