Checkpoint: password reset, rule expiry, migrations

- Self-service and admin password-change endpoints
  (PUT /v1/auth/password, PUT /v1/accounts/{id}/password)
- Policy rule time-scoped expiry (not_before / expires_at)
  with migration 000006 and engine filtering
- golang-migrate integration; embedded SQL migrations
- PolicyRecord fieldalignment lint fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 14:38:38 -07:00
parent d7b69ed983
commit 22158824bd
25 changed files with 1574 additions and 137 deletions

View File

@@ -1 +1 @@
[] [{"lang":"en","usageCount":1}]

83
AGENTS.md Normal file
View File

@@ -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.

View File

@@ -245,6 +245,61 @@ Key properties:
- Admin can revoke all tokens for a user (e.g., on account suspension) - Admin can revoke all tokens for a user (e.g., on account suspension)
- Token expiry is enforced at validation time, regardless of revocation table - 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 ## 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/login` | none | Username/password (+TOTP) login → JWT |
| POST | `/v1/auth/logout` | bearer JWT | Revoke current token | | POST | `/v1/auth/logout` | bearer JWT | Revoke current token |
| POST | `/v1/auth/renew` | bearer JWT | Exchange token for new 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 ### 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.) | | PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account | | 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) ### Role Endpoints (admin only)
| Method | Path | Auth required | Description | | 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/health` | none | Health check |
| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) | | 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 ## 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')) 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. -- Postgres credentials for system accounts, encrypted at rest.
CREATE TABLE pg_credentials ( CREATE TABLE pg_credentials (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE, 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_host TEXT NOT NULL,
pg_port INTEGER NOT NULL DEFAULT 5432, pg_port INTEGER NOT NULL DEFAULT 5432,
pg_database TEXT NOT NULL, 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')) 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. -- Audit log — append-only. Never contains credentials or secret material.
CREATE TABLE audit_log ( CREATE TABLE audit_log (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -496,7 +618,9 @@ CREATE TABLE policy_rules (
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by INTEGER REFERENCES accounts(id), created_by INTEGER REFERENCES accounts(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), 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. 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 ### Middleware Integration
`internal/middleware.RequirePolicy(engine, action, resourceType)` is a drop-in `internal/middleware.RequirePolicy(engine, action, resourceType)` is a drop-in

View File

@@ -17,8 +17,10 @@ MCIAS (Metacircular Identity and Access System) is a single-sign-on (SSO) and Id
## Binaries ## Binaries
- `mciassrv` — authentication server (REST API over HTTPS/TLS) - `mciassrv` — authentication server (REST + gRPC over HTTPS/TLS, with HTMX web UI)
- `mciasctl` — admin CLI for account/token/credential management - `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 ## Development Workflow

View File

