Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25d550a066 | |||
| 5d7d2cfc08 | |||
| 833775de83 | |||
| 562aad908e |
@@ -1 +1,11 @@
|
||||
{}
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go test:*)",
|
||||
"Bash(golangci-lint run:*)",
|
||||
"Bash(git restore:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,4 +34,5 @@ clients/python/*.egg-info/
|
||||
clients/lisp/**/*.fasl
|
||||
|
||||
# manual testing
|
||||
/run/
|
||||
/run/
|
||||
.env
|
||||
|
||||
0
.junie/memory/errors.md
Normal file
0
.junie/memory/errors.md
Normal file
0
.junie/memory/feedback.md
Normal file
0
.junie/memory/feedback.md
Normal file
1
.junie/memory/language.json
Normal file
1
.junie/memory/language.json
Normal file
@@ -0,0 +1 @@
|
||||
[{"lang":"en","usageCount":1}]
|
||||
1
.junie/memory/memory.version
Normal file
1
.junie/memory/memory.version
Normal file
@@ -0,0 +1 @@
|
||||
1.0
|
||||
0
.junie/memory/tasks.md
Normal file
0
.junie/memory/tasks.md
Normal file
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)
|
||||
- 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
|
||||
|
||||
@@ -17,8 +17,10 @@ MCIAS (Metacircular Identity and Access System) is a single-sign-on (SSO) and Id
|
||||
|
||||
## Binaries
|
||||
|
||||
- `mciassrv` — authentication server (REST API over HTTPS/TLS)
|
||||
- `mciasctl` — admin CLI for account/token/credential management
|
||||
- `mciassrv` — authentication server (REST + gRPC over HTTPS/TLS, with HTMX web UI)
|
||||
- `mciasctl` — admin CLI for account/token/credential/policy management (REST)
|
||||
- `mciasdb` — offline SQLite maintenance tool (schema, accounts, tokens, audit, pgcreds)
|
||||
- `mciasgrpcctl` — admin CLI for gRPC interface
|
||||
|
||||
## Development Workflow
|
||||
|
||||
|
||||
341
PROGRESS.md
341
PROGRESS.md
@@ -4,6 +4,304 @@ Source of truth for current development state.
|
||||
---
|
||||
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
||||
|
||||
### 2026-03-12 — Password change: self-service and admin reset
|
||||
|
||||
Added the ability for users to change their own password and for admins to
|
||||
reset any human account's password.
|
||||
|
||||
**Two new REST endpoints:**
|
||||
|
||||
- `PUT /v1/auth/password` — self-service: authenticated user changes their own
|
||||
password; requires `current_password` for verification; revokes all tokens
|
||||
except the caller's current session on success.
|
||||
- `PUT /v1/accounts/{id}/password` — admin reset: no current password needed;
|
||||
revokes all active sessions for the target account.
|
||||
|
||||
**internal/model/model.go**
|
||||
- Added `EventPasswordChanged = "password_changed"` audit event constant.
|
||||
|
||||
**internal/db/accounts.go**
|
||||
- Added `RevokeAllUserTokensExcept(accountID, exceptJTI, reason)`: revokes all
|
||||
non-expired tokens for an account except one specific JTI (used by the
|
||||
self-service flow to preserve the caller's session).
|
||||
|
||||
**internal/server/server.go**
|
||||
- `handleAdminSetPassword`: admin password reset handler; validates new
|
||||
password, hashes with Argon2id, revokes all target tokens, writes audit event.
|
||||
- `handleChangePassword`: self-service handler; verifies current password with
|
||||
Argon2id (same lockout/timing path as login), hashes new password, revokes
|
||||
all other tokens, clears failure counter.
|
||||
- Both routes registered in `Handler()`.
|
||||
|
||||
**internal/ui/handlers_accounts.go**
|
||||
- `handleAdminResetPassword`: web UI counterpart to the admin REST handler;
|
||||
renders `password_reset_result` fragment on success.
|
||||
|
||||
**internal/ui/ui.go**
|
||||
- `PUT /accounts/{id}/password` route registered with admin+CSRF middleware.
|
||||
- `templates/fragments/password_reset_form.html` added to shared template list.
|
||||
|
||||
**web/templates/fragments/password_reset_form.html** (new)
|
||||
- HTMX form fragment for the admin password reset UI.
|
||||
- `password_reset_result` template shows a success flash message followed by
|
||||
the reset form.
|
||||
|
||||
**web/templates/account_detail.html**
|
||||
- Added "Reset Password" card (human accounts only) using the new fragment.
|
||||
|
||||
**cmd/mciasctl/main.go**
|
||||
- `auth change-password`: self-service password change; both passwords always
|
||||
prompted interactively (no flag form — prevents shell-history exposure).
|
||||
- `account set-password -id UUID`: admin reset; new password always prompted
|
||||
interactively (no flag form).
|
||||
- `auth login`: `-password` flag removed; password always prompted.
|
||||
- `account create`: `-password` flag removed; password always prompted for
|
||||
human accounts.
|
||||
- All passwords read via `term.ReadPassword` (terminal echo disabled); raw
|
||||
byte slices zeroed after use.
|
||||
|
||||
**openapi.yaml + web/static/openapi.yaml**
|
||||
- `PUT /v1/auth/password`: self-service endpoint documented (Auth tag).
|
||||
- `PUT /v1/accounts/{id}/password`: admin reset documented (Admin — Accounts
|
||||
tag).
|
||||
|
||||
**ARCHITECTURE.md**
|
||||
- API endpoint tables updated with both new endpoints.
|
||||
- New "Password Change Flows" section in §6 (Session Management) documents the
|
||||
self-service and admin flows, their security properties, and differences.
|
||||
|
||||
All tests pass; golangci-lint clean.
|
||||
|
||||
### 2026-03-12 — Checkpoint: fix fieldalignment lint warning
|
||||
|
||||
**internal/policy/engine_wrapper.go**
|
||||
- Reordered `PolicyRecord` fields: `*time.Time` pointer fields moved before
|
||||
string fields, shrinking the GC pointer-scan bitmap from 56 to 40 bytes
|
||||
(govet fieldalignment)
|
||||
|
||||
All tests pass; `golangci-lint run ./...` clean.
|
||||
|
||||
### 2026-03-12 — Add time-scoped policy rule expiry
|
||||
|
||||
Policy rules now support optional `not_before` and `expires_at` fields for
|
||||
time-limited validity windows. Rules outside their validity window are
|
||||
automatically excluded at cache-load time (`Engine.SetRules`).
|
||||
|
||||
**internal/db/migrations/000006_policy_rule_expiry.up.sql** (new)
|
||||
- `ALTER TABLE policy_rules ADD COLUMN not_before TEXT DEFAULT NULL`
|
||||
- `ALTER TABLE policy_rules ADD COLUMN expires_at TEXT DEFAULT NULL`
|
||||
|
||||
**internal/db/migrate.go**
|
||||
- `LatestSchemaVersion` bumped from 5 to 6
|
||||
|
||||
**internal/model/model.go**
|
||||
- Added `NotBefore *time.Time` and `ExpiresAt *time.Time` to `PolicyRuleRecord`
|
||||
|
||||
**internal/db/policy.go**
|
||||
- `policyRuleCols` updated with `not_before, expires_at`
|
||||
- `CreatePolicyRule`: new params `notBefore, expiresAt *time.Time`
|
||||
- `UpdatePolicyRule`: new params `notBefore, expiresAt **time.Time` (double-pointer
|
||||
for three-state semantics: nil=no change, non-nil→nil=clear, non-nil→value=set)
|
||||
- `finishPolicyRuleScan`: extended to populate `NotBefore`/`ExpiresAt` via
|
||||
`nullableTime()`
|
||||
- Added `formatNullableTime(*time.Time) *string` helper
|
||||
|
||||
**internal/policy/engine_wrapper.go**
|
||||
- Added `NotBefore *time.Time` and `ExpiresAt *time.Time` to `PolicyRecord`
|
||||
- `SetRules`: filters out rules where `not_before > now()` or `expires_at <= now()`
|
||||
after the existing `Enabled` check
|
||||
|
||||
**internal/server/handlers_policy.go**
|
||||
- `policyRuleResponse`: added `not_before` and `expires_at` (RFC3339, omitempty)
|
||||
- `createPolicyRuleRequest`: added `not_before` and `expires_at`
|
||||
- `updatePolicyRuleRequest`: added `not_before`, `expires_at`,
|
||||
`clear_not_before`, `clear_expires_at`
|
||||
- `handleCreatePolicyRule`: parses/validates RFC3339 times; rejects
|
||||
`expires_at <= not_before`
|
||||
- `handleUpdatePolicyRule`: parses times, handles clear booleans via
|
||||
double-pointer pattern
|
||||
|
||||
**internal/ui/**
|
||||
- `PolicyRuleView`: added `NotBefore`, `ExpiresAt`, `IsExpired`, `IsPending`
|
||||
- `policyRuleToView`: populates time fields and computes expired/pending status
|
||||
- `handleCreatePolicyRule`: parses `datetime-local` form inputs for time fields
|
||||
|
||||
**web/templates/fragments/**
|
||||
- `policy_form.html`: added `datetime-local` inputs for not_before and expires_at
|
||||
- `policy_row.html`: shows time info and expired/scheduled badges
|
||||
|
||||
**cmd/mciasctl/main.go**
|
||||
- `policyCreate`: added `-not-before` and `-expires-at` flags (RFC3339)
|
||||
- `policyUpdate`: added `-not-before`, `-expires-at`, `-clear-not-before`,
|
||||
`-clear-expires-at` flags
|
||||
|
||||
**openapi.yaml**
|
||||
- `PolicyRule` schema: added `not_before` and `expires_at` (nullable date-time)
|
||||
- Create request: added `not_before` and `expires_at`
|
||||
- Update request: added `not_before`, `expires_at`, `clear_not_before`,
|
||||
`clear_expires_at`
|
||||
|
||||
**Tests**
|
||||
- `internal/db/policy_test.go`: 5 new tests — `WithExpiresAt`, `WithNotBefore`,
|
||||
`WithBothTimes`, `SetExpiresAt`, `ClearExpiresAt`; all existing tests updated
|
||||
with new `CreatePolicyRule`/`UpdatePolicyRule` signatures
|
||||
- `internal/policy/engine_test.go`: 4 new tests — `SkipsExpiredRule`,
|
||||
`SkipsNotYetActiveRule`, `IncludesActiveWindowRule`, `NilTimesAlwaysActive`
|
||||
|
||||
**ARCHITECTURE.md**
|
||||
- Schema: added `not_before` and `expires_at` columns to `policy_rules` DDL
|
||||
- Added Scenario D (time-scoped access) to §20
|
||||
|
||||
All new and existing policy tests pass; no new lint warnings.
|
||||
|
||||
### 2026-03-12 — Integrate golang-migrate for database migrations
|
||||
|
||||
**internal/db/migrations/** (new directory — 5 embedded SQL files)
|
||||
- `000001_initial_schema.up.sql` — full initial schema (verbatim from migration 1)
|
||||
- `000002_master_key_salt.up.sql` — adds `master_key_salt` to server_config
|
||||
- `000003_failed_logins.up.sql` — `failed_logins` table for brute-force lockout
|
||||
- `000004_tags_and_policy.up.sql` — `account_tags` and `policy_rules` tables
|
||||
- `000005_pgcred_access.up.sql` — `owner_id` column + `pg_credential_access` table
|
||||
- Files are embedded at compile time via `//go:embed migrations/*.sql`; no
|
||||
runtime filesystem access is needed
|
||||
|
||||
**internal/db/migrate.go** (rewritten)
|
||||
- Removed hand-rolled `migration` struct and `migrations []migration` slice
|
||||
- Uses `github.com/golang-migrate/migrate/v4` with the `database/sqlite`
|
||||
driver (modernc.org/sqlite, pure Go, no CGO) and `source/iofs` for embedded
|
||||
SQL files
|
||||
- `LatestSchemaVersion` changed from `var` to `const = 5`
|
||||
- `Migrate(db *DB) error`: compatibility shim reads legacy `schema_version`
|
||||
table; if version > 0, calls `m.Force(legacyVersion)` before `m.Up()` so
|
||||
existing databases are not re-migrated. Returns nil on ErrNoChange.
|
||||
- `SchemaVersion(db *DB) (int, error)`: delegates to `m.Version()`; returns 0
|
||||
on ErrNilVersion
|
||||
- `newMigrate(*DB)`: opens a **dedicated** `*sql.DB` for the migrator so that
|
||||
`m.Close()` (which closes the underlying connection) does not affect the
|
||||
caller's shared connection
|
||||
- `legacySchemaVersion(*DB)`: reads old schema_version table; returns 0 if
|
||||
absent (fresh DB or already on golang-migrate only)
|
||||
|
||||
**internal/db/db.go**
|
||||
- Added `path string` field to `DB` struct for the migrator's dedicated
|
||||
connection
|
||||
- `Open(":memory:")` now translates to a named shared-cache URI
|
||||
`file:mcias_N?mode=memory&cache=shared` (N is atomic counter) so the
|
||||
migration runner can open a second connection to the same in-memory database
|
||||
without sharing the `*sql.DB` handle that golang-migrate will close
|
||||
|
||||
**go.mod / go.sum**
|
||||
- Added `github.com/golang-migrate/migrate/v4 v4.19.1` (direct)
|
||||
- Transitive: `hashicorp/errwrap`, `hashicorp/go-multierror`,
|
||||
`go.uber.org/atomic`
|
||||
|
||||
All callers (`cmd/mciassrv`, `cmd/mciasdb`, all test helpers) continue to call
|
||||
`db.Open(path)` and `db.Migrate(database)` unchanged.
|
||||
|
||||
All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.
|
||||
|
||||
### 2026-03-12 — UI: pgcreds create button; show logged-in user
|
||||
|
||||
**web/templates/pgcreds.html**
|
||||
- "New Credentials" card is now always rendered; an "Add Credentials" toggle
|
||||
button reveals the create form (hidden by default). When all system accounts
|
||||
already have credentials, a message is shown instead of the form. Previously
|
||||
the entire card was hidden when `UncredentialedAccounts` was empty.
|
||||
|
||||
**internal/ui/ui.go**
|
||||
- Added `ActorName string` field to `PageData` (embedded in every page view struct)
|
||||
- Added `actorName(r *http.Request) string` helper — resolves username from JWT
|
||||
claims via a DB lookup; returns `""` if unauthenticated
|
||||
|
||||
**internal/ui/handlers_{accounts,audit,dashboard,policy}.go**
|
||||
- All full-page `PageData` constructors now pass `ActorName: u.actorName(r)`
|
||||
|
||||
**web/templates/base.html**
|
||||
- Nav bar renders the actor's username as a muted label immediately before the
|
||||
Logout button when logged in
|
||||
|
||||
**web/static/style.css**
|
||||
- Added `.nav-actor` rule (muted grey, 0.85rem) for the username label
|
||||
|
||||
All tests pass (`go test ./...`); `golangci-lint run ./...` clean.
|
||||
|
||||
### 2026-03-12 — PG credentials create form on /pgcreds page
|
||||
|
||||
**internal/ui/handlers_accounts.go**
|
||||
- `handlePGCredsList`: extended to build `UncredentialedAccounts` — system
|
||||
accounts that have no credentials yet, passed to the template for the create
|
||||
form; filters from `ListAccounts()` by type and excludes accounts already in
|
||||
the accessible-credentials set
|
||||
- `handleCreatePGCreds`: `POST /pgcreds` — validates selected account UUID
|
||||
(must be a system account), host, port, database, username, password;
|
||||
encrypts password with AES-256-GCM; calls `WritePGCredentials` then
|
||||
`SetPGCredentialOwner`; writes `EventPGCredUpdated` audit event; redirects
|
||||
to `GET /pgcreds` on success
|
||||
|
||||
**internal/ui/ui.go**
|
||||
- Registered `POST /pgcreds` route
|
||||
- Added `UncredentialedAccounts []*model.Account` field to `PGCredsData`
|
||||
|
||||
**web/templates/pgcreds.html**
|
||||
- New "New Credentials" card shown when `UncredentialedAccounts` is non-empty;
|
||||
contains a plain POST form (no HTMX, redirect on success) with:
|
||||
- Service Account dropdown populated from `UncredentialedAccounts`
|
||||
- Host / Port / Database / Username / Password inputs
|
||||
- CSRF token hidden field
|
||||
|
||||
All tests pass (`go test ./...`); `golangci-lint run ./...` clean.
|
||||
|
||||
### 2026-03-12 — PG credentials access grants UI
|
||||
|
||||
**internal/ui/handlers_accounts.go**
|
||||
- `handleGrantPGCredAccess`: `POST /accounts/{id}/pgcreds/access` — grants a
|
||||
nominated account read access to the credential set; ownership enforced
|
||||
server-side by comparing stored `owner_id` with the logged-in actor;
|
||||
grantee resolved via UUID lookup (not raw ID); writes
|
||||
`EventPGCredAccessGranted` audit event; re-renders `pgcreds_form` fragment
|
||||
- `handleRevokePGCredAccess`: `DELETE /accounts/{id}/pgcreds/access/{grantee}`
|
||||
— removes a specific grantee's read access; same ownership check as grant;
|
||||
writes `EventPGCredAccessRevoked` audit event; re-renders fragment
|
||||
- `handlePGCredsList`: `GET /pgcreds` — lists all pg_credentials accessible to
|
||||
the currently logged-in user (owned + explicitly granted)
|
||||
|
||||
**internal/ui/ui.go**
|
||||
- Registered three new routes: `POST /accounts/{id}/pgcreds/access`,
|
||||
`DELETE /accounts/{id}/pgcreds/access/{grantee}`, `GET /pgcreds`
|
||||
- Added `pgcreds` to the page template map (renders `pgcreds.html`)
|
||||
- Added `isPGCredOwner(*int64, *model.PGCredential) bool` template function
|
||||
— nil-safe ownership check used in `pgcreds_form` to gate owner-only controls
|
||||
- Added `derefInt64(*int64) int64` template function (nil-safe dereference)
|
||||
|
||||
**internal/model/model.go**
|
||||
- Added `ServiceAccountUUID string` field to `PGCredential` — populated by
|
||||
list queries so the PG creds list page can link to the account detail page
|
||||
|
||||
**internal/db/pgcred_access.go**
|
||||
- `ListAccessiblePGCreds`: extended SELECT to also fetch `a.uuid`; updated
|
||||
`scanPGCredWithUsername` to populate `ServiceAccountUUID`
|
||||
|
||||
**web/templates/fragments/pgcreds_form.html**
|
||||
- Owner sees a collapsible "Update credentials" `<details>` block; non-owners
|
||||
and grantees see metadata read-only
|
||||
- Non-owners who haven't yet created a credential see the full create form
|
||||
(first save sets them as owner)
|
||||
- New "Access Grants" section below the credential metadata:
|
||||
- Table listing all grantees with username and grant timestamp
|
||||
- Revoke button (DELETE HTMX, `hx-confirm`) — owner only
|
||||
- "Grant Access" dropdown form (POST HTMX) — owner only, populated with
|
||||
all accounts
|
||||
|
||||
**web/templates/pgcreds.html** (new page)
|
||||
- Lists all accessible credentials in a table: service account, host:port,
|
||||
database, username, updated-at, link to account detail page
|
||||
- Falls back to "No Postgres credentials accessible" when list is empty
|
||||
|
||||
**web/templates/base.html**
|
||||
- Added "PG Creds" nav link pointing to `/pgcreds`
|
||||
|
||||
All tests pass (`go test ./...`); `golangci-lint run ./...` clean.
|
||||
|
||||
### 2026-03-11 — Postgres Credentials UI + Policy/Tags UI completion
|
||||
|
||||
**internal/ui/**
|
||||
@@ -84,7 +382,7 @@ All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.
|
||||
- [x] Phase 6: mciasdb — direct SQLite maintenance tool
|
||||
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST)
|
||||
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
|
||||
- [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
|
||||
- [ ] Phase 9: Client libraries (Go, Rust, Common Lisp, Python) — designed in ARCHITECTURE.md §19 but not yet implemented; `clients/` directory does not exist
|
||||
- [x] Phase 10: Policy engine — ABAC with machine/service gating
|
||||
---
|
||||
### 2026-03-11 — Phase 10: Policy engine (ABAC + machine/service gating)
|
||||
@@ -188,44 +486,15 @@ All tests pass; `go test ./...` clean; `golangci-lint run ./...` clean.
|
||||
|
||||
All 5 packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
||||
|
||||
### 2026-03-11 — Phase 9: Client libraries
|
||||
### 2026-03-11 — Phase 9: Client libraries (DESIGNED, NOT IMPLEMENTED)
|
||||
|
||||
**clients/testdata/** — shared JSON fixtures
|
||||
- login_response.json, account_response.json, accounts_list_response.json
|
||||
- validate_token_response.json, public_key_response.json, pgcreds_response.json
|
||||
- error_response.json, roles_response.json
|
||||
|
||||
**clients/go/** — Go client library
|
||||
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`; package `mciasgoclient`
|
||||
- Typed errors: `MciasAuthError`, `MciasForbiddenError`, `MciasNotFoundError`,
|
||||
`MciasInputError`, `MciasConflictError`, `MciasServerError`
|
||||
- TLS 1.2+ enforced via `tls.Config{MinVersion: tls.VersionTLS12}`
|
||||
- Token state guarded by `sync.RWMutex` for concurrent safety
|
||||
- JSON decoded with `DisallowUnknownFields` on all responses
|
||||
- 25 tests in `client_test.go`; all pass with `go test -race`
|
||||
|
||||
**clients/rust/** — Rust async client library
|
||||
- Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep)
|
||||
- `MciasError` enum via `thiserror`; `Arc<RwLock<Option<String>>>` for token
|
||||
- 23 integration tests using `wiremock`; `cargo clippy -- -D warnings` clean
|
||||
|
||||
**clients/lisp/** — Common Lisp client library
|
||||
- ASDF system `mcias-client`; HTTP via dexador, JSON via yason
|
||||
- CLOS class `mcias-client`; plain functions for all operations
|
||||
- Conditions: `mcias-error` base + 6 typed subclasses
|
||||
- Mock server: Hunchentoot `mock-dispatcher` subclass (port 0, random per test)
|
||||
- 37 fiveam checks; all pass on SBCL 2.6.1
|
||||
- Fixed: yason decodes JSON `false` as `:false`; `validate-token` normalises
|
||||
to `t`/`nil` before returning
|
||||
|
||||
**clients/python/** — Python 3.11+ client library
|
||||
- Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27`
|
||||
- `Client` context manager; `py.typed` marker; all symbols fully annotated
|
||||
- Dataclasses: `Account`, `PublicKey`, `PGCreds`
|
||||
- 32 pytest tests using `respx` mock transport; `mypy --strict` clean; `ruff` clean
|
||||
**NOTE:** The client libraries described in ARCHITECTURE.md §19 were designed
|
||||
but never committed to the repository. The `clients/` directory does not exist.
|
||||
Only `test/mock/mockserver.go` was implemented. The designs remain in
|
||||
ARCHITECTURE.md for future implementation.
|
||||
|
||||
**test/mock/mockserver.go** — Go in-memory mock server
|
||||
- `Server` struct with `sync.RWMutex`; used by Go client integration test
|
||||
- `Server` struct with `sync.RWMutex`; used for Go integration tests
|
||||
- `NewServer()`, `AddAccount()`, `ServeHTTP()` for httptest.Server use
|
||||
|
||||
---
|
||||
|
||||
23
README.md
23
README.md
@@ -2,7 +2,8 @@
|
||||
|
||||
MCIAS is a self-hosted SSO and IAM service for personal projects.
|
||||
It provides authentication (JWT/Ed25519), account management, TOTP, and
|
||||
Postgres credential storage over a REST API (HTTPS) and a gRPC API (TLS).
|
||||
Postgres credential storage over a REST API (HTTPS), a gRPC API (TLS),
|
||||
and an HTMX-based web management UI.
|
||||
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for the technical design and
|
||||
[PROJECT_PLAN.md](PROJECT_PLAN.md) for the implementation roadmap.
|
||||
@@ -177,7 +178,7 @@ TOKEN=$(curl -sk https://localhost:8443/v1/auth/login \
|
||||
export MCIAS_TOKEN=$TOKEN
|
||||
|
||||
mciasctl -server https://localhost:8443 account list
|
||||
mciasctl account create -username alice -password s3cr3t
|
||||
mciasctl account create -username alice # password prompted interactively
|
||||
mciasctl role set -id $UUID -roles admin
|
||||
mciasctl token issue -id $SYSTEM_UUID
|
||||
mciasctl pgcreds set -id $UUID -host db.example.com -port 5432 \
|
||||
@@ -241,6 +242,24 @@ See `man mciasgrpcctl` and [ARCHITECTURE.md](ARCHITECTURE.md) §17.
|
||||
|
||||
---
|
||||
|
||||
## Web Management UI
|
||||
|
||||
mciassrv includes a built-in web interface for day-to-day administration.
|
||||
After starting the server, navigate to `https://localhost:8443/login` and
|
||||
log in with an admin account.
|
||||
|
||||
The UI provides:
|
||||
- **Dashboard** — account summary overview
|
||||
- **Accounts** — list, create, update, delete accounts; manage roles and tags
|
||||
- **PG Credentials** — view, create, and manage Postgres credential access grants
|
||||
- **Policies** — create and manage ABAC policy rules
|
||||
- **Audit** — browse the audit log
|
||||
|
||||
Sessions use `HttpOnly; Secure; SameSite=Strict` cookies with CSRF protection.
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md) §8 (Web Management UI) for design details.
|
||||
|
||||
---
|
||||
|
||||
## Deploying with Docker
|
||||
|
||||
```sh
|
||||
|
||||
@@ -16,13 +16,15 @@
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// auth login -username NAME [-password PASS] [-totp CODE]
|
||||
// auth login -username NAME [-totp CODE]
|
||||
// auth change-password (passwords always prompted interactively)
|
||||
//
|
||||
// account list
|
||||
// account create -username NAME [-password PASS] [-type human|system]
|
||||
// account get -id UUID
|
||||
// account update -id UUID [-status active|inactive]
|
||||
// account delete -id UUID
|
||||
// account create -username NAME [-type human|system]
|
||||
// account get -id UUID
|
||||
// account update -id UUID [-status active|inactive]
|
||||
// account delete -id UUID
|
||||
// account set-password -id UUID
|
||||
//
|
||||
// role list -id UUID
|
||||
// role set -id UUID -roles role1,role2,...
|
||||
@@ -34,9 +36,9 @@
|
||||
// pgcreds get -id UUID
|
||||
//
|
||||
// policy list
|
||||
// policy create -description STR -json FILE [-priority N]
|
||||
// policy create -description STR -json FILE [-priority N] [-not-before RFC3339] [-expires-at RFC3339]
|
||||
// policy get -id ID
|
||||
// policy update -id ID [-priority N] [-enabled true|false]
|
||||
// policy update -id ID [-priority N] [-enabled true|false] [-not-before RFC3339] [-expires-at RFC3339] [-clear-not-before] [-clear-expires-at]
|
||||
// policy delete -id ID
|
||||
//
|
||||
// tag list -id UUID
|
||||
@@ -123,28 +125,28 @@ type controller struct {
|
||||
|
||||
func (c *controller) runAuth(args []string) {
|
||||
if len(args) == 0 {
|
||||
fatalf("auth requires a subcommand: login")
|
||||
fatalf("auth requires a subcommand: login, change-password")
|
||||
}
|
||||
switch args[0] {
|
||||
case "login":
|
||||
c.authLogin(args[1:])
|
||||
case "change-password":
|
||||
c.authChangePassword(args[1:])
|
||||
default:
|
||||
fatalf("unknown auth subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// authLogin authenticates with the server using username and password, then
|
||||
// prints the resulting bearer token to stdout. If -password is not supplied on
|
||||
// the command line, the user is prompted interactively (input is hidden so the
|
||||
// password does not appear in shell history or terminal output).
|
||||
// prints the resulting bearer token to stdout. The password is always prompted
|
||||
// interactively; it is never accepted as a command-line flag to prevent it from
|
||||
// appearing in shell history, ps output, and process argument lists.
|
||||
//
|
||||
// Security: passwords are never stored by this process beyond the lifetime of
|
||||
// the HTTP request. Interactive reads use golang.org/x/term.ReadPassword so
|
||||
// that terminal echo is disabled; the byte slice is zeroed after use.
|
||||
// Security: terminal echo is disabled during password entry
|
||||
// (golang.org/x/term.ReadPassword); the raw byte slice is zeroed after use.
|
||||
func (c *controller) authLogin(args []string) {
|
||||
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
|
||||
username := fs.String("username", "", "username (required)")
|
||||
password := fs.String("password", "", "password (reads from stdin if omitted)")
|
||||
totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
@@ -152,21 +154,19 @@ func (c *controller) authLogin(args []string) {
|
||||
fatalf("auth login: -username is required")
|
||||
}
|
||||
|
||||
// If no password flag was provided, prompt interactively so it does not
|
||||
// appear in process arguments or shell history.
|
||||
passwd := *password
|
||||
if passwd == "" {
|
||||
fmt.Fprint(os.Stderr, "Password: ")
|
||||
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||
fmt.Fprintln(os.Stderr) // newline after hidden input
|
||||
if err != nil {
|
||||
fatalf("read password: %v", err)
|
||||
}
|
||||
passwd = string(raw)
|
||||
// Zero the raw byte slice once copied into the string.
|
||||
for i := range raw {
|
||||
raw[i] = 0
|
||||
}
|
||||
// Security: always prompt interactively; never accept password as a flag.
|
||||
// This prevents the credential from appearing in shell history, ps output,
|
||||
// and /proc/PID/cmdline.
|
||||
fmt.Fprint(os.Stderr, "Password: ")
|
||||
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||
fmt.Fprintln(os.Stderr) // newline after hidden input
|
||||
if err != nil {
|
||||
fatalf("read password: %v", err)
|
||||
}
|
||||
passwd := string(raw)
|
||||
// Zero the raw byte slice once copied into the string.
|
||||
for i := range raw {
|
||||
raw[i] = 0
|
||||
}
|
||||
|
||||
body := map[string]string{
|
||||
@@ -191,11 +191,53 @@ func (c *controller) authLogin(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// authChangePassword allows an authenticated user to change their own password.
|
||||
// A valid bearer token must be set (via -token flag or MCIAS_TOKEN env var).
|
||||
// Both passwords are always prompted interactively; they are never accepted as
|
||||
// command-line flags to prevent them from appearing in shell history, ps
|
||||
// output, and process argument lists.
|
||||
//
|
||||
// Security: terminal echo is disabled during entry (golang.org/x/term);
|
||||
// raw byte slices are zeroed after use. The server requires the current
|
||||
// password to prevent token-theft attacks. On success all other active
|
||||
// sessions are revoked server-side.
|
||||
func (c *controller) authChangePassword(_ []string) {
|
||||
// Security: always prompt interactively; never accept passwords as flags.
|
||||
fmt.Fprint(os.Stderr, "Current password: ")
|
||||
rawCurrent, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
fatalf("read current password: %v", err)
|
||||
}
|
||||
currentPasswd := string(rawCurrent)
|
||||
for i := range rawCurrent {
|
||||
rawCurrent[i] = 0
|
||||
}
|
||||
|
||||
fmt.Fprint(os.Stderr, "New password: ")
|
||||
rawNew, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
fatalf("read new password: %v", err)
|
||||
}
|
||||
newPasswd := string(rawNew)
|
||||
for i := range rawNew {
|
||||
rawNew[i] = 0
|
||||
}
|
||||
|
||||
body := map[string]string{
|
||||
"current_password": currentPasswd,
|
||||
"new_password": newPasswd,
|
||||
}
|
||||
c.doRequest("PUT", "/v1/auth/password", body, nil)
|
||||
fmt.Println("password changed; other active sessions revoked")
|
||||
}
|
||||
|
||||
// ---- account subcommands ----
|
||||
|
||||
func (c *controller) runAccount(args []string) {
|
||||
if len(args) == 0 {
|
||||
fatalf("account requires a subcommand: list, create, get, update, delete")
|
||||
fatalf("account requires a subcommand: list, create, get, update, delete, set-password")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
@@ -208,6 +250,8 @@ func (c *controller) runAccount(args []string) {
|
||||
c.accountUpdate(args[1:])
|
||||
case "delete":
|
||||
c.accountDelete(args[1:])
|
||||
case "set-password":
|
||||
c.accountSetPassword(args[1:])
|
||||
default:
|
||||
fatalf("unknown account subcommand %q", args[0])
|
||||
}
|
||||
@@ -222,7 +266,6 @@ func (c *controller) accountList() {
|
||||
func (c *controller) accountCreate(args []string) {
|
||||
fs := flag.NewFlagSet("account create", flag.ExitOnError)
|
||||
username := fs.String("username", "", "username (required)")
|
||||
password := fs.String("password", "", "password for human accounts (prompted if omitted)")
|
||||
accountType := fs.String("type", "human", "account type: human or system")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
@@ -230,12 +273,11 @@ func (c *controller) accountCreate(args []string) {
|
||||
fatalf("account create: -username is required")
|
||||
}
|
||||
|
||||
// For human accounts, prompt for a password interactively if one was not
|
||||
// supplied on the command line so it stays out of shell history.
|
||||
// Security: terminal echo is disabled during entry; the raw byte slice is
|
||||
// zeroed after conversion to string. System accounts have no password.
|
||||
passwd := *password
|
||||
if passwd == "" && *accountType == "human" {
|
||||
// Security: always prompt interactively for human-account passwords; never
|
||||
// accept them as a flag. Terminal echo is disabled; the raw byte slice is
|
||||
// zeroed after conversion to string. System accounts have no password.
|
||||
var passwd string
|
||||
if *accountType == "human" {
|
||||
fmt.Fprint(os.Stderr, "Password: ")
|
||||
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||
fmt.Fprintln(os.Stderr)
|
||||
@@ -306,6 +348,40 @@ func (c *controller) accountDelete(args []string) {
|
||||
fmt.Println("account deleted")
|
||||
}
|
||||
|
||||
// accountSetPassword resets a human account's password (admin operation).
|
||||
// No current password is required. All active sessions for the target account
|
||||
// are revoked by the server on success.
|
||||
//
|
||||
// Security: the new password is always prompted interactively; it is never
|
||||
// accepted as a command-line flag to prevent it from appearing in shell
|
||||
// history, ps output, and process argument lists. Terminal echo is disabled
|
||||
// (golang.org/x/term); the raw byte slice is zeroed after use.
|
||||
func (c *controller) accountSetPassword(args []string) {
|
||||
fs := flag.NewFlagSet("account set-password", flag.ExitOnError)
|
||||
id := fs.String("id", "", "account UUID (required)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *id == "" {
|
||||
fatalf("account set-password: -id is required")
|
||||
}
|
||||
|
||||
// Security: always prompt interactively; never accept password as a flag.
|
||||
fmt.Fprint(os.Stderr, "New password: ")
|
||||
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
fatalf("read password: %v", err)
|
||||
}
|
||||
passwd := string(raw)
|
||||
for i := range raw {
|
||||
raw[i] = 0
|
||||
}
|
||||
|
||||
body := map[string]string{"new_password": passwd}
|
||||
c.doRequest("PUT", "/v1/accounts/"+*id+"/password", body, nil)
|
||||
fmt.Println("password updated; all active sessions revoked")
|
||||
}
|
||||
|
||||
// ---- role subcommands ----
|
||||
|
||||
func (c *controller) runRole(args []string) {
|
||||
@@ -511,6 +587,8 @@ func (c *controller) policyCreate(args []string) {
|
||||
description := fs.String("description", "", "rule description (required)")
|
||||
jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)")
|
||||
priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)")
|
||||
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339, optional)")
|
||||
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339, optional)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *description == "" {
|
||||
@@ -537,6 +615,18 @@ func (c *controller) policyCreate(args []string) {
|
||||
"priority": *priority,
|
||||
"rule": ruleBody,
|
||||
}
|
||||
if *notBefore != "" {
|
||||
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
|
||||
fatalf("policy create: -not-before must be RFC3339: %v", err)
|
||||
}
|
||||
body["not_before"] = *notBefore
|
||||
}
|
||||
if *expiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
|
||||
fatalf("policy create: -expires-at must be RFC3339: %v", err)
|
||||
}
|
||||
body["expires_at"] = *expiresAt
|
||||
}
|
||||
|
||||
var result json.RawMessage
|
||||
c.doRequest("POST", "/v1/policy/rules", body, &result)
|
||||
@@ -562,6 +652,10 @@ func (c *controller) policyUpdate(args []string) {
|
||||
id := fs.String("id", "", "rule ID (required)")
|
||||
priority := fs.Int("priority", -1, "new priority (-1 = no change)")
|
||||
enabled := fs.String("enabled", "", "true or false")
|
||||
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339)")
|
||||
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339)")
|
||||
clearNotBefore := fs.Bool("clear-not-before", false, "remove not_before constraint")
|
||||
clearExpiresAt := fs.Bool("clear-expires-at", false, "remove expires_at constraint")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *id == "" {
|
||||
@@ -584,8 +678,24 @@ func (c *controller) policyUpdate(args []string) {
|
||||
fatalf("policy update: -enabled must be true or false")
|
||||
}
|
||||
}
|
||||
if *clearNotBefore {
|
||||
body["clear_not_before"] = true
|
||||
} else if *notBefore != "" {
|
||||
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
|
||||
fatalf("policy update: -not-before must be RFC3339: %v", err)
|
||||
}
|
||||
body["not_before"] = *notBefore
|
||||
}
|
||||
if *clearExpiresAt {
|
||||
body["clear_expires_at"] = true
|
||||
} else if *expiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
|
||||
fatalf("policy update: -expires-at must be RFC3339: %v", err)
|
||||
}
|
||||
body["expires_at"] = *expiresAt
|
||||
}
|
||||
if len(body) == 0 {
|
||||
fatalf("policy update: at least one of -priority or -enabled is required")
|
||||
fatalf("policy update: at least one flag is required")
|
||||
}
|
||||
|
||||
var result json.RawMessage
|
||||
@@ -766,16 +876,25 @@ Global flags:
|
||||
-cacert Path to CA certificate for TLS verification
|
||||
|
||||
Commands:
|
||||
auth login -username NAME [-password PASS] [-totp CODE]
|
||||
Obtain a bearer token. Password is prompted if -password is
|
||||
omitted. Token is written to stdout; expiry to stderr.
|
||||
auth login -username NAME [-totp CODE]
|
||||
Obtain a bearer token. Password is always prompted interactively
|
||||
(never accepted as a flag) to avoid shell-history exposure.
|
||||
Token is written to stdout; expiry to stderr.
|
||||
Example: export MCIAS_TOKEN=$(mciasctl auth login -username alice)
|
||||
auth change-password
|
||||
Change the current user's own password. Requires a valid bearer
|
||||
token. Current and new passwords are always prompted interactively.
|
||||
Revokes all other active sessions on success.
|
||||
|
||||
account list
|
||||
account create -username NAME [-password PASS] [-type human|system]
|
||||
account get -id UUID
|
||||
account update -id UUID -status active|inactive
|
||||
account delete -id UUID
|
||||
account create -username NAME [-type human|system]
|
||||
account get -id UUID
|
||||
account update -id UUID -status active|inactive
|
||||
account delete -id UUID
|
||||
account set-password -id UUID
|
||||
Admin: reset a human account's password without requiring the
|
||||
current password. New password is always prompted interactively.
|
||||
Revokes all active sessions for the account.
|
||||
|
||||
role list -id UUID
|
||||
role set -id UUID -roles role1,role2,...
|
||||
@@ -788,10 +907,13 @@ Commands:
|
||||
|
||||
policy list
|
||||
policy create -description STR -json FILE [-priority N]
|
||||
[-not-before RFC3339] [-expires-at RFC3339]
|
||||
FILE must contain a JSON rule body, e.g.:
|
||||
{"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true}
|
||||
policy get -id ID
|
||||
policy update -id ID [-priority N] [-enabled true|false]
|
||||
[-not-before RFC3339] [-expires-at RFC3339]
|
||||
[-clear-not-before] [-clear-expires-at]
|
||||
policy delete -id ID
|
||||
|
||||
tag list -id UUID
|
||||
|
||||
15
go.mod
15
go.mod
@@ -4,10 +4,13 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/term v0.29.0
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/term v0.37.0
|
||||
google.golang.org/grpc v1.74.2
|
||||
google.golang.org/protobuf v1.36.7
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
@@ -17,12 +20,10 @@ require (
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.29.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/grpc v1.68.0 // indirect
|
||||
google.golang.org/protobuf v1.36.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
64
go.sum
64
go.sum
@@ -1,46 +1,78 @@
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
|
||||
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
|
||||
google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ=
|
||||
google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
|
||||
@@ -128,14 +128,23 @@ func (db *DB) UpdateAccountStatus(accountID int64, status model.AccountStatus) e
|
||||
}
|
||||
|
||||
// UpdatePasswordHash updates the Argon2id password hash for an account.
|
||||
// Returns ErrNotFound if no active account with the given ID exists, consistent
|
||||
// with the RowsAffected checks in RevokeToken and RenewToken.
|
||||
func (db *DB) UpdatePasswordHash(accountID int64, hash string) error {
|
||||
_, err := db.sql.Exec(`
|
||||
result, err := db.sql.Exec(`
|
||||
UPDATE accounts SET password_hash = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, hash, now(), accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update password hash: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update password hash rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -450,16 +459,17 @@ func (db *DB) WritePGCredentials(accountID int64, host string, port int, dbName,
|
||||
func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
|
||||
var cred model.PGCredential
|
||||
var createdAtStr, updatedAtStr string
|
||||
var ownerID sql.NullInt64
|
||||
|
||||
err := db.sql.QueryRow(`
|
||||
SELECT id, account_id, pg_host, pg_port, pg_database, pg_username,
|
||||
pg_password_enc, pg_password_nonce, created_at, updated_at
|
||||
pg_password_enc, pg_password_nonce, created_at, updated_at, owner_id
|
||||
FROM pg_credentials WHERE account_id = ?
|
||||
`, accountID).Scan(
|
||||
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
|
||||
&cred.PGDatabase, &cred.PGUsername,
|
||||
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
|
||||
&createdAtStr, &updatedAtStr,
|
||||
&createdAtStr, &updatedAtStr, &ownerID,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -476,6 +486,10 @@ func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ownerID.Valid {
|
||||
v := ownerID.Int64
|
||||
cred.OwnerID = &v
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
@@ -635,6 +649,23 @@ func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeAllUserTokensExcept revokes all non-expired, non-revoked tokens for an
|
||||
// account except for the token identified by exceptJTI. Used by the
|
||||
// self-service password change flow to invalidate all other sessions while
|
||||
// keeping the caller's current session active.
|
||||
func (db *DB) RevokeAllUserTokensExcept(accountID int64, exceptJTI, reason string) error {
|
||||
n := now()
|
||||
_, err := db.sql.Exec(`
|
||||
UPDATE token_revocation
|
||||
SET revoked_at = ?, revoke_reason = ?
|
||||
WHERE account_id = ? AND jti != ? AND revoked_at IS NULL AND expires_at > ?
|
||||
`, n, nullString(reason), accountID, exceptJTI, n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: revoke all tokens except %q for account %d: %w", exceptJTI, accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneExpiredTokens removes token_revocation rows that are past their expiry.
|
||||
// Returns the number of rows deleted.
|
||||
func (db *DB) PruneExpiredTokens() (int64, error) {
|
||||
|
||||
@@ -12,19 +12,36 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // register the sqlite3 driver
|
||||
)
|
||||
|
||||
// memCounter generates unique names for in-memory shared-cache databases.
|
||||
var memCounter atomic.Int64
|
||||
|
||||
// DB wraps a *sql.DB with MCIAS-specific helpers.
|
||||
type DB struct {
|
||||
sql *sql.DB
|
||||
// path is the DSN used to open this database. For in-memory databases
|
||||
// (originally ":memory:") it is a unique shared-cache URI of the form
|
||||
// file:mcias_N?mode=memory&cache=shared so that a second connection can be
|
||||
// opened to the same in-memory database (needed by the migration runner).
|
||||
path string
|
||||
}
|
||||
|
||||
// Open opens (or creates) the SQLite database at path and configures it for
|
||||
// MCIAS use (WAL mode, foreign keys, busy timeout).
|
||||
func Open(path string) (*DB, error) {
|
||||
// Translate bare ":memory:" to a named shared-cache in-memory URI.
|
||||
// This allows the migration runner to open a second connection to the
|
||||
// same in-memory database without sharing the *sql.DB handle (which
|
||||
// would be closed by golang-migrate when the migrator is done).
|
||||
if path == ":memory:" {
|
||||
path = fmt.Sprintf("file:mcias_%d?mode=memory&cache=shared", memCounter.Add(1))
|
||||
}
|
||||
|
||||
// The modernc.org/sqlite driver is registered as "sqlite".
|
||||
sqlDB, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
@@ -34,7 +51,7 @@ func Open(path string) (*DB, error) {
|
||||
// Use a single connection for writes; reads can use the pool.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
db := &DB{sql: sqlDB}
|
||||
db := &DB{sql: sqlDB, path: path}
|
||||
if err := db.configure(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, err
|
||||
|
||||
@@ -2,239 +2,141 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
sqlitedriver "github.com/golang-migrate/migrate/v4/database/sqlite"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "modernc.org/sqlite" // driver registration
|
||||
)
|
||||
|
||||
// migration represents a single schema migration with an ID and SQL statement.
|
||||
type migration struct {
|
||||
sql string
|
||||
id int
|
||||
}
|
||||
// migrationsFS embeds all migration SQL files from the migrations/ directory.
|
||||
// Each file is named NNN_description.up.sql (and optionally .down.sql).
|
||||
//
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// migrations is the ordered list of schema migrations applied to the database.
|
||||
// Once applied, migrations must never be modified — only new ones appended.
|
||||
var migrations = []migration{
|
||||
{
|
||||
id: 1,
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
// LatestSchemaVersion is the highest migration version defined in the
|
||||
// migrations/ directory. Update this constant whenever a new migration file
|
||||
// is added.
|
||||
const LatestSchemaVersion = 6
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
signing_key_enc BLOB,
|
||||
signing_key_nonce BLOB,
|
||||
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'))
|
||||
);
|
||||
// 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
|
||||
// database so that calling m.Close() (which closes the underlying connection)
|
||||
// does not affect the caller's main database connection.
|
||||
//
|
||||
// Security: migration SQL is embedded at compile time from the migrations/
|
||||
// directory and is never loaded from the filesystem at runtime, preventing
|
||||
// injection of arbitrary SQL via a compromised working directory.
|
||||
func newMigrate(database *DB) (*migrate.Migrate, error) {
|
||||
src, err := iofs.New(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: create migration source: %w", err)
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
account_type TEXT NOT NULL CHECK (account_type IN ('human','system')),
|
||||
password_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','inactive','deleted')),
|
||||
totp_required INTEGER NOT NULL DEFAULT 0 CHECK (totp_required IN (0,1)),
|
||||
totp_secret_enc BLOB,
|
||||
totp_secret_nonce BLOB,
|
||||
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')),
|
||||
deleted_at TEXT
|
||||
);
|
||||
// Open a dedicated connection for the migrator. golang-migrate's sqlite
|
||||
// driver calls db.Close() when the migrator is closed; using a dedicated
|
||||
// connection (same DSN, different *sql.DB) prevents it from closing the
|
||||
// shared connection. For in-memory databases, Open() translates
|
||||
// ":memory:" to a named shared-cache URI so both connections see the same
|
||||
// data.
|
||||
migrateDB, err := sql.Open("sqlite", database.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: open migration connection: %w", err)
|
||||
}
|
||||
migrateDB.SetMaxOpenConns(1)
|
||||
if _, err := migrateDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
_ = migrateDB.Close()
|
||||
return nil, fmt.Errorf("db: migration connection pragma: %w", err)
|
||||
}
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts (username);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_uuid ON accounts (uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts (status);
|
||||
driver, err := sqlitedriver.WithInstance(migrateDB, &sqlitedriver.Config{
|
||||
MigrationsTable: "schema_migrations",
|
||||
})
|
||||
if err != nil {
|
||||
_ = migrateDB.Close()
|
||||
return nil, fmt.Errorf("db: create migration driver: %w", err)
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_roles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
granted_by INTEGER REFERENCES accounts(id),
|
||||
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
UNIQUE (account_id, role)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_account_roles_account ON account_roles (account_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_revocation (
|
||||
id INTEGER PRIMARY KEY,
|
||||
jti TEXT NOT NULL UNIQUE,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
revoke_reason TEXT,
|
||||
issued_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_token_jti ON token_revocation (jti);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_account ON token_revocation (account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_expires ON token_revocation (expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_tokens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
jti TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pg_credentials (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
pg_host TEXT NOT NULL,
|
||||
pg_port INTEGER NOT NULL DEFAULT 5432,
|
||||
pg_database TEXT NOT NULL,
|
||||
pg_username TEXT NOT NULL,
|
||||
pg_password_enc BLOB NOT NULL,
|
||||
pg_password_nonce BLOB NOT NULL,
|
||||
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'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
event_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
event_type TEXT NOT NULL,
|
||||
actor_id INTEGER REFERENCES accounts(id),
|
||||
target_id INTEGER REFERENCES accounts(id),
|
||||
ip_address TEXT,
|
||||
details TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_log (event_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log (event_type);
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sql: `
|
||||
-- Add master_key_salt to server_config for Argon2id KDF salt storage.
|
||||
-- The salt must be stable across restarts so the passphrase always yields the same key.
|
||||
-- We allow NULL signing_key_enc/nonce temporarily until the first signing key is generated.
|
||||
ALTER TABLE server_config ADD COLUMN master_key_salt BLOB;
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sql: `
|
||||
-- Track per-account failed login attempts for lockout enforcement (F-08).
|
||||
-- One row per account; window_start resets when the window expires or on
|
||||
-- a successful login. The DB layer enforces atomicity via UPDATE+INSERT.
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
sql: `
|
||||
-- Machine/service tags on accounts (many-to-many).
|
||||
-- Used by the policy engine to gate access by machine or service identity
|
||||
-- (e.g. env:production, svc:payments-api, machine:db-west-01).
|
||||
CREATE TABLE IF NOT EXISTS account_tags (
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
PRIMARY KEY (account_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_account_tags_account ON account_tags (account_id);
|
||||
|
||||
-- Policy rules stored in the database and evaluated in-process.
|
||||
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
|
||||
-- Built-in default rules are compiled into the binary and are not stored here.
|
||||
-- Rows with enabled=0 are loaded but skipped during evaluation.
|
||||
CREATE TABLE IF NOT EXISTS policy_rules (
|
||||
id INTEGER PRIMARY KEY,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
description TEXT NOT NULL,
|
||||
rule_json TEXT NOT NULL,
|
||||
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'))
|
||||
);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
// LatestSchemaVersion is the highest migration ID in the migrations list.
|
||||
// It is updated automatically when new migrations are appended.
|
||||
var LatestSchemaVersion = migrations[len(migrations)-1].id
|
||||
|
||||
// SchemaVersion returns the current applied schema version of the database.
|
||||
// Returns 0 if no migrations have been applied yet.
|
||||
func SchemaVersion(database *DB) (int, error) {
|
||||
return currentSchemaVersion(database.sql)
|
||||
m, err := migrate.NewWithInstance("iofs", src, "sqlite", driver)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: initialise migrator: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Migrate applies any unapplied schema migrations to the database in order.
|
||||
// It is idempotent: running it multiple times is safe.
|
||||
func Migrate(db *DB) error {
|
||||
// Ensure the schema_version table exists first.
|
||||
if _, err := db.sql.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("db: ensure schema_version: %w", err)
|
||||
}
|
||||
|
||||
currentVersion, err := currentSchemaVersion(db.sql)
|
||||
// It is idempotent: running it on an already-current database is safe and
|
||||
// returns nil.
|
||||
//
|
||||
// Existing databases that were migrated by the previous hand-rolled runner
|
||||
// (schema_version table) are handled by the compatibility shim below: the
|
||||
// legacy version is read and used to fast-forward the golang-migrate state
|
||||
// before calling Up, so no migration is applied twice.
|
||||
func Migrate(database *DB) error {
|
||||
// Compatibility shim: if the database was previously migrated by the
|
||||
// hand-rolled runner it has a schema_version table with the current
|
||||
// version. Inform golang-migrate of the existing version so it does
|
||||
// not try to re-apply already-applied migrations.
|
||||
legacyVersion, err := legacySchemaVersion(database)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: get current schema version: %w", err)
|
||||
return fmt.Errorf("db: read legacy schema version: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if m.id <= currentVersion {
|
||||
continue
|
||||
}
|
||||
m, err := newMigrate(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
|
||||
|
||||
tx, err := db.sql.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: begin migration %d transaction: %w", m.id, err)
|
||||
if legacyVersion > 0 {
|
||||
// Force the migrator to treat the database as already at
|
||||
// legacyVersion so Up only applies newer migrations.
|
||||
if err := m.Force(legacyVersion); err != nil {
|
||||
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(m.sql); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("db: apply migration %d: %w", m.id, err)
|
||||
}
|
||||
|
||||
// Update the schema version within the same transaction.
|
||||
if currentVersion == 0 {
|
||||
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.id); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("db: insert schema version %d: %w", m.id, err)
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(`UPDATE schema_version SET version = ?`, m.id); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("db: update schema version to %d: %w", m.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("db: commit migration %d: %w", m.id, err)
|
||||
}
|
||||
currentVersion = m.id
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("db: apply migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// currentSchemaVersion returns the current schema version, or 0 if none applied.
|
||||
func currentSchemaVersion(db *sql.DB) (int, error) {
|
||||
var version int
|
||||
err := db.QueryRow(`SELECT version FROM schema_version LIMIT 1`).Scan(&version)
|
||||
// SchemaVersion returns the current applied schema version of the database.
|
||||
// Returns 0 if no migrations have been applied yet.
|
||||
func SchemaVersion(database *DB) (int, error) {
|
||||
m, err := newMigrate(database)
|
||||
if err != nil {
|
||||
// No rows means version 0 (fresh database).
|
||||
return 0, err
|
||||
}
|
||||
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
|
||||
|
||||
v, _, err := m.Version()
|
||||
if errors.Is(err, migrate.ErrNilVersion) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: read schema version: %w", err)
|
||||
}
|
||||
// Security: v is a migration version number (small positive integer);
|
||||
// the uint→int conversion is safe for any realistic schema version count.
|
||||
return int(v), nil //nolint:gosec // G115: migration version is always a small positive integer
|
||||
}
|
||||
|
||||
// legacySchemaVersion reads the version from the old schema_version table
|
||||
// created by the hand-rolled migration runner. Returns 0 if the table does
|
||||
// not exist (fresh database or already migrated to golang-migrate only).
|
||||
func legacySchemaVersion(database *DB) (int, error) {
|
||||
var version int
|
||||
err := database.sql.QueryRow(
|
||||
`SELECT version FROM schema_version LIMIT 1`,
|
||||
).Scan(&version)
|
||||
if err != nil {
|
||||
// Table does not exist or is empty — treat as version 0.
|
||||
return 0, nil //nolint:nilerr
|
||||
}
|
||||
return version, nil
|
||||
|
||||
92
internal/db/migrations/000001_initial_schema.up.sql
Normal file
92
internal/db/migrations/000001_initial_schema.up.sql
Normal file
@@ -0,0 +1,92 @@
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
signing_key_enc BLOB,
|
||||
signing_key_nonce BLOB,
|
||||
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'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
account_type TEXT NOT NULL CHECK (account_type IN ('human','system')),
|
||||
password_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','inactive','deleted')),
|
||||
totp_required INTEGER NOT NULL DEFAULT 0 CHECK (totp_required IN (0,1)),
|
||||
totp_secret_enc BLOB,
|
||||
totp_secret_nonce BLOB,
|
||||
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')),
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts (username);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_uuid ON accounts (uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts (status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_roles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
granted_by INTEGER REFERENCES accounts(id),
|
||||
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
UNIQUE (account_id, role)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_account_roles_account ON account_roles (account_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_revocation (
|
||||
id INTEGER PRIMARY KEY,
|
||||
jti TEXT NOT NULL UNIQUE,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
revoke_reason TEXT,
|
||||
issued_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_token_jti ON token_revocation (jti);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_account ON token_revocation (account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_expires ON token_revocation (expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_tokens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
jti TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pg_credentials (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
pg_host TEXT NOT NULL,
|
||||
pg_port INTEGER NOT NULL DEFAULT 5432,
|
||||
pg_database TEXT NOT NULL,
|
||||
pg_username TEXT NOT NULL,
|
||||
pg_password_enc BLOB NOT NULL,
|
||||
pg_password_nonce BLOB NOT NULL,
|
||||
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'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
event_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
event_type TEXT NOT NULL,
|
||||
actor_id INTEGER REFERENCES accounts(id),
|
||||
target_id INTEGER REFERENCES accounts(id),
|
||||
ip_address TEXT,
|
||||
details TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_log (event_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log (event_type);
|
||||
4
internal/db/migrations/000002_master_key_salt.up.sql
Normal file
4
internal/db/migrations/000002_master_key_salt.up.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Add master_key_salt to server_config for Argon2id KDF salt storage.
|
||||
-- The salt must be stable across restarts so the passphrase always yields the same key.
|
||||
-- We allow NULL signing_key_enc/nonce temporarily until the first signing key is generated.
|
||||
ALTER TABLE server_config ADD COLUMN master_key_salt BLOB;
|
||||
8
internal/db/migrations/000003_failed_logins.up.sql
Normal file
8
internal/db/migrations/000003_failed_logins.up.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Track per-account failed login attempts for lockout enforcement (F-08).
|
||||
-- One row per account; window_start resets when the window expires or on
|
||||
-- a successful login. The DB layer enforces atomicity via UPDATE+INSERT.
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
26
internal/db/migrations/000004_tags_and_policy.up.sql
Normal file
26
internal/db/migrations/000004_tags_and_policy.up.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Machine/service tags on accounts (many-to-many).
|
||||
-- Used by the policy engine to gate access by machine or service identity
|
||||
-- (e.g. env:production, svc:payments-api, machine:db-west-01).
|
||||
CREATE TABLE IF NOT EXISTS account_tags (
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
PRIMARY KEY (account_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_account_tags_account ON account_tags (account_id);
|
||||
|
||||
-- Policy rules stored in the database and evaluated in-process.
|
||||
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
|
||||
-- Built-in default rules are compiled into the binary and are not stored here.
|
||||
-- Rows with enabled=0 are loaded but skipped during evaluation.
|
||||
CREATE TABLE IF NOT EXISTS policy_rules (
|
||||
id INTEGER PRIMARY KEY,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
description TEXT NOT NULL,
|
||||
rule_json TEXT NOT NULL,
|
||||
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'))
|
||||
);
|
||||
24
internal/db/migrations/000005_pgcred_access.up.sql
Normal file
24
internal/db/migrations/000005_pgcred_access.up.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Track which accounts own each set of pg_credentials and which other
|
||||
-- accounts have been granted read access to them.
|
||||
--
|
||||
-- owner_id: the account that administers the credentials and may grant/revoke
|
||||
-- access. Defaults to the system account itself. This column is
|
||||
-- nullable so that rows created before migration 5 are not broken.
|
||||
ALTER TABLE pg_credentials ADD COLUMN owner_id INTEGER REFERENCES accounts(id);
|
||||
|
||||
-- pg_credential_access records an explicit "all-or-nothing" read grant from
|
||||
-- the credential owner to another account. Grantees may view connection
|
||||
-- metadata (host, port, database, username) but the password is never
|
||||
-- decrypted for them in the UI. Only the owner may update or delete the
|
||||
-- credential set.
|
||||
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_pgcred_access_cred ON pg_credential_access (credential_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pgcred_access_grantee ON pg_credential_access (grantee_id);
|
||||
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;
|
||||
247
internal/db/pgcred_access.go
Normal file
247
internal/db/pgcred_access.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
// ListCredentialedAccountIDs returns the set of account IDs that already have
|
||||
// a pg_credentials row. Used to filter the "uncredentialed system accounts"
|
||||
// list on the /pgcreds create form without leaking credential content.
|
||||
func (db *DB) ListCredentialedAccountIDs() (map[int64]struct{}, error) {
|
||||
rows, err := db.sql.Query(`SELECT account_id FROM pg_credentials`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list credentialed account ids: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
ids := make(map[int64]struct{})
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("db: scan credentialed account id: %w", err)
|
||||
}
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// SetPGCredentialOwner records the owning account for a pg_credentials row.
|
||||
// This is called on first write so that pre-migration rows retain a nil owner.
|
||||
// It is idempotent: if the owner is already set it is overwritten.
|
||||
func (db *DB) SetPGCredentialOwner(credentialID, ownerID int64) error {
|
||||
_, err := db.sql.Exec(`
|
||||
UPDATE pg_credentials SET owner_id = ? WHERE id = ?
|
||||
`, ownerID, credentialID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: set pg credential owner: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPGCredentialByID retrieves a single pg_credentials row by its primary key.
|
||||
// Returns ErrNotFound if no such credential exists.
|
||||
func (db *DB) GetPGCredentialByID(id int64) (*model.PGCredential, error) {
|
||||
var cred model.PGCredential
|
||||
var createdAtStr, updatedAtStr string
|
||||
var ownerID sql.NullInt64
|
||||
|
||||
err := db.sql.QueryRow(`
|
||||
SELECT p.id, p.account_id, p.pg_host, p.pg_port, p.pg_database, p.pg_username,
|
||||
p.pg_password_enc, p.pg_password_nonce, p.created_at, p.updated_at, p.owner_id
|
||||
FROM pg_credentials p WHERE p.id = ?
|
||||
`, id).Scan(
|
||||
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
|
||||
&cred.PGDatabase, &cred.PGUsername,
|
||||
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
|
||||
&createdAtStr, &updatedAtStr, &ownerID,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: get pg credential by id: %w", err)
|
||||
}
|
||||
|
||||
cred.CreatedAt, err = parseTime(createdAtStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred.UpdatedAt, err = parseTime(updatedAtStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ownerID.Valid {
|
||||
v := ownerID.Int64
|
||||
cred.OwnerID = &v
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
// GrantPGCredAccess grants an account read access to a pg_credentials set.
|
||||
// If the grant already exists the call is a no-op (UNIQUE constraint).
|
||||
// grantedBy may be nil if the grant is made programmatically.
|
||||
func (db *DB) GrantPGCredAccess(credentialID, granteeID int64, grantedBy *int64) error {
|
||||
n := now()
|
||||
_, err := db.sql.Exec(`
|
||||
INSERT INTO pg_credential_access (credential_id, grantee_id, granted_by, granted_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(credential_id, grantee_id) DO NOTHING
|
||||
`, credentialID, granteeID, grantedBy, n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: grant pg cred access: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokePGCredAccess removes a grantee's access to a pg_credentials set.
|
||||
func (db *DB) RevokePGCredAccess(credentialID, granteeID int64) error {
|
||||
_, err := db.sql.Exec(`
|
||||
DELETE FROM pg_credential_access WHERE credential_id = ? AND grantee_id = ?
|
||||
`, credentialID, granteeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: revoke pg cred access: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPGCredAccess returns all access grants for a pg_credentials set,
|
||||
// joining against accounts to populate grantee username and UUID.
|
||||
func (db *DB) ListPGCredAccess(credentialID int64) ([]*model.PGCredAccessGrant, error) {
|
||||
rows, err := db.sql.Query(`
|
||||
SELECT pca.id, pca.credential_id, pca.grantee_id, pca.granted_by, pca.granted_at,
|
||||
a.uuid, a.username
|
||||
FROM pg_credential_access pca
|
||||
JOIN accounts a ON a.id = pca.grantee_id
|
||||
WHERE pca.credential_id = ?
|
||||
ORDER BY pca.granted_at ASC
|
||||
`, credentialID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list pg cred access: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var grants []*model.PGCredAccessGrant
|
||||
for rows.Next() {
|
||||
g, err := scanPGCredAccessGrant(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grants = append(grants, g)
|
||||
}
|
||||
return grants, rows.Err()
|
||||
}
|
||||
|
||||
// CheckPGCredAccess reports whether accountID has an explicit access grant for
|
||||
// credentialID. The credential owner always has access implicitly; callers
|
||||
// must check ownership separately.
|
||||
func (db *DB) CheckPGCredAccess(credentialID, accountID int64) (bool, error) {
|
||||
var count int
|
||||
err := db.sql.QueryRow(`
|
||||
SELECT COUNT(*) FROM pg_credential_access
|
||||
WHERE credential_id = ? AND grantee_id = ?
|
||||
`, credentialID, accountID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("db: check pg cred access: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// PGCredWithAccount extends PGCredential with the owning system account's
|
||||
// username, used for the "My PG Credentials" listing view.
|
||||
type PGCredWithAccount struct {
|
||||
model.PGCredential
|
||||
}
|
||||
|
||||
// ListAccessiblePGCreds returns all pg_credentials rows that accountID may
|
||||
// view: those where accountID is the owner, plus those where an explicit
|
||||
// access grant exists. The ServiceUsername and ServiceAccountUUID fields are
|
||||
// populated from the owning system account for display and navigation.
|
||||
func (db *DB) ListAccessiblePGCreds(accountID int64) ([]*model.PGCredential, error) {
|
||||
rows, err := db.sql.Query(`
|
||||
SELECT p.id, p.account_id, p.pg_host, p.pg_port, p.pg_database, p.pg_username,
|
||||
p.pg_password_enc, p.pg_password_nonce, p.created_at, p.updated_at, p.owner_id,
|
||||
a.username, a.uuid
|
||||
FROM pg_credentials p
|
||||
JOIN accounts a ON a.id = p.account_id
|
||||
WHERE p.owner_id = ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM pg_credential_access pca
|
||||
WHERE pca.credential_id = p.id AND pca.grantee_id = ?
|
||||
)
|
||||
ORDER BY a.username ASC
|
||||
`, accountID, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list accessible pg creds: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var creds []*model.PGCredential
|
||||
for rows.Next() {
|
||||
cred, err := scanPGCredWithUsername(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds = append(creds, cred)
|
||||
}
|
||||
return creds, rows.Err()
|
||||
}
|
||||
|
||||
func scanPGCredWithUsername(rows *sql.Rows) (*model.PGCredential, error) {
|
||||
var cred model.PGCredential
|
||||
var createdAtStr, updatedAtStr string
|
||||
var ownerID sql.NullInt64
|
||||
|
||||
err := rows.Scan(
|
||||
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
|
||||
&cred.PGDatabase, &cred.PGUsername,
|
||||
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
|
||||
&createdAtStr, &updatedAtStr, &ownerID,
|
||||
&cred.ServiceUsername, &cred.ServiceAccountUUID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: scan pg cred with username: %w", err)
|
||||
}
|
||||
|
||||
cred.CreatedAt, err = parseTime(createdAtStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred.UpdatedAt, err = parseTime(updatedAtStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ownerID.Valid {
|
||||
v := ownerID.Int64
|
||||
cred.OwnerID = &v
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func scanPGCredAccessGrant(rows *sql.Rows) (*model.PGCredAccessGrant, error) {
|
||||
var g model.PGCredAccessGrant
|
||||
var grantedAtStr string
|
||||
var grantedBy sql.NullInt64
|
||||
|
||||
err := rows.Scan(
|
||||
&g.ID, &g.CredentialID, &g.GranteeID, &grantedBy, &grantedAtStr,
|
||||
&g.GranteeUUID, &g.GranteeName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: scan pg cred access grant: %w", err)
|
||||
}
|
||||
|
||||
g.GrantedAt, err = time.Parse("2006-01-02T15:04:05Z", grantedAtStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: parse pg cred access grant time %q: %w", grantedAtStr, err)
|
||||
}
|
||||
if grantedBy.Valid {
|
||||
v := grantedBy.Int64
|
||||
g.GrantedBy = &v
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
@@ -4,18 +4,23 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
// policyRuleCols is the column list for all policy rule SELECT queries.
|
||||
const policyRuleCols = `id, priority, description, rule_json, enabled, created_by, created_at, updated_at, not_before, expires_at`
|
||||
|
||||
// CreatePolicyRule inserts a new policy rule record. The returned record
|
||||
// includes the database-assigned ID and timestamps.
|
||||
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64) (*model.PolicyRuleRecord, error) {
|
||||
// notBefore and expiresAt are optional; nil means no constraint.
|
||||
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64, notBefore, expiresAt *time.Time) (*model.PolicyRuleRecord, error) {
|
||||
n := now()
|
||||
result, err := db.sql.Exec(`
|
||||
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?)
|
||||
`, priority, description, ruleJSON, createdBy, n, n)
|
||||
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at, not_before, expires_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)
|
||||
`, priority, description, ruleJSON, createdBy, n, n, formatNullableTime(notBefore), formatNullableTime(expiresAt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: create policy rule: %w", err)
|
||||
}
|
||||
@@ -39,6 +44,8 @@ func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
NotBefore: notBefore,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -46,7 +53,7 @@ func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string
|
||||
// Returns ErrNotFound if no such rule exists.
|
||||
func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
|
||||
return db.scanPolicyRule(db.sql.QueryRow(`
|
||||
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
|
||||
SELECT `+policyRuleCols+`
|
||||
FROM policy_rules WHERE id = ?
|
||||
`, id))
|
||||
}
|
||||
@@ -55,7 +62,7 @@ func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
|
||||
// When enabledOnly is true, only rules with enabled=1 are returned.
|
||||
func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, error) {
|
||||
query := `
|
||||
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
|
||||
SELECT ` + policyRuleCols + `
|
||||
FROM policy_rules`
|
||||
if enabledOnly {
|
||||
query += ` WHERE enabled = 1`
|
||||
@@ -80,8 +87,12 @@ func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, erro
|
||||
}
|
||||
|
||||
// UpdatePolicyRule updates the mutable fields of a policy rule.
|
||||
// Only the fields in the update map are changed; other fields are untouched.
|
||||
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string) error {
|
||||
// Only non-nil fields are changed; nil fields are left untouched.
|
||||
// For notBefore and expiresAt, use a non-nil pointer-to-pointer:
|
||||
// - nil (outer) → don't change
|
||||
// - non-nil → nil → set column to NULL
|
||||
// - non-nil → non-nil → set column to the time value
|
||||
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string, notBefore, expiresAt **time.Time) error {
|
||||
n := now()
|
||||
|
||||
// Build SET clause dynamically to only update provided fields.
|
||||
@@ -102,6 +113,14 @@ func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, rul
|
||||
setClauses += ", rule_json = ?"
|
||||
args = append(args, *ruleJSON)
|
||||
}
|
||||
if notBefore != nil {
|
||||
setClauses += ", not_before = ?"
|
||||
args = append(args, formatNullableTime(*notBefore))
|
||||
}
|
||||
if expiresAt != nil {
|
||||
setClauses += ", expires_at = ?"
|
||||
args = append(args, formatNullableTime(*expiresAt))
|
||||
}
|
||||
args = append(args, id)
|
||||
|
||||
_, err := db.sql.Exec(`UPDATE policy_rules SET `+setClauses+` WHERE id = ?`, args...)
|
||||
@@ -141,10 +160,12 @@ func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
|
||||
var enabledInt int
|
||||
var createdAtStr, updatedAtStr string
|
||||
var createdBy *int64
|
||||
var notBeforeStr, expiresAtStr *string
|
||||
|
||||
err := row.Scan(
|
||||
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
||||
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
||||
¬BeforeStr, &expiresAtStr,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -153,7 +174,7 @@ func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
|
||||
return nil, fmt.Errorf("db: scan policy rule: %w", err)
|
||||
}
|
||||
|
||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
|
||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr, notBeforeStr, expiresAtStr)
|
||||
}
|
||||
|
||||
// scanPolicyRuleRow scans a single policy rule from *sql.Rows.
|
||||
@@ -162,19 +183,21 @@ func (db *DB) scanPolicyRuleRow(rows *sql.Rows) (*model.PolicyRuleRecord, error)
|
||||
var enabledInt int
|
||||
var createdAtStr, updatedAtStr string
|
||||
var createdBy *int64
|
||||
var notBeforeStr, expiresAtStr *string
|
||||
|
||||
err := rows.Scan(
|
||||
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
||||
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
||||
¬BeforeStr, &expiresAtStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: scan policy rule row: %w", err)
|
||||
}
|
||||
|
||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
|
||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr, notBeforeStr, expiresAtStr)
|
||||
}
|
||||
|
||||
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string) (*model.PolicyRuleRecord, error) {
|
||||
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string, notBeforeStr, expiresAtStr *string) (*model.PolicyRuleRecord, error) {
|
||||
r.Enabled = enabledInt == 1
|
||||
r.CreatedBy = createdBy
|
||||
|
||||
@@ -187,5 +210,23 @@ func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.NotBefore, err = nullableTime(notBeforeStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.ExpiresAt, err = nullableTime(expiresAtStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// formatNullableTime converts a *time.Time to a *string suitable for SQLite.
|
||||
// Returns nil if the input is nil (stores NULL).
|
||||
func formatNullableTime(t *time.Time) *string {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
s := t.UTC().Format(time.RFC3339)
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
@@ -11,7 +12,7 @@ func TestCreateAndGetPolicyRule(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
ruleJSON := `{"actions":["pgcreds:read"],"resource_type":"pgcreds","effect":"allow"}`
|
||||
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil)
|
||||
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule: %v", err)
|
||||
}
|
||||
@@ -49,9 +50,9 @@ func TestGetPolicyRule_NotFound(t *testing.T) {
|
||||
func TestListPolicyRules(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil)
|
||||
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil)
|
||||
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil)
|
||||
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil, nil, nil)
|
||||
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil, nil, nil)
|
||||
|
||||
rules, err := db.ListPolicyRules(false)
|
||||
if err != nil {
|
||||
@@ -70,8 +71,8 @@ func TestListPolicyRules(t *testing.T) {
|
||||
func TestListPolicyRules_EnabledOnly(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil)
|
||||
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil)
|
||||
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil, nil, nil)
|
||||
|
||||
if err := db.SetPolicyRuleEnabled(r2.ID, false); err != nil {
|
||||
t.Fatalf("SetPolicyRuleEnabled: %v", err)
|
||||
@@ -100,11 +101,11 @@ func TestListPolicyRules_EnabledOnly(t *testing.T) {
|
||||
func TestUpdatePolicyRule(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil)
|
||||
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
|
||||
newDesc := "updated description"
|
||||
newPriority := 25
|
||||
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil); err != nil {
|
||||
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil, nil, nil); err != nil {
|
||||
t.Fatalf("UpdatePolicyRule: %v", err)
|
||||
}
|
||||
|
||||
@@ -127,10 +128,10 @@ func TestUpdatePolicyRule(t *testing.T) {
|
||||
func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil)
|
||||
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
|
||||
newJSON := `{"effect":"deny","roles":["auditor"]}`
|
||||
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON); err != nil {
|
||||
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON, nil, nil); err != nil {
|
||||
t.Fatalf("UpdatePolicyRule (json only): %v", err)
|
||||
}
|
||||
|
||||
@@ -150,7 +151,7 @@ func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
|
||||
func TestSetPolicyRuleEnabled(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil)
|
||||
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
if !rec.Enabled {
|
||||
t.Fatal("new rule should be enabled")
|
||||
}
|
||||
@@ -175,7 +176,7 @@ func TestSetPolicyRuleEnabled(t *testing.T) {
|
||||
func TestDeletePolicyRule(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil)
|
||||
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
|
||||
if err := db.DeletePolicyRule(rec.ID); err != nil {
|
||||
t.Fatalf("DeletePolicyRule: %v", err)
|
||||
@@ -200,7 +201,7 @@ func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
acct, _ := db.CreateAccount("policy-creator", model.AccountTypeHuman, "hash")
|
||||
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID)
|
||||
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule with createdBy: %v", err)
|
||||
}
|
||||
@@ -210,3 +211,111 @@ func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
|
||||
t.Errorf("expected CreatedBy=%d, got %v", acct.ID, got.CreatedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePolicyRule_WithExpiresAt(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
rec, err := db.CreatePolicyRule("expiring rule", 100, `{"effect":"allow"}`, nil, nil, &exp)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule with expiresAt: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetPolicyRule(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyRule: %v", err)
|
||||
}
|
||||
if got.ExpiresAt == nil {
|
||||
t.Fatal("expected ExpiresAt to be set")
|
||||
}
|
||||
if !got.ExpiresAt.Equal(exp) {
|
||||
t.Errorf("expected ExpiresAt=%v, got %v", exp, *got.ExpiresAt)
|
||||
}
|
||||
if got.NotBefore != nil {
|
||||
t.Errorf("expected NotBefore=nil, got %v", *got.NotBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePolicyRule_WithNotBefore(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
nb := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
rec, err := db.CreatePolicyRule("scheduled rule", 100, `{"effect":"allow"}`, nil, &nb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule with notBefore: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetPolicyRule(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyRule: %v", err)
|
||||
}
|
||||
if got.NotBefore == nil {
|
||||
t.Fatal("expected NotBefore to be set")
|
||||
}
|
||||
if !got.NotBefore.Equal(nb) {
|
||||
t.Errorf("expected NotBefore=%v, got %v", nb, *got.NotBefore)
|
||||
}
|
||||
if got.ExpiresAt != nil {
|
||||
t.Errorf("expected ExpiresAt=nil, got %v", *got.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePolicyRule_WithBothTimes(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
nb := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
rec, err := db.CreatePolicyRule("windowed rule", 100, `{"effect":"allow"}`, nil, &nb, &exp)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule with both times: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetPolicyRule(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyRule: %v", err)
|
||||
}
|
||||
if got.NotBefore == nil || !got.NotBefore.Equal(nb) {
|
||||
t.Errorf("NotBefore mismatch: got %v", got.NotBefore)
|
||||
}
|
||||
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(exp) {
|
||||
t.Errorf("ExpiresAt mismatch: got %v", got.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePolicyRule_SetExpiresAt(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("no expiry", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||
|
||||
exp := time.Date(2030, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
expPtr := &exp
|
||||
if err := db.UpdatePolicyRule(rec.ID, nil, nil, nil, nil, &expPtr); err != nil {
|
||||
t.Fatalf("UpdatePolicyRule (set expires_at): %v", err)
|
||||
}
|
||||
|
||||
got, _ := db.GetPolicyRule(rec.ID)
|
||||
if got.ExpiresAt == nil {
|
||||
t.Fatal("expected ExpiresAt to be set after update")
|
||||
}
|
||||
if !got.ExpiresAt.Equal(exp) {
|
||||
t.Errorf("expected ExpiresAt=%v, got %v", exp, *got.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePolicyRule_ClearExpiresAt(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
rec, _ := db.CreatePolicyRule("will clear", 100, `{"effect":"allow"}`, nil, nil, &exp)
|
||||
|
||||
// Clear expires_at by passing non-nil outer, nil inner.
|
||||
var nilTime *time.Time
|
||||
if err := db.UpdatePolicyRule(rec.ID, nil, nil, nil, nil, &nilTime); err != nil {
|
||||
t.Fatalf("UpdatePolicyRule (clear expires_at): %v", err)
|
||||
}
|
||||
|
||||
got, _ := db.GetPolicyRule(rec.ID)
|
||||
if got.ExpiresAt != nil {
|
||||
t.Errorf("expected ExpiresAt=nil after clear, got %v", *got.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,18 +87,26 @@ type SystemToken struct {
|
||||
// PGCredential holds Postgres connection details for a system account.
|
||||
// The password is encrypted at rest; PGPassword is only populated after
|
||||
// decryption and must never be logged or included in API responses.
|
||||
//
|
||||
// OwnerID identifies the account permitted to update, delete, and manage
|
||||
// access grants for this credential set. A nil OwnerID means the credential
|
||||
// pre-dates ownership tracking; for backwards compatibility, nil is treated as
|
||||
// unowned (only admins can manage it via the UI).
|
||||
type PGCredential struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PGHost string `json:"host"`
|
||||
PGDatabase string `json:"database"`
|
||||
PGUsername string `json:"username"`
|
||||
PGPassword string `json:"-"`
|
||||
PGPasswordEnc []byte `json:"-"`
|
||||
PGPasswordNonce []byte `json:"-"`
|
||||
ID int64 `json:"-"`
|
||||
AccountID int64 `json:"-"`
|
||||
PGPort int `json:"port"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
OwnerID *int64 `json:"-"`
|
||||
ServiceAccountUUID string `json:"service_account_uuid,omitempty"`
|
||||
PGUsername string `json:"username"`
|
||||
PGPassword string `json:"-"`
|
||||
ServiceUsername string `json:"service_username,omitempty"`
|
||||
PGDatabase string `json:"database"`
|
||||
PGHost string `json:"host"`
|
||||
PGPasswordEnc []byte `json:"-"`
|
||||
PGPasswordNonce []byte `json:"-"`
|
||||
ID int64 `json:"-"`
|
||||
AccountID int64 `json:"-"`
|
||||
PGPort int `json:"port"`
|
||||
}
|
||||
|
||||
// AuditEvent represents a single entry in the append-only audit log.
|
||||
@@ -141,16 +149,42 @@ const (
|
||||
EventPolicyDeny = "policy_deny"
|
||||
)
|
||||
|
||||
// PGCredAccessGrant records that a specific account has been granted read
|
||||
// access to a pg_credentials set. Only the credential owner can manage
|
||||
// grants; grantees can view connection metadata but never the plaintext
|
||||
// password, and they cannot update or delete the credential set.
|
||||
type PGCredAccessGrant struct {
|
||||
GrantedAt time.Time `json:"granted_at"`
|
||||
GrantedBy *int64 `json:"-"`
|
||||
GranteeUUID string `json:"grantee_id"`
|
||||
GranteeName string `json:"grantee_username"`
|
||||
ID int64 `json:"-"`
|
||||
CredentialID int64 `json:"-"`
|
||||
GranteeID int64 `json:"-"`
|
||||
}
|
||||
|
||||
// Audit event type for pg_credential_access changes.
|
||||
const (
|
||||
EventPGCredAccessGranted = "pgcred_access_granted" //nolint:gosec // G101: audit event type, not a credential
|
||||
EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential
|
||||
|
||||
EventPasswordChanged = "password_changed"
|
||||
)
|
||||
|
||||
// PolicyRuleRecord is the database representation of a policy rule.
|
||||
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
||||
// The ID, Priority, and Description are stored as dedicated columns.
|
||||
// NotBefore and ExpiresAt define an optional validity window; nil means no
|
||||
// constraint (always active / never expires).
|
||||
type PolicyRuleRecord struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy *int64 `json:"-"`
|
||||
Description string `json:"description"`
|
||||
RuleJSON string `json:"rule_json"`
|
||||
ID int64 `json:"id"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedBy *int64 `json:"-"`
|
||||
Description string `json:"description"`
|
||||
RuleJSON string `json:"rule_json"`
|
||||
ID int64 `json:"id"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// adminInput is a convenience helper for building admin PolicyInputs.
|
||||
@@ -378,3 +379,131 @@ func TestEvaluate_AccountTypeGating(t *testing.T) {
|
||||
t.Error("human account should not match system-only rule")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Engine.SetRules time-filtering tests ----
|
||||
|
||||
func TestSetRules_SkipsExpiredRule(t *testing.T) {
|
||||
engine := NewEngine()
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
err := engine.SetRules([]PolicyRecord{
|
||||
{
|
||||
ID: 1,
|
||||
Description: "expired",
|
||||
Priority: 100,
|
||||
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||
Enabled: true,
|
||||
ExpiresAt: &past,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRules: %v", err)
|
||||
}
|
||||
|
||||
// The expired rule should not be in the cache; evaluation should deny.
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionListAccounts,
|
||||
Resource: Resource{Type: ResourceAccount},
|
||||
}
|
||||
effect, _ := engine.Evaluate(input)
|
||||
if effect != Deny {
|
||||
t.Error("expired rule should not match; expected Deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRules_SkipsNotYetActiveRule(t *testing.T) {
|
||||
engine := NewEngine()
|
||||
future := time.Now().Add(1 * time.Hour)
|
||||
|
||||
err := engine.SetRules([]PolicyRecord{
|
||||
{
|
||||
ID: 2,
|
||||
Description: "not yet active",
|
||||
Priority: 100,
|
||||
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||
Enabled: true,
|
||||
NotBefore: &future,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRules: %v", err)
|
||||
}
|
||||
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionListAccounts,
|
||||
Resource: Resource{Type: ResourceAccount},
|
||||
}
|
||||
effect, _ := engine.Evaluate(input)
|
||||
if effect != Deny {
|
||||
t.Error("future not_before rule should not match; expected Deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRules_IncludesActiveWindowRule(t *testing.T) {
|
||||
engine := NewEngine()
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
future := time.Now().Add(1 * time.Hour)
|
||||
|
||||
err := engine.SetRules([]PolicyRecord{
|
||||
{
|
||||
ID: 3,
|
||||
Description: "currently active",
|
||||
Priority: 100,
|
||||
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||
Enabled: true,
|
||||
NotBefore: &past,
|
||||
ExpiresAt: &future,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRules: %v", err)
|
||||
}
|
||||
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionListAccounts,
|
||||
Resource: Resource{Type: ResourceAccount},
|
||||
}
|
||||
effect, _ := engine.Evaluate(input)
|
||||
if effect != Allow {
|
||||
t.Error("rule within its active window should match; expected Allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRules_NilTimesAlwaysActive(t *testing.T) {
|
||||
engine := NewEngine()
|
||||
|
||||
err := engine.SetRules([]PolicyRecord{
|
||||
{
|
||||
ID: 4,
|
||||
Description: "no time constraints",
|
||||
Priority: 100,
|
||||
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||
Enabled: true,
|
||||
// NotBefore and ExpiresAt are both nil.
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRules: %v", err)
|
||||
}
|
||||
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionListAccounts,
|
||||
Resource: Resource{Type: ResourceAccount},
|
||||
}
|
||||
effect, _ := engine.Evaluate(input)
|
||||
if effect != Allow {
|
||||
t.Error("nil time fields mean always active; expected Allow")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Engine wraps the stateless Evaluate function with an in-memory cache of
|
||||
@@ -31,11 +32,19 @@ func NewEngine() *Engine {
|
||||
// into a Rule. This prevents the database from injecting values into the ID or
|
||||
// Description fields that are stored as dedicated columns.
|
||||
func (e *Engine) SetRules(records []PolicyRecord) error {
|
||||
now := time.Now()
|
||||
rules := make([]Rule, 0, len(records))
|
||||
for _, rec := range records {
|
||||
if !rec.Enabled {
|
||||
continue
|
||||
}
|
||||
// Skip rules outside their validity window.
|
||||
if rec.NotBefore != nil && now.Before(*rec.NotBefore) {
|
||||
continue
|
||||
}
|
||||
if rec.ExpiresAt != nil && now.After(*rec.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
var body RuleBody
|
||||
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
||||
return fmt.Errorf("policy: decode rule %d %q: %w", rec.ID, rec.Description, err)
|
||||
@@ -75,6 +84,8 @@ func (e *Engine) Evaluate(input PolicyInput) (Effect, *Rule) {
|
||||
// Using a local struct avoids importing the db or model packages from policy,
|
||||
// which would create a dependency cycle.
|
||||
type PolicyRecord struct {
|
||||
NotBefore *time.Time
|
||||
ExpiresAt *time.Time
|
||||
Description string
|
||||
RuleJSON string
|
||||
ID int64
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
@@ -90,6 +91,8 @@ func (s *Server) handleSetTags(w http.ResponseWriter, r *http.Request) {
|
||||
type policyRuleResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
NotBefore *string `json:"not_before,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
Description string `json:"description"`
|
||||
RuleBody policy.RuleBody `json:"rule"`
|
||||
ID int64 `json:"id"`
|
||||
@@ -102,15 +105,24 @@ func policyRuleToResponse(rec *model.PolicyRuleRecord) (policyRuleResponse, erro
|
||||
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
||||
return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err)
|
||||
}
|
||||
return policyRuleResponse{
|
||||
resp := policyRuleResponse{
|
||||
ID: rec.ID,
|
||||
Priority: rec.Priority,
|
||||
Description: rec.Description,
|
||||
RuleBody: body,
|
||||
Enabled: rec.Enabled,
|
||||
CreatedAt: rec.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: rec.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}, nil
|
||||
CreatedAt: rec.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: rec.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if rec.NotBefore != nil {
|
||||
s := rec.NotBefore.UTC().Format(time.RFC3339)
|
||||
resp.NotBefore = &s
|
||||
}
|
||||
if rec.ExpiresAt != nil {
|
||||
s := rec.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
resp.ExpiresAt = &s
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -133,6 +145,8 @@ func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
type createPolicyRuleRequest struct {
|
||||
Description string `json:"description"`
|
||||
NotBefore *string `json:"not_before,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
Rule policy.RuleBody `json:"rule"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
@@ -157,6 +171,29 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
|
||||
priority = 100 // default
|
||||
}
|
||||
|
||||
// Parse optional time-scoped validity window.
|
||||
var notBefore, expiresAt *time.Time
|
||||
if req.NotBefore != nil {
|
||||
t, err := time.Parse(time.RFC3339, *req.NotBefore)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "not_before must be RFC3339", "bad_request")
|
||||
return
|
||||
}
|
||||
notBefore = &t
|
||||
}
|
||||
if req.ExpiresAt != nil {
|
||||
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be RFC3339", "bad_request")
|
||||
return
|
||||
}
|
||||
expiresAt = &t
|
||||
}
|
||||
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be after not_before", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
ruleJSON, err := json.Marshal(req.Rule)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
@@ -171,7 +208,7 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy)
|
||||
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy, notBefore, expiresAt)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
@@ -202,10 +239,14 @@ func (s *Server) handleGetPolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type updatePolicyRuleRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
Rule *policy.RuleBody `json:"rule,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
NotBefore *string `json:"not_before,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
Rule *policy.RuleBody `json:"rule,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
ClearNotBefore *bool `json:"clear_not_before,omitempty"`
|
||||
ClearExpiresAt *bool `json:"clear_expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -230,11 +271,39 @@ func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
s := string(b)
|
||||
ruleJSON = &s
|
||||
js := string(b)
|
||||
ruleJSON = &js
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON); err != nil {
|
||||
// Parse optional time-scoped validity window updates.
|
||||
// Double-pointer semantics: nil = no change, non-nil→nil = clear, non-nil→non-nil = set.
|
||||
var notBefore, expiresAt **time.Time
|
||||
if req.ClearNotBefore != nil && *req.ClearNotBefore {
|
||||
var nilTime *time.Time
|
||||
notBefore = &nilTime // non-nil outer, nil inner → set to NULL
|
||||
} else if req.NotBefore != nil {
|
||||
t, err := time.Parse(time.RFC3339, *req.NotBefore)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "not_before must be RFC3339", "bad_request")
|
||||
return
|
||||
}
|
||||
tp := &t
|
||||
notBefore = &tp
|
||||
}
|
||||
if req.ClearExpiresAt != nil && *req.ClearExpiresAt {
|
||||
var nilTime *time.Time
|
||||
expiresAt = &nilTime // non-nil outer, nil inner → set to NULL
|
||||
} else if req.ExpiresAt != nil {
|
||||
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be RFC3339", "bad_request")
|
||||
return
|
||||
}
|
||||
tp := &t
|
||||
expiresAt = &tp
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON, notBefore, expiresAt); err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
||||
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword)))
|
||||
|
||||
// Self-service password change (requires valid token; actor must match target account).
|
||||
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
|
||||
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
|
||||
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
|
||||
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
|
||||
@@ -801,6 +805,183 @@ func (s *Server) handleTOTPRemove(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- Password change endpoints ----
|
||||
|
||||
// adminSetPasswordRequest is the request body for PUT /v1/accounts/{id}/password.
|
||||
// Used by admins to reset any human account's password without requiring the
|
||||
// current password.
|
||||
type adminSetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// handleAdminSetPassword allows an admin to reset any human account's password.
|
||||
// No current-password verification is required because the admin role already
|
||||
// represents a higher trust level, matching the break-glass recovery pattern.
|
||||
//
|
||||
// Security: new password is validated (minimum length) and hashed with Argon2id
|
||||
// before storage. The plaintext is never logged. All active tokens for the
|
||||
// target account are revoked so that a compromised-account recovery fully
|
||||
// invalidates any outstanding sessions.
|
||||
func (s *Server) handleAdminSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
acct, ok := s.loadAccount(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeHuman {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "password can only be set on human accounts", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
var req adminSetPasswordRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Security (F-13): enforce minimum length before hashing.
|
||||
if err := validate.Password(req.NewPassword); err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
|
||||
Time: s.cfg.Argon2.Time,
|
||||
Memory: s.cfg.Argon2.Memory,
|
||||
Threads: s.cfg.Argon2.Threads,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("hash password (admin reset)", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
||||
s.logger.Error("update password hash", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: revoke all active sessions so a compromised account cannot
|
||||
// continue to use old tokens after a password reset. Failure here means
|
||||
// the API's documented guarantee ("all active sessions revoked") cannot be
|
||||
// upheld, so we return 500 rather than silently succeeding.
|
||||
if err := s.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
|
||||
s.logger.Error("revoke tokens on password reset", "error", err, "account_id", acct.ID)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
actor := middleware.ClaimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if actor != nil {
|
||||
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
||||
actorID = &a.ID
|
||||
}
|
||||
}
|
||||
s.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// changePasswordRequest is the request body for PUT /v1/auth/password.
|
||||
// The current_password is required to prevent token-theft attacks: an attacker
|
||||
// who steals a valid JWT cannot change the password without also knowing the
|
||||
// existing one.
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// handleChangePassword allows an authenticated user to change their own password.
|
||||
// The current password must be verified before the new hash is written.
|
||||
//
|
||||
// Security: current password is verified with Argon2id (constant-time).
|
||||
// Lockout is checked and failures are recorded to prevent the endpoint from
|
||||
// being used as an oracle for the current password. On success, all other
|
||||
// active sessions (other JTIs) are revoked so stale tokens cannot be used
|
||||
// after a credential rotation.
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeHuman {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "password change is only available for human accounts", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
var req changePasswordRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.CurrentPassword == "" || req.NewPassword == "" {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "current_password and new_password are required", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check lockout before verifying (same as login flow) so an
|
||||
// attacker cannot use this endpoint to brute-force the current password.
|
||||
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
s.logger.Error("lockout check (password change)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
||||
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: verify the current password with the same constant-time
|
||||
// Argon2id path used at login to prevent timing oracles.
|
||||
ok, verifyErr := auth.VerifyPassword(req.CurrentPassword, acct.PasswordHash)
|
||||
if verifyErr != nil || !ok {
|
||||
_ = s.db.RecordLoginFailure(acct.ID)
|
||||
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "current password is incorrect", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Security (F-13): enforce minimum length on the new password before hashing.
|
||||
if err := validate.Password(req.NewPassword); err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
|
||||
Time: s.cfg.Argon2.Time,
|
||||
Memory: s.cfg.Argon2.Memory,
|
||||
Threads: s.cfg.Argon2.Threads,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("hash password (self-service change)", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
||||
s.logger.Error("update password hash", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: clear the failure counter since the user proved knowledge of
|
||||
// the current password, then revoke all tokens *except* the current one so
|
||||
// the caller retains their active session but any other stolen sessions are
|
||||
// invalidated. Revocation failure breaks the documented guarantee so we
|
||||
// return 500 rather than silently succeeding.
|
||||
_ = s.db.ClearLoginFailures(acct.ID)
|
||||
if err := s.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
|
||||
s.logger.Error("revoke other tokens on password change", "error", err, "account_id", acct.ID)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"self_service"}`)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- Postgres credential endpoints ----
|
||||
|
||||
type pgCredRequest struct {
|
||||
|
||||
@@ -32,7 +32,7 @@ func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
u.render(w, "accounts", AccountsData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||
Accounts: accounts,
|
||||
})
|
||||
}
|
||||
@@ -132,15 +132,41 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
tokens = nil
|
||||
}
|
||||
|
||||
// Resolve the currently logged-in actor.
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Load PG credentials for system accounts only; leave nil for human accounts
|
||||
// and when no credentials have been stored yet.
|
||||
var pgCred *model.PGCredential
|
||||
var pgCredGrants []*model.PGCredAccessGrant
|
||||
var grantableAccounts []*model.Account
|
||||
if acct.AccountType == model.AccountTypeSystem {
|
||||
pgCred, err = u.db.ReadPGCredentials(acct.ID)
|
||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||
u.logger.Warn("read pg credentials", "error", err)
|
||||
}
|
||||
// ErrNotFound is expected when no credentials have been stored yet.
|
||||
|
||||
// Load access grants; only show management controls when the actor is owner.
|
||||
if pgCred != nil {
|
||||
pgCredGrants, err = u.db.ListPGCredAccess(pgCred.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("list pg cred access", "error", err)
|
||||
}
|
||||
// Populate the "add grantee" dropdown only for the credential owner.
|
||||
if actorID != nil && pgCred.OwnerID != nil && *pgCred.OwnerID == *actorID {
|
||||
grantableAccounts, err = u.db.ListAccounts()
|
||||
if err != nil {
|
||||
u.logger.Warn("list accounts for pgcred grant", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := u.db.GetAccountTags(acct.ID)
|
||||
@@ -150,13 +176,16 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
u.render(w, "account_detail", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
Roles: roles,
|
||||
AllRoles: knownRoles,
|
||||
Tokens: tokens,
|
||||
PGCred: pgCred,
|
||||
Tags: tags,
|
||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||
Account: acct,
|
||||
Roles: roles,
|
||||
AllRoles: knownRoles,
|
||||
Tokens: tokens,
|
||||
PGCred: pgCred,
|
||||
PGCredGrants: pgCredGrants,
|
||||
GrantableAccounts: grantableAccounts,
|
||||
ActorID: actorID,
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -456,15 +485,505 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
|
||||
pgCred = nil
|
||||
}
|
||||
|
||||
// Security: set the credential owner to the actor on first write so that
|
||||
// subsequent grant/revoke operations can enforce ownership. If no actor
|
||||
// is present (e.g. bootstrap), the owner remains nil.
|
||||
if pgCred != nil && pgCred.OwnerID == nil && actorID != nil {
|
||||
if err := u.db.SetPGCredentialOwner(pgCred.ID, *actorID); err != nil {
|
||||
u.logger.Warn("set pg credential owner", "error", err)
|
||||
} else {
|
||||
pgCred.OwnerID = actorID
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing access grants to re-render the full section.
|
||||
var grants []*model.PGCredAccessGrant
|
||||
if pgCred != nil {
|
||||
grants, err = u.db.ListPGCredAccess(pgCred.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("list pg cred access after write", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load non-system accounts available to grant access to.
|
||||
grantableAccounts, err := u.db.ListAccounts()
|
||||
if err != nil {
|
||||
u.logger.Warn("list accounts for pgcred grant", "error", err)
|
||||
}
|
||||
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
csrfToken = ""
|
||||
}
|
||||
|
||||
u.render(w, "pgcreds_form", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
PGCred: pgCred,
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
PGCred: pgCred,
|
||||
PGCredGrants: grants,
|
||||
GrantableAccounts: grantableAccounts,
|
||||
ActorID: actorID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGrantPGCredAccess grants another account read access to a pg_credentials
|
||||
// set owned by the actor. Only the credential owner may grant access; this is
|
||||
// enforced by comparing the stored owner_id with the logged-in actor.
|
||||
//
|
||||
// Security: ownership is re-verified server-side on every request; the form
|
||||
// field grantee_uuid is looked up in the accounts table (no ID injection).
|
||||
// Audit event EventPGCredAccessGranted is recorded on success.
|
||||
func (u *UIServer) handleGrantPGCredAccess(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.AccountTypeSystem {
|
||||
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
|
||||
return
|
||||
}
|
||||
|
||||
pgCred, err := u.db.ReadPGCredentials(acct.ID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "no credentials stored for this account")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the currently logged-in actor.
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Security: only the credential owner may grant access.
|
||||
if actorID == nil || pgCred.OwnerID == nil || *pgCred.OwnerID != *actorID {
|
||||
u.renderError(w, r, http.StatusForbidden, "only the credential owner may grant access")
|
||||
return
|
||||
}
|
||||
|
||||
granteeUUID := strings.TrimSpace(r.FormValue("grantee_uuid"))
|
||||
if granteeUUID == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "grantee is required")
|
||||
return
|
||||
}
|
||||
grantee, err := u.db.GetAccountByUUID(granteeUUID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "grantee account not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.GrantPGCredAccess(pgCred.ID, grantee.ID, actorID); err != nil {
|
||||
u.logger.Error("grant pg cred access", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to grant access")
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventPGCredAccessGranted, actorID, &grantee.ID,
|
||||
fmt.Sprintf(`{"credential_id":%d,"grantee":%q}`, pgCred.ID, grantee.UUID))
|
||||
|
||||
// If the caller requested a redirect (e.g. from the /pgcreds page), honour it.
|
||||
if next := r.FormValue("_next"); next == "/pgcreds" {
|
||||
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Re-render the full pgcreds section so the new grant appears.
|
||||
grants, err := u.db.ListPGCredAccess(pgCred.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("list pg cred access after grant", "error", err)
|
||||
}
|
||||
grantableAccounts, err := u.db.ListAccounts()
|
||||
if err != nil {
|
||||
u.logger.Warn("list accounts for pgcred grant", "error", err)
|
||||
}
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
csrfToken = ""
|
||||
}
|
||||
u.render(w, "pgcreds_form", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
PGCred: pgCred,
|
||||
PGCredGrants: grants,
|
||||
GrantableAccounts: grantableAccounts,
|
||||
ActorID: actorID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleRevokePGCredAccess removes a grantee's read access to a pg_credentials set.
|
||||
// Only the credential owner may revoke grants; this is enforced server-side.
|
||||
//
|
||||
// Security: ownership re-verified on every request. grantee_uuid looked up
|
||||
// in accounts table — not taken from URL path to prevent enumeration.
|
||||
// Audit event EventPGCredAccessRevoked is recorded on success.
|
||||
func (u *UIServer) handleRevokePGCredAccess(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.AccountTypeSystem {
|
||||
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
|
||||
return
|
||||
}
|
||||
|
||||
pgCred, err := u.db.ReadPGCredentials(acct.ID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "no credentials stored for this account")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the currently logged-in actor.
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Security: only the credential owner may revoke access.
|
||||
if actorID == nil || pgCred.OwnerID == nil || *pgCred.OwnerID != *actorID {
|
||||
u.renderError(w, r, http.StatusForbidden, "only the credential owner may revoke access")
|
||||
return
|
||||
}
|
||||
|
||||
granteeUUID := r.PathValue("grantee")
|
||||
if granteeUUID == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "grantee is required")
|
||||
return
|
||||
}
|
||||
grantee, err := u.db.GetAccountByUUID(granteeUUID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "grantee account not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.RevokePGCredAccess(pgCred.ID, grantee.ID); err != nil {
|
||||
u.logger.Error("revoke pg cred access", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to revoke access")
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventPGCredAccessRevoked, actorID, &grantee.ID,
|
||||
fmt.Sprintf(`{"credential_id":%d,"grantee":%q}`, pgCred.ID, grantee.UUID))
|
||||
|
||||
// If the caller requested a redirect (e.g. from the /pgcreds page), honour it.
|
||||
if r.URL.Query().Get("_next") == "/pgcreds" {
|
||||
if isHTMX(r) {
|
||||
w.Header().Set("HX-Redirect", "/pgcreds")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Re-render the full pgcreds section with the grant removed.
|
||||
grants, err := u.db.ListPGCredAccess(pgCred.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("list pg cred access after revoke", "error", err)
|
||||
}
|
||||
grantableAccounts, err := u.db.ListAccounts()
|
||||
if err != nil {
|
||||
u.logger.Warn("list accounts for pgcred grant", "error", err)
|
||||
}
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
csrfToken = ""
|
||||
}
|
||||
u.render(w, "pgcreds_form", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
PGCred: pgCred,
|
||||
PGCredGrants: grants,
|
||||
GrantableAccounts: grantableAccounts,
|
||||
ActorID: actorID,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePGCredsList renders the "My PG Credentials" page, showing all
|
||||
// pg_credentials accessible to the currently logged-in user (owned + granted),
|
||||
// plus a create form for system accounts that have no credentials yet.
|
||||
func (u *UIServer) handlePGCredsList(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
u.redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "could not resolve actor")
|
||||
return
|
||||
}
|
||||
|
||||
creds, err := u.db.ListAccessiblePGCreds(actor.ID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// Build the list of system accounts that have no credentials at all
|
||||
// (not just those absent from this actor's accessible set) so the
|
||||
// create form remains available even when the actor has no existing creds.
|
||||
credAcctIDs, err := u.db.ListCredentialedAccountIDs()
|
||||
if err != nil {
|
||||
u.logger.Warn("list credentialed account ids", "error", err)
|
||||
credAcctIDs = map[int64]struct{}{}
|
||||
}
|
||||
allAccounts, err := u.db.ListAccounts()
|
||||
if err != nil {
|
||||
u.logger.Warn("list accounts for pgcreds create form", "error", err)
|
||||
}
|
||||
var uncredentialed []*model.Account
|
||||
for _, a := range allAccounts {
|
||||
if a.AccountType == model.AccountTypeSystem {
|
||||
if _, hasCredential := credAcctIDs[a.ID]; !hasCredential {
|
||||
uncredentialed = append(uncredentialed, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For each credential owned by the actor, load its access grants so the
|
||||
// /pgcreds page can render inline grant management controls.
|
||||
credGrants := make(map[int64][]*model.PGCredAccessGrant)
|
||||
for _, c := range creds {
|
||||
if c.OwnerID != nil && *c.OwnerID == actor.ID {
|
||||
grants, err := u.db.ListPGCredAccess(c.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("list pg cred access for owned cred", "cred_id", c.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
credGrants[c.ID] = grants
|
||||
}
|
||||
}
|
||||
|
||||
u.render(w, "pgcreds", PGCredsData{
|
||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||
Creds: creds,
|
||||
UncredentialedAccounts: uncredentialed,
|
||||
CredGrants: credGrants,
|
||||
AllAccounts: allAccounts,
|
||||
ActorID: &actor.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreatePGCreds creates a new PG credential set from the /pgcreds page.
|
||||
// The submitter selects a system account from the uncredentialed list and
|
||||
// provides connection details; on success they become the credential owner.
|
||||
//
|
||||
// Security: only system accounts may hold PG credentials; the submitted account
|
||||
// UUID is validated server-side. Password is encrypted with AES-256-GCM before
|
||||
// storage; the plaintext is never logged or included in any response.
|
||||
// Audit event EventPGCredUpdated is recorded on success.
|
||||
func (u *UIServer) handleCreatePGCreds(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
|
||||
}
|
||||
|
||||
accountUUID := strings.TrimSpace(r.FormValue("account_uuid"))
|
||||
if accountUUID == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "account is required")
|
||||
return
|
||||
}
|
||||
acct, err := u.db.GetAccountByUUID(accountUUID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeSystem {
|
||||
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
|
||||
return
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(r.FormValue("host"))
|
||||
portStr := strings.TrimSpace(r.FormValue("port"))
|
||||
dbName := strings.TrimSpace(r.FormValue("database"))
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
if host == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "host is required")
|
||||
return
|
||||
}
|
||||
if dbName == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "database is required")
|
||||
return
|
||||
}
|
||||
if username == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "username is required")
|
||||
return
|
||||
}
|
||||
// Security: password is required on every write — the UI never carries an
|
||||
// existing password, so callers must supply it explicitly.
|
||||
if password == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "password is required")
|
||||
return
|
||||
}
|
||||
|
||||
port := 5432
|
||||
if portStr != "" {
|
||||
port, err = strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
u.renderError(w, r, http.StatusBadRequest, "port must be an integer between 1 and 65535")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Security: encrypt with AES-256-GCM; fresh nonce per call.
|
||||
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
|
||||
if err != nil {
|
||||
u.logger.Error("encrypt pg password", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.WritePGCredentials(acct.ID, host, port, dbName, username, enc, nonce); err != nil {
|
||||
u.logger.Error("write pg credentials", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to save credentials")
|
||||
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.EventPGCredUpdated, actorID, &acct.ID, "")
|
||||
|
||||
// Security: set the credential owner to the actor on creation.
|
||||
pgCred, err := u.db.ReadPGCredentials(acct.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("re-read pg credentials after create", "error", err)
|
||||
}
|
||||
if pgCred != nil && pgCred.OwnerID == nil && actorID != nil {
|
||||
if err := u.db.SetPGCredentialOwner(pgCred.ID, *actorID); err != nil {
|
||||
u.logger.Warn("set pg credential owner", "error", err)
|
||||
} else {
|
||||
pgCred.OwnerID = actorID
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to the pgcreds list so the new entry appears in context.
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ func (u *UIServer) handleAuditDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
u.render(w, "audit_detail", AuditDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||
Event: event,
|
||||
})
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func (u *UIServer) buildAuditData(r *http.Request, page int, csrfToken string) (
|
||||
}
|
||||
|
||||
return AuditData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||
Events: events,
|
||||
EventTypes: auditEventTypes,
|
||||
FilterType: filterType,
|
||||
|
||||
@@ -37,7 +37,7 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
u.render(w, "dashboard", DashboardData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||
TotalAccounts: total,
|
||||
ActiveAccounts: active,
|
||||
RecentEvents: events,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
@@ -60,7 +61,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
data := PoliciesData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||
Rules: views,
|
||||
AllActions: allActionStrings,
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
|
||||
// policyRuleToView converts a DB record to a template-friendly view.
|
||||
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
|
||||
pretty := prettyJSONStr(rec.RuleJSON)
|
||||
return &PolicyRuleView{
|
||||
v := &PolicyRuleView{
|
||||
ID: rec.ID,
|
||||
Priority: rec.Priority,
|
||||
Description: rec.Description,
|
||||
@@ -79,6 +80,16 @@ func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
|
||||
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
|
||||
UpdatedAt: rec.UpdatedAt.Format("2006-01-02 15:04 UTC"),
|
||||
}
|
||||
now := time.Now()
|
||||
if rec.NotBefore != nil {
|
||||
v.NotBefore = rec.NotBefore.UTC().Format("2006-01-02 15:04 UTC")
|
||||
v.IsPending = now.Before(*rec.NotBefore)
|
||||
}
|
||||
if rec.ExpiresAt != nil {
|
||||
v.ExpiresAt = rec.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
|
||||
v.IsExpired = now.After(*rec.ExpiresAt)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func prettyJSONStr(s string) string {
|
||||
@@ -160,6 +171,29 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional time-scoped validity window from datetime-local inputs.
|
||||
var notBefore, expiresAt *time.Time
|
||||
if nbStr := strings.TrimSpace(r.FormValue("not_before")); nbStr != "" {
|
||||
t, err := time.Parse("2006-01-02T15:04", nbStr)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid not_before time format")
|
||||
return
|
||||
}
|
||||
notBefore = &t
|
||||
}
|
||||
if eaStr := strings.TrimSpace(r.FormValue("expires_at")); eaStr != "" {
|
||||
t, err := time.Parse("2006-01-02T15:04", eaStr)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid expires_at time format")
|
||||
return
|
||||
}
|
||||
expiresAt = &t
|
||||
}
|
||||
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
|
||||
u.renderError(w, r, http.StatusBadRequest, "expires_at must be after not_before")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
@@ -168,7 +202,7 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID)
|
||||
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID, notBefore, expiresAt)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
|
||||
return
|
||||
|
||||
@@ -141,6 +141,22 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
return false
|
||||
},
|
||||
"not": func(b bool) bool { return !b },
|
||||
// derefInt64 safely dereferences a *int64, returning 0 for nil.
|
||||
// Used in templates to compare owner IDs without triggering nil panics.
|
||||
"derefInt64": func(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
},
|
||||
// isPGCredOwner returns true when actorID and cred are both non-nil
|
||||
// and actorID matches cred.OwnerID. Safe to call with nil arguments.
|
||||
"isPGCredOwner": func(actorID *int64, cred *model.PGCredential) bool {
|
||||
if actorID == nil || cred == nil || cred.OwnerID == nil {
|
||||
return false
|
||||
}
|
||||
return *actorID == *cred.OwnerID
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"gt": func(a, b int) bool { return a > b },
|
||||
@@ -174,6 +190,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
"templates/fragments/tags_editor.html",
|
||||
"templates/fragments/policy_row.html",
|
||||
"templates/fragments/policy_form.html",
|
||||
"templates/fragments/password_reset_form.html",
|
||||
}
|
||||
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||
if err != nil {
|
||||
@@ -190,6 +207,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
"audit": "templates/audit.html",
|
||||
"audit_detail": "templates/audit_detail.html",
|
||||
"policies": "templates/policies.html",
|
||||
"pgcreds": "templates/pgcreds.html",
|
||||
}
|
||||
tmpls := make(map[string]*template.Template, len(pageFiles))
|
||||
for name, file := range pageFiles {
|
||||
@@ -264,6 +282,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
||||
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
|
||||
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
|
||||
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
|
||||
uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess))
|
||||
uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
|
||||
uiMux.Handle("GET /pgcreds", adminGet(u.handlePGCredsList))
|
||||
uiMux.Handle("POST /pgcreds", admin(u.handleCreatePGCreds))
|
||||
uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
|
||||
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
|
||||
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
|
||||
@@ -272,6 +294,7 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
||||
uiMux.Handle("PATCH /policies/{id}/enabled", admin(u.handleTogglePolicyRule))
|
||||
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
|
||||
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
|
||||
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
|
||||
|
||||
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
|
||||
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
|
||||
@@ -478,6 +501,21 @@ func clientIP(r *http.Request) string {
|
||||
return addr
|
||||
}
|
||||
|
||||
// actorName resolves the username of the currently authenticated user from the
|
||||
// request context. Returns an empty string if claims are absent or the account
|
||||
// cannot be found; callers should treat an empty string as "not logged in".
|
||||
func (u *UIServer) actorName(r *http.Request) string {
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
return ""
|
||||
}
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return acct.Username
|
||||
}
|
||||
|
||||
// ---- Page data types ----
|
||||
|
||||
// PageData is embedded in all page-level view structs.
|
||||
@@ -485,6 +523,9 @@ type PageData struct {
|
||||
CSRFToken string
|
||||
Flash string
|
||||
Error string
|
||||
// ActorName is the username of the currently logged-in user, populated by
|
||||
// handlers so the base template can display it in the navigation bar.
|
||||
ActorName string
|
||||
}
|
||||
|
||||
// LoginData is the view model for the login page.
|
||||
@@ -514,7 +555,16 @@ type AccountsData struct {
|
||||
// AccountDetailData is the view model for the account detail page.
|
||||
type AccountDetailData struct {
|
||||
Account *model.Account
|
||||
PGCred *model.PGCredential // nil if none stored or account is not a system account
|
||||
// PGCred is nil if none stored or the account is not a system account.
|
||||
PGCred *model.PGCredential
|
||||
// PGCredGrants lists accounts that have been granted read access to PGCred.
|
||||
// Only populated when the viewing actor is the credential owner.
|
||||
PGCredGrants []*model.PGCredAccessGrant
|
||||
// GrantableAccounts is the list of accounts the owner may grant access to.
|
||||
GrantableAccounts []*model.Account
|
||||
// ActorID is the DB id of the currently logged-in user; used in templates
|
||||
// to decide whether to show the owner-only management controls.
|
||||
ActorID *int64
|
||||
PageData
|
||||
Roles []string
|
||||
AllRoles []string
|
||||
@@ -545,9 +595,13 @@ type PolicyRuleView struct {
|
||||
RuleJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
NotBefore string // empty if not set
|
||||
ExpiresAt string // empty if not set
|
||||
ID int64
|
||||
Priority int
|
||||
Enabled bool
|
||||
IsExpired bool // true if expires_at is in the past
|
||||
IsPending bool // true if not_before is in the future
|
||||
}
|
||||
|
||||
// PoliciesData is the view model for the policies list page.
|
||||
@@ -556,3 +610,21 @@ type PoliciesData struct {
|
||||
Rules []*PolicyRuleView
|
||||
AllActions []string
|
||||
}
|
||||
|
||||
// PGCredsData is the view model for the "My PG Credentials" list page.
|
||||
// It shows all pg_credentials sets accessible to the currently logged-in user:
|
||||
// those they own and those they have been granted access to.
|
||||
// UncredentialedAccounts is the list of system accounts that have no credentials
|
||||
// yet, populated to drive the "New Credentials" create form on the same page.
|
||||
// CredGrants maps credential ID to the list of access grants for that credential;
|
||||
// only populated for credentials the actor owns.
|
||||
// AllAccounts is used to populate the grant-access dropdown for owned credentials.
|
||||
// ActorID is the DB id of the currently logged-in user.
|
||||
type PGCredsData struct {
|
||||
CredGrants map[int64][]*model.PGCredAccessGrant
|
||||
ActorID *int64
|
||||
PageData
|
||||
Creds []*model.PGCredential
|
||||
UncredentialedAccounts []*model.Account
|
||||
AllAccounts []*model.Account
|
||||
}
|
||||
|
||||
162
openapi.yaml
162
openapi.yaml
@@ -206,6 +206,24 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: true
|
||||
not_before:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: |
|
||||
Earliest time the rule becomes active. NULL means no constraint
|
||||
(always active). Rules where `not_before > now()` are skipped
|
||||
during evaluation.
|
||||
example: "2026-04-01T00:00:00Z"
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: |
|
||||
Time after which the rule is no longer active. NULL means no
|
||||
constraint (never expires). Rules where `expires_at <= now()` are
|
||||
skipped during evaluation.
|
||||
example: "2026-06-01T00:00:00Z"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -582,6 +600,68 @@ paths:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
/v1/auth/password:
|
||||
put:
|
||||
summary: Change own password (self-service)
|
||||
description: |
|
||||
Change the password of the currently authenticated human account.
|
||||
The caller must supply the correct `current_password` to prevent
|
||||
token-theft attacks: possession of a valid JWT alone is not sufficient.
|
||||
|
||||
On success:
|
||||
- The stored Argon2id hash is replaced with the new password hash.
|
||||
- All active sessions *except* the caller's current token are revoked.
|
||||
- The lockout failure counter is cleared.
|
||||
|
||||
On failure (wrong current password):
|
||||
- A login failure is recorded against the account, subject to the
|
||||
same lockout rules as `POST /v1/auth/login`.
|
||||
|
||||
Only applies to human accounts. System accounts have no password.
|
||||
operationId: changePassword
|
||||
tags: [Auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [current_password, new_password]
|
||||
properties:
|
||||
current_password:
|
||||
type: string
|
||||
description: The account's current password (required for verification).
|
||||
example: old-s3cr3t
|
||||
new_password:
|
||||
type: string
|
||||
description: The new password. Minimum 12 characters.
|
||||
example: new-s3cr3t-long
|
||||
responses:
|
||||
"204":
|
||||
description: Password changed. Other active sessions revoked.
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
description: Current password is incorrect.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
example:
|
||||
error: current password is incorrect
|
||||
code: unauthorized
|
||||
"429":
|
||||
description: Account temporarily locked due to too many failed attempts.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
example:
|
||||
error: account temporarily locked
|
||||
code: account_locked
|
||||
|
||||
# ── Admin ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/v1/auth/totp:
|
||||
@@ -984,7 +1064,10 @@ paths:
|
||||
`token_issued`, `token_renewed`, `token_revoked`, `token_expired`,
|
||||
`account_created`, `account_updated`, `account_deleted`,
|
||||
`role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`,
|
||||
`pgcred_accessed`, `pgcred_updated`.
|
||||
`pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`,
|
||||
`pgcred_access_revoked`, `tag_added`, `tag_removed`,
|
||||
`policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`,
|
||||
`policy_deny`.
|
||||
operationId: listAudit
|
||||
tags: [Admin — Audit]
|
||||
security:
|
||||
@@ -1118,6 +1201,57 @@ paths:
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/accounts/{id}/password:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
put:
|
||||
summary: Admin password reset (admin)
|
||||
description: |
|
||||
Reset the password for a human account without requiring the current
|
||||
password. This is intended for account recovery (e.g. a user forgot
|
||||
their password).
|
||||
|
||||
On success:
|
||||
- The stored Argon2id hash is replaced with the new password hash.
|
||||
- All active sessions for the target account are revoked.
|
||||
|
||||
Only applies to human accounts. The new password must be at least
|
||||
12 characters.
|
||||
operationId: adminSetPassword
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [new_password]
|
||||
properties:
|
||||
new_password:
|
||||
type: string
|
||||
description: The new password. Minimum 12 characters.
|
||||
example: new-s3cr3t-long
|
||||
responses:
|
||||
"204":
|
||||
description: Password reset. All active sessions for the account revoked.
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/policy/rules:
|
||||
get:
|
||||
summary: List policy rules (admin)
|
||||
@@ -1169,6 +1303,16 @@ paths:
|
||||
example: 50
|
||||
rule:
|
||||
$ref: "#/components/schemas/RuleBody"
|
||||
not_before:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Earliest activation time (RFC3339, optional).
|
||||
example: "2026-04-01T00:00:00Z"
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Expiry time (RFC3339, optional).
|
||||
example: "2026-06-01T00:00:00Z"
|
||||
responses:
|
||||
"201":
|
||||
description: Rule created.
|
||||
@@ -1239,6 +1383,22 @@ paths:
|
||||
example: false
|
||||
rule:
|
||||
$ref: "#/components/schemas/RuleBody"
|
||||
not_before:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Set earliest activation time (RFC3339).
|
||||
example: "2026-04-01T00:00:00Z"
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Set expiry time (RFC3339).
|
||||
example: "2026-06-01T00:00:00Z"
|
||||
clear_not_before:
|
||||
type: boolean
|
||||
description: Set to true to remove not_before constraint.
|
||||
clear_expires_at:
|
||||
type: boolean
|
||||
description: Set to true to remove expires_at constraint.
|
||||
responses:
|
||||
"200":
|
||||
description: Updated rule.
|
||||
|
||||
@@ -8,6 +8,18 @@ nav { background: #1a1a2e; color: #fff; padding: 0.5rem 1rem; }
|
||||
.nav-links { list-style: none; display: flex; gap: 1rem; margin: 0; padding: 0; }
|
||||
.nav-links a { color: #ccc; text-decoration: none; }
|
||||
.nav-links a:hover { color: #fff; }
|
||||
.nav-actor { color: #aaa; font-size: 0.85rem; }
|
||||
/* Login page layout */
|
||||
.login-wrapper { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 2rem; }
|
||||
.login-box { width: 100%; max-width: 380px; }
|
||||
.brand-heading { font-size: 2rem; font-weight: 700; text-align: center; letter-spacing: 0.05em; color: #1a1a2e; margin-bottom: 0.25rem; }
|
||||
.brand-subtitle { font-size: 0.8rem; text-align: center; color: #666; margin-bottom: 1.5rem; letter-spacing: 0.02em; }
|
||||
.login-box .card { background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1.75rem 2rem; box-shadow: 0 2px 8px rgba(0,0,0,0.07); }
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.35rem; font-size: 0.9rem; font-weight: 500; }
|
||||
.form-control { width: 100%; padding: 0.5rem 0.6rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.95rem; }
|
||||
.form-control:focus { outline: none; border-color: #0d6efd; box-shadow: 0 0 0 2px rgba(13,110,253,0.2); }
|
||||
.form-actions { margin-top: 1.25rem; }
|
||||
.btn { display: inline-block; padding: 0.4rem 0.8rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
|
||||
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
|
||||
.btn-primary { background: #0d6efd; color: #fff; }
|
||||
|
||||
@@ -44,4 +44,15 @@
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
|
||||
<div id="tags-editor">{{template "tags_editor" .}}</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}}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<li><a href="/accounts">Accounts</a></li>
|
||||
<li><a href="/audit">Audit</a></li>
|
||||
<li><a href="/policies">Policies</a></li>
|
||||
<li><a href="/pgcreds">PG Creds</a></li>
|
||||
{{if .ActorName}}<li><span class="nav-actor">{{.ActorName}}</span></li>{{end}}
|
||||
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
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}}
|
||||
@@ -11,25 +11,84 @@
|
||||
{{else}}
|
||||
<p class="text-muted text-small" style="margin-bottom:1rem">No credentials stored.</p>
|
||||
{{end}}
|
||||
<form hx-put="/accounts/{{.Account.UUID}}/pgcreds"
|
||||
hx-target="#pgcreds-section" hx-swap="outerHTML">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||
<input class="form-control" type="text" name="host" placeholder="Host" required
|
||||
value="{{if .PGCred}}{{.PGCred.PGHost}}{{end}}">
|
||||
<input class="form-control" type="number" name="port" placeholder="Port (5432)"
|
||||
min="1" max="65535"
|
||||
value="{{if .PGCred}}{{.PGCred.PGPort}}{{end}}">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||
<input class="form-control" type="text" name="database" placeholder="Database" required
|
||||
value="{{if .PGCred}}{{.PGCred.PGDatabase}}{{end}}">
|
||||
<input class="form-control" type="text" name="username" placeholder="Username" required
|
||||
value="{{if .PGCred}}{{.PGCred.PGUsername}}{{end}}">
|
||||
</div>
|
||||
<input class="form-control" type="password" name="password"
|
||||
placeholder="Password (required to update)" required
|
||||
style="margin-bottom:.5rem">
|
||||
<button class="btn btn-sm btn-secondary" type="submit">Save Credentials</button>
|
||||
</form>
|
||||
|
||||
{{/* Any admin can add or update credentials; creator of the first set becomes owner */}}
|
||||
<details style="margin-bottom:1rem">
|
||||
<summary class="text-small" style="cursor:pointer;color:var(--color-text-muted)">
|
||||
{{if .PGCred}}Update credentials{{else}}Add new credentials{{end}}
|
||||
</summary>
|
||||
<form hx-put="/accounts/{{.Account.UUID}}/pgcreds"
|
||||
hx-target="#pgcreds-section" hx-swap="outerHTML"
|
||||
style="margin-top:.75rem">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||
<input class="form-control" type="text" name="host" placeholder="Host" required
|
||||
{{if .PGCred}}value="{{.PGCred.PGHost}}"{{end}}>
|
||||
<input class="form-control" type="number" name="port" placeholder="Port (5432)"
|
||||
min="1" max="65535"
|
||||
{{if .PGCred}}value="{{.PGCred.PGPort}}"{{end}}>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||
<input class="form-control" type="text" name="database" placeholder="Database" required
|
||||
{{if .PGCred}}value="{{.PGCred.PGDatabase}}"{{end}}>
|
||||
<input class="form-control" type="text" name="username" placeholder="Username" required
|
||||
{{if .PGCred}}value="{{.PGCred.PGUsername}}"{{end}}>
|
||||
</div>
|
||||
<input class="form-control" type="password" name="password"
|
||||
placeholder="Password (required)" required
|
||||
style="margin-bottom:.5rem">
|
||||
<button class="btn btn-sm btn-secondary" type="submit">Save Credentials</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{{/* Access grants section — shown whenever credentials exist */}}
|
||||
{{if .PGCred}}
|
||||
<div style="margin-top:1.25rem">
|
||||
<h3 style="font-size:.9rem;font-weight:600;margin-bottom:.5rem">Access Grants</h3>
|
||||
{{if .PGCredGrants}}
|
||||
<table class="table table-sm" style="font-size:.85rem">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Granted</th>
|
||||
{{if isPGCredOwner $.ActorID $.PGCred}}<th></th>{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .PGCredGrants}}
|
||||
<tr>
|
||||
<td>{{.GranteeName}}</td>
|
||||
<td class="text-small text-muted">{{formatTime .GrantedAt}}</td>
|
||||
{{if isPGCredOwner $.ActorID $.PGCred}}
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="/accounts/{{$.Account.UUID}}/pgcreds/access/{{.GranteeUUID}}"
|
||||
hx-target="#pgcreds-section" hx-swap="outerHTML"
|
||||
hx-confirm="Revoke access for {{.GranteeName}}?">Revoke</button>
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="text-muted text-small">No access grants.</p>
|
||||
{{end}}
|
||||
|
||||
{{/* Grant form — owner only */}}
|
||||
{{if and (isPGCredOwner .ActorID .PGCred) .GrantableAccounts}}
|
||||
<form hx-post="/accounts/{{.Account.UUID}}/pgcreds/access"
|
||||
hx-target="#pgcreds-section" hx-swap="outerHTML"
|
||||
style="margin-top:.75rem;display:flex;gap:.5rem;align-items:center">
|
||||
<select class="form-control" name="grantee_uuid" required style="flex:1">
|
||||
<option value="">— select account to grant —</option>
|
||||
{{range .GrantableAccounts}}
|
||||
<option value="{{.UUID}}">{{.Username}} ({{.AccountType}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-secondary" type="submit">Grant Access</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -72,6 +72,16 @@
|
||||
Owner must match subject (self-service rules only)
|
||||
</label>
|
||||
</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>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
<td class="text-small">{{.Priority}}</td>
|
||||
<td>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="login-wrapper">
|
||||
<div class="login-box">
|
||||
<div class="brand-heading">MCIAS</div>
|
||||
<div class="brand-subtitle">Metacircular Identity & Access System</div>
|
||||
<div class="card">
|
||||
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||
<form id="login-form" method="POST" action="/login"
|
||||
|
||||
134
web/templates/pgcreds.html
Normal file
134
web/templates/pgcreds.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{{define "pgcreds"}}{{template "base" .}}{{end}}
|
||||
{{define "title"}}PG Credentials — MCIAS{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h1>Postgres Credentials</h1>
|
||||
<p class="text-muted text-small">Credentials you own or have been granted access to.</p>
|
||||
</div>
|
||||
|
||||
{{if .Creds}}
|
||||
<div class="card">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Your Credentials</h2>
|
||||
{{range .Creds}}
|
||||
<div style="border:1px solid var(--color-border);border-radius:6px;padding:1rem;margin-bottom:1rem">
|
||||
<dl style="display:grid;grid-template-columns:140px 1fr;gap:.35rem .75rem;font-size:.9rem;margin-bottom:.75rem">
|
||||
<dt class="text-muted">Service Account</dt><dd>{{.ServiceUsername}}</dd>
|
||||
<dt class="text-muted">Host</dt><dd>{{.PGHost}}:{{.PGPort}}</dd>
|
||||
<dt class="text-muted">Database</dt><dd>{{.PGDatabase}}</dd>
|
||||
<dt class="text-muted">Username</dt><dd>{{.PGUsername}}</dd>
|
||||
<dt class="text-muted">Updated</dt><dd class="text-small text-muted">{{formatTime .UpdatedAt}}</dd>
|
||||
</dl>
|
||||
|
||||
{{/* Grant management — only for the credential owner */}}
|
||||
{{$credID := .ID}}
|
||||
{{$svcUUID := .ServiceAccountUUID}}
|
||||
{{$grants := index $.CredGrants $credID}}
|
||||
{{if isPGCredOwner $.ActorID .}}
|
||||
<div style="margin-top:.75rem">
|
||||
<h3 style="font-size:.85rem;font-weight:600;margin-bottom:.5rem">Access Grants</h3>
|
||||
{{if $grants}}
|
||||
<table class="table table-sm" style="font-size:.85rem;margin-bottom:.75rem">
|
||||
<thead>
|
||||
<tr><th>User</th><th>Granted</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $grants}}
|
||||
<tr>
|
||||
<td>{{.GranteeName}}</td>
|
||||
<td class="text-small text-muted">{{formatTime .GrantedAt}}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="/accounts/{{$svcUUID}}/pgcreds/access/{{.GranteeUUID}}?_next=/pgcreds"
|
||||
hx-confirm="Revoke access for {{.GranteeName}}?">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="text-muted text-small" style="margin-bottom:.5rem">No access grants.</p>
|
||||
{{end}}
|
||||
|
||||
{{/* Plain POST grant form with redirect back to /pgcreds */}}
|
||||
<form method="POST" action="/accounts/{{.ServiceAccountUUID}}/pgcreds/access"
|
||||
style="display:flex;gap:.5rem;align-items:center">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="_next" value="/pgcreds">
|
||||
<select class="form-control" name="grantee_uuid" required style="flex:1">
|
||||
<option value="">— select account to grant —</option>
|
||||
{{range $.AllAccounts}}
|
||||
<option value="{{.UUID}}">{{.Username}} ({{.AccountType}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-secondary" type="submit">Grant Access</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div style="margin-top:.75rem">
|
||||
<a class="btn btn-sm btn-secondary" href="/accounts/{{.ServiceAccountUUID}}">View Account</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card">
|
||||
<p class="text-muted">No Postgres credentials are accessible to your account.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card" style="margin-top:1.5rem">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin:0">New Credentials</h2>
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
onclick="var f=document.getElementById('pgcreds-create-form');f.hidden=!f.hidden;this.textContent=f.hidden?'Add Credentials':'Cancel'">Add Credentials</button>
|
||||
</div>
|
||||
<div id="pgcreds-create-form" hidden>
|
||||
<p class="text-muted text-small" style="margin-bottom:1rem;margin-top:.5rem">
|
||||
Create a credential set for a system account. You will become the owner and
|
||||
may later grant other accounts read access.
|
||||
</p>
|
||||
{{if .UncredentialedAccounts}}
|
||||
<form method="POST" action="/pgcreds">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div style="margin-bottom:.75rem">
|
||||
<label class="form-label" for="pgcreds-account">Service Account</label>
|
||||
<select id="pgcreds-account" class="form-control" name="account_uuid" required>
|
||||
<option value="">— select system account —</option>
|
||||
{{range .UncredentialedAccounts}}
|
||||
<option value="{{.UUID}}">{{.Username}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||
<div>
|
||||
<label class="form-label" for="pgcreds-host">Host</label>
|
||||
<input id="pgcreds-host" class="form-control" type="text" name="host" placeholder="db.example.com" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="pgcreds-port">Port</label>
|
||||
<input id="pgcreds-port" class="form-control" type="number" name="port" placeholder="5432" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||
<div>
|
||||
<label class="form-label" for="pgcreds-db">Database</label>
|
||||
<input id="pgcreds-db" class="form-control" type="text" name="database" placeholder="myapp" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="pgcreds-user">Username</label>
|
||||
<input id="pgcreds-user" class="form-control" type="text" name="username" placeholder="svc_user" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:.75rem">
|
||||
<label class="form-label" for="pgcreds-password">Password</label>
|
||||
<input id="pgcreds-password" class="form-control" type="password" name="password" required>
|
||||
</div>
|
||||
<button class="btn btn-secondary" type="submit">Create Credentials</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="text-muted text-small">All system accounts already have credentials. Create a new system account first.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user