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:
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)
|
||||
- Token expiry is enforced at validation time, regardless of revocation table
|
||||
|
||||
### Password Change Flows
|
||||
|
||||
Two distinct flows exist for changing a password, with different trust assumptions:
|
||||
|
||||
#### Self-Service Password Change (`PUT /v1/auth/password`)
|
||||
|
||||
Used by a human account holder to change their own password.
|
||||
|
||||
1. Caller presents a valid JWT and supplies both `current_password` and
|
||||
`new_password` in the request body.
|
||||
2. The server looks up the account by the JWT subject.
|
||||
3. **Lockout check** — same policy as login (10 failures in 15 min → 15 min
|
||||
lockout). An attacker with a stolen token cannot use this endpoint to
|
||||
brute-force the current password without hitting the lockout.
|
||||
4. **Current password verified** with `auth.VerifyPassword` (Argon2id,
|
||||
constant-time via `crypto/subtle.ConstantTimeCompare`). On failure a login
|
||||
failure is recorded and HTTP 401 is returned.
|
||||
5. New password is validated (minimum 12 characters) and hashed with Argon2id
|
||||
using the server's configured parameters.
|
||||
6. The new hash is written atomically to the `accounts` table.
|
||||
7. **All tokens except the caller's current JTI are revoked** (reason:
|
||||
`password_changed`). The caller keeps their active session; all other
|
||||
concurrent sessions are invalidated. This limits the blast radius of a
|
||||
credential compromise without logging the user out mid-operation.
|
||||
8. Login failure counter is cleared (successful proof of knowledge).
|
||||
9. Audit event `password_changed` is written with `{"via":"self_service"}`.
|
||||
|
||||
#### Admin Password Reset (`PUT /v1/accounts/{id}/password`)
|
||||
|
||||
Used by an administrator to reset a human account's password for recovery
|
||||
purposes (e.g. user forgot their password, account handover).
|
||||
|
||||
1. Caller presents an admin JWT.
|
||||
2. Only `new_password` is required; no `current_password` verification is
|
||||
performed. The admin role represents a higher trust level.
|
||||
3. New password is validated (minimum 12 characters) and hashed with Argon2id.
|
||||
4. The new hash is written to the `accounts` table.
|
||||
5. **All active tokens for the target account are revoked** (reason:
|
||||
`password_reset`). Unlike the self-service flow, the admin cannot preserve
|
||||
the user's session because the reset is typically done during an outage of
|
||||
the user's access.
|
||||
6. Audit event `password_changed` is written with `{"via":"admin_reset"}`.
|
||||
|
||||
#### Security Notes
|
||||
|
||||
- The current password requirement on the self-service path prevents an
|
||||
attacker who steals a JWT from changing credentials. A stolen token grants
|
||||
access to resources for its remaining lifetime but cannot be used to
|
||||
permanently take over the account.
|
||||
- Admin resets are always audited with both actor and target IDs so the log
|
||||
shows which admin performed the reset.
|
||||
- Plaintext passwords are never logged, stored, or included in any response.
|
||||
- Both flows use the same Argon2id parameters (OWASP 2023: time=3, memory=64 MB,
|
||||
threads=4, hash length=32 bytes).
|
||||
|
||||
---
|
||||
|
||||
## 7. Multi-App Trust Boundaries
|
||||
@@ -285,6 +340,7 @@ All endpoints use JSON request/response bodies. All responses include a
|
||||
| POST | `/v1/auth/login` | none | Username/password (+TOTP) login → JWT |
|
||||
| POST | `/v1/auth/logout` | bearer JWT | Revoke current token |
|
||||
| POST | `/v1/auth/renew` | bearer JWT | Exchange token for new token |
|
||||
| PUT | `/v1/auth/password` | bearer JWT | Self-service password change (requires current password) |
|
||||
|
||||
### Token Endpoints
|
||||
|
||||
@@ -304,6 +360,13 @@ All endpoints use JSON request/response bodies. All responses include a
|
||||
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
|
||||
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
|
||||
|
||||
### Password Endpoints
|
||||
|
||||
| Method | Path | Auth required | Description |
|
||||
|---|---|---|---|
|
||||
| PUT | `/v1/auth/password` | bearer JWT | Self-service: change own password (current password required) |
|
||||
| PUT | `/v1/accounts/{id}/password` | admin JWT | Admin reset: set any human account's password |
|
||||
|
||||
### Role Endpoints (admin only)
|
||||
|
||||
| Method | Path | Auth required | Description |
|
||||
@@ -356,6 +419,38 @@ All endpoints use JSON request/response bodies. All responses include a
|
||||
| GET | `/v1/health` | none | Health check |
|
||||
| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) |
|
||||
|
||||
### Web Management UI
|
||||
|
||||
mciassrv embeds an HTMX-based web management interface served alongside the
|
||||
REST API. The UI is an admin-only interface providing a visual alternative to
|
||||
`mciasctl` for day-to-day management.
|
||||
|
||||
**Package:** `internal/ui/` — UI handlers call internal Go functions directly;
|
||||
no internal HTTP round-trips to the REST API.
|
||||
|
||||
**Template engine:** Go `html/template` with templates embedded at compile time
|
||||
via `web/` (`embed.FS`). Templates are parsed once at startup.
|
||||
|
||||
**Session management:** JWT stored as `HttpOnly; Secure; SameSite=Strict`
|
||||
cookie (`mcias_session`). CSRF protection uses HMAC-signed double-submit
|
||||
cookie pattern (`mcias_csrf`).
|
||||
|
||||
**Pages and features:**
|
||||
|
||||
| Path | Description |
|
||||
|---|---|
|
||||
| `/login` | Username/password login with optional TOTP step |
|
||||
| `/` | Dashboard (account summary) |
|
||||
| `/accounts` | Account list |
|
||||
| `/accounts/{id}` | Account detail — status, roles, tags, PG credentials (system accounts) |
|
||||
| `/pgcreds` | Postgres credentials list (owned + granted) with create form |
|
||||
| `/policies` | Policy rules management — create, enable/disable, delete |
|
||||
| `/audit` | Audit log viewer |
|
||||
|
||||
**HTMX fragments:** Mutating operations (role updates, tag edits, credential
|
||||
saves, policy toggles, access grants) use HTMX partial-page updates for a
|
||||
responsive experience without full-page reloads.
|
||||
|
||||
---
|
||||
|
||||
## 9. Database Schema
|
||||
@@ -445,10 +540,22 @@ CREATE TABLE system_tokens (
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
-- Per-account failed login attempts for brute-force lockout enforcement.
|
||||
-- One row per account; window_start resets when the window expires or on
|
||||
-- a successful login.
|
||||
CREATE TABLE failed_logins (
|
||||
account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
window_start TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Postgres credentials for system accounts, encrypted at rest.
|
||||
CREATE TABLE pg_credentials (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
-- owner_id: account that administers the credentials and may grant/revoke
|
||||
-- access. Nullable for backwards compatibility with pre-migration-5 rows.
|
||||
owner_id INTEGER REFERENCES accounts(id),
|
||||
pg_host TEXT NOT NULL,
|
||||
pg_port INTEGER NOT NULL DEFAULT 5432,
|
||||
pg_database TEXT NOT NULL,
|
||||
@@ -459,6 +566,21 @@ CREATE TABLE pg_credentials (
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
-- Explicit read-access grants from a credential owner to another account.
|
||||
-- Grantees may view connection metadata but the password is never decrypted
|
||||
-- for them in the UI. Only the owner may update or delete the credential set.
|
||||
CREATE TABLE pg_credential_access (
|
||||
id INTEGER PRIMARY KEY,
|
||||
credential_id INTEGER NOT NULL REFERENCES pg_credentials(id) ON DELETE CASCADE,
|
||||
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
granted_by INTEGER REFERENCES accounts(id),
|
||||
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
UNIQUE (credential_id, grantee_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pgcred_access_cred ON pg_credential_access (credential_id);
|
||||
CREATE INDEX idx_pgcred_access_grantee ON pg_credential_access (grantee_id);
|
||||
|
||||
-- Audit log — append-only. Never contains credentials or secret material.
|
||||
CREATE TABLE audit_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -496,7 +618,9 @@ CREATE TABLE policy_rules (
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
|
||||
created_by INTEGER REFERENCES accounts(id),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339)
|
||||
expires_at TEXT DEFAULT NULL -- optional: expiry time (RFC3339)
|
||||
);
|
||||
```
|
||||
|
||||
@@ -1440,6 +1564,26 @@ For belt-and-suspenders, an explicit deny for production tags:
|
||||
|
||||
No `ServiceNames` or `RequiredTags` field means this matches any service account.
|
||||
|
||||
**Scenario D — Time-scoped access:**
|
||||
|
||||
The `deploy-agent` needs temporary access to production pgcreds for a 4-hour
|
||||
maintenance window. Instead of creating a rule and remembering to delete it,
|
||||
the operator sets `not_before` and `expires_at`:
|
||||
|
||||
```
|
||||
mciasctl policy create \
|
||||
-description "deploy-agent: temp production access" \
|
||||
-json rule.json \
|
||||
-not-before 2026-03-12T02:00:00Z \
|
||||
-expires-at 2026-03-12T06:00:00Z
|
||||
```
|
||||
|
||||
The policy engine filters rules at cache-load time (`Engine.SetRules`): rules
|
||||
where `not_before > now()` or `expires_at <= now()` are excluded from the
|
||||
cached rule set. No changes to the `Evaluate()` or `matches()` functions are
|
||||
needed. Both fields are optional and nullable; `NULL` means no constraint
|
||||
(always active / never expires).
|
||||
|
||||
### Middleware Integration
|
||||
|
||||
`internal/middleware.RequirePolicy(engine, action, resourceType)` is a drop-in
|
||||
|
||||
Reference in New Issue
Block a user