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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ clients/lisp/**/*.fasl
|
|||||||
|
|
||||||
# manual testing
|
# 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)
|
- Admin can revoke all tokens for a user (e.g., on account suspension)
|
||||||
- Token expiry is enforced at validation time, regardless of revocation table
|
- Token expiry is enforced at validation time, regardless of revocation table
|
||||||
|
|
||||||
|
### Password Change Flows
|
||||||
|
|
||||||
|
Two distinct flows exist for changing a password, with different trust assumptions:
|
||||||
|
|
||||||
|
#### Self-Service Password Change (`PUT /v1/auth/password`)
|
||||||
|
|
||||||
|
Used by a human account holder to change their own password.
|
||||||
|
|
||||||
|
1. Caller presents a valid JWT and supplies both `current_password` and
|
||||||
|
`new_password` in the request body.
|
||||||
|
2. The server looks up the account by the JWT subject.
|
||||||
|
3. **Lockout check** — same policy as login (10 failures in 15 min → 15 min
|
||||||
|
lockout). An attacker with a stolen token cannot use this endpoint to
|
||||||
|
brute-force the current password without hitting the lockout.
|
||||||
|
4. **Current password verified** with `auth.VerifyPassword` (Argon2id,
|
||||||
|
constant-time via `crypto/subtle.ConstantTimeCompare`). On failure a login
|
||||||
|
failure is recorded and HTTP 401 is returned.
|
||||||
|
5. New password is validated (minimum 12 characters) and hashed with Argon2id
|
||||||
|
using the server's configured parameters.
|
||||||
|
6. The new hash is written atomically to the `accounts` table.
|
||||||
|
7. **All tokens except the caller's current JTI are revoked** (reason:
|
||||||
|
`password_changed`). The caller keeps their active session; all other
|
||||||
|
concurrent sessions are invalidated. This limits the blast radius of a
|
||||||
|
credential compromise without logging the user out mid-operation.
|
||||||
|
8. Login failure counter is cleared (successful proof of knowledge).
|
||||||
|
9. Audit event `password_changed` is written with `{"via":"self_service"}`.
|
||||||
|
|
||||||
|
#### Admin Password Reset (`PUT /v1/accounts/{id}/password`)
|
||||||
|
|
||||||
|
Used by an administrator to reset a human account's password for recovery
|
||||||
|
purposes (e.g. user forgot their password, account handover).
|
||||||
|
|
||||||
|
1. Caller presents an admin JWT.
|
||||||
|
2. Only `new_password` is required; no `current_password` verification is
|
||||||
|
performed. The admin role represents a higher trust level.
|
||||||
|
3. New password is validated (minimum 12 characters) and hashed with Argon2id.
|
||||||
|
4. The new hash is written to the `accounts` table.
|
||||||
|
5. **All active tokens for the target account are revoked** (reason:
|
||||||
|
`password_reset`). Unlike the self-service flow, the admin cannot preserve
|
||||||
|
the user's session because the reset is typically done during an outage of
|
||||||
|
the user's access.
|
||||||
|
6. Audit event `password_changed` is written with `{"via":"admin_reset"}`.
|
||||||
|
|
||||||
|
#### Security Notes
|
||||||
|
|
||||||
|
- The current password requirement on the self-service path prevents an
|
||||||
|
attacker who steals a JWT from changing credentials. A stolen token grants
|
||||||
|
access to resources for its remaining lifetime but cannot be used to
|
||||||
|
permanently take over the account.
|
||||||
|
- Admin resets are always audited with both actor and target IDs so the log
|
||||||
|
shows which admin performed the reset.
|
||||||
|
- Plaintext passwords are never logged, stored, or included in any response.
|
||||||
|
- Both flows use the same Argon2id parameters (OWASP 2023: time=3, memory=64 MB,
|
||||||
|
threads=4, hash length=32 bytes).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Multi-App Trust Boundaries
|
## 7. Multi-App Trust Boundaries
|
||||||
@@ -285,6 +340,7 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
| POST | `/v1/auth/login` | none | Username/password (+TOTP) login → JWT |
|
| POST | `/v1/auth/login` | none | Username/password (+TOTP) login → JWT |
|
||||||
| POST | `/v1/auth/logout` | bearer JWT | Revoke current token |
|
| POST | `/v1/auth/logout` | bearer JWT | Revoke current token |
|
||||||
| POST | `/v1/auth/renew` | bearer JWT | Exchange token for new token |
|
| POST | `/v1/auth/renew` | bearer JWT | Exchange token for new token |
|
||||||
|
| PUT | `/v1/auth/password` | bearer JWT | Self-service password change (requires current password) |
|
||||||
|
|
||||||
### Token Endpoints
|
### Token Endpoints
|
||||||
|
|
||||||
@@ -304,6 +360,13 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
|
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
|
||||||
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
|
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
|
||||||
|
|
||||||
|
### Password Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| PUT | `/v1/auth/password` | bearer JWT | Self-service: change own password (current password required) |
|
||||||
|
| PUT | `/v1/accounts/{id}/password` | admin JWT | Admin reset: set any human account's password |
|
||||||
|
|
||||||
### Role Endpoints (admin only)
|
### Role Endpoints (admin only)
|
||||||
|
|
||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
@@ -356,6 +419,38 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
| GET | `/v1/health` | none | Health check |
|
| GET | `/v1/health` | none | Health check |
|
||||||
| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) |
|
| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) |
|
||||||
|
|
||||||
|
### Web Management UI
|
||||||
|
|
||||||
|
mciassrv embeds an HTMX-based web management interface served alongside the
|
||||||
|
REST API. The UI is an admin-only interface providing a visual alternative to
|
||||||
|
`mciasctl` for day-to-day management.
|
||||||
|
|
||||||
|
**Package:** `internal/ui/` — UI handlers call internal Go functions directly;
|
||||||
|
no internal HTTP round-trips to the REST API.
|
||||||
|
|
||||||
|
**Template engine:** Go `html/template` with templates embedded at compile time
|
||||||
|
via `web/` (`embed.FS`). Templates are parsed once at startup.
|
||||||
|
|
||||||
|
**Session management:** JWT stored as `HttpOnly; Secure; SameSite=Strict`
|
||||||
|
cookie (`mcias_session`). CSRF protection uses HMAC-signed double-submit
|
||||||
|
cookie pattern (`mcias_csrf`).
|
||||||
|
|
||||||
|
**Pages and features:**
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|---|---|
|
||||||
|
| `/login` | Username/password login with optional TOTP step |
|
||||||
|
| `/` | Dashboard (account summary) |
|
||||||
|
| `/accounts` | Account list |
|
||||||
|
| `/accounts/{id}` | Account detail — status, roles, tags, PG credentials (system accounts) |
|
||||||
|
| `/pgcreds` | Postgres credentials list (owned + granted) with create form |
|
||||||
|
| `/policies` | Policy rules management — create, enable/disable, delete |
|
||||||
|
| `/audit` | Audit log viewer |
|
||||||
|
|
||||||
|
**HTMX fragments:** Mutating operations (role updates, tag edits, credential
|
||||||
|
saves, policy toggles, access grants) use HTMX partial-page updates for a
|
||||||
|
responsive experience without full-page reloads.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Database Schema
|
## 9. Database Schema
|
||||||
@@ -445,10 +540,22 @@ CREATE TABLE system_tokens (
|
|||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Per-account failed login attempts for brute-force lockout enforcement.
|
||||||
|
-- One row per account; window_start resets when the window expires or on
|
||||||
|
-- a successful login.
|
||||||
|
CREATE TABLE failed_logins (
|
||||||
|
account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
window_start TEXT NOT NULL,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
-- Postgres credentials for system accounts, encrypted at rest.
|
-- Postgres credentials for system accounts, encrypted at rest.
|
||||||
CREATE TABLE pg_credentials (
|
CREATE TABLE pg_credentials (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
-- owner_id: account that administers the credentials and may grant/revoke
|
||||||
|
-- access. Nullable for backwards compatibility with pre-migration-5 rows.
|
||||||
|
owner_id INTEGER REFERENCES accounts(id),
|
||||||
pg_host TEXT NOT NULL,
|
pg_host TEXT NOT NULL,
|
||||||
pg_port INTEGER NOT NULL DEFAULT 5432,
|
pg_port INTEGER NOT NULL DEFAULT 5432,
|
||||||
pg_database TEXT NOT NULL,
|
pg_database TEXT NOT NULL,
|
||||||
@@ -459,6 +566,21 @@ CREATE TABLE pg_credentials (
|
|||||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Explicit read-access grants from a credential owner to another account.
|
||||||
|
-- Grantees may view connection metadata but the password is never decrypted
|
||||||
|
-- for them in the UI. Only the owner may update or delete the credential set.
|
||||||
|
CREATE TABLE pg_credential_access (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
credential_id INTEGER NOT NULL REFERENCES pg_credentials(id) ON DELETE CASCADE,
|
||||||
|
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
granted_by INTEGER REFERENCES accounts(id),
|
||||||
|
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
UNIQUE (credential_id, grantee_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_pgcred_access_cred ON pg_credential_access (credential_id);
|
||||||
|
CREATE INDEX idx_pgcred_access_grantee ON pg_credential_access (grantee_id);
|
||||||
|
|
||||||
-- Audit log — append-only. Never contains credentials or secret material.
|
-- Audit log — append-only. Never contains credentials or secret material.
|
||||||
CREATE TABLE audit_log (
|
CREATE TABLE audit_log (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
@@ -496,7 +618,9 @@ CREATE TABLE policy_rules (
|
|||||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
|
||||||
created_by INTEGER REFERENCES accounts(id),
|
created_by INTEGER REFERENCES accounts(id),
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339)
|
||||||
|
expires_at TEXT DEFAULT NULL -- optional: expiry time (RFC3339)
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1440,6 +1564,26 @@ For belt-and-suspenders, an explicit deny for production tags:
|
|||||||
|
|
||||||
No `ServiceNames` or `RequiredTags` field means this matches any service account.
|
No `ServiceNames` or `RequiredTags` field means this matches any service account.
|
||||||
|
|
||||||
|
**Scenario D — Time-scoped access:**
|
||||||
|
|
||||||
|
The `deploy-agent` needs temporary access to production pgcreds for a 4-hour
|
||||||
|
maintenance window. Instead of creating a rule and remembering to delete it,
|
||||||
|
the operator sets `not_before` and `expires_at`:
|
||||||
|
|
||||||
|
```
|
||||||
|
mciasctl policy create \
|
||||||
|
-description "deploy-agent: temp production access" \
|
||||||
|
-json rule.json \
|
||||||
|
-not-before 2026-03-12T02:00:00Z \
|
||||||
|
-expires-at 2026-03-12T06:00:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
The policy engine filters rules at cache-load time (`Engine.SetRules`): rules
|
||||||
|
where `not_before > now()` or `expires_at <= now()` are excluded from the
|
||||||
|
cached rule set. No changes to the `Evaluate()` or `matches()` functions are
|
||||||
|
needed. Both fields are optional and nullable; `NULL` means no constraint
|
||||||
|
(always active / never expires).
|
||||||
|
|
||||||
### Middleware Integration
|
### Middleware Integration
|
||||||
|
|
||||||
`internal/middleware.RequirePolicy(engine, action, resourceType)` is a drop-in
|
`internal/middleware.RequirePolicy(engine, action, resourceType)` is a drop-in
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ MCIAS (Metacircular Identity and Access System) is a single-sign-on (SSO) and Id
|
|||||||
|
|
||||||
## Binaries
|
## Binaries
|
||||||
|
|
||||||
- `mciassrv` — authentication server (REST API over HTTPS/TLS)
|
- `mciassrv` — authentication server (REST + gRPC over HTTPS/TLS, with HTMX web UI)
|
||||||
- `mciasctl` — admin CLI for account/token/credential management
|
- `mciasctl` — admin CLI for account/token/credential/policy management (REST)
|
||||||
|
- `mciasdb` — offline SQLite maintenance tool (schema, accounts, tokens, audit, pgcreds)
|
||||||
|
- `mciasgrpcctl` — admin CLI for gRPC interface
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
|
|||||||
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.
|
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
|
### 2026-03-11 — Postgres Credentials UI + Policy/Tags UI completion
|
||||||
|
|
||||||
**internal/ui/**
|
**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 6: mciasdb — direct SQLite maintenance tool
|
||||||
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST)
|
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST)
|
||||||
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
|
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
|
||||||
- [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
|
- [ ] Phase 9: Client libraries (Go, Rust, Common Lisp, Python) — designed in ARCHITECTURE.md §19 but not yet implemented; `clients/` directory does not exist
|
||||||
- [x] Phase 10: Policy engine — ABAC with machine/service gating
|
- [x] Phase 10: Policy engine — ABAC with machine/service gating
|
||||||
---
|
---
|
||||||
### 2026-03-11 — Phase 10: Policy engine (ABAC + machine/service gating)
|
### 2026-03-11 — Phase 10: Policy engine (ABAC + machine/service gating)
|
||||||
@@ -188,44 +486,15 @@ All tests pass; `go test ./...` clean; `golangci-lint run ./...` clean.
|
|||||||
|
|
||||||
All 5 packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
All 5 packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
||||||
|
|
||||||
### 2026-03-11 — Phase 9: Client libraries
|
### 2026-03-11 — Phase 9: Client libraries (DESIGNED, NOT IMPLEMENTED)
|
||||||
|
|
||||||
**clients/testdata/** — shared JSON fixtures
|
**NOTE:** The client libraries described in ARCHITECTURE.md §19 were designed
|
||||||
- login_response.json, account_response.json, accounts_list_response.json
|
but never committed to the repository. The `clients/` directory does not exist.
|
||||||
- validate_token_response.json, public_key_response.json, pgcreds_response.json
|
Only `test/mock/mockserver.go` was implemented. The designs remain in
|
||||||
- error_response.json, roles_response.json
|
ARCHITECTURE.md for future implementation.
|
||||||
|
|
||||||
**clients/go/** — Go client library
|
|
||||||
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`; package `mciasgoclient`
|
|
||||||
- Typed errors: `MciasAuthError`, `MciasForbiddenError`, `MciasNotFoundError`,
|
|
||||||
`MciasInputError`, `MciasConflictError`, `MciasServerError`
|
|
||||||
- TLS 1.2+ enforced via `tls.Config{MinVersion: tls.VersionTLS12}`
|
|
||||||
- Token state guarded by `sync.RWMutex` for concurrent safety
|
|
||||||
- JSON decoded with `DisallowUnknownFields` on all responses
|
|
||||||
- 25 tests in `client_test.go`; all pass with `go test -race`
|
|
||||||
|
|
||||||
**clients/rust/** — Rust async client library
|
|
||||||
- Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep)
|
|
||||||
- `MciasError` enum via `thiserror`; `Arc<RwLock<Option<String>>>` for token
|
|
||||||
- 23 integration tests using `wiremock`; `cargo clippy -- -D warnings` clean
|
|
||||||
|
|
||||||
**clients/lisp/** — Common Lisp client library
|
|
||||||
- ASDF system `mcias-client`; HTTP via dexador, JSON via yason
|
|
||||||
- CLOS class `mcias-client`; plain functions for all operations
|
|
||||||
- Conditions: `mcias-error` base + 6 typed subclasses
|
|
||||||
- Mock server: Hunchentoot `mock-dispatcher` subclass (port 0, random per test)
|
|
||||||
- 37 fiveam checks; all pass on SBCL 2.6.1
|
|
||||||
- Fixed: yason decodes JSON `false` as `:false`; `validate-token` normalises
|
|
||||||
to `t`/`nil` before returning
|
|
||||||
|
|
||||||
**clients/python/** — Python 3.11+ client library
|
|
||||||
- Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27`
|
|
||||||
- `Client` context manager; `py.typed` marker; all symbols fully annotated
|
|
||||||
- Dataclasses: `Account`, `PublicKey`, `PGCreds`
|
|
||||||
- 32 pytest tests using `respx` mock transport; `mypy --strict` clean; `ruff` clean
|
|
||||||
|
|
||||||
**test/mock/mockserver.go** — Go in-memory mock server
|
**test/mock/mockserver.go** — Go in-memory mock server
|
||||||
- `Server` struct with `sync.RWMutex`; used by Go client integration test
|
- `Server` struct with `sync.RWMutex`; used for Go integration tests
|
||||||
- `NewServer()`, `AddAccount()`, `ServeHTTP()` for httptest.Server use
|
- `NewServer()`, `AddAccount()`, `ServeHTTP()` for httptest.Server use
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
MCIAS is a self-hosted SSO and IAM service for personal projects.
|
MCIAS is a self-hosted SSO and IAM service for personal projects.
|
||||||
It provides authentication (JWT/Ed25519), account management, TOTP, and
|
It provides authentication (JWT/Ed25519), account management, TOTP, and
|
||||||
Postgres credential storage over a REST API (HTTPS) and a gRPC API (TLS).
|
Postgres credential storage over a REST API (HTTPS), a gRPC API (TLS),
|
||||||
|
and an HTMX-based web management UI.
|
||||||
|
|
||||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for the technical design and
|
See [ARCHITECTURE.md](ARCHITECTURE.md) for the technical design and
|
||||||
[PROJECT_PLAN.md](PROJECT_PLAN.md) for the implementation roadmap.
|
[PROJECT_PLAN.md](PROJECT_PLAN.md) for the implementation roadmap.
|
||||||
@@ -177,7 +178,7 @@ TOKEN=$(curl -sk https://localhost:8443/v1/auth/login \
|
|||||||
export MCIAS_TOKEN=$TOKEN
|
export MCIAS_TOKEN=$TOKEN
|
||||||
|
|
||||||
mciasctl -server https://localhost:8443 account list
|
mciasctl -server https://localhost:8443 account list
|
||||||
mciasctl account create -username alice -password s3cr3t
|
mciasctl account create -username alice # password prompted interactively
|
||||||
mciasctl role set -id $UUID -roles admin
|
mciasctl role set -id $UUID -roles admin
|
||||||
mciasctl token issue -id $SYSTEM_UUID
|
mciasctl token issue -id $SYSTEM_UUID
|
||||||
mciasctl pgcreds set -id $UUID -host db.example.com -port 5432 \
|
mciasctl pgcreds set -id $UUID -host db.example.com -port 5432 \
|
||||||
@@ -241,6 +242,24 @@ See `man mciasgrpcctl` and [ARCHITECTURE.md](ARCHITECTURE.md) §17.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Web Management UI
|
||||||
|
|
||||||
|
mciassrv includes a built-in web interface for day-to-day administration.
|
||||||
|
After starting the server, navigate to `https://localhost:8443/login` and
|
||||||
|
log in with an admin account.
|
||||||
|
|
||||||
|
The UI provides:
|
||||||
|
- **Dashboard** — account summary overview
|
||||||
|
- **Accounts** — list, create, update, delete accounts; manage roles and tags
|
||||||
|
- **PG Credentials** — view, create, and manage Postgres credential access grants
|
||||||
|
- **Policies** — create and manage ABAC policy rules
|
||||||
|
- **Audit** — browse the audit log
|
||||||
|
|
||||||
|
Sessions use `HttpOnly; Secure; SameSite=Strict` cookies with CSRF protection.
|
||||||
|
See [ARCHITECTURE.md](ARCHITECTURE.md) §8 (Web Management UI) for design details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Deploying with Docker
|
## Deploying with Docker
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -16,13 +16,15 @@
|
|||||||
//
|
//
|
||||||
// Commands:
|
// Commands:
|
||||||
//
|
//
|
||||||
// auth login -username NAME [-password PASS] [-totp CODE]
|
// auth login -username NAME [-totp CODE]
|
||||||
|
// auth change-password (passwords always prompted interactively)
|
||||||
//
|
//
|
||||||
// account list
|
// account list
|
||||||
// account create -username NAME [-password PASS] [-type human|system]
|
// account create -username NAME [-type human|system]
|
||||||
// account get -id UUID
|
// account get -id UUID
|
||||||
// account update -id UUID [-status active|inactive]
|
// account update -id UUID [-status active|inactive]
|
||||||
// account delete -id UUID
|
// account delete -id UUID
|
||||||
|
// account set-password -id UUID
|
||||||
//
|
//
|
||||||
// role list -id UUID
|
// role list -id UUID
|
||||||
// role set -id UUID -roles role1,role2,...
|
// role set -id UUID -roles role1,role2,...
|
||||||
@@ -34,9 +36,9 @@
|
|||||||
// pgcreds get -id UUID
|
// pgcreds get -id UUID
|
||||||
//
|
//
|
||||||
// policy list
|
// policy list
|
||||||
// policy create -description STR -json FILE [-priority N]
|
// policy create -description STR -json FILE [-priority N] [-not-before RFC3339] [-expires-at RFC3339]
|
||||||
// policy get -id ID
|
// policy get -id ID
|
||||||
// policy update -id ID [-priority N] [-enabled true|false]
|
// policy update -id ID [-priority N] [-enabled true|false] [-not-before RFC3339] [-expires-at RFC3339] [-clear-not-before] [-clear-expires-at]
|
||||||
// policy delete -id ID
|
// policy delete -id ID
|
||||||
//
|
//
|
||||||
// tag list -id UUID
|
// tag list -id UUID
|
||||||
@@ -123,28 +125,28 @@ type controller struct {
|
|||||||
|
|
||||||
func (c *controller) runAuth(args []string) {
|
func (c *controller) runAuth(args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fatalf("auth requires a subcommand: login")
|
fatalf("auth requires a subcommand: login, change-password")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "login":
|
case "login":
|
||||||
c.authLogin(args[1:])
|
c.authLogin(args[1:])
|
||||||
|
case "change-password":
|
||||||
|
c.authChangePassword(args[1:])
|
||||||
default:
|
default:
|
||||||
fatalf("unknown auth subcommand %q", args[0])
|
fatalf("unknown auth subcommand %q", args[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// authLogin authenticates with the server using username and password, then
|
// authLogin authenticates with the server using username and password, then
|
||||||
// prints the resulting bearer token to stdout. If -password is not supplied on
|
// prints the resulting bearer token to stdout. The password is always prompted
|
||||||
// the command line, the user is prompted interactively (input is hidden so the
|
// interactively; it is never accepted as a command-line flag to prevent it from
|
||||||
// password does not appear in shell history or terminal output).
|
// appearing in shell history, ps output, and process argument lists.
|
||||||
//
|
//
|
||||||
// Security: passwords are never stored by this process beyond the lifetime of
|
// Security: terminal echo is disabled during password entry
|
||||||
// the HTTP request. Interactive reads use golang.org/x/term.ReadPassword so
|
// (golang.org/x/term.ReadPassword); the raw byte slice is zeroed after use.
|
||||||
// that terminal echo is disabled; the byte slice is zeroed after use.
|
|
||||||
func (c *controller) authLogin(args []string) {
|
func (c *controller) authLogin(args []string) {
|
||||||
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
|
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
|
||||||
username := fs.String("username", "", "username (required)")
|
username := fs.String("username", "", "username (required)")
|
||||||
password := fs.String("password", "", "password (reads from stdin if omitted)")
|
|
||||||
totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
|
totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
@@ -152,22 +154,20 @@ func (c *controller) authLogin(args []string) {
|
|||||||
fatalf("auth login: -username is required")
|
fatalf("auth login: -username is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no password flag was provided, prompt interactively so it does not
|
// Security: always prompt interactively; never accept password as a flag.
|
||||||
// appear in process arguments or shell history.
|
// This prevents the credential from appearing in shell history, ps output,
|
||||||
passwd := *password
|
// and /proc/PID/cmdline.
|
||||||
if passwd == "" {
|
|
||||||
fmt.Fprint(os.Stderr, "Password: ")
|
fmt.Fprint(os.Stderr, "Password: ")
|
||||||
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||||
fmt.Fprintln(os.Stderr) // newline after hidden input
|
fmt.Fprintln(os.Stderr) // newline after hidden input
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("read password: %v", err)
|
fatalf("read password: %v", err)
|
||||||
}
|
}
|
||||||
passwd = string(raw)
|
passwd := string(raw)
|
||||||
// Zero the raw byte slice once copied into the string.
|
// Zero the raw byte slice once copied into the string.
|
||||||
for i := range raw {
|
for i := range raw {
|
||||||
raw[i] = 0
|
raw[i] = 0
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
body := map[string]string{
|
body := map[string]string{
|
||||||
"username": *username,
|
"username": *username,
|
||||||
@@ -191,11 +191,53 @@ func (c *controller) authLogin(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authChangePassword allows an authenticated user to change their own password.
|
||||||
|
// A valid bearer token must be set (via -token flag or MCIAS_TOKEN env var).
|
||||||
|
// Both passwords are always prompted interactively; they are never accepted as
|
||||||
|
// command-line flags to prevent them from appearing in shell history, ps
|
||||||
|
// output, and process argument lists.
|
||||||
|
//
|
||||||
|
// Security: terminal echo is disabled during entry (golang.org/x/term);
|
||||||
|
// raw byte slices are zeroed after use. The server requires the current
|
||||||
|
// password to prevent token-theft attacks. On success all other active
|
||||||
|
// sessions are revoked server-side.
|
||||||
|
func (c *controller) authChangePassword(_ []string) {
|
||||||
|
// Security: always prompt interactively; never accept passwords as flags.
|
||||||
|
fmt.Fprint(os.Stderr, "Current password: ")
|
||||||
|
rawCurrent, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read current password: %v", err)
|
||||||
|
}
|
||||||
|
currentPasswd := string(rawCurrent)
|
||||||
|
for i := range rawCurrent {
|
||||||
|
rawCurrent[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(os.Stderr, "New password: ")
|
||||||
|
rawNew, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read new password: %v", err)
|
||||||
|
}
|
||||||
|
newPasswd := string(rawNew)
|
||||||
|
for i := range rawNew {
|
||||||
|
rawNew[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]string{
|
||||||
|
"current_password": currentPasswd,
|
||||||
|
"new_password": newPasswd,
|
||||||
|
}
|
||||||
|
c.doRequest("PUT", "/v1/auth/password", body, nil)
|
||||||
|
fmt.Println("password changed; other active sessions revoked")
|
||||||
|
}
|
||||||
|
|
||||||
// ---- account subcommands ----
|
// ---- account subcommands ----
|
||||||
|
|
||||||
func (c *controller) runAccount(args []string) {
|
func (c *controller) runAccount(args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fatalf("account requires a subcommand: list, create, get, update, delete")
|
fatalf("account requires a subcommand: list, create, get, update, delete, set-password")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "list":
|
case "list":
|
||||||
@@ -208,6 +250,8 @@ func (c *controller) runAccount(args []string) {
|
|||||||
c.accountUpdate(args[1:])
|
c.accountUpdate(args[1:])
|
||||||
case "delete":
|
case "delete":
|
||||||
c.accountDelete(args[1:])
|
c.accountDelete(args[1:])
|
||||||
|
case "set-password":
|
||||||
|
c.accountSetPassword(args[1:])
|
||||||
default:
|
default:
|
||||||
fatalf("unknown account subcommand %q", args[0])
|
fatalf("unknown account subcommand %q", args[0])
|
||||||
}
|
}
|
||||||
@@ -222,7 +266,6 @@ func (c *controller) accountList() {
|
|||||||
func (c *controller) accountCreate(args []string) {
|
func (c *controller) accountCreate(args []string) {
|
||||||
fs := flag.NewFlagSet("account create", flag.ExitOnError)
|
fs := flag.NewFlagSet("account create", flag.ExitOnError)
|
||||||
username := fs.String("username", "", "username (required)")
|
username := fs.String("username", "", "username (required)")
|
||||||
password := fs.String("password", "", "password for human accounts (prompted if omitted)")
|
|
||||||
accountType := fs.String("type", "human", "account type: human or system")
|
accountType := fs.String("type", "human", "account type: human or system")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
@@ -230,12 +273,11 @@ func (c *controller) accountCreate(args []string) {
|
|||||||
fatalf("account create: -username is required")
|
fatalf("account create: -username is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// For human accounts, prompt for a password interactively if one was not
|
// Security: always prompt interactively for human-account passwords; never
|
||||||
// supplied on the command line so it stays out of shell history.
|
// accept them as a flag. Terminal echo is disabled; the raw byte slice is
|
||||||
// Security: terminal echo is disabled during entry; the raw byte slice is
|
|
||||||
// zeroed after conversion to string. System accounts have no password.
|
// zeroed after conversion to string. System accounts have no password.
|
||||||
passwd := *password
|
var passwd string
|
||||||
if passwd == "" && *accountType == "human" {
|
if *accountType == "human" {
|
||||||
fmt.Fprint(os.Stderr, "Password: ")
|
fmt.Fprint(os.Stderr, "Password: ")
|
||||||
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||||
fmt.Fprintln(os.Stderr)
|
fmt.Fprintln(os.Stderr)
|
||||||
@@ -306,6 +348,40 @@ func (c *controller) accountDelete(args []string) {
|
|||||||
fmt.Println("account deleted")
|
fmt.Println("account deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// accountSetPassword resets a human account's password (admin operation).
|
||||||
|
// No current password is required. All active sessions for the target account
|
||||||
|
// are revoked by the server on success.
|
||||||
|
//
|
||||||
|
// Security: the new password is always prompted interactively; it is never
|
||||||
|
// accepted as a command-line flag to prevent it from appearing in shell
|
||||||
|
// history, ps output, and process argument lists. Terminal echo is disabled
|
||||||
|
// (golang.org/x/term); the raw byte slice is zeroed after use.
|
||||||
|
func (c *controller) accountSetPassword(args []string) {
|
||||||
|
fs := flag.NewFlagSet("account set-password", flag.ExitOnError)
|
||||||
|
id := fs.String("id", "", "account UUID (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *id == "" {
|
||||||
|
fatalf("account set-password: -id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: always prompt interactively; never accept password as a flag.
|
||||||
|
fmt.Fprint(os.Stderr, "New password: ")
|
||||||
|
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read password: %v", err)
|
||||||
|
}
|
||||||
|
passwd := string(raw)
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]string{"new_password": passwd}
|
||||||
|
c.doRequest("PUT", "/v1/accounts/"+*id+"/password", body, nil)
|
||||||
|
fmt.Println("password updated; all active sessions revoked")
|
||||||
|
}
|
||||||
|
|
||||||
// ---- role subcommands ----
|
// ---- role subcommands ----
|
||||||
|
|
||||||
func (c *controller) runRole(args []string) {
|
func (c *controller) runRole(args []string) {
|
||||||
@@ -511,6 +587,8 @@ func (c *controller) policyCreate(args []string) {
|
|||||||
description := fs.String("description", "", "rule description (required)")
|
description := fs.String("description", "", "rule description (required)")
|
||||||
jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)")
|
jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)")
|
||||||
priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)")
|
priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)")
|
||||||
|
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339, optional)")
|
||||||
|
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339, optional)")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
if *description == "" {
|
if *description == "" {
|
||||||
@@ -537,6 +615,18 @@ func (c *controller) policyCreate(args []string) {
|
|||||||
"priority": *priority,
|
"priority": *priority,
|
||||||
"rule": ruleBody,
|
"rule": ruleBody,
|
||||||
}
|
}
|
||||||
|
if *notBefore != "" {
|
||||||
|
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
|
||||||
|
fatalf("policy create: -not-before must be RFC3339: %v", err)
|
||||||
|
}
|
||||||
|
body["not_before"] = *notBefore
|
||||||
|
}
|
||||||
|
if *expiresAt != "" {
|
||||||
|
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
|
||||||
|
fatalf("policy create: -expires-at must be RFC3339: %v", err)
|
||||||
|
}
|
||||||
|
body["expires_at"] = *expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
var result json.RawMessage
|
var result json.RawMessage
|
||||||
c.doRequest("POST", "/v1/policy/rules", body, &result)
|
c.doRequest("POST", "/v1/policy/rules", body, &result)
|
||||||
@@ -562,6 +652,10 @@ func (c *controller) policyUpdate(args []string) {
|
|||||||
id := fs.String("id", "", "rule ID (required)")
|
id := fs.String("id", "", "rule ID (required)")
|
||||||
priority := fs.Int("priority", -1, "new priority (-1 = no change)")
|
priority := fs.Int("priority", -1, "new priority (-1 = no change)")
|
||||||
enabled := fs.String("enabled", "", "true or false")
|
enabled := fs.String("enabled", "", "true or false")
|
||||||
|
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339)")
|
||||||
|
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339)")
|
||||||
|
clearNotBefore := fs.Bool("clear-not-before", false, "remove not_before constraint")
|
||||||
|
clearExpiresAt := fs.Bool("clear-expires-at", false, "remove expires_at constraint")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
if *id == "" {
|
if *id == "" {
|
||||||
@@ -584,8 +678,24 @@ func (c *controller) policyUpdate(args []string) {
|
|||||||
fatalf("policy update: -enabled must be true or false")
|
fatalf("policy update: -enabled must be true or false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if *clearNotBefore {
|
||||||
|
body["clear_not_before"] = true
|
||||||
|
} else if *notBefore != "" {
|
||||||
|
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
|
||||||
|
fatalf("policy update: -not-before must be RFC3339: %v", err)
|
||||||
|
}
|
||||||
|
body["not_before"] = *notBefore
|
||||||
|
}
|
||||||
|
if *clearExpiresAt {
|
||||||
|
body["clear_expires_at"] = true
|
||||||
|
} else if *expiresAt != "" {
|
||||||
|
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
|
||||||
|
fatalf("policy update: -expires-at must be RFC3339: %v", err)
|
||||||
|
}
|
||||||
|
body["expires_at"] = *expiresAt
|
||||||
|
}
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
fatalf("policy update: at least one of -priority or -enabled is required")
|
fatalf("policy update: at least one flag is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
var result json.RawMessage
|
var result json.RawMessage
|
||||||
@@ -766,16 +876,25 @@ Global flags:
|
|||||||
-cacert Path to CA certificate for TLS verification
|
-cacert Path to CA certificate for TLS verification
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
auth login -username NAME [-password PASS] [-totp CODE]
|
auth login -username NAME [-totp CODE]
|
||||||
Obtain a bearer token. Password is prompted if -password is
|
Obtain a bearer token. Password is always prompted interactively
|
||||||
omitted. Token is written to stdout; expiry to stderr.
|
(never accepted as a flag) to avoid shell-history exposure.
|
||||||
|
Token is written to stdout; expiry to stderr.
|
||||||
Example: export MCIAS_TOKEN=$(mciasctl auth login -username alice)
|
Example: export MCIAS_TOKEN=$(mciasctl auth login -username alice)
|
||||||
|
auth change-password
|
||||||
|
Change the current user's own password. Requires a valid bearer
|
||||||
|
token. Current and new passwords are always prompted interactively.
|
||||||
|
Revokes all other active sessions on success.
|
||||||
|
|
||||||
account list
|
account list
|
||||||
account create -username NAME [-password PASS] [-type human|system]
|
account create -username NAME [-type human|system]
|
||||||
account get -id UUID
|
account get -id UUID
|
||||||
account update -id UUID -status active|inactive
|
account update -id UUID -status active|inactive
|
||||||
account delete -id UUID
|
account delete -id UUID
|
||||||
|
account set-password -id UUID
|
||||||
|
Admin: reset a human account's password without requiring the
|
||||||
|
current password. New password is always prompted interactively.
|
||||||
|
Revokes all active sessions for the account.
|
||||||
|
|
||||||
role list -id UUID
|
role list -id UUID
|
||||||
role set -id UUID -roles role1,role2,...
|
role set -id UUID -roles role1,role2,...
|
||||||
@@ -788,10 +907,13 @@ Commands:
|
|||||||
|
|
||||||
policy list
|
policy list
|
||||||
policy create -description STR -json FILE [-priority N]
|
policy create -description STR -json FILE [-priority N]
|
||||||
|
[-not-before RFC3339] [-expires-at RFC3339]
|
||||||
FILE must contain a JSON rule body, e.g.:
|
FILE must contain a JSON rule body, e.g.:
|
||||||
{"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true}
|
{"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true}
|
||||||
policy get -id ID
|
policy get -id ID
|
||||||
policy update -id ID [-priority N] [-enabled true|false]
|
policy update -id ID [-priority N] [-enabled true|false]
|
||||||
|
[-not-before RFC3339] [-expires-at RFC3339]
|
||||||
|
[-clear-not-before] [-clear-expires-at]
|
||||||
policy delete -id ID
|
policy delete -id ID
|
||||||
|
|
||||||
tag list -id UUID
|
tag list -id UUID
|
||||||
|
|||||||
15
go.mod
15
go.mod
@@ -4,10 +4,13 @@ go 1.26.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
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/google/uuid v1.6.0
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
golang.org/x/crypto v0.33.0
|
golang.org/x/crypto v0.45.0
|
||||||
golang.org/x/term v0.29.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
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,12 +20,10 @@ require (
|
|||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // 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/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
|
||||||
google.golang.org/grpc v1.68.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.0 // indirect
|
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
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 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
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 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
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 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
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-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
|
||||||
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
|
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||||
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
|
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||||
google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ=
|
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||||
google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
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 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
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.
|
// UpdatePasswordHash updates the Argon2id password hash for an account.
|
||||||
|
// Returns ErrNotFound if no active account with the given ID exists, consistent
|
||||||
|
// with the RowsAffected checks in RevokeToken and RenewToken.
|
||||||
func (db *DB) UpdatePasswordHash(accountID int64, hash string) error {
|
func (db *DB) UpdatePasswordHash(accountID int64, hash string) error {
|
||||||
_, err := db.sql.Exec(`
|
result, err := db.sql.Exec(`
|
||||||
UPDATE accounts SET password_hash = ?, updated_at = ?
|
UPDATE accounts SET password_hash = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`, hash, now(), accountID)
|
`, hash, now(), accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("db: update password hash: %w", err)
|
return fmt.Errorf("db: update password hash: %w", err)
|
||||||
}
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: update password hash rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,16 +459,17 @@ func (db *DB) WritePGCredentials(accountID int64, host string, port int, dbName,
|
|||||||
func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
|
func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
|
||||||
var cred model.PGCredential
|
var cred model.PGCredential
|
||||||
var createdAtStr, updatedAtStr string
|
var createdAtStr, updatedAtStr string
|
||||||
|
var ownerID sql.NullInt64
|
||||||
|
|
||||||
err := db.sql.QueryRow(`
|
err := db.sql.QueryRow(`
|
||||||
SELECT id, account_id, pg_host, pg_port, pg_database, pg_username,
|
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 = ?
|
FROM pg_credentials WHERE account_id = ?
|
||||||
`, accountID).Scan(
|
`, accountID).Scan(
|
||||||
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
|
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
|
||||||
&cred.PGDatabase, &cred.PGUsername,
|
&cred.PGDatabase, &cred.PGUsername,
|
||||||
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
|
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
|
||||||
&createdAtStr, &updatedAtStr,
|
&createdAtStr, &updatedAtStr, &ownerID,
|
||||||
)
|
)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -476,6 +486,10 @@ func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if ownerID.Valid {
|
||||||
|
v := ownerID.Int64
|
||||||
|
cred.OwnerID = &v
|
||||||
|
}
|
||||||
return &cred, nil
|
return &cred, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +649,23 @@ func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevokeAllUserTokensExcept revokes all non-expired, non-revoked tokens for an
|
||||||
|
// account except for the token identified by exceptJTI. Used by the
|
||||||
|
// self-service password change flow to invalidate all other sessions while
|
||||||
|
// keeping the caller's current session active.
|
||||||
|
func (db *DB) RevokeAllUserTokensExcept(accountID int64, exceptJTI, reason string) error {
|
||||||
|
n := now()
|
||||||
|
_, err := db.sql.Exec(`
|
||||||
|
UPDATE token_revocation
|
||||||
|
SET revoked_at = ?, revoke_reason = ?
|
||||||
|
WHERE account_id = ? AND jti != ? AND revoked_at IS NULL AND expires_at > ?
|
||||||
|
`, n, nullString(reason), accountID, exceptJTI, n)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: revoke all tokens except %q for account %d: %w", exceptJTI, accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// PruneExpiredTokens removes token_revocation rows that are past their expiry.
|
// PruneExpiredTokens removes token_revocation rows that are past their expiry.
|
||||||
// Returns the number of rows deleted.
|
// Returns the number of rows deleted.
|
||||||
func (db *DB) PruneExpiredTokens() (int64, error) {
|
func (db *DB) PruneExpiredTokens() (int64, error) {
|
||||||
|
|||||||
@@ -12,19 +12,36 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // register the sqlite3 driver
|
_ "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.
|
// DB wraps a *sql.DB with MCIAS-specific helpers.
|
||||||
type DB struct {
|
type DB struct {
|
||||||
sql *sql.DB
|
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
|
// Open opens (or creates) the SQLite database at path and configures it for
|
||||||
// MCIAS use (WAL mode, foreign keys, busy timeout).
|
// MCIAS use (WAL mode, foreign keys, busy timeout).
|
||||||
func Open(path string) (*DB, error) {
|
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".
|
// The modernc.org/sqlite driver is registered as "sqlite".
|
||||||
sqlDB, err := sql.Open("sqlite", path)
|
sqlDB, err := sql.Open("sqlite", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,7 +51,7 @@ func Open(path string) (*DB, error) {
|
|||||||
// Use a single connection for writes; reads can use the pool.
|
// Use a single connection for writes; reads can use the pool.
|
||||||
sqlDB.SetMaxOpenConns(1)
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
db := &DB{sql: sqlDB}
|
db := &DB{sql: sqlDB, path: path}
|
||||||
if err := db.configure(); err != nil {
|
if err := db.configure(); err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -2,239 +2,141 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"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.
|
// migrationsFS embeds all migration SQL files from the migrations/ directory.
|
||||||
type migration struct {
|
// Each file is named NNN_description.up.sql (and optionally .down.sql).
|
||||||
sql string
|
//
|
||||||
id int
|
//go:embed migrations/*.sql
|
||||||
}
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
// migrations is the ordered list of schema migrations applied to the database.
|
// LatestSchemaVersion is the highest migration version defined in the
|
||||||
// Once applied, migrations must never be modified — only new ones appended.
|
// migrations/ directory. Update this constant whenever a new migration file
|
||||||
var migrations = []migration{
|
// is added.
|
||||||
{
|
const LatestSchemaVersion = 6
|
||||||
id: 1,
|
|
||||||
sql: `
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
|
||||||
version INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_config (
|
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
// files. It opens a dedicated *sql.DB using the same DSN as the main
|
||||||
signing_key_enc BLOB,
|
// database so that calling m.Close() (which closes the underlying connection)
|
||||||
signing_key_nonce BLOB,
|
// does not affect the caller's main database connection.
|
||||||
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'))
|
// 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 (
|
// Open a dedicated connection for the migrator. golang-migrate's sqlite
|
||||||
id INTEGER PRIMARY KEY,
|
// driver calls db.Close() when the migrator is closed; using a dedicated
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
// connection (same DSN, different *sql.DB) prevents it from closing the
|
||||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
// shared connection. For in-memory databases, Open() translates
|
||||||
account_type TEXT NOT NULL CHECK (account_type IN ('human','system')),
|
// ":memory:" to a named shared-cache URI so both connections see the same
|
||||||
password_hash TEXT,
|
// data.
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
migrateDB, err := sql.Open("sqlite", database.path)
|
||||||
CHECK (status IN ('active','inactive','deleted')),
|
if err != nil {
|
||||||
totp_required INTEGER NOT NULL DEFAULT 0 CHECK (totp_required IN (0,1)),
|
return nil, fmt.Errorf("db: open migration connection: %w", err)
|
||||||
totp_secret_enc BLOB,
|
}
|
||||||
totp_secret_nonce BLOB,
|
migrateDB.SetMaxOpenConns(1)
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
if _, err := migrateDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
_ = migrateDB.Close()
|
||||||
deleted_at TEXT
|
return nil, fmt.Errorf("db: migration connection pragma: %w", err)
|
||||||
);
|
}
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts (username);
|
driver, err := sqlitedriver.WithInstance(migrateDB, &sqlitedriver.Config{
|
||||||
CREATE INDEX IF NOT EXISTS idx_accounts_uuid ON accounts (uuid);
|
MigrationsTable: "schema_migrations",
|
||||||
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts (status);
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = migrateDB.Close()
|
||||||
|
return nil, fmt.Errorf("db: create migration driver: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS account_roles (
|
m, err := migrate.NewWithInstance("iofs", src, "sqlite", driver)
|
||||||
id INTEGER PRIMARY KEY,
|
if err != nil {
|
||||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
return nil, fmt.Errorf("db: initialise migrator: %w", err)
|
||||||
role TEXT NOT NULL,
|
}
|
||||||
granted_by INTEGER REFERENCES accounts(id),
|
return m, nil
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate applies any unapplied schema migrations to the database in order.
|
// Migrate applies any unapplied schema migrations to the database in order.
|
||||||
// It is idempotent: running it multiple times is safe.
|
// It is idempotent: running it on an already-current database is safe and
|
||||||
func Migrate(db *DB) error {
|
// returns nil.
|
||||||
// Ensure the schema_version table exists first.
|
//
|
||||||
if _, err := db.sql.Exec(`
|
// Existing databases that were migrated by the previous hand-rolled runner
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
// (schema_version table) are handled by the compatibility shim below: the
|
||||||
version INTEGER NOT NULL
|
// legacy version is read and used to fast-forward the golang-migrate state
|
||||||
)
|
// before calling Up, so no migration is applied twice.
|
||||||
`); err != nil {
|
func Migrate(database *DB) error {
|
||||||
return fmt.Errorf("db: ensure schema_version: %w", err)
|
// 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
|
||||||
currentVersion, err := currentSchemaVersion(db.sql)
|
// not try to re-apply already-applied migrations.
|
||||||
|
legacyVersion, err := legacySchemaVersion(database)
|
||||||
if err != nil {
|
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 {
|
m, err := newMigrate(database)
|
||||||
if m.id <= currentVersion {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := db.sql.Begin()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("db: begin migration %d transaction: %w", m.id, err)
|
return err
|
||||||
}
|
}
|
||||||
|
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
|
||||||
|
|
||||||
if _, err := tx.Exec(m.sql); err != nil {
|
if legacyVersion > 0 {
|
||||||
_ = tx.Rollback()
|
// Force the migrator to treat the database as already at
|
||||||
return fmt.Errorf("db: apply migration %d: %w", m.id, err)
|
// 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)
|
||||||
// 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 {
|
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
return fmt.Errorf("db: commit migration %d: %w", m.id, err)
|
return fmt.Errorf("db: apply migrations: %w", err)
|
||||||
}
|
|
||||||
currentVersion = m.id
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentSchemaVersion returns the current schema version, or 0 if none applied.
|
// SchemaVersion returns the current applied schema version of the database.
|
||||||
func currentSchemaVersion(db *sql.DB) (int, error) {
|
// Returns 0 if no migrations have been applied yet.
|
||||||
var version int
|
func SchemaVersion(database *DB) (int, error) {
|
||||||
err := db.QueryRow(`SELECT version FROM schema_version LIMIT 1`).Scan(&version)
|
m, err := newMigrate(database)
|
||||||
if err != nil {
|
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 0, nil //nolint:nilerr
|
||||||
}
|
}
|
||||||
return version, nil
|
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"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// policyRuleCols is the column list for all policy rule SELECT queries.
|
||||||
|
const policyRuleCols = `id, priority, description, rule_json, enabled, created_by, created_at, updated_at, not_before, expires_at`
|
||||||
|
|
||||||
// CreatePolicyRule inserts a new policy rule record. The returned record
|
// CreatePolicyRule inserts a new policy rule record. The returned record
|
||||||
// includes the database-assigned ID and timestamps.
|
// includes the database-assigned ID and timestamps.
|
||||||
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64) (*model.PolicyRuleRecord, error) {
|
// notBefore and expiresAt are optional; nil means no constraint.
|
||||||
|
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64, notBefore, expiresAt *time.Time) (*model.PolicyRuleRecord, error) {
|
||||||
n := now()
|
n := now()
|
||||||
result, err := db.sql.Exec(`
|
result, err := db.sql.Exec(`
|
||||||
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at)
|
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at, not_before, expires_at)
|
||||||
VALUES (?, ?, ?, 1, ?, ?, ?)
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)
|
||||||
`, priority, description, ruleJSON, createdBy, n, n)
|
`, priority, description, ruleJSON, createdBy, n, n, formatNullableTime(notBefore), formatNullableTime(expiresAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("db: create policy rule: %w", err)
|
return nil, fmt.Errorf("db: create policy rule: %w", err)
|
||||||
}
|
}
|
||||||
@@ -39,6 +44,8 @@ func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string
|
|||||||
CreatedBy: createdBy,
|
CreatedBy: createdBy,
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
UpdatedAt: createdAt,
|
UpdatedAt: createdAt,
|
||||||
|
NotBefore: notBefore,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +53,7 @@ func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string
|
|||||||
// Returns ErrNotFound if no such rule exists.
|
// Returns ErrNotFound if no such rule exists.
|
||||||
func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
|
func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
|
||||||
return db.scanPolicyRule(db.sql.QueryRow(`
|
return db.scanPolicyRule(db.sql.QueryRow(`
|
||||||
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
|
SELECT `+policyRuleCols+`
|
||||||
FROM policy_rules WHERE id = ?
|
FROM policy_rules WHERE id = ?
|
||||||
`, id))
|
`, id))
|
||||||
}
|
}
|
||||||
@@ -55,7 +62,7 @@ func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
|
|||||||
// When enabledOnly is true, only rules with enabled=1 are returned.
|
// When enabledOnly is true, only rules with enabled=1 are returned.
|
||||||
func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, error) {
|
func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
|
SELECT ` + policyRuleCols + `
|
||||||
FROM policy_rules`
|
FROM policy_rules`
|
||||||
if enabledOnly {
|
if enabledOnly {
|
||||||
query += ` WHERE enabled = 1`
|
query += ` WHERE enabled = 1`
|
||||||
@@ -80,8 +87,12 @@ func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePolicyRule updates the mutable fields of a policy rule.
|
// UpdatePolicyRule updates the mutable fields of a policy rule.
|
||||||
// Only the fields in the update map are changed; other fields are untouched.
|
// Only non-nil fields are changed; nil fields are left untouched.
|
||||||
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string) error {
|
// For notBefore and expiresAt, use a non-nil pointer-to-pointer:
|
||||||
|
// - nil (outer) → don't change
|
||||||
|
// - non-nil → nil → set column to NULL
|
||||||
|
// - non-nil → non-nil → set column to the time value
|
||||||
|
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string, notBefore, expiresAt **time.Time) error {
|
||||||
n := now()
|
n := now()
|
||||||
|
|
||||||
// Build SET clause dynamically to only update provided fields.
|
// Build SET clause dynamically to only update provided fields.
|
||||||
@@ -102,6 +113,14 @@ func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, rul
|
|||||||
setClauses += ", rule_json = ?"
|
setClauses += ", rule_json = ?"
|
||||||
args = append(args, *ruleJSON)
|
args = append(args, *ruleJSON)
|
||||||
}
|
}
|
||||||
|
if notBefore != nil {
|
||||||
|
setClauses += ", not_before = ?"
|
||||||
|
args = append(args, formatNullableTime(*notBefore))
|
||||||
|
}
|
||||||
|
if expiresAt != nil {
|
||||||
|
setClauses += ", expires_at = ?"
|
||||||
|
args = append(args, formatNullableTime(*expiresAt))
|
||||||
|
}
|
||||||
args = append(args, id)
|
args = append(args, id)
|
||||||
|
|
||||||
_, err := db.sql.Exec(`UPDATE policy_rules SET `+setClauses+` WHERE id = ?`, args...)
|
_, err := db.sql.Exec(`UPDATE policy_rules SET `+setClauses+` WHERE id = ?`, args...)
|
||||||
@@ -141,10 +160,12 @@ func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
|
|||||||
var enabledInt int
|
var enabledInt int
|
||||||
var createdAtStr, updatedAtStr string
|
var createdAtStr, updatedAtStr string
|
||||||
var createdBy *int64
|
var createdBy *int64
|
||||||
|
var notBeforeStr, expiresAtStr *string
|
||||||
|
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
||||||
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
||||||
|
¬BeforeStr, &expiresAtStr,
|
||||||
)
|
)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -153,7 +174,7 @@ func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
|
|||||||
return nil, fmt.Errorf("db: scan policy rule: %w", err)
|
return nil, fmt.Errorf("db: scan policy rule: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
|
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr, notBeforeStr, expiresAtStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanPolicyRuleRow scans a single policy rule from *sql.Rows.
|
// scanPolicyRuleRow scans a single policy rule from *sql.Rows.
|
||||||
@@ -162,19 +183,21 @@ func (db *DB) scanPolicyRuleRow(rows *sql.Rows) (*model.PolicyRuleRecord, error)
|
|||||||
var enabledInt int
|
var enabledInt int
|
||||||
var createdAtStr, updatedAtStr string
|
var createdAtStr, updatedAtStr string
|
||||||
var createdBy *int64
|
var createdBy *int64
|
||||||
|
var notBeforeStr, expiresAtStr *string
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
||||||
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
||||||
|
¬BeforeStr, &expiresAtStr,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("db: scan policy rule row: %w", err)
|
return nil, fmt.Errorf("db: scan policy rule row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
|
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr, notBeforeStr, expiresAtStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string) (*model.PolicyRuleRecord, error) {
|
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string, notBeforeStr, expiresAtStr *string) (*model.PolicyRuleRecord, error) {
|
||||||
r.Enabled = enabledInt == 1
|
r.Enabled = enabledInt == 1
|
||||||
r.CreatedBy = createdBy
|
r.CreatedBy = createdBy
|
||||||
|
|
||||||
@@ -187,5 +210,23 @@ func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
r.NotBefore, err = nullableTime(notBeforeStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.ExpiresAt, err = nullableTime(expiresAtStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatNullableTime converts a *time.Time to a *string suitable for SQLite.
|
||||||
|
// Returns nil if the input is nil (stores NULL).
|
||||||
|
func formatNullableTime(t *time.Time) *string {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := t.UTC().Format(time.RFC3339)
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
)
|
)
|
||||||
@@ -11,7 +12,7 @@ func TestCreateAndGetPolicyRule(t *testing.T) {
|
|||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
|
|
||||||
ruleJSON := `{"actions":["pgcreds:read"],"resource_type":"pgcreds","effect":"allow"}`
|
ruleJSON := `{"actions":["pgcreds:read"],"resource_type":"pgcreds","effect":"allow"}`
|
||||||
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil)
|
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreatePolicyRule: %v", err)
|
t.Fatalf("CreatePolicyRule: %v", err)
|
||||||
}
|
}
|
||||||
@@ -49,9 +50,9 @@ func TestGetPolicyRule_NotFound(t *testing.T) {
|
|||||||
func TestListPolicyRules(t *testing.T) {
|
func TestListPolicyRules(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
|
|
||||||
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil)
|
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||||
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil)
|
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil, nil, nil)
|
||||||
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil)
|
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil, nil, nil)
|
||||||
|
|
||||||
rules, err := db.ListPolicyRules(false)
|
rules, err := db.ListPolicyRules(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,8 +71,8 @@ func TestListPolicyRules(t *testing.T) {
|
|||||||
func TestListPolicyRules_EnabledOnly(t *testing.T) {
|
func TestListPolicyRules_EnabledOnly(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
|
|
||||||
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil)
|
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||||
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil)
|
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil, nil, nil)
|
||||||
|
|
||||||
if err := db.SetPolicyRuleEnabled(r2.ID, false); err != nil {
|
if err := db.SetPolicyRuleEnabled(r2.ID, false); err != nil {
|
||||||
t.Fatalf("SetPolicyRuleEnabled: %v", err)
|
t.Fatalf("SetPolicyRuleEnabled: %v", err)
|
||||||
@@ -100,11 +101,11 @@ func TestListPolicyRules_EnabledOnly(t *testing.T) {
|
|||||||
func TestUpdatePolicyRule(t *testing.T) {
|
func TestUpdatePolicyRule(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
|
|
||||||
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil)
|
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||||
|
|
||||||
newDesc := "updated description"
|
newDesc := "updated description"
|
||||||
newPriority := 25
|
newPriority := 25
|
||||||
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil); err != nil {
|
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil, nil, nil); err != nil {
|
||||||
t.Fatalf("UpdatePolicyRule: %v", err)
|
t.Fatalf("UpdatePolicyRule: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,10 +128,10 @@ func TestUpdatePolicyRule(t *testing.T) {
|
|||||||
func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
|
func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
|
|
||||||
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil)
|
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||||
|
|
||||||
newJSON := `{"effect":"deny","roles":["auditor"]}`
|
newJSON := `{"effect":"deny","roles":["auditor"]}`
|
||||||
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON); err != nil {
|
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON, nil, nil); err != nil {
|
||||||
t.Fatalf("UpdatePolicyRule (json only): %v", err)
|
t.Fatalf("UpdatePolicyRule (json only): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
|
|||||||
func TestSetPolicyRuleEnabled(t *testing.T) {
|
func TestSetPolicyRuleEnabled(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
|
|
||||||
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil)
|
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||||
if !rec.Enabled {
|
if !rec.Enabled {
|
||||||
t.Fatal("new rule should be enabled")
|
t.Fatal("new rule should be enabled")
|
||||||
}
|
}
|
||||||
@@ -175,7 +176,7 @@ func TestSetPolicyRuleEnabled(t *testing.T) {
|
|||||||
func TestDeletePolicyRule(t *testing.T) {
|
func TestDeletePolicyRule(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
|
|
||||||
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil)
|
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||||
|
|
||||||
if err := db.DeletePolicyRule(rec.ID); err != nil {
|
if err := db.DeletePolicyRule(rec.ID); err != nil {
|
||||||
t.Fatalf("DeletePolicyRule: %v", err)
|
t.Fatalf("DeletePolicyRule: %v", err)
|
||||||
@@ -200,7 +201,7 @@ func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
|
|||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
|
|
||||||
acct, _ := db.CreateAccount("policy-creator", model.AccountTypeHuman, "hash")
|
acct, _ := db.CreateAccount("policy-creator", model.AccountTypeHuman, "hash")
|
||||||
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID)
|
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreatePolicyRule with createdBy: %v", err)
|
t.Fatalf("CreatePolicyRule with createdBy: %v", err)
|
||||||
}
|
}
|
||||||
@@ -210,3 +211,111 @@ func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
|
|||||||
t.Errorf("expected CreatedBy=%d, got %v", acct.ID, got.CreatedBy)
|
t.Errorf("expected CreatedBy=%d, got %v", acct.ID, got.CreatedBy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreatePolicyRule_WithExpiresAt(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
rec, err := db.CreatePolicyRule("expiring rule", 100, `{"effect":"allow"}`, nil, nil, &exp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePolicyRule with expiresAt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetPolicyRule(rec.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
if got.ExpiresAt == nil {
|
||||||
|
t.Fatal("expected ExpiresAt to be set")
|
||||||
|
}
|
||||||
|
if !got.ExpiresAt.Equal(exp) {
|
||||||
|
t.Errorf("expected ExpiresAt=%v, got %v", exp, *got.ExpiresAt)
|
||||||
|
}
|
||||||
|
if got.NotBefore != nil {
|
||||||
|
t.Errorf("expected NotBefore=nil, got %v", *got.NotBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePolicyRule_WithNotBefore(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
nb := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
rec, err := db.CreatePolicyRule("scheduled rule", 100, `{"effect":"allow"}`, nil, &nb, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePolicyRule with notBefore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetPolicyRule(rec.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
if got.NotBefore == nil {
|
||||||
|
t.Fatal("expected NotBefore to be set")
|
||||||
|
}
|
||||||
|
if !got.NotBefore.Equal(nb) {
|
||||||
|
t.Errorf("expected NotBefore=%v, got %v", nb, *got.NotBefore)
|
||||||
|
}
|
||||||
|
if got.ExpiresAt != nil {
|
||||||
|
t.Errorf("expected ExpiresAt=nil, got %v", *got.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePolicyRule_WithBothTimes(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
nb := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
rec, err := db.CreatePolicyRule("windowed rule", 100, `{"effect":"allow"}`, nil, &nb, &exp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePolicyRule with both times: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetPolicyRule(rec.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
if got.NotBefore == nil || !got.NotBefore.Equal(nb) {
|
||||||
|
t.Errorf("NotBefore mismatch: got %v", got.NotBefore)
|
||||||
|
}
|
||||||
|
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(exp) {
|
||||||
|
t.Errorf("ExpiresAt mismatch: got %v", got.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePolicyRule_SetExpiresAt(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
rec, _ := db.CreatePolicyRule("no expiry", 100, `{"effect":"allow"}`, nil, nil, nil)
|
||||||
|
|
||||||
|
exp := time.Date(2030, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
expPtr := &exp
|
||||||
|
if err := db.UpdatePolicyRule(rec.ID, nil, nil, nil, nil, &expPtr); err != nil {
|
||||||
|
t.Fatalf("UpdatePolicyRule (set expires_at): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := db.GetPolicyRule(rec.ID)
|
||||||
|
if got.ExpiresAt == nil {
|
||||||
|
t.Fatal("expected ExpiresAt to be set after update")
|
||||||
|
}
|
||||||
|
if !got.ExpiresAt.Equal(exp) {
|
||||||
|
t.Errorf("expected ExpiresAt=%v, got %v", exp, *got.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePolicyRule_ClearExpiresAt(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
rec, _ := db.CreatePolicyRule("will clear", 100, `{"effect":"allow"}`, nil, nil, &exp)
|
||||||
|
|
||||||
|
// Clear expires_at by passing non-nil outer, nil inner.
|
||||||
|
var nilTime *time.Time
|
||||||
|
if err := db.UpdatePolicyRule(rec.ID, nil, nil, nil, nil, &nilTime); err != nil {
|
||||||
|
t.Fatalf("UpdatePolicyRule (clear expires_at): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := db.GetPolicyRule(rec.ID)
|
||||||
|
if got.ExpiresAt != nil {
|
||||||
|
t.Errorf("expected ExpiresAt=nil after clear, got %v", *got.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,13 +87,21 @@ type SystemToken struct {
|
|||||||
// PGCredential holds Postgres connection details for a system account.
|
// PGCredential holds Postgres connection details for a system account.
|
||||||
// The password is encrypted at rest; PGPassword is only populated after
|
// The password is encrypted at rest; PGPassword is only populated after
|
||||||
// decryption and must never be logged or included in API responses.
|
// 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 {
|
type PGCredential struct {
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
PGHost string `json:"host"`
|
OwnerID *int64 `json:"-"`
|
||||||
PGDatabase string `json:"database"`
|
ServiceAccountUUID string `json:"service_account_uuid,omitempty"`
|
||||||
PGUsername string `json:"username"`
|
PGUsername string `json:"username"`
|
||||||
PGPassword string `json:"-"`
|
PGPassword string `json:"-"`
|
||||||
|
ServiceUsername string `json:"service_username,omitempty"`
|
||||||
|
PGDatabase string `json:"database"`
|
||||||
|
PGHost string `json:"host"`
|
||||||
PGPasswordEnc []byte `json:"-"`
|
PGPasswordEnc []byte `json:"-"`
|
||||||
PGPasswordNonce []byte `json:"-"`
|
PGPasswordNonce []byte `json:"-"`
|
||||||
ID int64 `json:"-"`
|
ID int64 `json:"-"`
|
||||||
@@ -141,12 +149,38 @@ const (
|
|||||||
EventPolicyDeny = "policy_deny"
|
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.
|
// PolicyRuleRecord is the database representation of a policy rule.
|
||||||
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
||||||
// The ID, Priority, and Description are stored as dedicated columns.
|
// The ID, Priority, and Description are stored as dedicated columns.
|
||||||
|
// NotBefore and ExpiresAt define an optional validity window; nil means no
|
||||||
|
// constraint (always active / never expires).
|
||||||
type PolicyRuleRecord struct {
|
type PolicyRuleRecord struct {
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
CreatedBy *int64 `json:"-"`
|
CreatedBy *int64 `json:"-"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
RuleJSON string `json:"rule_json"`
|
RuleJSON string `json:"rule_json"`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package policy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminInput is a convenience helper for building admin PolicyInputs.
|
// adminInput is a convenience helper for building admin PolicyInputs.
|
||||||
@@ -378,3 +379,131 @@ func TestEvaluate_AccountTypeGating(t *testing.T) {
|
|||||||
t.Error("human account should not match system-only rule")
|
t.Error("human account should not match system-only rule")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Engine.SetRules time-filtering tests ----
|
||||||
|
|
||||||
|
func TestSetRules_SkipsExpiredRule(t *testing.T) {
|
||||||
|
engine := NewEngine()
|
||||||
|
past := time.Now().Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
err := engine.SetRules([]PolicyRecord{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Description: "expired",
|
||||||
|
Priority: 100,
|
||||||
|
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||||
|
Enabled: true,
|
||||||
|
ExpiresAt: &past,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetRules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The expired rule should not be in the cache; evaluation should deny.
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "user-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionListAccounts,
|
||||||
|
Resource: Resource{Type: ResourceAccount},
|
||||||
|
}
|
||||||
|
effect, _ := engine.Evaluate(input)
|
||||||
|
if effect != Deny {
|
||||||
|
t.Error("expired rule should not match; expected Deny")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRules_SkipsNotYetActiveRule(t *testing.T) {
|
||||||
|
engine := NewEngine()
|
||||||
|
future := time.Now().Add(1 * time.Hour)
|
||||||
|
|
||||||
|
err := engine.SetRules([]PolicyRecord{
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Description: "not yet active",
|
||||||
|
Priority: 100,
|
||||||
|
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||||
|
Enabled: true,
|
||||||
|
NotBefore: &future,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetRules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "user-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionListAccounts,
|
||||||
|
Resource: Resource{Type: ResourceAccount},
|
||||||
|
}
|
||||||
|
effect, _ := engine.Evaluate(input)
|
||||||
|
if effect != Deny {
|
||||||
|
t.Error("future not_before rule should not match; expected Deny")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRules_IncludesActiveWindowRule(t *testing.T) {
|
||||||
|
engine := NewEngine()
|
||||||
|
past := time.Now().Add(-1 * time.Hour)
|
||||||
|
future := time.Now().Add(1 * time.Hour)
|
||||||
|
|
||||||
|
err := engine.SetRules([]PolicyRecord{
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Description: "currently active",
|
||||||
|
Priority: 100,
|
||||||
|
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||||
|
Enabled: true,
|
||||||
|
NotBefore: &past,
|
||||||
|
ExpiresAt: &future,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetRules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "user-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionListAccounts,
|
||||||
|
Resource: Resource{Type: ResourceAccount},
|
||||||
|
}
|
||||||
|
effect, _ := engine.Evaluate(input)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Error("rule within its active window should match; expected Allow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRules_NilTimesAlwaysActive(t *testing.T) {
|
||||||
|
engine := NewEngine()
|
||||||
|
|
||||||
|
err := engine.SetRules([]PolicyRecord{
|
||||||
|
{
|
||||||
|
ID: 4,
|
||||||
|
Description: "no time constraints",
|
||||||
|
Priority: 100,
|
||||||
|
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
|
||||||
|
Enabled: true,
|
||||||
|
// NotBefore and ExpiresAt are both nil.
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetRules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "user-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionListAccounts,
|
||||||
|
Resource: Resource{Type: ResourceAccount},
|
||||||
|
}
|
||||||
|
effect, _ := engine.Evaluate(input)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Error("nil time fields mean always active; expected Allow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine wraps the stateless Evaluate function with an in-memory cache of
|
// Engine wraps the stateless Evaluate function with an in-memory cache of
|
||||||
@@ -31,11 +32,19 @@ func NewEngine() *Engine {
|
|||||||
// into a Rule. This prevents the database from injecting values into the ID or
|
// into a Rule. This prevents the database from injecting values into the ID or
|
||||||
// Description fields that are stored as dedicated columns.
|
// Description fields that are stored as dedicated columns.
|
||||||
func (e *Engine) SetRules(records []PolicyRecord) error {
|
func (e *Engine) SetRules(records []PolicyRecord) error {
|
||||||
|
now := time.Now()
|
||||||
rules := make([]Rule, 0, len(records))
|
rules := make([]Rule, 0, len(records))
|
||||||
for _, rec := range records {
|
for _, rec := range records {
|
||||||
if !rec.Enabled {
|
if !rec.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Skip rules outside their validity window.
|
||||||
|
if rec.NotBefore != nil && now.Before(*rec.NotBefore) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec.ExpiresAt != nil && now.After(*rec.ExpiresAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var body RuleBody
|
var body RuleBody
|
||||||
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
||||||
return fmt.Errorf("policy: decode rule %d %q: %w", rec.ID, rec.Description, err)
|
return fmt.Errorf("policy: decode rule %d %q: %w", rec.ID, rec.Description, err)
|
||||||
@@ -75,6 +84,8 @@ func (e *Engine) Evaluate(input PolicyInput) (Effect, *Rule) {
|
|||||||
// Using a local struct avoids importing the db or model packages from policy,
|
// Using a local struct avoids importing the db or model packages from policy,
|
||||||
// which would create a dependency cycle.
|
// which would create a dependency cycle.
|
||||||
type PolicyRecord struct {
|
type PolicyRecord struct {
|
||||||
|
NotBefore *time.Time
|
||||||
|
ExpiresAt *time.Time
|
||||||
Description string
|
Description string
|
||||||
RuleJSON string
|
RuleJSON string
|
||||||
ID int64
|
ID int64
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||||
@@ -90,6 +91,8 @@ func (s *Server) handleSetTags(w http.ResponseWriter, r *http.Request) {
|
|||||||
type policyRuleResponse struct {
|
type policyRuleResponse struct {
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
NotBefore *string `json:"not_before,omitempty"`
|
||||||
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
RuleBody policy.RuleBody `json:"rule"`
|
RuleBody policy.RuleBody `json:"rule"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -102,15 +105,24 @@ func policyRuleToResponse(rec *model.PolicyRuleRecord) (policyRuleResponse, erro
|
|||||||
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
||||||
return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err)
|
return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err)
|
||||||
}
|
}
|
||||||
return policyRuleResponse{
|
resp := policyRuleResponse{
|
||||||
ID: rec.ID,
|
ID: rec.ID,
|
||||||
Priority: rec.Priority,
|
Priority: rec.Priority,
|
||||||
Description: rec.Description,
|
Description: rec.Description,
|
||||||
RuleBody: body,
|
RuleBody: body,
|
||||||
Enabled: rec.Enabled,
|
Enabled: rec.Enabled,
|
||||||
CreatedAt: rec.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: rec.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: rec.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
UpdatedAt: rec.UpdatedAt.Format(time.RFC3339),
|
||||||
}, nil
|
}
|
||||||
|
if rec.NotBefore != nil {
|
||||||
|
s := rec.NotBefore.UTC().Format(time.RFC3339)
|
||||||
|
resp.NotBefore = &s
|
||||||
|
}
|
||||||
|
if rec.ExpiresAt != nil {
|
||||||
|
s := rec.ExpiresAt.UTC().Format(time.RFC3339)
|
||||||
|
resp.ExpiresAt = &s
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -133,6 +145,8 @@ func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
|
|||||||
|
|
||||||
type createPolicyRuleRequest struct {
|
type createPolicyRuleRequest struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
NotBefore *string `json:"not_before,omitempty"`
|
||||||
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||||
Rule policy.RuleBody `json:"rule"`
|
Rule policy.RuleBody `json:"rule"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
}
|
}
|
||||||
@@ -157,6 +171,29 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
|
|||||||
priority = 100 // default
|
priority = 100 // default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse optional time-scoped validity window.
|
||||||
|
var notBefore, expiresAt *time.Time
|
||||||
|
if req.NotBefore != nil {
|
||||||
|
t, err := time.Parse(time.RFC3339, *req.NotBefore)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "not_before must be RFC3339", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notBefore = &t
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != nil {
|
||||||
|
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be RFC3339", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be after not_before", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ruleJSON, err := json.Marshal(req.Rule)
|
ruleJSON, err := json.Marshal(req.Rule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
@@ -171,7 +208,7 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy)
|
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy, notBefore, expiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
@@ -203,9 +240,13 @@ func (s *Server) handleGetPolicyRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
type updatePolicyRuleRequest struct {
|
type updatePolicyRuleRequest struct {
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
|
NotBefore *string `json:"not_before,omitempty"`
|
||||||
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||||
Rule *policy.RuleBody `json:"rule,omitempty"`
|
Rule *policy.RuleBody `json:"rule,omitempty"`
|
||||||
Priority *int `json:"priority,omitempty"`
|
Priority *int `json:"priority,omitempty"`
|
||||||
Enabled *bool `json:"enabled,omitempty"`
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
ClearNotBefore *bool `json:"clear_not_before,omitempty"`
|
||||||
|
ClearExpiresAt *bool `json:"clear_expires_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -230,11 +271,39 @@ func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request)
|
|||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s := string(b)
|
js := string(b)
|
||||||
ruleJSON = &s
|
ruleJSON = &js
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON); err != nil {
|
// Parse optional time-scoped validity window updates.
|
||||||
|
// Double-pointer semantics: nil = no change, non-nil→nil = clear, non-nil→non-nil = set.
|
||||||
|
var notBefore, expiresAt **time.Time
|
||||||
|
if req.ClearNotBefore != nil && *req.ClearNotBefore {
|
||||||
|
var nilTime *time.Time
|
||||||
|
notBefore = &nilTime // non-nil outer, nil inner → set to NULL
|
||||||
|
} else if req.NotBefore != nil {
|
||||||
|
t, err := time.Parse(time.RFC3339, *req.NotBefore)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "not_before must be RFC3339", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tp := &t
|
||||||
|
notBefore = &tp
|
||||||
|
}
|
||||||
|
if req.ClearExpiresAt != nil && *req.ClearExpiresAt {
|
||||||
|
var nilTime *time.Time
|
||||||
|
expiresAt = &nilTime // non-nil outer, nil inner → set to NULL
|
||||||
|
} else if req.ExpiresAt != nil {
|
||||||
|
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be RFC3339", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tp := &t
|
||||||
|
expiresAt = &tp
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON, notBefore, expiresAt); err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
||||||
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
|
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
|
||||||
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
|
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
|
||||||
|
mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword)))
|
||||||
|
|
||||||
|
// Self-service password change (requires valid token; actor must match target account).
|
||||||
|
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
|
||||||
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
|
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
|
||||||
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
|
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
|
||||||
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
|
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
|
||||||
@@ -801,6 +805,183 @@ func (s *Server) handleTOTPRemove(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Password change endpoints ----
|
||||||
|
|
||||||
|
// adminSetPasswordRequest is the request body for PUT /v1/accounts/{id}/password.
|
||||||
|
// Used by admins to reset any human account's password without requiring the
|
||||||
|
// current password.
|
||||||
|
type adminSetPasswordRequest struct {
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminSetPassword allows an admin to reset any human account's password.
|
||||||
|
// No current-password verification is required because the admin role already
|
||||||
|
// represents a higher trust level, matching the break-glass recovery pattern.
|
||||||
|
//
|
||||||
|
// Security: new password is validated (minimum length) and hashed with Argon2id
|
||||||
|
// before storage. The plaintext is never logged. All active tokens for the
|
||||||
|
// target account are revoked so that a compromised-account recovery fully
|
||||||
|
// invalidates any outstanding sessions.
|
||||||
|
func (s *Server) handleAdminSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acct, ok := s.loadAccount(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if acct.AccountType != model.AccountTypeHuman {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "password can only be set on human accounts", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req adminSetPasswordRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security (F-13): enforce minimum length before hashing.
|
||||||
|
if err := validate.Password(req.NewPassword); err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
|
||||||
|
Time: s.cfg.Argon2.Time,
|
||||||
|
Memory: s.cfg.Argon2.Memory,
|
||||||
|
Threads: s.cfg.Argon2.Threads,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("hash password (admin reset)", "error", err)
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
||||||
|
s.logger.Error("update password hash", "error", err)
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: revoke all active sessions so a compromised account cannot
|
||||||
|
// continue to use old tokens after a password reset. Failure here means
|
||||||
|
// the API's documented guarantee ("all active sessions revoked") cannot be
|
||||||
|
// upheld, so we return 500 rather than silently succeeding.
|
||||||
|
if err := s.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
|
||||||
|
s.logger.Error("revoke tokens on password reset", "error", err, "account_id", acct.ID)
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if actor != nil {
|
||||||
|
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
||||||
|
actorID = &a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// changePasswordRequest is the request body for PUT /v1/auth/password.
|
||||||
|
// The current_password is required to prevent token-theft attacks: an attacker
|
||||||
|
// who steals a valid JWT cannot change the password without also knowing the
|
||||||
|
// existing one.
|
||||||
|
type changePasswordRequest struct {
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleChangePassword allows an authenticated user to change their own password.
|
||||||
|
// The current password must be verified before the new hash is written.
|
||||||
|
//
|
||||||
|
// Security: current password is verified with Argon2id (constant-time).
|
||||||
|
// Lockout is checked and failures are recorded to prevent the endpoint from
|
||||||
|
// being used as an oracle for the current password. On success, all other
|
||||||
|
// active sessions (other JTIs) are revoked so stale tokens cannot be used
|
||||||
|
// after a credential rotation.
|
||||||
|
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if acct.AccountType != model.AccountTypeHuman {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "password change is only available for human accounts", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req changePasswordRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.CurrentPassword == "" || req.NewPassword == "" {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "current_password and new_password are required", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: check lockout before verifying (same as login flow) so an
|
||||||
|
// attacker cannot use this endpoint to brute-force the current password.
|
||||||
|
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
||||||
|
if lockErr != nil {
|
||||||
|
s.logger.Error("lockout check (password change)", "error", lockErr)
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
||||||
|
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: verify the current password with the same constant-time
|
||||||
|
// Argon2id path used at login to prevent timing oracles.
|
||||||
|
ok, verifyErr := auth.VerifyPassword(req.CurrentPassword, acct.PasswordHash)
|
||||||
|
if verifyErr != nil || !ok {
|
||||||
|
_ = s.db.RecordLoginFailure(acct.ID)
|
||||||
|
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
|
||||||
|
middleware.WriteError(w, http.StatusUnauthorized, "current password is incorrect", "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security (F-13): enforce minimum length on the new password before hashing.
|
||||||
|
if err := validate.Password(req.NewPassword); err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
|
||||||
|
Time: s.cfg.Argon2.Time,
|
||||||
|
Memory: s.cfg.Argon2.Memory,
|
||||||
|
Threads: s.cfg.Argon2.Threads,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("hash password (self-service change)", "error", err)
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
||||||
|
s.logger.Error("update password hash", "error", err)
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: clear the failure counter since the user proved knowledge of
|
||||||
|
// the current password, then revoke all tokens *except* the current one so
|
||||||
|
// the caller retains their active session but any other stolen sessions are
|
||||||
|
// invalidated. Revocation failure breaks the documented guarantee so we
|
||||||
|
// return 500 rather than silently succeeding.
|
||||||
|
_ = s.db.ClearLoginFailures(acct.ID)
|
||||||
|
if err := s.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
|
||||||
|
s.logger.Error("revoke other tokens on password change", "error", err, "account_id", acct.ID)
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"self_service"}`)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Postgres credential endpoints ----
|
// ---- Postgres credential endpoints ----
|
||||||
|
|
||||||
type pgCredRequest struct {
|
type pgCredRequest struct {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
u.render(w, "accounts", AccountsData{
|
u.render(w, "accounts", AccountsData{
|
||||||
PageData: PageData{CSRFToken: csrfToken},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||||
Accounts: accounts,
|
Accounts: accounts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -132,15 +132,41 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
tokens = nil
|
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
|
// Load PG credentials for system accounts only; leave nil for human accounts
|
||||||
// and when no credentials have been stored yet.
|
// and when no credentials have been stored yet.
|
||||||
var pgCred *model.PGCredential
|
var pgCred *model.PGCredential
|
||||||
|
var pgCredGrants []*model.PGCredAccessGrant
|
||||||
|
var grantableAccounts []*model.Account
|
||||||
if acct.AccountType == model.AccountTypeSystem {
|
if acct.AccountType == model.AccountTypeSystem {
|
||||||
pgCred, err = u.db.ReadPGCredentials(acct.ID)
|
pgCred, err = u.db.ReadPGCredentials(acct.ID)
|
||||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||||
u.logger.Warn("read pg credentials", "error", err)
|
u.logger.Warn("read pg credentials", "error", err)
|
||||||
}
|
}
|
||||||
// ErrNotFound is expected when no credentials have been stored yet.
|
// 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)
|
tags, err := u.db.GetAccountTags(acct.ID)
|
||||||
@@ -150,12 +176,15 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
u.render(w, "account_detail", AccountDetailData{
|
u.render(w, "account_detail", AccountDetailData{
|
||||||
PageData: PageData{CSRFToken: csrfToken},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||||
Account: acct,
|
Account: acct,
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
AllRoles: knownRoles,
|
AllRoles: knownRoles,
|
||||||
Tokens: tokens,
|
Tokens: tokens,
|
||||||
PGCred: pgCred,
|
PGCred: pgCred,
|
||||||
|
PGCredGrants: pgCredGrants,
|
||||||
|
GrantableAccounts: grantableAccounts,
|
||||||
|
ActorID: actorID,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -456,6 +485,32 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
|
|||||||
pgCred = nil
|
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)
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
csrfToken = ""
|
csrfToken = ""
|
||||||
@@ -465,6 +520,470 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
|
|||||||
PageData: PageData{CSRFToken: csrfToken},
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
Account: acct,
|
Account: acct,
|
||||||
PGCred: pgCred,
|
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{
|
u.render(w, "audit_detail", AuditDetailData{
|
||||||
PageData: PageData{CSRFToken: csrfToken},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||||
Event: event,
|
Event: event,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ func (u *UIServer) buildAuditData(r *http.Request, page int, csrfToken string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return AuditData{
|
return AuditData{
|
||||||
PageData: PageData{CSRFToken: csrfToken},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||||
Events: events,
|
Events: events,
|
||||||
EventTypes: auditEventTypes,
|
EventTypes: auditEventTypes,
|
||||||
FilterType: filterType,
|
FilterType: filterType,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
u.render(w, "dashboard", DashboardData{
|
u.render(w, "dashboard", DashboardData{
|
||||||
PageData: PageData{CSRFToken: csrfToken},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||||
TotalAccounts: total,
|
TotalAccounts: total,
|
||||||
ActiveAccounts: active,
|
ActiveAccounts: active,
|
||||||
RecentEvents: events,
|
RecentEvents: events,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
@@ -60,7 +61,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := PoliciesData{
|
data := PoliciesData{
|
||||||
PageData: PageData{CSRFToken: csrfToken},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||||
Rules: views,
|
Rules: views,
|
||||||
AllActions: allActionStrings,
|
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.
|
// policyRuleToView converts a DB record to a template-friendly view.
|
||||||
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
|
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
|
||||||
pretty := prettyJSONStr(rec.RuleJSON)
|
pretty := prettyJSONStr(rec.RuleJSON)
|
||||||
return &PolicyRuleView{
|
v := &PolicyRuleView{
|
||||||
ID: rec.ID,
|
ID: rec.ID,
|
||||||
Priority: rec.Priority,
|
Priority: rec.Priority,
|
||||||
Description: rec.Description,
|
Description: rec.Description,
|
||||||
@@ -79,6 +80,16 @@ func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
|
|||||||
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
|
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
|
||||||
UpdatedAt: rec.UpdatedAt.Format("2006-01-02 15:04 UTC"),
|
UpdatedAt: rec.UpdatedAt.Format("2006-01-02 15:04 UTC"),
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if rec.NotBefore != nil {
|
||||||
|
v.NotBefore = rec.NotBefore.UTC().Format("2006-01-02 15:04 UTC")
|
||||||
|
v.IsPending = now.Before(*rec.NotBefore)
|
||||||
|
}
|
||||||
|
if rec.ExpiresAt != nil {
|
||||||
|
v.ExpiresAt = rec.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
|
||||||
|
v.IsExpired = now.After(*rec.ExpiresAt)
|
||||||
|
}
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func prettyJSONStr(s string) string {
|
func prettyJSONStr(s string) string {
|
||||||
@@ -160,6 +171,29 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse optional time-scoped validity window from datetime-local inputs.
|
||||||
|
var notBefore, expiresAt *time.Time
|
||||||
|
if nbStr := strings.TrimSpace(r.FormValue("not_before")); nbStr != "" {
|
||||||
|
t, err := time.Parse("2006-01-02T15:04", nbStr)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid not_before time format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notBefore = &t
|
||||||
|
}
|
||||||
|
if eaStr := strings.TrimSpace(r.FormValue("expires_at")); eaStr != "" {
|
||||||
|
t, err := time.Parse("2006-01-02T15:04", eaStr)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid expires_at time format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "expires_at must be after not_before")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
claims := claimsFromContext(r.Context())
|
claims := claimsFromContext(r.Context())
|
||||||
var actorID *int64
|
var actorID *int64
|
||||||
if claims != nil {
|
if claims != nil {
|
||||||
@@ -168,7 +202,7 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID)
|
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID, notBefore, expiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
|
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
"not": func(b bool) bool { return !b },
|
"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 },
|
"add": func(a, b int) int { return a + b },
|
||||||
"sub": 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 },
|
"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/tags_editor.html",
|
||||||
"templates/fragments/policy_row.html",
|
"templates/fragments/policy_row.html",
|
||||||
"templates/fragments/policy_form.html",
|
"templates/fragments/policy_form.html",
|
||||||
|
"templates/fragments/password_reset_form.html",
|
||||||
}
|
}
|
||||||
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -190,6 +207,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
"audit": "templates/audit.html",
|
"audit": "templates/audit.html",
|
||||||
"audit_detail": "templates/audit_detail.html",
|
"audit_detail": "templates/audit_detail.html",
|
||||||
"policies": "templates/policies.html",
|
"policies": "templates/policies.html",
|
||||||
|
"pgcreds": "templates/pgcreds.html",
|
||||||
}
|
}
|
||||||
tmpls := make(map[string]*template.Template, len(pageFiles))
|
tmpls := make(map[string]*template.Template, len(pageFiles))
|
||||||
for name, file := range 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("DELETE /token/{jti}", admin(u.handleRevokeToken))
|
||||||
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
|
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
|
||||||
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
|
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", adminGet(u.handleAuditPage))
|
||||||
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
|
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
|
||||||
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
|
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("PATCH /policies/{id}/enabled", admin(u.handleTogglePolicyRule))
|
||||||
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
|
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
|
||||||
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
|
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
|
||||||
|
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
|
||||||
|
|
||||||
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
|
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
|
||||||
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
|
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
|
||||||
@@ -478,6 +501,21 @@ func clientIP(r *http.Request) string {
|
|||||||
return addr
|
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 ----
|
// ---- Page data types ----
|
||||||
|
|
||||||
// PageData is embedded in all page-level view structs.
|
// PageData is embedded in all page-level view structs.
|
||||||
@@ -485,6 +523,9 @@ type PageData struct {
|
|||||||
CSRFToken string
|
CSRFToken string
|
||||||
Flash string
|
Flash string
|
||||||
Error 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.
|
// 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.
|
// AccountDetailData is the view model for the account detail page.
|
||||||
type AccountDetailData struct {
|
type AccountDetailData struct {
|
||||||
Account *model.Account
|
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
|
PageData
|
||||||
Roles []string
|
Roles []string
|
||||||
AllRoles []string
|
AllRoles []string
|
||||||
@@ -545,9 +595,13 @@ type PolicyRuleView struct {
|
|||||||
RuleJSON string
|
RuleJSON string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
|
NotBefore string // empty if not set
|
||||||
|
ExpiresAt string // empty if not set
|
||||||
ID int64
|
ID int64
|
||||||
Priority int
|
Priority int
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
IsExpired bool // true if expires_at is in the past
|
||||||
|
IsPending bool // true if not_before is in the future
|
||||||
}
|
}
|
||||||
|
|
||||||
// PoliciesData is the view model for the policies list page.
|
// PoliciesData is the view model for the policies list page.
|
||||||
@@ -556,3 +610,21 @@ type PoliciesData struct {
|
|||||||
Rules []*PolicyRuleView
|
Rules []*PolicyRuleView
|
||||||
AllActions []string
|
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:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: true
|
example: true
|
||||||
|
not_before:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: |
|
||||||
|
Earliest time the rule becomes active. NULL means no constraint
|
||||||
|
(always active). Rules where `not_before > now()` are skipped
|
||||||
|
during evaluation.
|
||||||
|
example: "2026-04-01T00:00:00Z"
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: |
|
||||||
|
Time after which the rule is no longer active. NULL means no
|
||||||
|
constraint (never expires). Rules where `expires_at <= now()` are
|
||||||
|
skipped during evaluation.
|
||||||
|
example: "2026-06-01T00:00:00Z"
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -582,6 +600,68 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
|
||||||
|
/v1/auth/password:
|
||||||
|
put:
|
||||||
|
summary: Change own password (self-service)
|
||||||
|
description: |
|
||||||
|
Change the password of the currently authenticated human account.
|
||||||
|
The caller must supply the correct `current_password` to prevent
|
||||||
|
token-theft attacks: possession of a valid JWT alone is not sufficient.
|
||||||
|
|
||||||
|
On success:
|
||||||
|
- The stored Argon2id hash is replaced with the new password hash.
|
||||||
|
- All active sessions *except* the caller's current token are revoked.
|
||||||
|
- The lockout failure counter is cleared.
|
||||||
|
|
||||||
|
On failure (wrong current password):
|
||||||
|
- A login failure is recorded against the account, subject to the
|
||||||
|
same lockout rules as `POST /v1/auth/login`.
|
||||||
|
|
||||||
|
Only applies to human accounts. System accounts have no password.
|
||||||
|
operationId: changePassword
|
||||||
|
tags: [Auth]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [current_password, new_password]
|
||||||
|
properties:
|
||||||
|
current_password:
|
||||||
|
type: string
|
||||||
|
description: The account's current password (required for verification).
|
||||||
|
example: old-s3cr3t
|
||||||
|
new_password:
|
||||||
|
type: string
|
||||||
|
description: The new password. Minimum 12 characters.
|
||||||
|
example: new-s3cr3t-long
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Password changed. Other active sessions revoked.
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
description: Current password is incorrect.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
example:
|
||||||
|
error: current password is incorrect
|
||||||
|
code: unauthorized
|
||||||
|
"429":
|
||||||
|
description: Account temporarily locked due to too many failed attempts.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
example:
|
||||||
|
error: account temporarily locked
|
||||||
|
code: account_locked
|
||||||
|
|
||||||
# ── Admin ──────────────────────────────────────────────────────────────────
|
# ── Admin ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/v1/auth/totp:
|
/v1/auth/totp:
|
||||||
@@ -984,7 +1064,10 @@ paths:
|
|||||||
`token_issued`, `token_renewed`, `token_revoked`, `token_expired`,
|
`token_issued`, `token_renewed`, `token_revoked`, `token_expired`,
|
||||||
`account_created`, `account_updated`, `account_deleted`,
|
`account_created`, `account_updated`, `account_deleted`,
|
||||||
`role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`,
|
`role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`,
|
||||||
`pgcred_accessed`, `pgcred_updated`.
|
`pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`,
|
||||||
|
`pgcred_access_revoked`, `tag_added`, `tag_removed`,
|
||||||
|
`policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`,
|
||||||
|
`policy_deny`.
|
||||||
operationId: listAudit
|
operationId: listAudit
|
||||||
tags: [Admin — Audit]
|
tags: [Admin — Audit]
|
||||||
security:
|
security:
|
||||||
@@ -1118,6 +1201,57 @@ paths:
|
|||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/v1/accounts/{id}/password:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
|
||||||
|
put:
|
||||||
|
summary: Admin password reset (admin)
|
||||||
|
description: |
|
||||||
|
Reset the password for a human account without requiring the current
|
||||||
|
password. This is intended for account recovery (e.g. a user forgot
|
||||||
|
their password).
|
||||||
|
|
||||||
|
On success:
|
||||||
|
- The stored Argon2id hash is replaced with the new password hash.
|
||||||
|
- All active sessions for the target account are revoked.
|
||||||
|
|
||||||
|
Only applies to human accounts. The new password must be at least
|
||||||
|
12 characters.
|
||||||
|
operationId: adminSetPassword
|
||||||
|
tags: [Admin — Accounts]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [new_password]
|
||||||
|
properties:
|
||||||
|
new_password:
|
||||||
|
type: string
|
||||||
|
description: The new password. Minimum 12 characters.
|
||||||
|
example: new-s3cr3t-long
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Password reset. All active sessions for the account revoked.
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
/v1/policy/rules:
|
/v1/policy/rules:
|
||||||
get:
|
get:
|
||||||
summary: List policy rules (admin)
|
summary: List policy rules (admin)
|
||||||
@@ -1169,6 +1303,16 @@ paths:
|
|||||||
example: 50
|
example: 50
|
||||||
rule:
|
rule:
|
||||||
$ref: "#/components/schemas/RuleBody"
|
$ref: "#/components/schemas/RuleBody"
|
||||||
|
not_before:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Earliest activation time (RFC3339, optional).
|
||||||
|
example: "2026-04-01T00:00:00Z"
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Expiry time (RFC3339, optional).
|
||||||
|
example: "2026-06-01T00:00:00Z"
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Rule created.
|
description: Rule created.
|
||||||
@@ -1239,6 +1383,22 @@ paths:
|
|||||||
example: false
|
example: false
|
||||||
rule:
|
rule:
|
||||||
$ref: "#/components/schemas/RuleBody"
|
$ref: "#/components/schemas/RuleBody"
|
||||||
|
not_before:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Set earliest activation time (RFC3339).
|
||||||
|
example: "2026-04-01T00:00:00Z"
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Set expiry time (RFC3339).
|
||||||
|
example: "2026-06-01T00:00:00Z"
|
||||||
|
clear_not_before:
|
||||||
|
type: boolean
|
||||||
|
description: Set to true to remove not_before constraint.
|
||||||
|
clear_expires_at:
|
||||||
|
type: boolean
|
||||||
|
description: Set to true to remove expires_at constraint.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Updated rule.
|
description: Updated rule.
|
||||||
|
|||||||
@@ -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 { list-style: none; display: flex; gap: 1rem; margin: 0; padding: 0; }
|
||||||
.nav-links a { color: #ccc; text-decoration: none; }
|
.nav-links a { color: #ccc; text-decoration: none; }
|
||||||
.nav-links a:hover { color: #fff; }
|
.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 { 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-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
|
||||||
.btn-primary { background: #0d6efd; color: #fff; }
|
.btn-primary { background: #0d6efd; color: #fff; }
|
||||||
|
|||||||
@@ -44,4 +44,15 @@
|
|||||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
|
||||||
<div id="tags-editor">{{template "tags_editor" .}}</div>
|
<div id="tags-editor">{{template "tags_editor" .}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{if eq (string .Account.AccountType) "human"}}
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Reset Password</h2>
|
||||||
|
<p class="text-muted text-small" style="margin-bottom:.75rem">
|
||||||
|
Set a new password for this account. All active sessions will be revoked.
|
||||||
|
</p>
|
||||||
|
<div id="password-reset-section">
|
||||||
|
{{template "password_reset_form" .}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
<li><a href="/accounts">Accounts</a></li>
|
<li><a href="/accounts">Accounts</a></li>
|
||||||
<li><a href="/audit">Audit</a></li>
|
<li><a href="/audit">Audit</a></li>
|
||||||
<li><a href="/policies">Policies</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>
|
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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}}
|
{{else}}
|
||||||
<p class="text-muted text-small" style="margin-bottom:1rem">No credentials stored.</p>
|
<p class="text-muted text-small" style="margin-bottom:1rem">No credentials stored.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* 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"
|
<form hx-put="/accounts/{{.Account.UUID}}/pgcreds"
|
||||||
hx-target="#pgcreds-section" hx-swap="outerHTML">
|
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">
|
<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
|
<input class="form-control" type="text" name="host" placeholder="Host" required
|
||||||
value="{{if .PGCred}}{{.PGCred.PGHost}}{{end}}">
|
{{if .PGCred}}value="{{.PGCred.PGHost}}"{{end}}>
|
||||||
<input class="form-control" type="number" name="port" placeholder="Port (5432)"
|
<input class="form-control" type="number" name="port" placeholder="Port (5432)"
|
||||||
min="1" max="65535"
|
min="1" max="65535"
|
||||||
value="{{if .PGCred}}{{.PGCred.PGPort}}{{end}}">
|
{{if .PGCred}}value="{{.PGCred.PGPort}}"{{end}}>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
<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
|
<input class="form-control" type="text" name="database" placeholder="Database" required
|
||||||
value="{{if .PGCred}}{{.PGCred.PGDatabase}}{{end}}">
|
{{if .PGCred}}value="{{.PGCred.PGDatabase}}"{{end}}>
|
||||||
<input class="form-control" type="text" name="username" placeholder="Username" required
|
<input class="form-control" type="text" name="username" placeholder="Username" required
|
||||||
value="{{if .PGCred}}{{.PGCred.PGUsername}}{{end}}">
|
{{if .PGCred}}value="{{.PGCred.PGUsername}}"{{end}}>
|
||||||
</div>
|
</div>
|
||||||
<input class="form-control" type="password" name="password"
|
<input class="form-control" type="password" name="password"
|
||||||
placeholder="Password (required to update)" required
|
placeholder="Password (required)" required
|
||||||
style="margin-bottom:.5rem">
|
style="margin-bottom:.5rem">
|
||||||
<button class="btn btn-sm btn-secondary" type="submit">Save Credentials</button>
|
<button class="btn btn-sm btn-secondary" type="submit">Save Credentials</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -72,6 +72,16 @@
|
|||||||
Owner must match subject (self-service rules only)
|
Owner must match subject (self-service rules only)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||||
|
<div>
|
||||||
|
<label class="text-small text-muted">Not before (UTC, optional)</label>
|
||||||
|
<input class="form-control" type="datetime-local" name="not_before" style="font-size:.85rem">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-small text-muted">Expires at (UTC, optional)</label>
|
||||||
|
<input class="form-control" type="datetime-local" name="expires_at" style="font-size:.85rem">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
|
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
<td class="text-small">{{.Priority}}</td>
|
<td class="text-small">{{.Priority}}</td>
|
||||||
<td>
|
<td>
|
||||||
<strong>{{.Description}}</strong>
|
<strong>{{.Description}}</strong>
|
||||||
|
{{if .IsExpired}}<span class="badge" style="background:#dc2626;color:#fff;margin-left:.4rem">expired</span>{{end}}
|
||||||
|
{{if .IsPending}}<span class="badge" style="background:#d97706;color:#fff;margin-left:.4rem">scheduled</span>{{end}}
|
||||||
|
{{if or .NotBefore .ExpiresAt}}
|
||||||
|
<div class="text-small text-muted" style="margin-top:.2rem">
|
||||||
|
{{if .NotBefore}}Not before: {{.NotBefore}}{{end}}
|
||||||
|
{{if and .NotBefore .ExpiresAt}} · {{end}}
|
||||||
|
{{if .ExpiresAt}}Expires: {{.ExpiresAt}}{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
<details style="margin-top:.25rem">
|
<details style="margin-top:.25rem">
|
||||||
<summary class="text-small text-muted" style="cursor:pointer">Show rule JSON</summary>
|
<summary class="text-small text-muted" style="cursor:pointer">Show rule JSON</summary>
|
||||||
<pre style="font-size:.75rem;background:#f8fafc;padding:.5rem;border-radius:4px;overflow:auto;margin-top:.25rem">{{.RuleJSON}}</pre>
|
<pre style="font-size:.75rem;background:#f8fafc;padding:.5rem;border-radius:4px;overflow:auto;margin-top:.25rem">{{.RuleJSON}}</pre>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<div class="login-wrapper">
|
<div class="login-wrapper">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<div class="brand-heading">MCIAS</div>
|
<div class="brand-heading">MCIAS</div>
|
||||||
|
<div class="brand-subtitle">Metacircular Identity & Access System</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||||
<form id="login-form" method="POST" action="/login"
|
<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