@@ -4,6 +4,156 @@ Source of truth for current development state.
--- ---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean. 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 ### 2026-03-12 — Integrate golang-migrate for database migrations
**internal/db/migrations/** (new directory — 5 embedded SQL files) **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 6: mciasdb — direct SQLite maintenance tool
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST) - [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 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 - [x] Phase 10: Policy engine — ABAC with machine/service gating
--- ---
### 2026-03-11 — Phase 10: Policy engine (ABAC + 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. 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 **NOTE:** The client libraries described in ARCHITECTURE.md §19 were designed
- login_response.json, account_response.json, accounts_list_response.json but never committed to the repository. The `clients/` directory does not exist.
- validate_token_response.json, public_key_response.json, pgcreds_response.json Only `test/mock/mockserver.go` was implemented. The designs remain in
- error_response.json, roles_response.json ARCHITECTURE.md for future implementation.
**clients/go/** — Go client library
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`; package `mciasgoclient`
- Typed errors: `MciasAuthError`, `MciasForbiddenError`, `MciasNotFoundError`,
`MciasInputError`, `MciasConflictError`, `MciasServerError`
- TLS 1.2+ enforced via `tls.Config{MinVersion: tls.VersionTLS12}`
- Token state guarded by `sync.RWMutex` for concurrent safety
- JSON decoded with `DisallowUnknownFields` on all responses
- 25 tests in `client_test.go`; all pass with `go test -race`
**clients/rust/** — Rust async client library
- Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep)
- `MciasError` enum via `thiserror`; `Arc<RwLock<Option<String>>>` for token
- 23 integration tests using `wiremock`; `cargo clippy -- -D warnings` clean
**clients/lisp/** — Common Lisp client library
- ASDF system `mcias-client`; HTTP via dexador, JSON via yason
- CLOS class `mcias-client`; plain functions for all operations
- Conditions: `mcias-error` base + 6 typed subclasses
- Mock server: Hunchentoot `mock-dispatcher` subclass (port 0, random per test)
- 37 fiveam checks; all pass on SBCL 2.6.1
- Fixed: yason decodes JSON `false` as `:false`; `validate-token` normalises
to `t`/`nil` before returning
**clients/python/** — Python 3.11+ client library
- Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27`
- `Client` context manager; `py.typed` marker; all symbols fully annotated
- Dataclasses: `Account`, `PublicKey`, `PGCreds`
- 32 pytest tests using `respx` mock transport; `mypy --strict` clean; `ruff` clean
**test/mock/mockserver.go** — Go in-memory mock server **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 - `NewServer()`, `AddAccount()`, `ServeHTTP()` for httptest.Server use
--- ---

View File

@@ -2,7 +2,8 @@
MCIAS is a self-hosted SSO and IAM service for personal projects. MCIAS is a self-hosted SSO and IAM service for personal projects.
It provides authentication (JWT/Ed25519), account management, TOTP, and 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 See [ARCHITECTURE.md](ARCHITECTURE.md) for the technical design and
[PROJECT_PLAN.md](PROJECT_PLAN.md) for the implementation roadmap. [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 export MCIAS_TOKEN=$TOKEN
mciasctl -server https://localhost:8443 account list 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 role set -id $UUID -roles admin
mciasctl token issue -id $SYSTEM_UUID mciasctl token issue -id $SYSTEM_UUID
mciasctl pgcreds set -id $UUID -host db.example.com -port 5432 \ 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 ## Deploying with Docker
```sh ```sh

View File

@@ -16,13 +16,15 @@
// //
// Commands: // 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 list
// account create -username NAME [-password PASS] [-type human|system] // account create -username NAME [-type human|system]
// account get -id UUID // account get -id UUID
// account update -id UUID [-status active|inactive] // account update -id UUID [-status active|inactive]
// account delete -id UUID // account delete -id UUID
// account set-password -id UUID
// //
// role list -id UUID // role list -id UUID
// role set -id UUID -roles role1,role2,... // role set -id UUID -roles role1,role2,...
@@ -34,9 +36,9 @@
// pgcreds get -id UUID // pgcreds get -id UUID
// //
// policy list // 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 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 // policy delete -id ID
// //
// tag list -id UUID // tag list -id UUID
@@ -123,28 +125,28 @@ type controller struct {
func (c *controller) runAuth(args []string) { func (c *controller) runAuth(args []string) {
if len(args) == 0 { if len(args) == 0 {
fatalf("auth requires a subcommand: login") fatalf("auth requires a subcommand: login, change-password")
} }
switch args[0] { switch args[0] {
case "login": case "login":
c.authLogin(args[1:]) c.authLogin(args[1:])
case "change-password":
c.authChangePassword(args[1:])
default: default:
fatalf("unknown auth subcommand %q", args[0]) fatalf("unknown auth subcommand %q", args[0])
} }
} }
// authLogin authenticates with the server using username and password, then // authLogin authenticates with the server using username and password, then
// prints the resulting bearer token to stdout. If -password is not supplied on // prints the resulting bearer token to stdout. The password is always prompted
// the command line, the user is prompted interactively (input is hidden so the // interactively; it is never accepted as a command-line flag to prevent it from
// password does not appear in shell history or terminal output). // appearing in shell history, ps output, and process argument lists.
// //
// Security: passwords are never stored by this process beyond the lifetime of // Security: terminal echo is disabled during password entry
// the HTTP request. Interactive reads use golang.org/x/term.ReadPassword so // (golang.org/x/term.ReadPassword); the raw byte slice is zeroed after use.
// that terminal echo is disabled; the byte slice is zeroed after use.
func (c *controller) authLogin(args []string) { func (c *controller) authLogin(args []string) {
fs := flag.NewFlagSet("auth login", flag.ExitOnError) fs := flag.NewFlagSet("auth login", flag.ExitOnError)
username := fs.String("username", "", "username (required)") 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)") totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
_ = fs.Parse(args) _ = fs.Parse(args)
@@ -152,21 +154,19 @@ func (c *controller) authLogin(args []string) {
fatalf("auth login: -username is required") fatalf("auth login: -username is required")
} }
// If no password flag was provided, prompt interactively so it does not // Security: always prompt interactively; never accept password as a flag.
// appear in process arguments or shell history. // This prevents the credential from appearing in shell history, ps output,
passwd := *password // and /proc/PID/cmdline.
if passwd == "" { fmt.Fprint(os.Stderr, "Password: ")
fmt.Fprint(os.Stderr, "Password: ") raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms fmt.Fprintln(os.Stderr) // newline after hidden input
fmt.Fprintln(os.Stderr) // newline after hidden input if err != nil {
if err != nil { fatalf("read password: %v", err)
fatalf("read password: %v", err) }
} passwd := string(raw)
passwd = string(raw) // Zero the raw byte slice once copied into the string.
// Zero the raw byte slice once copied into the string. for i := range raw {
for i := range raw { raw[i] = 0
raw[i] = 0
}
} }
body := map[string]string{ 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 ---- // ---- account subcommands ----
func (c *controller) runAccount(args []string) { func (c *controller) runAccount(args []string) {
if len(args) == 0 { 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] { switch args[0] {
case "list": case "list":
@@ -208,6 +250,8 @@ func (c *controller) runAccount(args []string) {
c.accountUpdate(args[1:]) c.accountUpdate(args[1:])
case "delete": case "delete":
c.accountDelete(args[1:]) c.accountDelete(args[1:])
case "set-password":
c.accountSetPassword(args[1:])
default: default:
fatalf("unknown account subcommand %q", args[0]) fatalf("unknown account subcommand %q", args[0])
} }
@@ -222,7 +266,6 @@ func (c *controller) accountList() {
func (c *controller) accountCreate(args []string) { func (c *controller) accountCreate(args []string) {
fs := flag.NewFlagSet("account create", flag.ExitOnError) fs := flag.NewFlagSet("account create", flag.ExitOnError)
username := fs.String("username", "", "username (required)") 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") accountType := fs.String("type", "human", "account type: human or system")
_ = fs.Parse(args) _ = fs.Parse(args)
@@ -230,12 +273,11 @@ func (c *controller) accountCreate(args []string) {
fatalf("account create: -username is required") fatalf("account create: -username is required")
} }
// For human accounts, prompt for a password interactively if one was not // Security: always prompt interactively for human-account passwords; never
// supplied on the command line so it stays out of shell history. // accept them as a flag. Terminal echo is disabled; the raw byte slice is
// Security: terminal echo is disabled during entry; the raw byte slice is // zeroed after conversion to string. System accounts have no password.
// zeroed after conversion to string. System accounts have no password. var passwd string
passwd := *password if *accountType == "human" {
if passwd == "" && *accountType == "human" {
fmt.Fprint(os.Stderr, "Password: ") fmt.Fprint(os.Stderr, "Password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr)
@@ -306,6 +348,40 @@ func (c *controller) accountDelete(args []string) {
fmt.Println("account deleted") 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 ---- // ---- role subcommands ----
func (c *controller) runRole(args []string) { func (c *controller) runRole(args []string) {
@@ -511,6 +587,8 @@ func (c *controller) policyCreate(args []string) {
description := fs.String("description", "", "rule description (required)") description := fs.String("description", "", "rule description (required)")
jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)") jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)")
priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)") 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) _ = fs.Parse(args)
if *description == "" { if *description == "" {
@@ -537,6 +615,18 @@ func (c *controller) policyCreate(args []string) {
"priority": *priority, "priority": *priority,
"rule": ruleBody, "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 var result json.RawMessage
c.doRequest("POST", "/v1/policy/rules", body, &result) 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)") id := fs.String("id", "", "rule ID (required)")
priority := fs.Int("priority", -1, "new priority (-1 = no change)") priority := fs.Int("priority", -1, "new priority (-1 = no change)")
enabled := fs.String("enabled", "", "true or false") 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) _ = fs.Parse(args)
if *id == "" { if *id == "" {
@@ -584,8 +678,24 @@ func (c *controller) policyUpdate(args []string) {
fatalf("policy update: -enabled must be true or false") 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 { 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 var result json.RawMessage
@@ -766,16 +876,25 @@ Global flags:
-cacert Path to CA certificate for TLS verification -cacert Path to CA certificate for TLS verification
Commands: Commands:
auth login -username NAME [-password PASS] [-totp CODE] auth login -username NAME [-totp CODE]
Obtain a bearer token. Password is prompted if -password is Obtain a bearer token. Password is always prompted interactively
omitted. Token is written to stdout; expiry to stderr. (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) 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 list
account create -username NAME [-password PASS] [-type human|system] account create -username NAME [-type human|system]
account get -id UUID account get -id UUID
account update -id UUID -status active|inactive account update -id UUID -status active|inactive
account delete -id UUID 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 list -id UUID
role set -id UUID -roles role1,role2,... role set -id UUID -roles role1,role2,...
@@ -788,10 +907,13 @@ Commands:
policy list policy list
policy create -description STR -json FILE [-priority N] policy create -description STR -json FILE [-priority N]
[-not-before RFC3339] [-expires-at RFC3339]
FILE must contain a JSON rule body, e.g.: FILE must contain a JSON rule body, e.g.:
{"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true} {"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true}
policy get -id ID 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 policy delete -id ID
tag list -id UUID tag list -id UUID

View File

@@ -128,14 +128,23 @@ func (db *DB) UpdateAccountStatus(accountID int64, status model.AccountStatus) e
} }
// UpdatePasswordHash updates the Argon2id password hash for an account. // 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 { 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 = ? UPDATE accounts SET password_hash = ?, updated_at = ?
WHERE id = ? WHERE id = ?
`, hash, now(), accountID) `, hash, now(), accountID)
if err != nil { if err != nil {
return fmt.Errorf("db: update password hash: %w", err) 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 return nil
} }
@@ -640,6 +649,23 @@ func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error {
return nil 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. // PruneExpiredTokens removes token_revocation rows that are past their expiry.
// Returns the number of rows deleted. // Returns the number of rows deleted.
func (db *DB) PruneExpiredTokens() (int64, error) { func (db *DB) PruneExpiredTokens() (int64, error) {

View File

@@ -21,7 +21,7 @@ var migrationsFS embed.FS
// LatestSchemaVersion is the highest migration version defined in the // LatestSchemaVersion is the highest migration version defined in the
// migrations/ directory. Update this constant whenever a new migration file // migrations/ directory. Update this constant whenever a new migration file
// is added. // is added.
const LatestSchemaVersion = 5 const LatestSchemaVersion = 6
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL // 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 // files. It opens a dedicated *sql.DB using the same DSN as the main

View File

@@ -0,0 +1,6 @@
-- Add optional time-scoped validity window to policy rules.
-- NULL means "no constraint" (rule is always active / never expires).
-- The policy engine skips rules where not_before > now() or expires_at <= now()
-- at cache-load time (SetRules), not at query time.
ALTER TABLE policy_rules ADD COLUMN not_before TEXT DEFAULT NULL;
ALTER TABLE policy_rules ADD COLUMN expires_at TEXT DEFAULT NULL;

View File

@@ -4,18 +4,23 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model" "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 // CreatePolicyRule inserts a new policy rule record. The returned record
// includes the database-assigned ID and timestamps. // 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() n := now()
result, err := db.sql.Exec(` result, err := db.sql.Exec(`
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at) INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at, not_before, expires_at)
VALUES (?, ?, ?, 1, ?, ?, ?) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)
`, priority, description, ruleJSON, createdBy, n, n) `, priority, description, ruleJSON, createdBy, n, n, formatNullableTime(notBefore), formatNullableTime(expiresAt))
if err != nil { if err != nil {
return nil, fmt.Errorf("db: create policy rule: %w", err) 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, CreatedBy: createdBy,
CreatedAt: createdAt, CreatedAt: createdAt,
UpdatedAt: createdAt, UpdatedAt: createdAt,
NotBefore: notBefore,
ExpiresAt: expiresAt,
}, nil }, nil
} }
@@ -46,7 +53,7 @@ func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string
// Returns ErrNotFound if no such rule exists. // Returns ErrNotFound if no such rule exists.
func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) { func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
return db.scanPolicyRule(db.sql.QueryRow(` 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 = ? FROM policy_rules WHERE id = ?
`, 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. // When enabledOnly is true, only rules with enabled=1 are returned.
func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, error) { func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, error) {
query := ` query := `
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at SELECT ` + policyRuleCols + `
FROM policy_rules` FROM policy_rules`
if enabledOnly { if enabledOnly {
query += ` WHERE enabled = 1` 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. // UpdatePolicyRule updates the mutable fields of a policy rule.
// Only the fields in the update map are changed; other fields are untouched. // Only non-nil fields are changed; nil fields are left untouched.
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string) error { // 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() n := now()
// Build SET clause dynamically to only update provided fields. // 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 = ?" setClauses += ", rule_json = ?"
args = append(args, *ruleJSON) 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) args = append(args, id)
_, err := db.sql.Exec(`UPDATE policy_rules SET `+setClauses+` WHERE id = ?`, args...) _, 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 enabledInt int
var createdAtStr, updatedAtStr string var createdAtStr, updatedAtStr string
var createdBy *int64 var createdBy *int64
var notBeforeStr, expiresAtStr *string
err := row.Scan( err := row.Scan(
&r.ID, &r.Priority, &r.Description, &r.RuleJSON, &r.ID, &r.Priority, &r.Description, &r.RuleJSON,
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr, &enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
&notBeforeStr, &expiresAtStr,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound 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 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. // 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 enabledInt int
var createdAtStr, updatedAtStr string var createdAtStr, updatedAtStr string
var createdBy *int64 var createdBy *int64
var notBeforeStr, expiresAtStr *string
err := rows.Scan( err := rows.Scan(
&r.ID, &r.Priority, &r.Description, &r.RuleJSON, &r.ID, &r.Priority, &r.Description, &r.RuleJSON,
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr, &enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
&notBeforeStr, &expiresAtStr,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("db: scan policy rule row: %w", err) 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.Enabled = enabledInt == 1
r.CreatedBy = createdBy r.CreatedBy = createdBy
@@ -187,5 +210,23 @@ func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *
if err != nil { if err != nil {
return nil, err 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 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
}

View File

@@ -3,6 +3,7 @@ package db
import ( import (
"errors" "errors"
"testing" "testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/model"
) )
@@ -11,7 +12,7 @@ func TestCreateAndGetPolicyRule(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ruleJSON := `{"actions":["pgcreds:read"],"resource_type":"pgcreds","effect":"allow"}` 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 { if err != nil {
t.Fatalf("CreatePolicyRule: %v", err) t.Fatalf("CreatePolicyRule: %v", err)
} }
@@ -49,9 +50,9 @@ func TestGetPolicyRule_NotFound(t *testing.T) {
func TestListPolicyRules(t *testing.T) { func TestListPolicyRules(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil) _, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil, nil, nil)
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil) _, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil, nil, nil)
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil) _, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil, nil, nil)
rules, err := db.ListPolicyRules(false) rules, err := db.ListPolicyRules(false)
if err != nil { if err != nil {
@@ -70,8 +71,8 @@ func TestListPolicyRules(t *testing.T) {
func TestListPolicyRules_EnabledOnly(t *testing.T) { func TestListPolicyRules_EnabledOnly(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil) r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil, nil, nil)
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil) r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil, nil, nil)
if err := db.SetPolicyRuleEnabled(r2.ID, false); err != nil { if err := db.SetPolicyRuleEnabled(r2.ID, false); err != nil {
t.Fatalf("SetPolicyRuleEnabled: %v", err) t.Fatalf("SetPolicyRuleEnabled: %v", err)
@@ -100,11 +101,11 @@ func TestListPolicyRules_EnabledOnly(t *testing.T) {
func TestUpdatePolicyRule(t *testing.T) { func TestUpdatePolicyRule(t *testing.T) {
db := openTestDB(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" newDesc := "updated description"
newPriority := 25 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) t.Fatalf("UpdatePolicyRule: %v", err)
} }
@@ -127,10 +128,10 @@ func TestUpdatePolicyRule(t *testing.T) {
func TestUpdatePolicyRule_RuleJSON(t *testing.T) { func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
db := openTestDB(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"]}` 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) t.Fatalf("UpdatePolicyRule (json only): %v", err)
} }
@@ -150,7 +151,7 @@ func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
func TestSetPolicyRuleEnabled(t *testing.T) { func TestSetPolicyRuleEnabled(t *testing.T) {
db := openTestDB(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 { if !rec.Enabled {
t.Fatal("new rule should be enabled") t.Fatal("new rule should be enabled")
} }
@@ -175,7 +176,7 @@ func TestSetPolicyRuleEnabled(t *testing.T) {
func TestDeletePolicyRule(t *testing.T) { func TestDeletePolicyRule(t *testing.T) {
db := openTestDB(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 { if err := db.DeletePolicyRule(rec.ID); err != nil {
t.Fatalf("DeletePolicyRule: %v", err) t.Fatalf("DeletePolicyRule: %v", err)
@@ -200,7 +201,7 @@ func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
acct, _ := db.CreateAccount("policy-creator", model.AccountTypeHuman, "hash") 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 { if err != nil {
t.Fatalf("CreatePolicyRule with createdBy: %v", err) 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) 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)
}
}

View File

@@ -167,18 +167,24 @@ type PGCredAccessGrant struct {
const ( const (
EventPGCredAccessGranted = "pgcred_access_granted" //nolint:gosec // G101: audit event type, not a credential 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 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. // PolicyRuleRecord is the database representation of a policy rule.
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields). // RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
// The ID, Priority, and Description are stored as dedicated columns. // 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 { type PolicyRuleRecord struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
CreatedBy *int64 `json:"-"` NotBefore *time.Time `json:"not_before,omitempty"`
Description string `json:"description"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
RuleJSON string `json:"rule_json"` CreatedBy *int64 `json:"-"`
ID int64 `json:"id"` Description string `json:"description"`
Priority int `json:"priority"` RuleJSON string `json:"rule_json"`
Enabled bool `json:"enabled"` ID int64 `json:"id"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
} }

View File

@@ -2,6 +2,7 @@ package policy
import ( import (
"testing" "testing"
"time"
) )
// adminInput is a convenience helper for building admin PolicyInputs. // 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") 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")
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"sync" "sync"
"time"
) )
// Engine wraps the stateless Evaluate function with an in-memory cache of // 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 // into a Rule. This prevents the database from injecting values into the ID or
// Description fields that are stored as dedicated columns. // Description fields that are stored as dedicated columns.
func (e *Engine) SetRules(records []PolicyRecord) error { func (e *Engine) SetRules(records []PolicyRecord) error {
now := time.Now()
rules := make([]Rule, 0, len(records)) rules := make([]Rule, 0, len(records))
for _, rec := range records { for _, rec := range records {
if !rec.Enabled { if !rec.Enabled {
continue 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 var body RuleBody
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil { if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
return fmt.Errorf("policy: decode rule %d %q: %w", rec.ID, rec.Description, err) 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, // Using a local struct avoids importing the db or model packages from policy,
// which would create a dependency cycle. // which would create a dependency cycle.
type PolicyRecord struct { type PolicyRecord struct {
NotBefore *time.Time
ExpiresAt *time.Time
Description string Description string
RuleJSON string RuleJSON string
ID int64 ID int64

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/kyle/mcias/internal/middleware"
@@ -90,6 +91,8 @@ func (s *Server) handleSetTags(w http.ResponseWriter, r *http.Request) {
type policyRuleResponse struct { type policyRuleResponse struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
NotBefore *string `json:"not_before,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
Description string `json:"description"` Description string `json:"description"`
RuleBody policy.RuleBody `json:"rule"` RuleBody policy.RuleBody `json:"rule"`
ID int64 `json:"id"` 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 { if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err) return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err)
} }
return policyRuleResponse{ resp := policyRuleResponse{
ID: rec.ID, ID: rec.ID,
Priority: rec.Priority, Priority: rec.Priority,
Description: rec.Description, Description: rec.Description,
RuleBody: body, RuleBody: body,
Enabled: rec.Enabled, Enabled: rec.Enabled,
CreatedAt: rec.CreatedAt.Format("2006-01-02T15:04:05Z"), CreatedAt: rec.CreatedAt.Format(time.RFC3339),
UpdatedAt: rec.UpdatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: rec.UpdatedAt.Format(time.RFC3339),
}, nil }
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) { 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 { type createPolicyRuleRequest struct {
Description string `json:"description"` Description string `json:"description"`
NotBefore *string `json:"not_before,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
Rule policy.RuleBody `json:"rule"` Rule policy.RuleBody `json:"rule"`
Priority int `json:"priority"` Priority int `json:"priority"`
} }
@@ -157,6 +171,29 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
priority = 100 // default 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) ruleJSON, err := json.Marshal(req.Rule)
if err != nil { if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") 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 { if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return return
@@ -202,10 +239,14 @@ func (s *Server) handleGetPolicyRule(w http.ResponseWriter, r *http.Request) {
} }
type updatePolicyRuleRequest struct { type updatePolicyRuleRequest struct {
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Rule *policy.RuleBody `json:"rule,omitempty"` NotBefore *string `json:"not_before,omitempty"`
Priority *int `json:"priority,omitempty"` ExpiresAt *string `json:"expires_at,omitempty"`
Enabled *bool `json:"enabled,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) { 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") middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return return
} }
s := string(b) js := string(b)
ruleJSON = &s 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") middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return return
} }

View File

@@ -121,6 +121,10 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit))) mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags))) 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}/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("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule))) mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule))) 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) 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 ---- // ---- Postgres credential endpoints ----
type pgCredRequest struct { type pgCredRequest struct {

View File

@@ -896,6 +896,97 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther) 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. // handleIssueSystemToken issues a long-lived service token for a system account.
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) { func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "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. // policyRuleToView converts a DB record to a template-friendly view.
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView { func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
pretty := prettyJSONStr(rec.RuleJSON) pretty := prettyJSONStr(rec.RuleJSON)
return &PolicyRuleView{ v := &PolicyRuleView{
ID: rec.ID, ID: rec.ID,
Priority: rec.Priority, Priority: rec.Priority,
Description: rec.Description, Description: rec.Description,
@@ -79,6 +80,16 @@ func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"), CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
UpdatedAt: rec.UpdatedAt.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 { func prettyJSONStr(s string) string {
@@ -160,6 +171,29 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
return 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()) claims := claimsFromContext(r.Context())
var actorID *int64 var actorID *int64
if claims != nil { 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 { if err != nil {
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err)) u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
return return

View File

@@ -190,6 +190,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"templates/fragments/tags_editor.html", "templates/fragments/tags_editor.html",
"templates/fragments/policy_row.html", "templates/fragments/policy_row.html",
"templates/fragments/policy_form.html", "templates/fragments/policy_form.html",
"templates/fragments/password_reset_form.html",
} }
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...) base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil { 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("PATCH /policies/{id}/enabled", admin(u.handleTogglePolicyRule))
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule)) uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags)) 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 // 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 // catch-all for all UI paths; the more-specific /v1/ API patterns registered
@@ -593,9 +595,13 @@ type PolicyRuleView struct {
RuleJSON string RuleJSON string
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
NotBefore string // empty if not set
ExpiresAt string // empty if not set
ID int64 ID int64
Priority int Priority int
Enabled bool 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. // PoliciesData is the view model for the policies list page.

View File

@@ -206,6 +206,24 @@ components:
enabled: enabled:
type: boolean type: boolean
example: true 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: created_at:
type: string type: string
format: date-time format: date-time
@@ -582,6 +600,68 @@ paths:
"401": "401":
$ref: "#/components/responses/Unauthorized" $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 ────────────────────────────────────────────────────────────────── # ── Admin ──────────────────────────────────────────────────────────────────
/v1/auth/totp: /v1/auth/totp:
@@ -984,7 +1064,10 @@ paths:
`token_issued`, `token_renewed`, `token_revoked`, `token_expired`, `token_issued`, `token_renewed`, `token_revoked`, `token_expired`,
`account_created`, `account_updated`, `account_deleted`, `account_created`, `account_updated`, `account_deleted`,
`role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`, `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 operationId: listAudit
tags: [Admin — Audit] tags: [Admin — Audit]
security: security:
@@ -1118,6 +1201,57 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $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: /v1/policy/rules:
get: get:
summary: List policy rules (admin) summary: List policy rules (admin)
@@ -1169,6 +1303,16 @@ paths:
example: 50 example: 50
rule: rule:
$ref: "#/components/schemas/RuleBody" $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: responses:
"201": "201":
description: Rule created. description: Rule created.
@@ -1239,6 +1383,22 @@ paths:
example: false example: false
rule: rule:
$ref: "#/components/schemas/RuleBody" $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: responses:
"200": "200":
description: Updated rule. description: Updated rule.

View File

@@ -44,4 +44,15 @@
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2> <h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
<div id="tags-editor">{{template "tags_editor" .}}</div> <div id="tags-editor">{{template "tags_editor" .}}</div>
</div> </div>
{{if eq (string .Account.AccountType) "human"}}
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Reset Password</h2>
<p class="text-muted text-small" style="margin-bottom:.75rem">
Set a new password for this account. All active sessions will be revoked.
</p>
<div id="password-reset-section">
{{template "password_reset_form" .}}
</div>
</div>
{{end}}
{{end}} {{end}}

View File

@@ -0,0 +1,47 @@
{{define "password_reset_form"}}
<form id="password-reset-form"
hx-put="/accounts/{{.Account.UUID}}/password"
hx-target="#password-reset-section"
hx-swap="innerHTML"
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'
onsubmit="return mciasPwConfirm(this)">
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password"
class="form-control" autocomplete="new-password"
placeholder="Minimum 12 characters" required minlength="12">
</div>
<div class="form-group" style="margin-top:.5rem">
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password"
class="form-control" autocomplete="new-password"
placeholder="Repeat new password" required minlength="12">
</div>
<div id="pw-reset-error" role="alert"
style="display:none;color:var(--color-danger,#c0392b);font-size:.85rem;margin-top:.35rem"></div>
<button type="submit" class="btn btn-danger btn-sm" style="margin-top:.75rem">
Reset Password
</button>
</form>
<script>
function mciasPwConfirm(form) {
var pw = form.querySelector('#new_password').value;
var cfm = form.querySelector('#confirm_password').value;
var err = form.querySelector('#pw-reset-error');
if (pw !== cfm) {
err.textContent = 'Passwords do not match.';
err.style.display = 'block';
return false;
}
err.style.display = 'none';
return true;
}
</script>
{{end}}
{{define "password_reset_result"}}
{{if .Flash}}
<div class="alert alert-success" role="alert">{{.Flash}}</div>
{{end}}
{{template "password_reset_form" .}}
{{end}}

View File

@@ -72,6 +72,16 @@
Owner must match subject (self-service rules only) Owner must match subject (self-service rules only)
</label> </label>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div>
<label class="text-small text-muted">Not before (UTC, optional)</label>
<input class="form-control" type="datetime-local" name="not_before" style="font-size:.85rem">
</div>
<div>
<label class="text-small text-muted">Expires at (UTC, optional)</label>
<input class="form-control" type="datetime-local" name="expires_at" style="font-size:.85rem">
</div>
</div>
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button> <button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
</form> </form>
{{end}} {{end}}

View File

@@ -4,6 +4,15 @@
<td class="text-small">{{.Priority}}</td> <td class="text-small">{{.Priority}}</td>
<td> <td>
<strong>{{.Description}}</strong> <strong>{{.Description}}</strong>
{{if .IsExpired}}<span class="badge" style="background:#dc2626;color:#fff;margin-left:.4rem">expired</span>{{end}}
{{if .IsPending}}<span class="badge" style="background:#d97706;color:#fff;margin-left:.4rem">scheduled</span>{{end}}
{{if or .NotBefore .ExpiresAt}}
<div class="text-small text-muted" style="margin-top:.2rem">
{{if .NotBefore}}Not before: {{.NotBefore}}{{end}}
{{if and .NotBefore .ExpiresAt}} · {{end}}
{{if .ExpiresAt}}Expires: {{.ExpiresAt}}{{end}}
</div>
{{end}}
<details style="margin-top:.25rem"> <details style="margin-top:.25rem">
<summary class="text-small text-muted" style="cursor:pointer">Show rule JSON</summary> <summary class="text-small text-muted" style="cursor:pointer">Show rule JSON</summary>
<pre style="font-size:.75rem;background:#f8fafc;padding:.5rem;border-radius:4px;overflow:auto;margin-top:.25rem">{{.RuleJSON}}</pre> <pre style="font-size:.75rem;background:#f8fafc;padding:.5rem;border-radius:4px;overflow:auto;margin-top:.25rem">{{.RuleJSON}}</pre>