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:
@@ -1 +1 @@
|
|||||||
[]
|
[{"lang":"en","usageCount":1}]
|
||||||
83
AGENTS.md
Normal file
83
AGENTS.md
Normal 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.
|
||||||
146
ARCHITECTURE.md
146
ARCHITECTURE.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
193
PROGRESS.md
193
PROGRESS.md
@@ -4,6 +4,156 @@ Source of truth for current development state.
|
|||||||
---
|
---
|
||||||
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -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
|
||||||
|
|||||||
@@ -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,22 +154,20 @@ 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{
|
||||||
"username": *username,
|
"username": *username,
|
||||||
@@ -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.
|
||||||
passwd := *password
|
var passwd string
|
||||||
if passwd == "" && *accountType == "human" {
|
if *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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
6
internal/db/migrations/000006_policy_rule_expiry.up.sql
Normal file
6
internal/db/migrations/000006_policy_rule_expiry.up.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add optional time-scoped validity window to policy rules.
|
||||||
|
-- NULL means "no constraint" (rule is always active / never expires).
|
||||||
|
-- The policy engine skips rules where not_before > now() or expires_at <= now()
|
||||||
|
-- at cache-load time (SetRules), not at query time.
|
||||||
|
ALTER TABLE policy_rules ADD COLUMN not_before TEXT DEFAULT NULL;
|
||||||
|
ALTER TABLE policy_rules ADD COLUMN expires_at TEXT DEFAULT NULL;
|
||||||
@@ -4,18 +4,23 @@ import (
|
|||||||
"database/sql"
|
"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,
|
||||||
|
¬BeforeStr, &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,
|
||||||
|
¬BeforeStr, &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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,14 +167,20 @@ 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"`
|
||||||
|
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
CreatedBy *int64 `json:"-"`
|
CreatedBy *int64 `json:"-"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
RuleJSON string `json:"rule_json"`
|
RuleJSON string `json:"rule_json"`
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -203,9 +240,13 @@ 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"`
|
||||||
|
NotBefore *string `json:"not_before,omitempty"`
|
||||||
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||||
Rule *policy.RuleBody `json:"rule,omitempty"`
|
Rule *policy.RuleBody `json:"rule,omitempty"`
|
||||||
Priority *int `json:"priority,omitempty"`
|
Priority *int `json:"priority,omitempty"`
|
||||||
Enabled *bool `json:"enabled,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
162
openapi.yaml
162
openapi.yaml
@@ -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.
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
47
web/templates/fragments/password_reset_form.html
Normal file
47
web/templates/fragments/password_reset_form.html
Normal 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}}
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user