Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9657f18784 | |||
| d4e8ef90ee | |||
| d6cc82755d | |||
| 0d38bbae00 | |||
| 23a27be57e | |||
| b1b52000c4 | |||
| d87b4b4042 | |||
| 5c242f8abb | |||
| 1121b7d4fd | |||
| 2a85d4bf2b | |||
| 8f09e0e81a | |||
| 7e5fc9f111 | |||
| cf02b8e2d8 | |||
| fe780bf873 | |||
| cb96650e59 | |||
| bef5a3269d | |||
| 6191c5e00a | |||
| fa45836612 | |||
| 0bc7943d8f | |||
| 97ba7ab74c | |||
| 582645f9d6 | |||
| 8840317cce | |||
| 482300b8b1 | |||
| 8545473703 | |||
| 3b17f7f70b | |||
| eef7d1bc1a | |||
| d7d7ba21d9 | |||
| 4d3d438253 | |||
| 7cc2c86300 | |||
| 51a5277062 | |||
| d3b63b1f87 | |||
| 70e4f715f7 | |||
| 3f09d5eb4f | |||
| 036a0b8be4 | |||
| 30fc3470fa | |||
| 586d4e3355 | |||
| 394a9fb754 | |||
| 1c16354725 | |||
| 89f78a38dd | |||
| 4d6c5cb67c | |||
| f880bbb6de | |||
| d3d656a23f | |||
| 28bc33a96d | |||
| 98ed858c67 | |||
| 35f27b7c4f |
@@ -5,7 +5,27 @@
|
|||||||
"Bash(golangci-lint run:*)",
|
"Bash(golangci-lint run:*)",
|
||||||
"Bash(git restore:*)",
|
"Bash(git restore:*)",
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)"
|
"Bash(git commit:*)",
|
||||||
|
"Bash(grep -n \"handleAdminResetPassword\\\\|handleChangePassword\" /Users/kyle/src/mcias/internal/ui/*.go)",
|
||||||
|
"Bash(go build:*)",
|
||||||
|
"Bash(sqlite3 /Users/kyle/src/mcias/run/mcias.db \"PRAGMA table_info\\(policy_rules\\);\" 2>&1)",
|
||||||
|
"Bash(sqlite3 /Users/kyle/src/mcias/run/mcias.db \"SELECT * FROM schema_version;\" 2>&1; sqlite3 /Users/kyle/src/mcias/run/mcias.db \"SELECT * FROM schema_migrations;\" 2>&1)",
|
||||||
|
"Bash(go run:*)",
|
||||||
|
"Bash(go list:*)",
|
||||||
|
"Bash(go vet:*)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "go build ./... 2>&1 | head -20"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
.claude/skills/checkpoint.zip
Normal file
BIN
.claude/skills/checkpoint.zip
Normal file
Binary file not shown.
8
.claude/skills/checkpoint/SKILL.md
Normal file
8
.claude/skills/checkpoint/SKILL.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Checkpoint Skill
|
||||||
|
|
||||||
|
1. Run `go build ./...` abort if errors
|
||||||
|
2. Run `go test ./...` abort if failures
|
||||||
|
3. Run `go vet ./...`
|
||||||
|
4. Run `git add -A && git status` show user what will be committed
|
||||||
|
5. Generate an appropriate commit message based on your instructions.
|
||||||
|
6. Run `git commit -m "<message>"` and verify with `git log -1`
|
||||||
8
.claude/tasks/security-audit/TASK.md
Normal file
8
.claude/tasks/security-audit/TASK.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Run a full security audit of this Go codebase. For each finding rated
|
||||||
|
HIGH or CRITICAL: spawn a sub-agent using Task to implement the fix
|
||||||
|
across all affected files (models, handlers, migrations, templates,
|
||||||
|
tests). Each sub-agent must: 1) write a failing test that reproduces the
|
||||||
|
vulnerability, 2) implement the fix, 3) run `go test ./...` and `go vet
|
||||||
|
./...` in a loop until all pass, 4) commit with a message referencing
|
||||||
|
the finding ID. After all sub-agents complete, generate a summary of
|
||||||
|
what was fixed and what needs manual review.
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -34,5 +34,10 @@ clients/python/*.egg-info/
|
|||||||
clients/lisp/**/*.fasl
|
clients/lisp/**/*.fasl
|
||||||
|
|
||||||
# manual testing
|
# manual testing
|
||||||
/run/
|
run/
|
||||||
.env
|
.env
|
||||||
|
/cmd/mciasctl/mciasctl
|
||||||
|
/cmd/mciasdb/mciasdb
|
||||||
|
/cmd/mciasgrpcctl/mciasgrpcctl
|
||||||
|
/cmd/mciassrv/mciassrv
|
||||||
|
|
||||||
|
|||||||
100
ARCHITECTURE.md
100
ARCHITECTURE.md
@@ -15,7 +15,7 @@ parties that delegate authentication decisions to it.
|
|||||||
### Components
|
### Components
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────┐
|
||||||
│ MCIAS Server (mciassrv) │
|
│ MCIAS Server (mciassrv) │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
|
||||||
│ │ Auth │ │ Token │ │ Account / Role │ │
|
│ │ Auth │ │ Token │ │ Account / Role │ │
|
||||||
@@ -26,25 +26,35 @@ parties that delegate authentication decisions to it.
|
|||||||
│ ┌─────────▼──────────┐ │
|
│ ┌─────────▼──────────┐ │
|
||||||
│ │ SQLite Database │ │
|
│ │ SQLite Database │ │
|
||||||
│ └────────────────────┘ │
|
│ └────────────────────┘ │
|
||||||
└────────────────────────────────────────────────────┘
|
│ │
|
||||||
▲ ▲ ▲
|
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||||
│ HTTPS/REST │ HTTPS/REST │ direct file I/O
|
│ │ REST listener │ │ gRPC listener │ │
|
||||||
│ │ │
|
│ │ (net/http) │ │ (google.golang.org/ │ │
|
||||||
┌──────┴──────┐ ┌────┴─────┐ ┌──────┴──────┐
|
│ │ :8443 │ │ grpc) :9443 │ │
|
||||||
│ Personal │ │ mciasctl │ │ mciasdb │
|
│ └──────────────────┘ └──────────────────────┘ │
|
||||||
│ Apps │ │ (admin │ │ (DB tool) │
|
└──────────────────────────────────────────────────────────┘
|
||||||
└─────────────┘ │ CLI) │ └─────────────┘
|
▲ ▲ ▲ ▲
|
||||||
└──────────┘
|
│ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O
|
||||||
|
│ │ │ │
|
||||||
|
┌────┴──────┐ ┌────┴─────┐ ┌─────┴────────┐ ┌───┴────────┐
|
||||||
|
│ Personal │ │ mciasctl │ │ mciasgrpcctl │ │ mciasdb │
|
||||||
|
│ Apps │ │ (admin │ │ (gRPC admin │ │ (DB tool) │
|
||||||
|
└───────────┘ │ CLI) │ │ CLI) │ └────────────┘
|
||||||
|
└──────────┘ └──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**mciassrv** — The authentication server. Exposes a REST API over HTTPS/TLS.
|
**mciassrv** — The authentication server. Exposes a REST API and gRPC API over
|
||||||
Handles login, token issuance, token validation, token renewal, and token
|
HTTPS/TLS (dual-stack; see §17). Handles login, token issuance, token
|
||||||
revocation.
|
validation, token renewal, and token revocation.
|
||||||
|
|
||||||
**mciasctl** — The administrator CLI. Communicates with mciassrv's REST API
|
**mciasctl** — The administrator CLI. Communicates with mciassrv's REST API
|
||||||
using an admin JWT. Creates/manages human accounts, system accounts, roles,
|
using an admin JWT. Creates/manages human accounts, system accounts, roles,
|
||||||
and Postgres credential records.
|
and Postgres credential records.
|
||||||
|
|
||||||
|
**mciasgrpcctl** — The gRPC administrator CLI. Mirrors mciasctl's subcommands
|
||||||
|
but communicates over gRPC/TLS instead of REST. Both CLIs can coexist; neither
|
||||||
|
depends on the other.
|
||||||
|
|
||||||
**mciasdb** — The database maintenance tool. Operates directly on the SQLite
|
**mciasdb** — The database maintenance tool. Operates directly on the SQLite
|
||||||
file, bypassing the server API. Intended for break-glass recovery, offline
|
file, bypassing the server API. Intended for break-glass recovery, offline
|
||||||
inspection, schema verification, and maintenance tasks that cannot be
|
inspection, schema verification, and maintenance tasks that cannot be
|
||||||
@@ -127,13 +137,21 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
|
|||||||
|
|
||||||
### Roles
|
### Roles
|
||||||
|
|
||||||
Roles are simple string labels stored in the `account_roles` table.
|
Roles are simple string labels stored in the `account_roles` table. Only
|
||||||
|
compile-time allowlisted role names are accepted; attempting to grant an
|
||||||
|
unknown role returns an error (prevents typos like "admim" from silently
|
||||||
|
creating a useless role).
|
||||||
|
|
||||||
Reserved roles:
|
Compile-time allowlisted roles:
|
||||||
- `admin` — superuser; can manage all accounts, tokens, and credentials
|
- `admin` — superuser; can manage all accounts, tokens, and credentials
|
||||||
|
- `user` — standard user role
|
||||||
|
- `guest` — limited read-only access
|
||||||
|
- `viewer` — read-only access
|
||||||
|
- `editor` — create/modify access
|
||||||
|
- `commenter` — comment/annotate access
|
||||||
- Any role named identically to a system account — grants that human account
|
- Any role named identically to a system account — grants that human account
|
||||||
the ability to issue/revoke tokens and retrieve Postgres credentials for that
|
the ability to issue/revoke tokens and retrieve Postgres credentials for that
|
||||||
system account
|
system account (via policy rules, not the allowlist)
|
||||||
|
|
||||||
Role assignment requires admin privileges.
|
Role assignment requires admin privileges.
|
||||||
|
|
||||||
@@ -340,7 +358,6 @@ 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
|
||||||
|
|
||||||
@@ -372,7 +389,9 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/v1/accounts/{id}/roles` | admin JWT | List roles for account |
|
| GET | `/v1/accounts/{id}/roles` | admin JWT | List roles for account |
|
||||||
| PUT | `/v1/accounts/{id}/roles` | admin JWT | Replace role set |
|
| PUT | `/v1/accounts/{id}/roles` | admin JWT | Replace role set (atomic) |
|
||||||
|
| POST | `/v1/accounts/{id}/roles` | admin JWT | Grant a single role |
|
||||||
|
| DELETE | `/v1/accounts/{id}/roles/{role}` | admin JWT | Revoke a single role |
|
||||||
|
|
||||||
### TOTP Endpoints
|
### TOTP Endpoints
|
||||||
|
|
||||||
@@ -412,11 +431,23 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/v1/audit` | admin JWT | List audit log events |
|
| GET | `/v1/audit` | admin JWT | List audit log events |
|
||||||
|
|
||||||
|
### Vault Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/v1/vault/status` | none | Returns `{"sealed": bool}`; always accessible |
|
||||||
|
| POST | `/v1/vault/unseal` | none | Accept passphrase, derive key, unseal (rate-limited 3/s burst 5) |
|
||||||
|
| POST | `/v1/vault/seal` | admin JWT | Zero key material and seal the vault; invalidates all JWTs |
|
||||||
|
|
||||||
|
When the vault is sealed, all endpoints except health, vault status, and unseal
|
||||||
|
return 503 with `{"error":"vault is sealed","code":"vault_sealed"}`. The UI
|
||||||
|
redirects non-exempt paths to `/unseal`.
|
||||||
|
|
||||||
### Admin / Server Endpoints
|
### Admin / Server Endpoints
|
||||||
|
|
||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/v1/health` | none | Health check |
|
| GET | `/v1/health` | none | Health check — returns `{"status":"ok"}` or `{"status":"sealed"}` |
|
||||||
| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) |
|
| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) |
|
||||||
|
|
||||||
### Web Management UI
|
### Web Management UI
|
||||||
@@ -439,6 +470,7 @@ cookie pattern (`mcias_csrf`).
|
|||||||
|
|
||||||
| Path | Description |
|
| Path | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `/unseal` | Passphrase form to unseal the vault; shown for all paths when sealed |
|
||||||
| `/login` | Username/password login with optional TOTP step |
|
| `/login` | Username/password login with optional TOTP step |
|
||||||
| `/` | Dashboard (account summary) |
|
| `/` | Dashboard (account summary) |
|
||||||
| `/accounts` | Account list |
|
| `/accounts` | Account list |
|
||||||
@@ -446,6 +478,7 @@ cookie pattern (`mcias_csrf`).
|
|||||||
| `/pgcreds` | Postgres credentials list (owned + granted) with create form |
|
| `/pgcreds` | Postgres credentials list (owned + granted) with create form |
|
||||||
| `/policies` | Policy rules management — create, enable/disable, delete |
|
| `/policies` | Policy rules management — create, enable/disable, delete |
|
||||||
| `/audit` | Audit log viewer |
|
| `/audit` | Audit log viewer |
|
||||||
|
| `/profile` | User profile — self-service password change (any authenticated user) |
|
||||||
|
|
||||||
**HTMX fragments:** Mutating operations (role updates, tag edits, credential
|
**HTMX fragments:** Mutating operations (role updates, tag edits, credential
|
||||||
saves, policy toggles, access grants) use HTMX partial-page updates for a
|
saves, policy toggles, access grants) use HTMX partial-page updates for a
|
||||||
@@ -490,6 +523,9 @@ CREATE TABLE accounts (
|
|||||||
-- AES-256-GCM encrypted TOTP secret; NULL if not enrolled
|
-- AES-256-GCM encrypted TOTP secret; NULL if not enrolled
|
||||||
totp_secret_enc BLOB,
|
totp_secret_enc BLOB,
|
||||||
totp_secret_nonce BLOB,
|
totp_secret_nonce BLOB,
|
||||||
|
-- Last accepted TOTP counter value; prevents replay attacks within the
|
||||||
|
-- ±1 time-step window (RFC 6238 §5.2). NULL = no code accepted yet.
|
||||||
|
last_totp_counter INTEGER DEFAULT NULL,
|
||||||
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')),
|
||||||
deleted_at TEXT
|
deleted_at TEXT
|
||||||
@@ -665,13 +701,16 @@ listen_addr = "0.0.0.0:8443"
|
|||||||
grpc_addr = "0.0.0.0:9443" # optional; omit to disable gRPC
|
grpc_addr = "0.0.0.0:9443" # optional; omit to disable gRPC
|
||||||
tls_cert = "/etc/mcias/server.crt"
|
tls_cert = "/etc/mcias/server.crt"
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/etc/mcias/server.key"
|
||||||
|
# trusted_proxy = "127.0.0.1" # optional; IP of reverse proxy — when set,
|
||||||
|
# X-Forwarded-For is trusted only from this IP
|
||||||
|
# for rate limiting and audit log IP extraction
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
path = "/var/lib/mcias/mcias.db"
|
path = "/var/lib/mcias/mcias.db"
|
||||||
|
|
||||||
[tokens]
|
[tokens]
|
||||||
issuer = "https://auth.example.com"
|
issuer = "https://auth.example.com"
|
||||||
default_expiry = "720h" # 30 days
|
default_expiry = "168h" # 7 days
|
||||||
admin_expiry = "8h"
|
admin_expiry = "8h"
|
||||||
service_expiry = "8760h" # 365 days
|
service_expiry = "8760h" # 365 days
|
||||||
|
|
||||||
@@ -711,7 +750,8 @@ mcias/
|
|||||||
│ ├── policy/ # in-process authorization policy engine (§20)
|
│ ├── policy/ # in-process authorization policy engine (§20)
|
||||||
│ ├── server/ # HTTP handlers, router setup
|
│ ├── server/ # HTTP handlers, router setup
|
||||||
│ ├── token/ # JWT issuance, validation, revocation
|
│ ├── token/ # JWT issuance, validation, revocation
|
||||||
│ └── ui/ # web UI context, CSRF, session, template handlers
|
│ ├── ui/ # web UI context, CSRF, session, template handlers
|
||||||
|
│ └── validate/ # input validation helpers (username, password strength)
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── static/ # CSS and static assets
|
│ ├── static/ # CSS and static assets
|
||||||
│ └── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
│ └── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
||||||
@@ -761,12 +801,17 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into
|
|||||||
| `totp_removed` | TOTP removed from account |
|
| `totp_removed` | TOTP removed from account |
|
||||||
| `pgcred_accessed` | Postgres credentials retrieved |
|
| `pgcred_accessed` | Postgres credentials retrieved |
|
||||||
| `pgcred_updated` | Postgres credentials stored/updated |
|
| `pgcred_updated` | Postgres credentials stored/updated |
|
||||||
|
| `pgcred_access_granted` | Read access to PG credentials granted to another account |
|
||||||
|
| `pgcred_access_revoked` | Read access to PG credentials revoked from an account |
|
||||||
|
| `password_changed` | Account password changed (self-service or admin reset) |
|
||||||
| `tag_added` | Tag added to account |
|
| `tag_added` | Tag added to account |
|
||||||
| `tag_removed` | Tag removed from account |
|
| `tag_removed` | Tag removed from account |
|
||||||
| `policy_rule_created` | Policy rule created |
|
| `policy_rule_created` | Policy rule created |
|
||||||
| `policy_rule_updated` | Policy rule updated (priority, enabled, description) |
|
| `policy_rule_updated` | Policy rule updated (priority, enabled, description) |
|
||||||
| `policy_rule_deleted` | Policy rule deleted |
|
| `policy_rule_deleted` | Policy rule deleted |
|
||||||
| `policy_deny` | Policy engine denied a request (logged for every explicit deny) |
|
| `policy_deny` | Policy engine denied a request (logged for every explicit deny) |
|
||||||
|
| `vault_unsealed` | Vault unsealed via REST API or web UI; details include `source` (api\|ui) and `ip` |
|
||||||
|
| `vault_sealed` | Vault sealed via REST API; details include actor ID, `source`, and `ip` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -838,6 +883,7 @@ mciasdb --config PATH <subcommand> [flags]
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `mciasdb schema verify` | Open DB, run migrations in dry-run mode, report version |
|
| `mciasdb schema verify` | Open DB, run migrations in dry-run mode, report version |
|
||||||
| `mciasdb schema migrate` | Apply any pending migrations and exit |
|
| `mciasdb schema migrate` | Apply any pending migrations and exit |
|
||||||
|
| `mciasdb schema force --version N` | Force schema version (clears dirty state); break-glass recovery |
|
||||||
| `mciasdb prune tokens` | Delete expired rows from `token_revocation` and `system_tokens` |
|
| `mciasdb prune tokens` | Delete expired rows from `token_revocation` and `system_tokens` |
|
||||||
|
|
||||||
**Account management (offline):**
|
**Account management (offline):**
|
||||||
@@ -943,7 +989,7 @@ in `proto/generate.go` using `protoc-gen-go` and `protoc-gen-go-grpc`.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `AuthService` | `Login`, `Logout`, `RenewToken`, `EnrollTOTP`, `ConfirmTOTP`, `RemoveTOTP` |
|
| `AuthService` | `Login`, `Logout`, `RenewToken`, `EnrollTOTP`, `ConfirmTOTP`, `RemoveTOTP` |
|
||||||
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
|
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
|
||||||
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles` |
|
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
|
||||||
| `CredentialService` | `GetPGCreds`, `SetPGCreds` |
|
| `CredentialService` | `GetPGCreds`, `SetPGCreds` |
|
||||||
| `AdminService` | `Health`, `GetPublicKey` |
|
| `AdminService` | `Health`, `GetPublicKey` |
|
||||||
|
|
||||||
@@ -979,9 +1025,12 @@ details.
|
|||||||
### Interceptor Chain
|
### Interceptor Chain
|
||||||
|
|
||||||
```
|
```
|
||||||
[Request Logger] → [Auth Interceptor] → [Rate Limiter] → [Handler]
|
[Sealed Interceptor] → [Request Logger] → [Auth Interceptor] → [Rate Limiter] → [Handler]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **Sealed Interceptor**: first in chain; blocks all RPCs with
|
||||||
|
`codes.Unavailable` ("vault sealed") when the vault is sealed, except
|
||||||
|
`AdminService/Health` which returns the sealed status.
|
||||||
- **Request Logger**: logs method, peer IP, status code, duration; never logs
|
- **Request Logger**: logs method, peer IP, status code, duration; never logs
|
||||||
the `authorization` metadata value.
|
the `authorization` metadata value.
|
||||||
- **Auth Interceptor**: validates Bearer JWT, injects claims. Public RPCs
|
- **Auth Interceptor**: validates Bearer JWT, injects claims. Public RPCs
|
||||||
@@ -1376,6 +1425,7 @@ const (
|
|||||||
ActionRemoveTOTP Action = "totp:remove" // admin
|
ActionRemoveTOTP Action = "totp:remove" // admin
|
||||||
ActionLogin Action = "auth:login" // public
|
ActionLogin Action = "auth:login" // public
|
||||||
ActionLogout Action = "auth:logout" // self-service
|
ActionLogout Action = "auth:logout" // self-service
|
||||||
|
ActionChangePassword Action = "auth:change_password" // self-service
|
||||||
ActionListRules Action = "policy:list"
|
ActionListRules Action = "policy:list"
|
||||||
ActionManageRules Action = "policy:manage"
|
ActionManageRules Action = "policy:manage"
|
||||||
|
|
||||||
@@ -1476,8 +1526,10 @@ at the same priority level.
|
|||||||
|
|
||||||
```
|
```
|
||||||
Priority 0, Allow: roles=[admin], actions=<all> — admin wildcard
|
Priority 0, Allow: roles=[admin], actions=<all> — admin wildcard
|
||||||
Priority 0, Allow: actions=[tokens:renew, auth:logout] — self-service logout/renew
|
Priority 0, Allow: actions=[auth:logout, tokens:renew] — self-service logout/renew
|
||||||
Priority 0, Allow: actions=[totp:enroll] — self-service TOTP enrollment
|
Priority 0, Allow: actions=[totp:enroll] — self-service TOTP enrollment
|
||||||
|
Priority 0, Allow: accountTypes=[human], actions=[auth:change_password]
|
||||||
|
— self-service password change
|
||||||
Priority 0, Allow: accountTypes=[system], actions=[pgcreds:read],
|
Priority 0, Allow: accountTypes=[system], actions=[pgcreds:read],
|
||||||
resourceType=pgcreds, ownerMatchesSubject=true
|
resourceType=pgcreds, ownerMatchesSubject=true
|
||||||
— system account reads own creds
|
— system account reads own creds
|
||||||
|
|||||||
473
AUDIT.md
473
AUDIT.md
@@ -1,258 +1,349 @@
|
|||||||
# MCIAS Security Audit Report
|
# MCIAS Security Audit Report
|
||||||
|
|
||||||
**Scope:** Full codebase review of `git.wntrmute.dev/kyle/mcias` (commit `4596ea0`) aka mcias.
|
**Date:** 2026-03-14 (updated — penetration test round 4)
|
||||||
**Auditor:** Comprehensive source review of all Go source files, protobuf definitions, Dockerfile, systemd unit, and client libraries
|
**Original audit date:** 2026-03-13
|
||||||
**Classification:** Findings rated as **CRITICAL**, **HIGH**, **MEDIUM**, **LOW**, or **INFORMATIONAL**
|
**Auditor role:** Penetration tester (code review + live instance probing)
|
||||||
|
**Scope:** Full codebase and running instance at mcias.metacircular.net:8443 — authentication flows, token lifecycle, cryptography, database layer, REST/gRPC/UI servers, authorization, headers, and operational security.
|
||||||
|
**Methodology:** Static code analysis, live HTTP probing, architectural review.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
MCIAS is well-engineered for a security-critical system. The code demonstrates strong awareness of common vulnerability classes: JWT algorithm confusion is properly mitigated, constant-time comparisons are used throughout, timing-uniform dummy operations prevent user enumeration, and credential material is systematically excluded from logs and API responses. The cryptographic choices are sound and current.
|
MCIAS has a strong security posture. All findings from the first three audit rounds (CRIT-01/CRIT-02, DEF-01 through DEF-10, and SEC-01 through SEC-12) have been remediated. The cryptographic foundations are sound, JWT validation is correct, SQL injection is not possible, XSS is prevented by Go's html/template auto-escaping, and CSRF protection is well-implemented.
|
||||||
|
|
||||||
That said, I identified **16 findings** ranging from medium-severity design issues to low-severity hardening opportunities. There are no critical vulnerabilities that would allow immediate remote compromise, but several medium-severity items warrant remediation before production deployment.
|
A fourth-round penetration test (PEN-01 through PEN-07) against the live instance at `mcias.metacircular.net:8443` identified 7 new findings: 2 medium, 2 low, and 3 informational. **Unauthorized access was not achieved** — the system's defense-in-depth held. See the open findings table below for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FINDINGS
|
## Open Findings (PEN-01 through PEN-07)
|
||||||
|
|
||||||
### F-01: TOTP Enrollment Sets `totp_required=1` Before Confirmation (MEDIUM)
|
Identified during the fourth-round penetration test on 2026-03-14 against the live instance at `mcias.metacircular.net:8443` and the source code at the same commit.
|
||||||
|
|
||||||
**Location:** `internal/db/accounts.go:131-141`, `internal/server/server.go:651-658`
|
| ID | Severity | Finding | Status |
|
||||||
|
|----|----------|---------|--------|
|
||||||
|
| PEN-01 | Medium | `extractBearerFromRequest` does not validate "Bearer " prefix | **Fixed** — uses `strings.SplitN` + `strings.EqualFold` prefix validation, matching middleware implementation |
|
||||||
|
| PEN-02 | Medium | Security headers missing from live instance responses | **Fixed** — redeployed; all headers confirmed present on live instance 2026-03-15 |
|
||||||
|
| PEN-03 | Low | CSP `unsafe-inline` on `/docs` Swagger UI endpoint | **Accepted** — self-hosting Swagger UI (1.7 MB) to enable nonces adds complexity disproportionate to the risk; inline script is static, no user-controlled input |
|
||||||
|
| PEN-04 | Info | OpenAPI spec publicly accessible without authentication | **Accepted** — intentional; public access required for agents and external developers |
|
||||||
|
| PEN-05 | Info | gRPC port 9443 publicly accessible | **Accepted** — intentional; required for server-to-server access by external systems |
|
||||||
|
| PEN-06 | Low | REST login increments lockout counter for missing TOTP code | **Fixed** — `RecordLoginFailure` removed from TOTP-missing branch; `TestTOTPMissingDoesNotIncrementLockout` added |
|
||||||
|
| PEN-07 | Info | Rate limiter is per-IP only, no per-account limiting | **Accepted** — per-account hard lockout (10 failures/15 min) already covers distributed brute-force; per-account rate limiting adds marginal benefit at this scale |
|
||||||
|
|
||||||
`SetTOTP` unconditionally sets `totp_required = 1`. This means during the enrollment phase (before the user has confirmed), the TOTP requirement flag is already true. If the user abandons enrollment after calling `/v1/auth/totp/enroll` but before calling `/confirm`, the account is now locked: TOTP is "required" but the user was never shown a QR code they can use to generate valid codes.
|
<details>
|
||||||
|
<summary>Finding descriptions (click to expand)</summary>
|
||||||
|
|
||||||
**Recommendation:** Add a separate `StorePendingTOTP(accountID, secretEnc, secretNonce)` that writes the encrypted secret but leaves `totp_required = 0`. Only set `totp_required = 1` in the confirm handler via the existing `SetTOTP`. Alternatively, add a `ClearTOTP` recovery step to the enrollment flow on timeout/failure.
|
### PEN-01 — `extractBearerFromRequest` Does Not Validate "Bearer " Prefix (Medium)
|
||||||
|
|
||||||
---
|
**File:** `internal/server/server.go` (lines 1414–1425)
|
||||||
|
|
||||||
### F-02: Password Embedded in HTML Hidden Fields During TOTP Step (MEDIUM)
|
The server-level `extractBearerFromRequest` function extracts the token by slicing the `Authorization` header at offset 7 (`len("Bearer ")`) without first verifying that the header actually starts with `"Bearer "`. Any 8+ character `Authorization` value is accepted — e.g., `Authorization: XXXXXXXX` would extract `X` as the token string.
|
||||||
|
|
||||||
**Location:** `internal/ui/handlers_auth.go:74-84`
|
```go
|
||||||
|
// Current (vulnerable):
|
||||||
During the TOTP step of UI login, the plaintext password is embedded as a hidden form field so it can be re-verified on the second POST. This means:
|
if len(auth) <= len(prefix) {
|
||||||
1. The password exists in the DOM and is accessible to any browser extension or XSS-via-extension vector.
|
return "", fmt.Errorf("malformed Authorization header")
|
||||||
2. The password is sent over the wire a second time (TLS protects transit, but it doubles the exposure window).
|
}
|
||||||
3. Browser form autofill or "view source" can reveal it.
|
return auth[len(prefix):], nil // no prefix check
|
||||||
|
|
||||||
**Recommendation:** On successful password verification in the first step, issue a short-lived (e.g., 60-second), single-use, server-side nonce that represents "password verified for user X". Store this nonce in the DB or an in-memory cache. The TOTP confirmation step presents this nonce instead of the password. The server validates the nonce + TOTP code and issues the session token.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-03: Token Renewal Is Not Atomic — Race Window Between Revoke and Track (MEDIUM)
|
|
||||||
|
|
||||||
**Location:** `internal/server/server.go:281-289`, `internal/grpcserver/auth.go:148-155`
|
|
||||||
|
|
||||||
The token renewal flow revokes the old token and tracks the new one as separate operations. The code comments acknowledge "atomically is not possible in SQLite without a transaction." However, SQLite does support transactions, and both operations use the same `*db.DB` instance with `MaxOpenConns(1)`. If the revoke succeeds but `TrackToken` fails, the user's old token is revoked but no new token is tracked, leaving them in a broken state.
|
|
||||||
|
|
||||||
**Recommendation:** Wrap the revoke-old + track-new pair in a single SQLite transaction. Add a method like `db.RenewToken(oldJTI, reason, newJTI, accountID, issuedAt, expiresAt)` that performs both in one `tx`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-04: Rate Limiter Not Applied to REST Login Endpoint (MEDIUM)
|
|
||||||
|
|
||||||
**Location:** `internal/server/server.go:96-100`
|
|
||||||
|
|
||||||
Despite the comment saying "login-path rate limiting," the REST server applies `RequestLogger` as global middleware but **does not apply the `RateLimit` middleware at all**. The rate limiter is imported but never wired into the handler chain for the REST server. The `/v1/auth/login` endpoint has no rate limiting on the REST side.
|
|
||||||
|
|
||||||
In contrast, the gRPC server correctly applies `rateLimitInterceptor` in its interceptor chain (applied to all RPCs).
|
|
||||||
|
|
||||||
**Recommendation:** Apply `middleware.RateLimit(...)` to at minimum the `/v1/auth/login` and `/v1/token/validate` routes in the REST server. Consider a more restrictive rate for login (e.g., 5/min) versus general API endpoints.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-05: No `nbf` (Not Before) Claim in Issued JWTs (LOW)
|
|
||||||
|
|
||||||
**Location:** `internal/token/token.go:68-99`
|
|
||||||
|
|
||||||
Tokens are issued with `iss`, `sub`, `iat`, `exp`, and `jti` but not `nbf` (Not Before). While the architecture document states `nbf` is validated "if present," it is never set during issuance. Setting `nbf = iat` is a defense-in-depth measure that prevents premature token use if there is any clock skew between systems, and ensures relying parties that validate `nbf` don't reject MCIAS tokens.
|
|
||||||
|
|
||||||
**Recommendation:** Set `NotBefore: jwt.NewNumericDate(now)` in the `jwtClaims.RegisteredClaims`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-06: `HasRole` Uses Non-Constant-Time String Comparison (LOW)
|
|
||||||
|
|
||||||
**Location:** `internal/token/token.go:174-181`
|
|
||||||
|
|
||||||
`HasRole` uses plain `==` string comparison for role names. Role names are not secret material, and this is authorization (not authentication), so this is low severity. However, if role names ever contained sensitive information, this could leak information via timing. Given the project's stated principle of using constant-time comparisons "wherever token or credential equality is checked," this is a minor inconsistency.
|
|
||||||
|
|
||||||
**Recommendation:** Acceptable as-is since role names are public knowledge. Document the decision.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-07: Dummy Argon2 Hash Uses Hardcoded Invalid PHC String (LOW)
|
|
||||||
|
|
||||||
**Location:** `internal/server/server.go:154`
|
|
||||||
|
|
||||||
The dummy Argon2 hash `"$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g"` uses m=65536 but the actual default config uses m=65536 too. The timing should be close. However, the dummy hash uses a 6-byte salt ("testsalt" base64) and a 6-byte hash ("testhash" base64), while real hashes use 16-byte salt and 32-byte hash. This produces a slightly different (faster) Argon2 computation than a real password verification.
|
|
||||||
|
|
||||||
**Recommendation:** Pre-compute a real dummy hash at server startup using `auth.HashPassword("dummy-password", actualArgonParams)` and store it as a `sync.Once` variable. This guarantees identical timing regardless of configuration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-08: No Account Lockout After Repeated Failed Login Attempts (LOW)
|
|
||||||
|
|
||||||
**Location:** `internal/server/server.go:138-176`
|
|
||||||
|
|
||||||
There is no mechanism to lock an account after N failed login attempts. The system relies solely on rate limiting (which, per F-04, isn't applied on the REST side). An attacker with distributed IPs could attempt brute-force attacks against accounts without triggering any lockout.
|
|
||||||
|
|
||||||
**Recommendation:** Implement a configurable per-account failed login counter (e.g., 10 failures in 15 minutes triggers a 15-minute lockout). The counter should be stored in the DB or in memory with per-account tracking. Audit events for `login_fail` already exist and can be queried, but proactive lockout would be more effective.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-09: `PRAGMA synchronous=NORMAL` Risks Data Loss on Power Failure (LOW)
|
|
||||||
|
|
||||||
**Location:** `internal/db/db.go:50`
|
|
||||||
|
|
||||||
`PRAGMA synchronous=NORMAL` combined with WAL mode means a power failure could lose the most recent committed transactions. For a security-critical system where audit log integrity and token revocation records matter, `synchronous=FULL` is safer.
|
|
||||||
|
|
||||||
**Recommendation:** Change to `PRAGMA synchronous=FULL` for production deployments. The performance impact on a personal SSO system is negligible. Alternatively, document this trade-off and leave `NORMAL` as a conscious choice.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-10: No Maximum Token Expiry Validation (LOW)
|
|
||||||
|
|
||||||
**Location:** `internal/config/config.go:150-159`
|
|
||||||
|
|
||||||
Token expiry durations are validated to be positive but have no maximum. An operator could accidentally configure `default_expiry = "876000h"` (100 years). The config validation should enforce reasonable ceilings.
|
|
||||||
|
|
||||||
**Recommendation:** Add maximum expiry validation: e.g., `default_expiry <= 8760h` (1 year), `admin_expiry <= 168h` (1 week), `service_expiry <= 87600h` (10 years). These can be generous ceilings that prevent obvious misconfiguration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-11: Missing `Content-Security-Policy` and Other Security Headers on UI Responses (MEDIUM)
|
|
||||||
|
|
||||||
**Location:** `internal/ui/ui.go:318-333`
|
|
||||||
|
|
||||||
The UI serves HTML pages but sets no security headers: no `Content-Security-Policy`, no `X-Content-Type-Options`, no `X-Frame-Options`, no `Strict-Transport-Security`. Since this is an admin panel for an authentication system:
|
|
||||||
|
|
||||||
- Without CSP, any XSS vector (e.g., via a malicious username stored in the DB) could execute arbitrary JavaScript in the admin's browser.
|
|
||||||
- Without `X-Frame-Options: DENY`, the admin panel could be framed for clickjacking.
|
|
||||||
- Without HSTS, a MITM could strip TLS on the first connection.
|
|
||||||
|
|
||||||
**Recommendation:** Add a middleware that sets:
|
|
||||||
```
|
|
||||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'
|
|
||||||
X-Content-Type-Options: nosniff
|
|
||||||
X-Frame-Options: DENY
|
|
||||||
Strict-Transport-Security: max-age=63072000; includeSubDomains
|
|
||||||
Referrer-Policy: no-referrer
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
The middleware-level `extractBearerToken` in `internal/middleware/middleware.go` (lines 303–316) correctly uses `strings.SplitN` and `strings.EqualFold` to validate the prefix. The server-level function should be replaced with a call to the middleware version, or the same validation logic should be applied.
|
||||||
|
|
||||||
### F-12: No Input Validation on Username Length or Character Set (LOW)
|
**Impact:** Low in practice because the extracted garbage is then passed to JWT validation which will reject it. However, it violates defense-in-depth: a future change to token validation could widen the attack surface, and the inconsistency between the two extraction functions is a maintenance hazard.
|
||||||
|
|
||||||
**Location:** `internal/server/server.go:465-507`
|
**Recommendation:** Replace `extractBearerFromRequest` with a call to `middleware.extractBearerToken` (after exporting it or moving the function), or replicate the prefix validation.
|
||||||
|
|
||||||
`handleCreateAccount` checks that username is non-empty but does not validate length or character set. A username containing control characters, null bytes, or extremely long strings (up to SQLite's TEXT limit) could cause rendering issues in the UI, log injection, or storage abuse.
|
**Fix:** `extractBearerFromRequest` now uses `strings.SplitN` and `strings.EqualFold` to validate the `"Bearer"` prefix before extracting the token, matching the middleware implementation. Test `TestExtractBearerFromRequest` covers valid tokens, missing headers, non-Bearer schemes (Token, Basic), empty tokens, case-insensitive matching, and the previously-accepted garbage input.
|
||||||
|
|
||||||
**Recommendation:** Validate: length 1-255, alphanumeric + limited symbols (e.g., `^[a-zA-Z0-9._@-]{1,255}$`). Reject control characters, embedded NULs, and newlines.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-13: No Password Complexity or Minimum Length Enforcement (LOW)
|
### PEN-02 — Security Headers Missing from Live Instance Responses (Medium)
|
||||||
|
|
||||||
**Location:** `internal/auth/auth.go:63-66`
|
**Live probe:** `https://mcias.metacircular.net:8443/login`
|
||||||
|
|
||||||
`HashPassword` only checks that the password is non-empty. A 1-character password is accepted and hashed. While Argon2id makes brute-force expensive, a minimum password length of 8-12 characters (per NIST SP 800-63B) would prevent trivially weak passwords.
|
The live instance's `/login` response did not include the security headers (`X-Content-Type-Options`, `Strict-Transport-Security`, `Cache-Control`, `Permissions-Policy`) that the source code's `globalSecurityHeaders` and UI `securityHeaders` middleware should be applying (SEC-04 and SEC-10 fixes).
|
||||||
|
|
||||||
**Recommendation:** Enforce a minimum password length (e.g., 12 characters) at the server/handler level before passing to `HashPassword`. Optionally check against a breached-password list.
|
This is likely a code/deployment discrepancy — the deployed binary may predate the SEC-04/SEC-10 fixes, or the middleware may not be wired into the route chain correctly for all paths.
|
||||||
|
|
||||||
|
**Impact:** Without HSTS, browsers will not enforce HTTPS-only access. Without `X-Content-Type-Options: nosniff`, MIME-type sniffing attacks are possible. Without `Cache-Control: no-store`, authenticated responses may be cached by proxies or browsers.
|
||||||
|
|
||||||
|
**Recommendation:** Redeploy the current source to the live instance and verify headers with `curl -I`.
|
||||||
|
|
||||||
|
**Fix:** Redeployed 2026-03-15. Live probe confirms all headers present on `/login`, `/v1/health`, and `/`:
|
||||||
|
`cache-control: no-store`, `content-security-policy`, `permissions-policy`, `referrer-policy`, `strict-transport-security: max-age=63072000; includeSubDomains`, `x-content-type-options: nosniff`, `x-frame-options: DENY`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-14: Passphrase Not Zeroed After Use in `loadMasterKey` (LOW)
|
### PEN-03 — CSP `unsafe-inline` on `/docs` Swagger UI Endpoint (Low)
|
||||||
|
|
||||||
**Location:** `cmd/mciassrv/main.go:246-272`
|
**File:** `internal/server/server.go` (lines 1450–1452)
|
||||||
|
|
||||||
The passphrase is read from the environment variable and passed to `crypto.DeriveKey`, but the Go `string` holding the passphrase is not zeroed afterward. The environment variable is correctly unset, and the master key is zeroed on shutdown, but the passphrase string remains in the Go heap until GC'd. Go strings are immutable, so zeroing is not straightforward, but converting to `[]byte` first and zeroing after KDF would reduce the exposure window.
|
The `docsSecurityHeaders` wrapper sets a Content-Security-Policy that includes `script-src 'self' 'unsafe-inline'` and `style-src 'self' 'unsafe-inline'`. This is required by Swagger UI's rendering approach, but it weakens CSP protection on the docs endpoint.
|
||||||
|
|
||||||
**Recommendation:** Read the environment variable into a `[]byte` (via `os.Getenv` then `[]byte` copy), pass it to a modified `DeriveKey` that accepts `[]byte`, then zero the `[]byte` immediately after. Alternatively, accept this as a Go language limitation and document it.
|
**Impact:** If an attacker can inject content into the Swagger UI page (e.g., via a reflected parameter in the OpenAPI spec URL), inline scripts would execute. The blast radius is limited to the `/docs` path, which requires no authentication (see PEN-04).
|
||||||
|
|
||||||
|
**Recommendation:** Consider serving Swagger UI from a separate subdomain or using CSP nonces instead of `unsafe-inline`. Alternatively, accept the risk given the limited scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-15: `extractBearerFromRequest` Does Not Verify "Bearer" Prefix Case-Insensitively (INFORMATIONAL)
|
### PEN-04 — OpenAPI Spec Publicly Accessible Without Authentication (Informational)
|
||||||
|
|
||||||
**Location:** `internal/server/server.go:932-942`
|
**Live probe:** `GET /openapi.yaml` returns the full API specification without authentication.
|
||||||
|
|
||||||
The REST `extractBearerFromRequest` (used by `handleTokenValidate`) does a substring check with `auth[len("Bearer ")]` without verifying the prefix actually says "Bearer". It trusts that if the header is long enough, the prefix is correct. Meanwhile, the middleware's `extractBearerToken` correctly uses `strings.EqualFold`. The gRPC `extractBearerFromMD` also correctly uses `strings.EqualFold`.
|
The OpenAPI spec reveals all API endpoints, request/response schemas, authentication flows, and error codes. While security-through-obscurity is not a defense, exposing the full API surface to unauthenticated users provides a roadmap for attackers.
|
||||||
|
|
||||||
**Recommendation:** Use `strings.EqualFold` for the prefix check in `extractBearerFromRequest` for consistency.
|
**Recommendation:** Consider requiring authentication for `/openapi.yaml` and `/docs`, or accept the risk if the API surface is intended to be public.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-16: UI System Token Issuance Does Not Revoke Previous System Token (LOW)
|
### PEN-05 — gRPC Port 9443 Publicly Accessible (Informational)
|
||||||
|
|
||||||
**Location:** `internal/ui/handlers_accounts.go:334-403`
|
**Live probe:** Port 9443 accepts TLS connections and serves gRPC.
|
||||||
|
|
||||||
The REST `handleTokenIssue` and gRPC `IssueServiceToken` both revoke the existing system token before issuing a new one. However, `handleIssueSystemToken` in the UI handler does not revoke the old system token — it calls `SetSystemToken` (which updates the system_tokens table via UPSERT) but never revokes the old token's entry in the token_revocation table. The old token remains valid until it naturally expires.
|
The gRPC interface is accessible from the public internet. While it requires authentication for all RPCs, exposing it increases the attack surface (gRPC-specific vulnerabilities, protocol-level attacks).
|
||||||
|
|
||||||
**Recommendation:** Before issuing a new token in `handleIssueSystemToken`, replicate the pattern from the REST handler: look up `GetSystemToken`, and if found, call `RevokeToken(existing.JTI, "rotated")`.
|
**Recommendation:** If gRPC is only used for server-to-server communication, restrict access at the firewall/network level. If it must be public, ensure gRPC-specific rate limiting and monitoring are in place (SEC-06 fix applies here).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Positive Findings (Things Done Well)
|
### PEN-06 — REST Login Increments Lockout Counter for Missing TOTP Code (Low)
|
||||||
|
|
||||||
1. **JWT algorithm confusion defense** is correctly implemented. The `alg` header is validated inside the key function before signature verification, and only `EdDSA` is accepted. This is the correct implementation pattern.
|
**File:** `internal/server/server.go` (lines 271–277)
|
||||||
|
|
||||||
2. **Constant-time comparisons** are consistently used for password verification, TOTP validation, and CSRF token validation via `crypto/subtle.ConstantTimeCompare`.
|
When a TOTP-enrolled account submits a login request without a TOTP code, the REST handler calls `s.db.RecordLoginFailure(acct.ID)` before returning the `"TOTP code required"` error. This increments the lockout counter even though the user has not actually failed authentication — they simply omitted the TOTP field.
|
||||||
|
|
||||||
3. **Timing uniformity** for failed logins: dummy Argon2 operations run for unknown users and inactive accounts, preventing username enumeration via timing differences.
|
The gRPC handler was fixed for this exact issue in DEF-08, but the REST handler was not updated to match.
|
||||||
|
|
||||||
4. **Credential material exclusion** is thorough: `json:"-"` tags on `PasswordHash`, `TOTPSecretEnc`, `TOTPSecretNonce`, `PGPasswordEnc`, `PGPasswordNonce` in model types, plus deliberate omission from API responses and log statements.
|
```go
|
||||||
|
// Current (REST — increments lockout for missing TOTP):
|
||||||
|
if acct.TOTPRequired {
|
||||||
|
if req.TOTPCode == "" {
|
||||||
|
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"totp_missing"}`)
|
||||||
|
_ = s.db.RecordLoginFailure(acct.ID) // should not increment
|
||||||
|
middleware.WriteError(w, http.StatusUnauthorized, "TOTP code required", "totp_required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
5. **Parameterized SQL** is used consistently throughout. No string concatenation in queries. The dynamic query builder in `ListAuditEvents`/`ListAuditEventsPaged` correctly uses parameter placeholders.
|
**Impact:** An attacker who knows a username with TOTP enabled can lock the account by sending 10 login requests with a valid password but no TOTP code. The password must be correct (wrong passwords also increment the counter), but this lowers the bar from "must guess TOTP" to "must omit TOTP." More practically, legitimate users with buggy clients that forget the TOTP field could self-lock.
|
||||||
|
|
||||||
6. **TLS configuration** is solid: TLS 1.2 minimum, X25519/P256 curves, enforced at the listener level with no plaintext fallback.
|
**Recommendation:** Remove the `RecordLoginFailure` call from the TOTP-missing branch, matching the gRPC handler's behavior after the DEF-08 fix.
|
||||||
|
|
||||||
7. **Master key handling** is well-designed: passphrase derived via Argon2id with strong parameters (128 MiB memory), env var cleared after reading, key zeroed on shutdown.
|
**Fix:** `RecordLoginFailure` removed from the TOTP-missing branch in `internal/server/server.go`. The branch now matches the gRPC handler exactly, including the rationale comment. `TestTOTPMissingDoesNotIncrementLockout` verifies the fix: it fully enrolls TOTP via the HTTP API, sets `LockoutThreshold=1`, issues a TOTP-missing login, and asserts the account is not locked.
|
||||||
|
|
||||||
8. **Systemd hardening** is comprehensive: `ProtectSystem=strict`, `NoNewPrivileges`, `MemoryDenyWriteExecute`, empty `CapabilityBoundingSet`, and `PrivateDevices`.
|
|
||||||
|
|
||||||
9. **AES-GCM usage** is correct: fresh random nonces per encryption, key size validated, error details not exposed on decryption failure.
|
|
||||||
|
|
||||||
10. **CSRF protection** is well-implemented with HMAC-signed double-submit cookies and `SameSite=Strict`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Summary Table
|
### PEN-07 — Rate Limiter Is Per-IP Only, No Per-Account Limiting (Informational)
|
||||||
|
|
||||||
| Fixed? | ID | Severity | Title | Effort |
|
The rate limiter uses a per-IP token bucket. An attacker with access to multiple IP addresses (botnet, cloud instances, rotating proxies) can distribute brute-force attempts across IPs to bypass the per-IP limit.
|
||||||
|--------|----|----------|-------|--------|
|
|
||||||
| Yes | F-01 | MEDIUM | TOTP enrollment sets required=1 before confirmation | Small |
|
The account lockout mechanism (10 failures in 15 minutes) provides a secondary defense, but it is a blunt instrument — it locks out the legitimate user as well.
|
||||||
| Yes | F-02 | MEDIUM | Password in HTML hidden fields during TOTP step | Medium |
|
|
||||||
| Yes | F-03 | MEDIUM | Token renewal not atomic (race window) | Small |
|
**Recommendation:** Consider adding per-account rate limiting as a complement to per-IP limiting. This would cap login attempts per username regardless of source IP, without affecting other users. The account lockout already partially serves this role, but a softer rate limit (e.g., 1 req/s per username) would slow distributed attacks without locking out the user.
|
||||||
| Yes | F-04 | MEDIUM | Rate limiter not applied to REST login endpoint | Small |
|
|
||||||
| Yes | F-11 | MEDIUM | Missing security headers on UI responses | Small |
|
</details>
|
||||||
| No | F-05 | LOW | No `nbf` claim in issued JWTs | Trivial |
|
|
||||||
| No | F-06 | LOW | `HasRole` uses non-constant-time comparison | Trivial |
|
|
||||||
| Yes | F-07 | LOW | Dummy Argon2 hash timing mismatch | Small |
|
|
||||||
| Yes | F-08 | LOW | No account lockout after repeated failures | Medium |
|
|
||||||
| No | F-09 | LOW | `synchronous=NORMAL` risks audit data loss | Trivial |
|
|
||||||
| No | F-10 | LOW | No maximum token expiry validation | Small |
|
|
||||||
| Yes | F-12 | LOW | No username length/charset validation | Small |
|
|
||||||
| Yes | F-13 | LOW | No minimum password length enforcement | Small |
|
|
||||||
| No | F-14 | LOW | Passphrase string not zeroed after KDF | Small |
|
|
||||||
| Yes | F-16 | LOW | UI system token issuance skips old token revocation | Small |
|
|
||||||
| No | F-15 | INFO | Bearer prefix check inconsistency | Trivial |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recommended Remediation Priority
|
## Remediated Findings (SEC-01 through SEC-12)
|
||||||
|
|
||||||
**Immediate (before production deployment):**
|
All findings from the SEC audit round have been remediated. The original descriptions are preserved below for reference.
|
||||||
1. F-04 — Wire the rate limiter into the REST server. This is the most impactful gap.
|
|
||||||
2. F-11 — Add security headers to UI responses.
|
|
||||||
3. F-01 — Fix TOTP enrollment to not lock accounts prematurely.
|
|
||||||
|
|
||||||
**Short-term:**
|
| ID | Severity | Finding | Status |
|
||||||
4. F-03 — Make token renewal atomic.
|
|----|----------|---------|--------|
|
||||||
5. F-02 — Replace password-in-hidden-field with a server-side nonce.
|
| SEC-01 | Medium | TOTP enrollment did not require password re-authentication | **Fixed** — both REST and gRPC now require current password, with lockout counter on failure |
|
||||||
6. F-16 — Fix UI system token issuance to revoke old tokens.
|
| SEC-02 | Medium | Account lockout response leaked account existence | **Fixed** — locked accounts now return same 401 `"invalid credentials"` as wrong password, with dummy Argon2 for timing uniformity |
|
||||||
7. F-07 — Use a real dummy hash with matching parameters.
|
| SEC-03 | Medium | Token renewal had no proximity or re-auth check | **Fixed** — renewal requires token to have consumed ≥50% of its lifetime |
|
||||||
|
| SEC-04 | Low-Med | REST API responses lacked security headers | **Fixed** — `globalSecurityHeaders` middleware applies `X-Content-Type-Options`, HSTS, and `Cache-Control: no-store` to all routes |
|
||||||
|
| SEC-05 | Low | No request body size limit on REST API | **Fixed** — `decodeJSON` wraps body with `http.MaxBytesReader` (1 MiB); max password length enforced |
|
||||||
|
| SEC-06 | Low | gRPC rate limiter ignored TrustedProxy | **Fixed** — `grpcClientIP` extracts real client IP via metadata when peer matches trusted proxy |
|
||||||
|
| SEC-07 | Low | Static file directory listing enabled | **Fixed** — `noDirListing` wrapper returns 404 for directory requests |
|
||||||
|
| SEC-08 | Low | System token issuance was not atomic | **Fixed** — `IssueSystemToken` wraps revoke+track in a single SQLite transaction |
|
||||||
|
| SEC-09 | Info | Navigation bar exposed admin UI structure to non-admin users | **Fixed** — nav links conditionally rendered with `{{if .IsAdmin}}` |
|
||||||
|
| SEC-10 | Info | No `Permissions-Policy` header | **Fixed** — `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()` added |
|
||||||
|
| SEC-11 | Info | Audit log details used `fmt.Sprintf` instead of `json.Marshal` | **Fixed** — `audit.JSON` and `audit.JSONWithRoles` helpers use `json.Marshal` |
|
||||||
|
| SEC-12 | Info | Default token expiry was 30 days | **Fixed** — default reduced to 7 days (168h); renewal proximity check (SEC-03) further limits exposure |
|
||||||
|
|
||||||
**Medium-term:**
|
<details>
|
||||||
8. F-08 — Implement account lockout.
|
<summary>Original finding descriptions (click to expand)</summary>
|
||||||
9. F-12, F-13 — Input validation for usernames and passwords.
|
|
||||||
10. Remaining LOW/INFO items at maintainer discretion.
|
### SEC-01 — TOTP Enrollment Does Not Require Password Re-authentication (Medium)
|
||||||
|
|
||||||
|
**Files:** `internal/server/server.go`, `internal/grpcserver/auth.go`
|
||||||
|
|
||||||
|
`POST /v1/auth/totp/enroll` and the gRPC `EnrollTOTP` RPC originally required only a valid JWT — no password confirmation. If an attacker stole a session token, they could enroll TOTP on the victim's account.
|
||||||
|
|
||||||
|
**Fix:** Both endpoints now require the current password, with lockout counter incremented on failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-02 — Account Lockout Response Leaks Account Existence (Medium)
|
||||||
|
|
||||||
|
Locked accounts originally returned HTTP 429 / gRPC `ResourceExhausted` with `"account temporarily locked"`, distinguishable from the HTTP 401 `"invalid credentials"` returned for wrong passwords.
|
||||||
|
|
||||||
|
**Fix:** All login paths now return the same `"invalid credentials"` response for locked accounts, with dummy Argon2 to maintain timing uniformity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-03 — Token Renewal Has No Proximity or Re-auth Check (Medium)
|
||||||
|
|
||||||
|
`POST /v1/auth/renew` originally accepted any valid token regardless of remaining lifetime.
|
||||||
|
|
||||||
|
**Fix:** Renewal now requires the token to have consumed ≥50% of its lifetime before it can be renewed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-04 — REST API Responses Lack Security Headers (Low-Medium)
|
||||||
|
|
||||||
|
API endpoints originally returned only `Content-Type` — no `Cache-Control`, `X-Content-Type-Options`, or HSTS.
|
||||||
|
|
||||||
|
**Fix:** `globalSecurityHeaders` middleware applies these headers to all routes (API and UI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-05 — No Request Body Size Limit on REST API Endpoints (Low)
|
||||||
|
|
||||||
|
`decodeJSON` originally read from `r.Body` without any size limit.
|
||||||
|
|
||||||
|
**Fix:** `http.MaxBytesReader` with 1 MiB limit added to `decodeJSON`. Maximum password length also enforced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-06 — gRPC Rate Limiter Ignores TrustedProxy (Low)
|
||||||
|
|
||||||
|
The gRPC rate limiter originally used `peer.FromContext` directly, always getting the proxy IP behind a reverse proxy.
|
||||||
|
|
||||||
|
**Fix:** `grpcClientIP` now reads from gRPC metadata headers when the peer matches the trusted proxy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-07 — Static File Directory Listing Enabled (Low)
|
||||||
|
|
||||||
|
`http.FileServerFS` served directory listings by default.
|
||||||
|
|
||||||
|
**Fix:** `noDirListing` wrapper returns 404 for directory requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-08 — System Token Issuance Is Not Atomic (Low)
|
||||||
|
|
||||||
|
`handleTokenIssue` originally performed three sequential non-transactional operations.
|
||||||
|
|
||||||
|
**Fix:** `IssueSystemToken` wraps all operations in a single SQLite transaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-09 — Navigation Bar Exposes Admin UI Structure to Non-Admin Users (Informational)
|
||||||
|
|
||||||
|
Nav links were rendered for all authenticated users.
|
||||||
|
|
||||||
|
**Fix:** Admin nav links wrapped in `{{if .IsAdmin}}` conditional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-10 — No `Permissions-Policy` Header (Informational)
|
||||||
|
|
||||||
|
The security headers middleware did not include `Permissions-Policy`.
|
||||||
|
|
||||||
|
**Fix:** `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()` added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-11 — Audit Log Details Use `fmt.Sprintf` Instead of `json.Marshal` (Informational)
|
||||||
|
|
||||||
|
Audit details were constructed with `fmt.Sprintf` and `%q`, which is fragile for JSON.
|
||||||
|
|
||||||
|
**Fix:** `audit.JSON` and `audit.JSONWithRoles` helpers use `json.Marshal`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEC-12 — Default Token Expiry Is 30 Days (Informational / Configuration)
|
||||||
|
|
||||||
|
Default expiry was 720h (30 days).
|
||||||
|
|
||||||
|
**Fix:** Reduced to 168h (7 days). Combined with SEC-03's renewal proximity check, exposure window is significantly reduced.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Previously Remediated Findings (CRIT/DEF series)
|
||||||
|
|
||||||
|
The following findings from the initial audit (2026-03-12) were confirmed fixed in the 2026-03-13 audit:
|
||||||
|
|
||||||
|
| ID | Finding | Status |
|
||||||
|
|----|---------|--------|
|
||||||
|
| CRIT-01 | TOTP replay attack — no counter tracking | **Fixed** — `CheckAndUpdateTOTPCounter` with atomic SQL, migration 000007 |
|
||||||
|
| CRIT-02 | gRPC `EnrollTOTP` called `SetTOTP` instead of `StorePendingTOTP` | **Fixed** — now calls `StorePendingTOTP` |
|
||||||
|
| DEF-01 | No rate limiting on UI login | **Fixed** — `loginRateLimit` applied to `POST /login` |
|
||||||
|
| DEF-02 | `pendingLogins` map had no expiry cleanup | **Fixed** — `cleanupPendingLogins` goroutine runs every 5 minutes |
|
||||||
|
| DEF-03 | Rate limiter ignored `X-Forwarded-For` | **Fixed** — `ClientIP()` respects `TrustedProxy` config |
|
||||||
|
| DEF-04 | Missing `nbf` claim on tokens | **Fixed** — `NotBefore: jwt.NewNumericDate(now)` added |
|
||||||
|
| DEF-05 | No max token expiry ceiling | **Fixed** — upper bounds enforced in config validation |
|
||||||
|
| DEF-06 | Incorrect case-sensitivity comment | **Fixed** — comment corrected |
|
||||||
|
| DEF-07 | SQLite `synchronous=NORMAL` | **Fixed** — changed to `PRAGMA synchronous=FULL` |
|
||||||
|
| DEF-08 | gRPC counted TOTP-missing as failure | **Fixed** — no longer increments lockout counter |
|
||||||
|
| DEF-09 | Security headers missing on docs endpoints | **Fixed** — `docsSecurityHeaders` wrapper added |
|
||||||
|
| DEF-10 | Role strings not validated | **Fixed** — `model.ValidateRole()` with compile-time allowlist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Positive Findings (Preserved)
|
||||||
|
|
||||||
|
These implementation details are exemplary and should be maintained:
|
||||||
|
|
||||||
|
| Area | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| JWT alg confusion | `ValidateToken` enforces `alg=EdDSA` in the key function before signature verification |
|
||||||
|
| Constant-time operations | `crypto/subtle.ConstantTimeCompare` for password hashes, CSRF tokens; all three TOTP windows evaluated without early exit |
|
||||||
|
| Timing uniformity | Dummy Argon2 via `sync.Once` for unknown/inactive users on all login paths |
|
||||||
|
| Token revocation | Fail-closed: untracked tokens are rejected, not silently accepted |
|
||||||
|
| Token renewal atomicity | `RenewToken` wraps revoke+track in a single SQLite transaction |
|
||||||
|
| TOTP replay prevention | Counter-based replay detection with atomic SQL UPDATE/WHERE |
|
||||||
|
| TOTP nonce design | 128-bit single-use server-side nonce; password never retransmitted in step 2 |
|
||||||
|
| CSRF protection | HMAC-SHA256 double-submit cookie, domain-separated key derivation, SameSite=Strict, constant-time validation |
|
||||||
|
| Credential exclusion | `json:"-"` on all credential fields; password hash never in API responses |
|
||||||
|
| Security headers (UI) | CSP (no unsafe-inline), X-Content-Type-Options, X-Frame-Options DENY, HSTS 2yr, Referrer-Policy no-referrer |
|
||||||
|
| Cookie hardening | HttpOnly + Secure + SameSite=Strict on session cookie |
|
||||||
|
| Account lockout | 10-attempt rolling window, checked before Argon2, with timing-safe dummy hash |
|
||||||
|
| Argon2id parameters | Config validator enforces OWASP 2023 minimums; rejects weakening |
|
||||||
|
| SQL injection | Zero string concatenation — all queries parameterized |
|
||||||
|
| Input validation | Username regex + length, password min length, account type enum, role allowlist, JSON strict decoder |
|
||||||
|
| Audit logging | Append-only, no delete path, credentials never logged, actor/target/IP captured |
|
||||||
|
| Master key hygiene | Env var cleared after read, key zeroed on shutdown, AES-256-GCM at rest |
|
||||||
|
| TLS | MinVersion TLS 1.2, X25519 preferred, no plaintext listener, read/write/idle timeouts set |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Penetration Test — Attacks That Failed (2026-03-14)
|
||||||
|
|
||||||
|
The following attacks were attempted against the live instance and failed, confirming the effectiveness of existing defenses:
|
||||||
|
|
||||||
|
| Attack | Result |
|
||||||
|
|--------|--------|
|
||||||
|
| JWT `alg:none` bypass | Rejected — `ValidateToken` enforces `alg=EdDSA` |
|
||||||
|
| JWT `alg:HS256` key-confusion | Rejected — only EdDSA accepted |
|
||||||
|
| Forged JWT with random Ed25519 key | Rejected — signature verification failed |
|
||||||
|
| Username enumeration via timing | Not possible — ~355ms for both existing and non-existing users (dummy Argon2 working) |
|
||||||
|
| Username enumeration via error messages | Not possible — identical `"invalid credentials"` for all failure modes |
|
||||||
|
| Account lockout enumeration | Not possible — locked accounts return same response as wrong password (SEC-02 fix confirmed) |
|
||||||
|
| SQL injection via login fields | Not possible — parameterized queries throughout |
|
||||||
|
| JSON body bomb (oversized payload) | Rejected — `http.MaxBytesReader` returns 413 (SEC-05 fix confirmed) |
|
||||||
|
| Unknown JSON fields | Rejected — `DisallowUnknownFields` active on decoder |
|
||||||
|
| Rate limit bypass | Working correctly — 429 after burst exhaustion, `Retry-After` header present |
|
||||||
|
| Admin endpoint access without auth | Properly returns 401 |
|
||||||
|
| Directory traversal on static files | Not possible — `noDirListing` wrapper returns 404 (SEC-07 fix confirmed) |
|
||||||
|
| Public key endpoint | Returns Ed25519 PKIX key (expected; public by design) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remediation Status
|
||||||
|
|
||||||
|
**CRIT/DEF/SEC series:** All 24 findings remediated. No open items.
|
||||||
|
|
||||||
|
**PEN series (2026-03-14):** All 7 findings resolved — 4 fixed, 3 accepted by design. Unauthorized access was not achieved. No open items remain.
|
||||||
|
|
||||||
|
Next audit should focus on:
|
||||||
|
- Any new features added since 2026-03-15
|
||||||
|
- Dependency updates and CVE review
|
||||||
|
- Re-evaluate PEN-03 if Swagger UI self-hosting becomes desirable
|
||||||
|
|||||||
20
CLAUDE.md
20
CLAUDE.md
@@ -74,6 +74,26 @@ This is a security-critical project. The following rules are non-negotiable:
|
|||||||
- Prefer explicit error handling over panics; never silently discard errors
|
- 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
|
- Use `log/slog` (or goutils equivalents) for structured logging; never `fmt.Println` in production paths
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After any code edit, always verify the fix by running `go build ./...` and `go test ./...` before claiming the issue is resolved. Never claim lint/tests pass without actually running them.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
When working with migrations (golang-migrate or SQLite), always test migrations against a fresh database AND an existing database to catch duplicate column/table errors. SQLite does not support IF NOT EXISTS for ALTER TABLE.
|
||||||
|
|
||||||
|
## File Editing
|
||||||
|
|
||||||
|
Before editing files, re-read the current on-disk version to confirm it matches expectations. If files seem inconsistent, stop and flag this to the user before proceeding.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
For this project (MCIAS): Go codebase, uses golang-migrate, SQLite (with shared-cache for in-memory), htmx frontend with Go html/template, golangci-lint (use `go vet` if version incompatible), and cert tool for TLS certificates. Check `docs/` for tool-specific usage before guessing CLI flags.
|
||||||
|
|
||||||
|
## UI Development
|
||||||
|
|
||||||
|
When implementing UI features, ensure they work for the empty-state case (e.g., no credentials exist yet, no accounts created). Always test with zero records.
|
||||||
|
|
||||||
## Key Documents
|
## Key Documents
|
||||||
|
|
||||||
- `PROJECT.md` — Project specifications and requirements
|
- `PROJECT.md` — Project specifications and requirements
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -134,6 +134,10 @@ dist: man
|
|||||||
docker:
|
docker:
|
||||||
docker build -t mcias:$(VERSION) -t mcias:latest .
|
docker build -t mcias:$(VERSION) -t mcias:latest .
|
||||||
|
|
||||||
|
.PHONY: install-local
|
||||||
|
install-local: build
|
||||||
|
cp bin/* $(HOME)/.local/bin/
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Help
|
# Help
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
200
PROGRESS.md
200
PROGRESS.md
@@ -2,7 +2,198 @@
|
|||||||
|
|
||||||
Source of truth for current development state.
|
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 (pre-existing warnings only).
|
||||||
|
|
||||||
|
### 2026-03-15 — Service account token delegation and download
|
||||||
|
|
||||||
|
**Problem:** Only admins could issue tokens for service accounts, and the only way to retrieve the token was a flash message (copy-paste). There was no delegation mechanism for non-admin users.
|
||||||
|
|
||||||
|
**Solution:** Added token-issue delegation and a one-time secure file download flow.
|
||||||
|
|
||||||
|
**DB (`internal/db/`):**
|
||||||
|
- Migration `000008`: new `service_account_delegates` table — tracks which human accounts may issue tokens for a given system account
|
||||||
|
- `GrantTokenIssueAccess`, `RevokeTokenIssueAccess`, `ListTokenIssueDelegates`, `HasTokenIssueAccess`, `ListDelegatedServiceAccounts` functions
|
||||||
|
|
||||||
|
**Model (`internal/model/`):**
|
||||||
|
- New `ServiceAccountDelegate` type
|
||||||
|
- New audit event constants: `EventTokenDelegateGranted`, `EventTokenDelegateRevoked`
|
||||||
|
|
||||||
|
**UI (`internal/ui/`):**
|
||||||
|
- `handleIssueSystemToken`: now allows admins and delegates (not just admins); after issuance stores token in a short-lived (5 min) single-use download nonce; returns download link in the HTMX fragment
|
||||||
|
- `handleDownloadToken`: serves the token as `Content-Disposition: attachment` via the one-time nonce; nonce deleted on first use to prevent replay
|
||||||
|
- `handleGrantTokenDelegate` / `handleRevokeTokenDelegate`: admin-only endpoints to manage delegate access for a system account
|
||||||
|
- `handleServiceAccountsPage`: new `/service-accounts` page for non-admin delegates to see their assigned service accounts and issue tokens
|
||||||
|
- New `tokenDownloads sync.Map` in `UIServer` with background cleanup goroutine
|
||||||
|
|
||||||
|
**Routes:**
|
||||||
|
- `POST /accounts/{id}/token` — changed from admin-only to authed+CSRF, authorization checked in handler
|
||||||
|
- `GET /token/download/{nonce}` — new, authed
|
||||||
|
- `POST /accounts/{id}/token/delegates` — new, admin-only
|
||||||
|
- `DELETE /accounts/{id}/token/delegates/{grantee}` — new, admin-only
|
||||||
|
- `GET /service-accounts` — new, authed (delegates' token management page)
|
||||||
|
|
||||||
|
**Templates:**
|
||||||
|
- `token_list.html`: shows download link after issuance
|
||||||
|
- `token_delegates.html`: new fragment for admin delegate management
|
||||||
|
- `account_detail.html`: added "Token Issue Access" section for system accounts
|
||||||
|
- `service_accounts.html`: new page listing delegated service accounts with issue button
|
||||||
|
- `base.html`: non-admin nav now shows "Service Accounts" link
|
||||||
|
|
||||||
|
### 2026-03-14 — Vault seal/unseal lifecycle
|
||||||
|
|
||||||
|
**Problem:** `mciassrv` required the master passphrase at startup and refused to start without it. Operators needed a way to start the server in a degraded state and provide the passphrase at runtime, plus the ability to re-seal at runtime.
|
||||||
|
|
||||||
|
**Solution:** Implemented a `Vault` abstraction that manages key material lifecycle with seal/unseal state transitions.
|
||||||
|
|
||||||
|
**New package: `internal/vault/`**
|
||||||
|
- `vault.go`: Thread-safe `Vault` struct with `sync.RWMutex`-protected state. Methods: `IsSealed()`, `Unseal()`, `Seal()`, `MasterKey()`, `PrivKey()`, `PubKey()`. `Seal()` zeroes all key material before nilling.
|
||||||
|
- `derive.go`: Extracted `DeriveFromPassphrase()` and `DecryptSigningKey()` from `cmd/mciassrv/main.go` for reuse by unseal handlers.
|
||||||
|
- `vault_test.go`: Tests for state transitions, key zeroing, concurrent access.
|
||||||
|
|
||||||
|
**REST API (`internal/server/`):**
|
||||||
|
- `POST /v1/vault/unseal`: Accept passphrase, derive key, unseal (rate-limited 3/s burst 5)
|
||||||
|
- `POST /v1/vault/seal`: Admin-only, seals vault and zeroes key material
|
||||||
|
- `GET /v1/vault/status`: Returns `{"sealed": bool}`
|
||||||
|
- `GET /v1/health`: Now returns `{"status":"sealed"}` when sealed
|
||||||
|
- All other `/v1/*` endpoints return 503 `vault_sealed` when sealed
|
||||||
|
|
||||||
|
**Web UI (`internal/ui/`):**
|
||||||
|
- New unseal page at `/unseal` with passphrase form (same styling as login)
|
||||||
|
- All UI routes redirect to `/unseal` when sealed (except `/static/`)
|
||||||
|
- CSRF manager now derives key lazily from vault
|
||||||
|
|
||||||
|
**gRPC (`internal/grpcserver/`):**
|
||||||
|
- New `sealedInterceptor` first in interceptor chain — returns `codes.Unavailable` for all RPCs except Health
|
||||||
|
- Health RPC returns `status: "sealed"` when sealed
|
||||||
|
|
||||||
|
**Startup (`cmd/mciassrv/main.go`):**
|
||||||
|
- When passphrase env var is empty/unset (and not first run): starts in sealed state
|
||||||
|
- When passphrase is available: backward-compatible unsealed startup
|
||||||
|
- First run still requires passphrase to generate signing key
|
||||||
|
|
||||||
|
**Refactoring:**
|
||||||
|
- All three servers (REST, UI, gRPC) share a single `*vault.Vault` by pointer
|
||||||
|
- Replaced static `privKey`, `pubKey`, `masterKey` fields with vault accessor calls
|
||||||
|
- `middleware.RequireAuth` now reads pubkey from vault at request time
|
||||||
|
- New `middleware.RequireUnsealed` middleware wired before request logger
|
||||||
|
|
||||||
|
**Audit events:** Added `vault_sealed` and `vault_unsealed` event types.
|
||||||
|
|
||||||
|
**OpenAPI:** Updated `openapi.yaml` with vault endpoints and sealed health response.
|
||||||
|
|
||||||
|
**Files changed:** 19 files (3 new packages, 3 new handlers, 1 new template, extensive refactoring across all server packages and tests).
|
||||||
|
|
||||||
|
### 2026-03-13 — Make pgcreds discoverable via CLI and UI
|
||||||
|
|
||||||
|
**Problem:** Users had no way to discover which pgcreds were available to them or what their credential IDs were, making it functionally impossible to use the system without manual database inspection.
|
||||||
|
|
||||||
|
**Solution:** Added two complementary discovery paths:
|
||||||
|
|
||||||
|
**REST API:**
|
||||||
|
- New `GET /v1/pgcreds` endpoint (requires authentication) returns all accessible credentials (owned + explicitly granted) with their IDs, host, port, database, username, and timestamps
|
||||||
|
- Response includes `id` field so users can then fetch full credentials via `GET /v1/accounts/{id}/pgcreds`
|
||||||
|
|
||||||
|
**CLI (`cmd/mciasctl/main.go`):**
|
||||||
|
- New `pgcreds list` subcommand calls `GET /v1/pgcreds` and displays accessible credentials with IDs
|
||||||
|
- Updated usage documentation to include `pgcreds list`
|
||||||
|
|
||||||
|
**Web UI (`web/templates/pgcreds.html`):**
|
||||||
|
- Credential ID now displayed in a `<code>` element at the top of each credential's metadata block
|
||||||
|
- Styled with monospace font for easy copying and reference
|
||||||
|
|
||||||
|
**Files modified:**
|
||||||
|
- `internal/server/server.go`: Added route `GET /v1/pgcreds` (requires auth, not admin) + handler `handleListAccessiblePGCreds`
|
||||||
|
- `cmd/mciasctl/main.go`: Added `pgCredsList` function and switch case
|
||||||
|
- `web/templates/pgcreds.html`: Display credential ID in the credentials list
|
||||||
|
- Struct field alignment fixed in `pgCredResponse` to pass `go vet`
|
||||||
|
|
||||||
|
All tests pass; `go vet ./...` clean.
|
||||||
|
|
||||||
|
### 2026-03-12 — Update web UI and model for all compile-time roles
|
||||||
|
|
||||||
|
- `internal/model/model.go`: added `RoleGuest`, `RoleViewer`, `RoleEditor`, and
|
||||||
|
`RoleCommenter` constants; updated `allowedRoles` map and `ValidateRole` error
|
||||||
|
message to include the full set of recognised roles.
|
||||||
|
- `internal/ui/`: updated `knownRoles` to include guest, viewer, editor, and
|
||||||
|
commenter; replaced hardcoded role strings with model constants; removed
|
||||||
|
obsolete "service" role from UI dropdowns.
|
||||||
|
- All tests pass; build verified.
|
||||||
|
|
||||||
|
### 2026-03-12 — Fix UI privilege escalation vulnerability
|
||||||
|
|
||||||
|
**internal/ui/ui.go**
|
||||||
|
- Added `requireAdminRole` middleware that checks `claims.HasRole("admin")`
|
||||||
|
and returns 403 if absent
|
||||||
|
- Updated `admin` and `adminGet` middleware wrappers to include
|
||||||
|
`requireAdminRole` in the chain — previously only `requireCookieAuth`
|
||||||
|
was applied, allowing any authenticated user to access admin endpoints
|
||||||
|
- Profile routes correctly use only `requireCookieAuth` (not admin-gated)
|
||||||
|
|
||||||
|
**internal/ui/handlers_accounts.go**
|
||||||
|
- Removed redundant inline admin check from `handleAdminResetPassword`
|
||||||
|
(now handled by route-level middleware)
|
||||||
|
|
||||||
|
**Full audit performed across all three API surfaces:**
|
||||||
|
- REST (`internal/server/server.go`): all admin routes use
|
||||||
|
`requireAuth → RequireRole("admin")` — correct
|
||||||
|
- gRPC (all service files): every admin RPC calls `requireAdmin(ctx)` as
|
||||||
|
first statement — correct
|
||||||
|
- UI: was vulnerable, now fixed with `requireAdminRole` middleware
|
||||||
|
|
||||||
|
All tests pass; `go vet ./...` clean.
|
||||||
|
|
||||||
|
### 2026-03-12 — Checkpoint: password change UI enforcement + migration recovery
|
||||||
|
|
||||||
|
**internal/ui/handlers_accounts.go**
|
||||||
|
- `handleAdminResetPassword`: added server-side admin role check at the top of
|
||||||
|
the handler; any authenticated non-admin calling this route now receives 403.
|
||||||
|
Previously only cookie validity + CSRF were checked.
|
||||||
|
|
||||||
|
**internal/ui/handlers_auth.go**
|
||||||
|
- Added `handleProfilePage`: renders the new `/profile` page for any
|
||||||
|
authenticated user.
|
||||||
|
- Added `handleSelfChangePassword`: self-service password change for non-admin
|
||||||
|
users; validates current password (Argon2id, lockout-checked), enforces
|
||||||
|
server-side confirmation equality check, hashes new password, revokes all
|
||||||
|
other sessions, audits as `{"via":"ui_self_service"}`.
|
||||||
|
|
||||||
|
**internal/ui/ui.go**
|
||||||
|
- Added `ProfileData` view model.
|
||||||
|
- Registered `GET /profile` and `PUT /profile/password` routes (cookie auth +
|
||||||
|
CSRF; no admin role required).
|
||||||
|
- Added `password_change_form.html` to shared template list; added `profile`
|
||||||
|
page template.
|
||||||
|
- Nav bar actor-name span changed to a link pointing to `/profile`.
|
||||||
|
|
||||||
|
**web/templates/fragments/password_change_form.html** (new)
|
||||||
|
- HTMX form with `current_password`, `new_password`, `confirm_password` fields.
|
||||||
|
- Client-side JS confirmation guard; server-side equality check in handler.
|
||||||
|
|
||||||
|
**web/templates/profile.html** (new)
|
||||||
|
- Profile page hosting the self-service password change form.
|
||||||
|
|
||||||
|
**internal/db/migrate.go**
|
||||||
|
- Compatibility shim now only calls `m.Force(legacyVersion)` when
|
||||||
|
`schema_migrations` is completely empty (`ErrNilVersion`); leaves existing
|
||||||
|
version entries (including dirty ones) alone to prevent re-running already-
|
||||||
|
attempted migrations.
|
||||||
|
- Added duplicate-column-name recovery: when `m.Up()` fails with "duplicate
|
||||||
|
column name" and the dirty version equals `LatestSchemaVersion`, the migrator
|
||||||
|
is force-cleaned and returns nil (handles databases where columns were added
|
||||||
|
outside the runner before migration 006 existed).
|
||||||
|
- Added `ForceSchemaVersion(database *DB, version int) error`: break-glass
|
||||||
|
exported function; forces golang-migrate version without running SQL.
|
||||||
|
|
||||||
|
**cmd/mciasdb/schema.go**
|
||||||
|
- Added `schema force --version N` subcommand backed by `db.ForceSchemaVersion`.
|
||||||
|
|
||||||
|
**cmd/mciasdb/main.go**
|
||||||
|
- `schema` commands now open the database via `openDBRaw` (no auto-migration)
|
||||||
|
so the tool stays usable when the database is in a dirty migration state.
|
||||||
|
- `openDB` refactored to call `openDBRaw` then `db.Migrate`.
|
||||||
|
- Updated usage text.
|
||||||
|
|
||||||
|
All tests pass; `golangci-lint run ./...` clean.
|
||||||
|
|
||||||
### 2026-03-12 — Password change: self-service and admin reset
|
### 2026-03-12 — Password change: self-service and admin reset
|
||||||
|
|
||||||
@@ -394,9 +585,10 @@ All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.
|
|||||||
- `engine.go` — `Evaluate(input, operatorRules) (Effect, *Rule)`: pure function;
|
- `engine.go` — `Evaluate(input, operatorRules) (Effect, *Rule)`: pure function;
|
||||||
merges operator rules with default rules, sorts by priority, deny-wins,
|
merges operator rules with default rules, sorts by priority, deny-wins,
|
||||||
then first allow, then default-deny
|
then first allow, then default-deny
|
||||||
- `defaults.go` — 6 compiled-in rules (IDs -1 to -6, Priority 0): admin
|
- `defaults.go` — 7 compiled-in rules (IDs -1 to -7, Priority 0): admin
|
||||||
wildcard, self-service logout/renew, self-service TOTP, system account own
|
wildcard, self-service logout/renew, self-service TOTP, self-service password
|
||||||
pgcreds, system account own service token, public login/validate endpoints
|
change (human only), system account own pgcreds, system account own service
|
||||||
|
token, public login/validate endpoints
|
||||||
- `engine_wrapper.go` — `Engine` struct with `sync.RWMutex`; `SetRules()`
|
- `engine_wrapper.go` — `Engine` struct with `sync.RWMutex`; `SetRules()`
|
||||||
decodes DB records; `PolicyRecord` type avoids import cycle
|
decodes DB records; `PolicyRecord` type avoids import cycle
|
||||||
- `engine_test.go` — 11 tests: DefaultDeny, AdminWildcard, SelfService*,
|
- `engine_test.go` — 11 tests: DefaultDeny, AdminWildcard, SelfService*,
|
||||||
|
|||||||
@@ -165,18 +165,27 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
### Step 4.1: `cmd/mciasctl` — admin CLI
|
### Step 4.1: `cmd/mciasctl` — admin CLI
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
- Subcommands:
|
- Subcommands:
|
||||||
- `mciasctl account create --username NAME --type human|system`
|
- `mciasctl account create -username NAME -type human|system`
|
||||||
- `mciasctl account list`
|
- `mciasctl account list`
|
||||||
- `mciasctl account suspend --id UUID`
|
- `mciasctl account update -id UUID -status active|inactive`
|
||||||
- `mciasctl account delete --id UUID`
|
- `mciasctl account delete -id UUID`
|
||||||
- `mciasctl role grant --account UUID --role ROLE`
|
- `mciasctl account get -id UUID`
|
||||||
- `mciasctl role revoke --account UUID --role ROLE`
|
- `mciasctl account set-password -id UUID`
|
||||||
- `mciasctl token issue --account UUID` (system accounts)
|
- `mciasctl role list -id UUID`
|
||||||
- `mciasctl token revoke --jti JTI`
|
- `mciasctl role set -id UUID -roles role1,role2`
|
||||||
- `mciasctl pgcreds set --account UUID --host H --port P --db D --user U --password P`
|
- `mciasctl role grant -id UUID -role ROLE`
|
||||||
- `mciasctl pgcreds get --account UUID`
|
- `mciasctl role revoke -id UUID -role ROLE`
|
||||||
- CLI reads admin JWT from `MCIAS_ADMIN_TOKEN` env var or `--token` flag
|
- `mciasctl token issue -id UUID` (system accounts)
|
||||||
- All commands make HTTPS requests to mciassrv (base URL from `--server` flag
|
- `mciasctl token revoke -jti JTI`
|
||||||
|
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
|
||||||
|
- `mciasctl pgcreds get -id UUID`
|
||||||
|
- `mciasctl auth login`
|
||||||
|
- `mciasctl auth change-password`
|
||||||
|
- `mciasctl tag list -id UUID`
|
||||||
|
- `mciasctl tag set -id UUID -tags tag1,tag2`
|
||||||
|
- `mciasctl policy list|create|get|update|delete`
|
||||||
|
- CLI reads admin JWT from `MCIAS_TOKEN` env var or `-token` flag
|
||||||
|
- All commands make HTTPS requests to mciassrv (base URL from `-server` flag
|
||||||
or `MCIAS_SERVER` env var)
|
or `MCIAS_SERVER` env var)
|
||||||
- Tests: flag parsing; missing required flags → error; help text complete
|
- Tests: flag parsing; missing required flags → error; help text complete
|
||||||
|
|
||||||
|
|||||||
75
README.md
75
README.md
@@ -64,10 +64,10 @@ EOF
|
|||||||
|
|
||||||
Generate the certificate:
|
Generate the certificate:
|
||||||
```sh
|
```sh
|
||||||
cert genkey -a ec -s 521 > /etc/mcias/server.key
|
cert genkey -a ec -s 521 > /srv/mcias/server.key
|
||||||
cert selfsign -p /etc/mcias/server.key -f /tmp/request.yaml > /etc/mcias/server.crt
|
cert selfsign -p /srv/mcias/server.key -f /tmp/request.yaml > /srv/mcias/server.crt
|
||||||
chmod 0640 /etc/mcias/server.key
|
chmod 0640 /srv/mcias/server.key
|
||||||
chown root:mcias /etc/mcias/server.key
|
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
|
||||||
rm /tmp/request.yaml
|
rm /tmp/request.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,21 +75,21 @@ rm /tmp/request.yaml
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
openssl req -x509 -newkey ed25519 -days 3650 \
|
openssl req -x509 -newkey ed25519 -days 3650 \
|
||||||
-keyout /etc/mcias/server.key \
|
-keyout /srv/mcias/server.key \
|
||||||
-out /etc/mcias/server.crt \
|
-out /srv/mcias/server.crt \
|
||||||
-subj "/CN=auth.example.com" \
|
-subj "/CN=auth.example.com" \
|
||||||
-nodes
|
-nodes
|
||||||
chmod 0640 /etc/mcias/server.key
|
chmod 0640 /srv/mcias/server.key
|
||||||
chown root:mcias /etc/mcias/server.key
|
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure the server
|
### 2. Configure the server
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp dist/mcias.conf.example /etc/mcias/mcias.conf
|
cp dist/mcias.conf.example /srv/mcias/mcias.toml
|
||||||
$EDITOR /etc/mcias/mcias.conf
|
$EDITOR /srv/mcias/mcias.toml
|
||||||
chmod 0640 /etc/mcias/mcias.conf
|
chmod 0640 /srv/mcias/mcias.toml
|
||||||
chown root:mcias /etc/mcias/mcias.conf
|
chown mcias:mcias /srv/mcias/mcias.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
Minimum required fields:
|
Minimum required fields:
|
||||||
@@ -97,11 +97,11 @@ Minimum required fields:
|
|||||||
```toml
|
```toml
|
||||||
[server]
|
[server]
|
||||||
listen_addr = "0.0.0.0:8443"
|
listen_addr = "0.0.0.0:8443"
|
||||||
tls_cert = "/etc/mcias/server.crt"
|
tls_cert = "/srv/mcias/server.crt"
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/srv/mcias/server.key"
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
path = "/var/lib/mcias/mcias.db"
|
path = "/srv/mcias/mcias.db"
|
||||||
|
|
||||||
[tokens]
|
[tokens]
|
||||||
issuer = "https://auth.example.com"
|
issuer = "https://auth.example.com"
|
||||||
@@ -116,10 +116,10 @@ For local development, use `dist/mcias-dev.conf.example`.
|
|||||||
### 3. Set the master key passphrase
|
### 3. Set the master key passphrase
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp dist/mcias.env.example /etc/mcias/env
|
cp dist/mcias.env.example /srv/mcias/env
|
||||||
$EDITOR /etc/mcias/env # replace the placeholder passphrase
|
$EDITOR /srv/mcias/env # replace the placeholder passphrase
|
||||||
chmod 0640 /etc/mcias/env
|
chmod 0640 /srv/mcias/env
|
||||||
chown root:mcias /etc/mcias/env
|
chown mcias:mcias /srv/mcias/env
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Important:** Back up the passphrase to a secure offline location.
|
> **Important:** Back up the passphrase to a secure offline location.
|
||||||
@@ -130,10 +130,10 @@ chown root:mcias /etc/mcias/env
|
|||||||
```sh
|
```sh
|
||||||
export MCIAS_MASTER_PASSPHRASE=your-passphrase
|
export MCIAS_MASTER_PASSPHRASE=your-passphrase
|
||||||
|
|
||||||
mciasdb --config /etc/mcias/mcias.conf account create \
|
mciasdb --config /srv/mcias/mcias.toml account create \
|
||||||
--username admin --type human
|
--username admin --type human
|
||||||
mciasdb --config /etc/mcias/mcias.conf account set-password --id <UUID>
|
mciasdb --config /srv/mcias/mcias.toml account set-password --id <UUID>
|
||||||
mciasdb --config /etc/mcias/mcias.conf role grant --id <UUID> --role admin
|
mciasdb --config /srv/mcias/mcias.toml role grant --id <UUID> --role admin
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Start the server
|
### 5. Start the server
|
||||||
@@ -143,13 +143,13 @@ mciasdb --config /etc/mcias/mcias.conf role grant --id <UUID> --role admin
|
|||||||
systemctl enable --now mcias
|
systemctl enable --now mcias
|
||||||
|
|
||||||
# manual
|
# manual
|
||||||
MCIAS_MASTER_PASSPHRASE=your-passphrase mciassrv -config /etc/mcias/mcias.conf
|
MCIAS_MASTER_PASSPHRASE=your-passphrase mciassrv -config /srv/mcias/mcias.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Verify
|
### 6. Verify
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -k https://localhost:8443/v1/health
|
curl -k https://mcias.metacircular.net:8443/v1/health
|
||||||
# {"status":"ok"}
|
# {"status":"ok"}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -173,11 +173,11 @@ make docker # build Docker image mcias:<version>
|
|||||||
## Admin CLI (mciasctl)
|
## Admin CLI (mciasctl)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
TOKEN=$(curl -sk https://localhost:8443/v1/auth/login \
|
TOKEN=$(curl -sk https://mcias.metacircular.net:8443/v1/auth/login \
|
||||||
-d '{"username":"admin","password":"..."}' | jq -r .token)
|
-d '{"username":"admin","password":"..."}' | jq -r .token)
|
||||||
export MCIAS_TOKEN=$TOKEN
|
export MCIAS_TOKEN=$TOKEN
|
||||||
|
|
||||||
mciasctl -server https://localhost:8443 account list
|
mciasctl -server https://mcias.metacircular.net:8443 account list
|
||||||
mciasctl account create -username alice # password prompted interactively
|
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
|
||||||
@@ -193,7 +193,7 @@ See `man mciasctl` for the full reference.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
export MCIAS_MASTER_PASSPHRASE=your-passphrase
|
export MCIAS_MASTER_PASSPHRASE=your-passphrase
|
||||||
CONF="--config /etc/mcias/mcias.conf"
|
CONF="--config /srv/mcias/mcias.toml"
|
||||||
|
|
||||||
mciasdb $CONF schema verify
|
mciasdb $CONF schema verify
|
||||||
mciasdb $CONF account list
|
mciasdb $CONF account list
|
||||||
@@ -217,22 +217,22 @@ Enable the gRPC listener in config:
|
|||||||
[server]
|
[server]
|
||||||
listen_addr = "0.0.0.0:8443"
|
listen_addr = "0.0.0.0:8443"
|
||||||
grpc_addr = "0.0.0.0:9443"
|
grpc_addr = "0.0.0.0:9443"
|
||||||
tls_cert = "/etc/mcias/server.crt"
|
tls_cert = "/srv/mcias/server.crt"
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/srv/mcias/server.key"
|
||||||
```
|
```
|
||||||
|
|
||||||
Using mciasgrpcctl:
|
Using mciasgrpcctl:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export MCIAS_TOKEN=$ADMIN_JWT
|
export MCIAS_TOKEN=$ADMIN_JWT
|
||||||
mciasgrpcctl -server auth.example.com:9443 -cacert /etc/mcias/server.crt health
|
mciasgrpcctl -server auth.example.com:9443 -cacert /srv/mcias/server.crt health
|
||||||
mciasgrpcctl account list
|
mciasgrpcctl account list
|
||||||
```
|
```
|
||||||
|
|
||||||
Using grpcurl:
|
Using grpcurl:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
grpcurl -cacert /etc/mcias/server.crt \
|
grpcurl -cacert /srv/mcias/server.crt \
|
||||||
-H "authorization: Bearer $ADMIN_JWT" \
|
-H "authorization: Bearer $ADMIN_JWT" \
|
||||||
auth.example.com:9443 \
|
auth.example.com:9443 \
|
||||||
mcias.v1.AdminService/Health
|
mcias.v1.AdminService/Health
|
||||||
@@ -245,7 +245,7 @@ See `man mciasgrpcctl` and [ARCHITECTURE.md](ARCHITECTURE.md) §17.
|
|||||||
## Web Management UI
|
## Web Management UI
|
||||||
|
|
||||||
mciassrv includes a built-in web interface for day-to-day administration.
|
mciassrv includes a built-in web interface for day-to-day administration.
|
||||||
After starting the server, navigate to `https://localhost:8443/login` and
|
After starting the server, navigate to `https://mcias.metacircular.net:8443/login` and
|
||||||
log in with an admin account.
|
log in with an admin account.
|
||||||
|
|
||||||
The UI provides:
|
The UI provides:
|
||||||
@@ -265,20 +265,19 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) §8 (Web Management UI) for design detail
|
|||||||
```sh
|
```sh
|
||||||
make docker
|
make docker
|
||||||
|
|
||||||
mkdir -p /srv/mcias/config
|
mkdir -p /srv/mcias
|
||||||
cp dist/mcias.conf.docker.example /srv/mcias/config/mcias.conf
|
cp dist/mcias.conf.docker.example /srv/mcias/mcias.toml
|
||||||
$EDITOR /srv/mcias/config/mcias.conf
|
$EDITOR /srv/mcias/mcias.toml
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name mcias \
|
--name mcias \
|
||||||
-v /srv/mcias/config:/etc/mcias:ro \
|
-v /srv/mcias:/srv/mcias \
|
||||||
-v mcias-data:/data \
|
|
||||||
-e MCIAS_MASTER_PASSPHRASE=your-passphrase \
|
-e MCIAS_MASTER_PASSPHRASE=your-passphrase \
|
||||||
-p 8443:8443 \
|
-p 8443:8443 \
|
||||||
-p 9443:9443 \
|
-p 9443:9443 \
|
||||||
mcias:latest
|
mcias:latest
|
||||||
|
|
||||||
curl -k https://localhost:8443/v1/health
|
curl -k https://mcias.metacircular.net:8443/v1/health
|
||||||
```
|
```
|
||||||
|
|
||||||
The container runs as uid 10001 (mcias) with no capabilities.
|
The container runs as uid 10001 (mcias) with no capabilities.
|
||||||
|
|||||||
464
RUNBOOK.md
Normal file
464
RUNBOOK.md
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
# MCIAS Runbook
|
||||||
|
|
||||||
|
Operational procedures for running and maintaining the MCIAS authentication
|
||||||
|
server. All required files live under `/srv/mcias`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/srv/mcias/
|
||||||
|
mcias.toml — server configuration (TOML)
|
||||||
|
server.crt — TLS certificate (PEM)
|
||||||
|
server.key — TLS private key (PEM, mode 0640)
|
||||||
|
mcias.db — SQLite database (WAL mode creates .db-wal and .db-shm)
|
||||||
|
env — environment file: MCIAS_MASTER_PASSPHRASE (mode 0640)
|
||||||
|
master.key — optional raw AES-256 key file (mode 0640, alternative to env)
|
||||||
|
```
|
||||||
|
|
||||||
|
All files are owned by the `mcias` system user and group (`mcias:mcias`).
|
||||||
|
The directory itself is mode `0750`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Run as root from the repository root after `make build`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh dist/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This script is idempotent. It:
|
||||||
|
1. Creates the `mcias` system user and group if they do not exist.
|
||||||
|
2. Installs binaries to `/usr/local/bin/`.
|
||||||
|
3. Creates `/srv/mcias/` with correct ownership and permissions.
|
||||||
|
4. Installs the systemd service unit to `/etc/systemd/system/mcias.service`.
|
||||||
|
5. Installs example config files to `/srv/mcias/` (will not overwrite existing files).
|
||||||
|
|
||||||
|
After installation, complete the steps below before starting the service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First-Run Setup
|
||||||
|
|
||||||
|
### 1. Generate a TLS certificate
|
||||||
|
|
||||||
|
**Self-signed (personal/development use):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl req -x509 -newkey ed25519 -days 3650 \
|
||||||
|
-keyout /srv/mcias/server.key \
|
||||||
|
-out /srv/mcias/server.crt \
|
||||||
|
-subj "/CN=auth.example.com" \
|
||||||
|
-nodes
|
||||||
|
chmod 0640 /srv/mcias/server.key
|
||||||
|
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Using the `cert` tool:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install github.com/kisom/cert@latest
|
||||||
|
|
||||||
|
cat > /tmp/request.yaml <<EOF
|
||||||
|
subject:
|
||||||
|
common_name: auth.example.com
|
||||||
|
hosts:
|
||||||
|
- auth.example.com
|
||||||
|
key:
|
||||||
|
algo: ecdsa
|
||||||
|
size: 521
|
||||||
|
ca:
|
||||||
|
expiry: 87600h
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cert genkey -a ec -s 521 > /srv/mcias/server.key
|
||||||
|
cert selfsign -p /srv/mcias/server.key -f /tmp/request.yaml > /srv/mcias/server.crt
|
||||||
|
chmod 0640 /srv/mcias/server.key
|
||||||
|
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
|
||||||
|
rm /tmp/request.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Write the configuration file
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp /srv/mcias/mcias.conf.example /srv/mcias/mcias.toml
|
||||||
|
$EDITOR /srv/mcias/mcias.toml
|
||||||
|
chmod 0640 /srv/mcias/mcias.toml
|
||||||
|
chown mcias:mcias /srv/mcias/mcias.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimum required settings:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
listen_addr = "0.0.0.0:8443"
|
||||||
|
tls_cert = "/srv/mcias/server.crt"
|
||||||
|
tls_key = "/srv/mcias/server.key"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
path = "/srv/mcias/mcias.db"
|
||||||
|
|
||||||
|
[tokens]
|
||||||
|
issuer = "https://auth.example.com"
|
||||||
|
|
||||||
|
[master_key]
|
||||||
|
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
||||||
|
```
|
||||||
|
|
||||||
|
See `dist/mcias.conf.example` for the full annotated reference.
|
||||||
|
|
||||||
|
### 3. Set the master key passphrase
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp /srv/mcias/mcias.env.example /srv/mcias/env
|
||||||
|
$EDITOR /srv/mcias/env # set MCIAS_MASTER_PASSPHRASE to a long random value
|
||||||
|
chmod 0640 /srv/mcias/env
|
||||||
|
chown mcias:mcias /srv/mcias/env
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a strong passphrase:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
> **IMPORTANT:** Back up the passphrase to a secure offline location.
|
||||||
|
> Losing it permanently destroys access to all encrypted data in the database.
|
||||||
|
|
||||||
|
### 4. Create the first admin account
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export MCIAS_MASTER_PASSPHRASE=your-passphrase
|
||||||
|
|
||||||
|
mciasdb --config /srv/mcias/mcias.toml account create \
|
||||||
|
--username admin --type human
|
||||||
|
# note the UUID printed
|
||||||
|
|
||||||
|
mciasdb --config /srv/mcias/mcias.toml account set-password --id <UUID>
|
||||||
|
mciasdb --config /srv/mcias/mcias.toml role grant --id <UUID> --role admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Enable and start the service
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl enable mcias
|
||||||
|
systemctl start mcias
|
||||||
|
systemctl status mcias
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Verify
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -k https://auth.example.com:8443/v1/health
|
||||||
|
# {"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routine Operations
|
||||||
|
|
||||||
|
### Start / stop / restart
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl start mcias
|
||||||
|
systemctl stop mcias
|
||||||
|
systemctl restart mcias
|
||||||
|
```
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
|
||||||
|
```sh
|
||||||
|
journalctl -u mcias -f
|
||||||
|
journalctl -u mcias --since "1 hour ago"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check service status
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl status mcias
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reload configuration
|
||||||
|
|
||||||
|
The server reads its configuration at startup only. To apply config changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl restart mcias
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Account Management
|
||||||
|
|
||||||
|
All account management can be done via `mciasctl` (REST API) when the server
|
||||||
|
is running, or `mciasdb` for offline/break-glass operations.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Set env for offline tool
|
||||||
|
export MCIAS_MASTER_PASSPHRASE=your-passphrase
|
||||||
|
CONF="--config /srv/mcias/mcias.toml"
|
||||||
|
|
||||||
|
# List accounts
|
||||||
|
mciasdb $CONF account list
|
||||||
|
|
||||||
|
# Create account
|
||||||
|
mciasdb $CONF account create --username alice --type human
|
||||||
|
|
||||||
|
# Set password (prompts interactively)
|
||||||
|
mciasdb $CONF account set-password --id <UUID>
|
||||||
|
|
||||||
|
# Grant or revoke a role
|
||||||
|
mciasdb $CONF role grant --id <UUID> --role admin
|
||||||
|
mciasdb $CONF role revoke --id <UUID> --role admin
|
||||||
|
|
||||||
|
# Disable account
|
||||||
|
mciasdb $CONF account set-status --id <UUID> --status inactive
|
||||||
|
|
||||||
|
# Delete account
|
||||||
|
mciasdb $CONF account set-status --id <UUID> --status deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token Management
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CONF="--config /srv/mcias/mcias.toml"
|
||||||
|
|
||||||
|
# List active tokens for an account
|
||||||
|
mciasdb $CONF token list --id <UUID>
|
||||||
|
|
||||||
|
# Revoke a specific token by JTI
|
||||||
|
mciasdb $CONF token revoke --jti <JTI>
|
||||||
|
|
||||||
|
# Revoke all tokens for an account (e.g., suspected compromise)
|
||||||
|
mciasdb $CONF token revoke-all --id <UUID>
|
||||||
|
|
||||||
|
# Prune expired tokens from the database
|
||||||
|
mciasdb $CONF prune tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Maintenance
|
||||||
|
|
||||||
|
### Verify schema
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasdb --config /srv/mcias/mcias.toml schema verify
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run pending migrations
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasdb --config /srv/mcias/mcias.toml schema migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force schema version (break-glass)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasdb --config /srv/mcias/mcias.toml schema force --version N
|
||||||
|
```
|
||||||
|
|
||||||
|
Use only when `schema migrate` reports a dirty version after a failed migration.
|
||||||
|
|
||||||
|
### Backup the database
|
||||||
|
|
||||||
|
SQLite WAL mode creates three files. Back up all three atomically using the
|
||||||
|
SQLite backup API or by stopping the server first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Online backup (preferred — no downtime):
|
||||||
|
sqlite3 /srv/mcias/mcias.db ".backup /path/to/backup/mcias-$(date +%F).db"
|
||||||
|
|
||||||
|
# Offline backup:
|
||||||
|
systemctl stop mcias
|
||||||
|
cp /srv/mcias/mcias.db /path/to/backup/mcias-$(date +%F).db
|
||||||
|
systemctl start mcias
|
||||||
|
```
|
||||||
|
|
||||||
|
Store backups alongside a copy of the master key passphrase in a secure
|
||||||
|
offline location. A database backup without the passphrase is unrecoverable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Log
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CONF="--config /srv/mcias/mcias.toml"
|
||||||
|
|
||||||
|
# Show last 50 audit events
|
||||||
|
mciasdb $CONF audit tail --n 50
|
||||||
|
|
||||||
|
# Query by account
|
||||||
|
mciasdb $CONF audit query --account <UUID>
|
||||||
|
|
||||||
|
# Query by event type since a given time
|
||||||
|
mciasdb $CONF audit query --type login_failure --since 2026-01-01T00:00:00Z
|
||||||
|
|
||||||
|
# Output as JSON (for log shipping)
|
||||||
|
mciasdb $CONF audit query --json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
1. Build the new binaries: `make build`
|
||||||
|
2. Stop the service: `systemctl stop mcias`
|
||||||
|
3. Install new binaries: `sh dist/install.sh`
|
||||||
|
- The script will not overwrite existing config files.
|
||||||
|
- New example files are placed with a `.new` suffix for review.
|
||||||
|
4. Review any `.new` config files in `/srv/mcias/` and merge changes manually.
|
||||||
|
5. Run schema migrations if required:
|
||||||
|
```sh
|
||||||
|
mciasdb --config /srv/mcias/mcias.toml schema migrate
|
||||||
|
```
|
||||||
|
6. Start the service: `systemctl start mcias`
|
||||||
|
7. Verify: `curl -k https://auth.example.com:8443/v1/health`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Master Key Rotation
|
||||||
|
|
||||||
|
> This operation is not yet automated. Until a rotation command is
|
||||||
|
> implemented, rotation requires a full re-encryption of the database.
|
||||||
|
> Contact the project maintainer for the current procedure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TLS Certificate Renewal
|
||||||
|
|
||||||
|
Replace the certificate and key files, then restart the server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Generate or obtain new cert/key, then:
|
||||||
|
cp new-server.crt /srv/mcias/server.crt
|
||||||
|
cp new-server.key /srv/mcias/server.key
|
||||||
|
chmod 0640 /srv/mcias/server.key
|
||||||
|
chown mcias:mcias /srv/mcias/server.crt /srv/mcias/server.key
|
||||||
|
systemctl restart mcias
|
||||||
|
```
|
||||||
|
|
||||||
|
For Let's Encrypt with Certbot, add a deploy hook:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# /etc/letsencrypt/renewal-hooks/deploy/mcias.sh
|
||||||
|
#!/bin/sh
|
||||||
|
cp /etc/letsencrypt/live/auth.example.com/fullchain.pem /srv/mcias/server.crt
|
||||||
|
cp /etc/letsencrypt/live/auth.example.com/privkey.pem /srv/mcias/server.key
|
||||||
|
chmod 0640 /srv/mcias/server.key
|
||||||
|
chown mcias:mcias /srv/mcias/server.crt /srv/mcias/server.key
|
||||||
|
systemctl restart mcias
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make docker
|
||||||
|
|
||||||
|
mkdir -p /srv/mcias
|
||||||
|
cp dist/mcias.conf.docker.example /srv/mcias/mcias.toml
|
||||||
|
$EDITOR /srv/mcias/mcias.toml
|
||||||
|
|
||||||
|
# Place TLS cert and key under /srv/mcias/
|
||||||
|
# Set ownership so uid 10001 (container mcias user) can read them.
|
||||||
|
chown -R 10001:10001 /srv/mcias
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name mcias \
|
||||||
|
-v /srv/mcias:/srv/mcias \
|
||||||
|
-e MCIAS_MASTER_PASSPHRASE=your-passphrase \
|
||||||
|
-p 8443:8443 \
|
||||||
|
-p 9443:9443 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
mcias:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
See `dist/mcias.conf.docker.example` for the full annotated Docker config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Server fails to start: "open database"
|
||||||
|
|
||||||
|
Check that `/srv/mcias/` is writable by the `mcias` user:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -la /srv/mcias/
|
||||||
|
stat /srv/mcias/mcias.db # if it already exists
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix: `chown mcias:mcias /srv/mcias`
|
||||||
|
|
||||||
|
### Server fails to start: "environment variable ... is not set"
|
||||||
|
|
||||||
|
The `MCIAS_MASTER_PASSPHRASE` env var is missing. Ensure `/srv/mcias/env`
|
||||||
|
exists, is readable by the mcias user, and contains the correct variable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
grep MCIAS_MASTER_PASSPHRASE /srv/mcias/env
|
||||||
|
```
|
||||||
|
|
||||||
|
Also confirm the systemd unit loads it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl cat mcias | grep EnvironmentFile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server fails to start: "decrypt signing key"
|
||||||
|
|
||||||
|
The master key passphrase has changed or is wrong. The passphrase must match
|
||||||
|
the one used when the database was first initialized (the KDF salt is stored
|
||||||
|
in the database). Restore the correct passphrase from your offline backup.
|
||||||
|
|
||||||
|
### TLS errors in client connections
|
||||||
|
|
||||||
|
Verify the certificate is valid and covers the correct hostname:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl x509 -in /srv/mcias/server.crt -noout -text | grep -E "Subject|DNS"
|
||||||
|
openssl x509 -in /srv/mcias/server.crt -noout -dates
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database locked / WAL not cleaning up
|
||||||
|
|
||||||
|
Check for lingering `mcias.db-wal` and `mcias.db-shm` files after an unclean
|
||||||
|
shutdown. These are safe to leave in place — SQLite will recover on next open.
|
||||||
|
Do not delete them while the server is running.
|
||||||
|
|
||||||
|
### Schema dirty after failed migration
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasdb --config /srv/mcias/mcias.toml schema verify
|
||||||
|
mciasdb --config /srv/mcias/mcias.toml schema force --version N
|
||||||
|
mciasdb --config /srv/mcias/mcias.toml schema migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `N` with the last successfully applied version number.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Permissions Reference
|
||||||
|
|
||||||
|
| Path | Mode | Owner |
|
||||||
|
|------|------|-------|
|
||||||
|
| `/srv/mcias/` | `0750` | `mcias:mcias` |
|
||||||
|
| `/srv/mcias/mcias.toml` | `0640` | `mcias:mcias` |
|
||||||
|
| `/srv/mcias/server.crt` | `0644` | `mcias:mcias` |
|
||||||
|
| `/srv/mcias/server.key` | `0640` | `mcias:mcias` |
|
||||||
|
| `/srv/mcias/mcias.db` | `0640` | `mcias:mcias` |
|
||||||
|
| `/srv/mcias/env` | `0640` | `mcias:mcias` |
|
||||||
|
| `/srv/mcias/master.key` | `0640` | `mcias:mcias` |
|
||||||
|
|
||||||
|
Verify permissions:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -la /srv/mcias/
|
||||||
|
```
|
||||||
@@ -15,10 +15,10 @@ go get git.wntrmute.dev/kyle/mcias/clients/go
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go"
|
import "git.wntrmute.dev/kyle/mcias/clients/go/mcias"
|
||||||
|
|
||||||
// Connect to the MCIAS server.
|
// Connect to the MCIAS server.
|
||||||
client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{})
|
client, err := mcias.New("https://auth.example.com", mcias.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ if err := client.Logout(); err != nil {
|
|||||||
## Custom CA Certificate
|
## Custom CA Certificate
|
||||||
|
|
||||||
```go
|
```go
|
||||||
client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{
|
client, err := mcias.New("https://auth.example.com", mcias.Options{
|
||||||
CACertPath: "/etc/mcias/ca.pem",
|
CACertPath: "/etc/mcias/ca.pem",
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -55,17 +55,17 @@ All methods return typed errors:
|
|||||||
```go
|
```go
|
||||||
_, _, err := client.Login("alice", "wrongpass", "")
|
_, _, err := client.Login("alice", "wrongpass", "")
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, new(mciasgoclient.MciasAuthError)):
|
case errors.Is(err, new(mcias.MciasAuthError)):
|
||||||
// 401 — wrong credentials or token invalid
|
// 401 — wrong credentials or token invalid
|
||||||
case errors.Is(err, new(mciasgoclient.MciasForbiddenError)):
|
case errors.Is(err, new(mcias.MciasForbiddenError)):
|
||||||
// 403 — insufficient role
|
// 403 — insufficient role
|
||||||
case errors.Is(err, new(mciasgoclient.MciasNotFoundError)):
|
case errors.Is(err, new(mcias.MciasNotFoundError)):
|
||||||
// 404 — resource not found
|
// 404 — resource not found
|
||||||
case errors.Is(err, new(mciasgoclient.MciasInputError)):
|
case errors.Is(err, new(mcias.MciasInputError)):
|
||||||
// 400 — malformed request
|
// 400 — malformed request
|
||||||
case errors.Is(err, new(mciasgoclient.MciasConflictError)):
|
case errors.Is(err, new(mcias.MciasConflictError)):
|
||||||
// 409 — conflict (e.g. duplicate username)
|
// 409 — conflict (e.g. duplicate username)
|
||||||
case errors.Is(err, new(mciasgoclient.MciasServerError)):
|
case errors.Is(err, new(mcias.MciasServerError)):
|
||||||
// 5xx — unexpected server error
|
// 5xx — unexpected server error
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Package mciasgoclient provides a thread-safe Go client for the MCIAS REST API.
|
// Package mcias provides a thread-safe Go client for the MCIAS REST API.
|
||||||
//
|
//
|
||||||
// Security: bearer tokens are stored under a sync.RWMutex and are never written
|
// Security: bearer tokens are stored under a sync.RWMutex and are never written
|
||||||
// to logs or included in error messages anywhere in this package.
|
// to logs or included in error messages anywhere in this package.
|
||||||
package mciasgoclient
|
package mcias
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
@@ -15,32 +16,43 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Error types
|
// Error types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// MciasError is the base error type for all MCIAS client errors.
|
// MciasError is the base error type for all MCIAS client errors.
|
||||||
type MciasError struct {
|
type MciasError struct {
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *MciasError) Error() string {
|
func (e *MciasError) Error() string {
|
||||||
return fmt.Sprintf("mciasgoclient: HTTP %d: %s", e.StatusCode, e.Message)
|
return fmt.Sprintf("mcias: HTTP %d: %s", e.StatusCode, e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MciasAuthError is returned for 401 Unauthorized responses.
|
// MciasAuthError is returned for 401 Unauthorized responses.
|
||||||
type MciasAuthError struct{ MciasError }
|
type MciasAuthError struct{ MciasError }
|
||||||
|
|
||||||
// MciasForbiddenError is returned for 403 Forbidden responses.
|
// MciasForbiddenError is returned for 403 Forbidden responses.
|
||||||
type MciasForbiddenError struct{ MciasError }
|
type MciasForbiddenError struct{ MciasError }
|
||||||
|
|
||||||
// MciasNotFoundError is returned for 404 Not Found responses.
|
// MciasNotFoundError is returned for 404 Not Found responses.
|
||||||
type MciasNotFoundError struct{ MciasError }
|
type MciasNotFoundError struct{ MciasError }
|
||||||
|
|
||||||
// MciasInputError is returned for 400 Bad Request responses.
|
// MciasInputError is returned for 400 Bad Request responses.
|
||||||
type MciasInputError struct{ MciasError }
|
type MciasInputError struct{ MciasError }
|
||||||
|
|
||||||
// MciasConflictError is returned for 409 Conflict responses.
|
// MciasConflictError is returned for 409 Conflict responses.
|
||||||
type MciasConflictError struct{ MciasError }
|
type MciasConflictError struct{ MciasError }
|
||||||
|
|
||||||
// MciasServerError is returned for 5xx responses.
|
// MciasServerError is returned for 5xx responses.
|
||||||
type MciasServerError struct{ MciasError }
|
type MciasServerError struct{ MciasError }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Data types
|
// Data types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Account represents a user or service account.
|
// Account represents a user or service account.
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -51,6 +63,7 @@ type Account struct {
|
|||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
TOTPEnabled bool `json:"totp_enabled"`
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicKey represents the server's Ed25519 public key in JWK format.
|
// PublicKey represents the server's Ed25519 public key in JWK format.
|
||||||
type PublicKey struct {
|
type PublicKey struct {
|
||||||
Kty string `json:"kty"`
|
Kty string `json:"kty"`
|
||||||
@@ -59,13 +72,16 @@ type PublicKey struct {
|
|||||||
Use string `json:"use,omitempty"`
|
Use string `json:"use,omitempty"`
|
||||||
Alg string `json:"alg,omitempty"`
|
Alg string `json:"alg,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenClaims is returned by ValidateToken.
|
// TokenClaims is returned by ValidateToken.
|
||||||
type TokenClaims struct {
|
type TokenClaims struct {
|
||||||
Valid bool `json:"valid"`
|
Valid bool `json:"valid"`
|
||||||
Sub string `json:"sub,omitempty"`
|
Sub string `json:"sub,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
Roles []string `json:"roles,omitempty"`
|
Roles []string `json:"roles,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at,omitempty"`
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PGCreds holds Postgres connection credentials.
|
// PGCreds holds Postgres connection credentials.
|
||||||
type PGCreds struct {
|
type PGCreds struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
@@ -74,9 +90,94 @@ type PGCreds struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTPEnrollResponse is returned by EnrollTOTP.
|
||||||
|
type TOTPEnrollResponse struct {
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
OTPAuthURI string `json:"otpauth_uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditEvent is a single entry in the audit log.
|
||||||
|
type AuditEvent struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
EventTime string `json:"event_time"`
|
||||||
|
ActorID string `json:"actor_id,omitempty"`
|
||||||
|
TargetID string `json:"target_id,omitempty"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditListResponse is returned by ListAudit.
|
||||||
|
type AuditListResponse struct {
|
||||||
|
Events []AuditEvent `json:"events"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditFilter holds optional filter parameters for ListAudit.
|
||||||
|
type AuditFilter struct {
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
EventType string
|
||||||
|
ActorID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyRuleBody holds the match conditions and effect of a policy rule.
|
||||||
|
// All fields except Effect are optional; an omitted field acts as a wildcard.
|
||||||
|
type PolicyRuleBody struct {
|
||||||
|
Effect string `json:"effect"`
|
||||||
|
Roles []string `json:"roles,omitempty"`
|
||||||
|
AccountTypes []string `json:"account_types,omitempty"`
|
||||||
|
SubjectUUID string `json:"subject_uuid,omitempty"`
|
||||||
|
Actions []string `json:"actions,omitempty"`
|
||||||
|
ResourceType string `json:"resource_type,omitempty"`
|
||||||
|
OwnerMatchesSubject bool `json:"owner_matches_subject,omitempty"`
|
||||||
|
ServiceNames []string `json:"service_names,omitempty"`
|
||||||
|
RequiredTags []string `json:"required_tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyRule is a complete operator-defined policy rule as returned by the API.
|
||||||
|
type PolicyRule struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Rule PolicyRuleBody `json:"rule"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
NotBefore string `json:"not_before,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePolicyRuleRequest holds the parameters for creating a policy rule.
|
||||||
|
type CreatePolicyRuleRequest struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Priority int `json:"priority,omitempty"`
|
||||||
|
Rule PolicyRuleBody `json:"rule"`
|
||||||
|
NotBefore string `json:"not_before,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePolicyRuleRequest holds the parameters for updating a policy rule.
|
||||||
|
// All fields are optional; omitted fields are left unchanged.
|
||||||
|
// Set ClearNotBefore or ClearExpiresAt to true to remove those constraints.
|
||||||
|
type UpdatePolicyRuleRequest struct {
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Priority *int `json:"priority,omitempty"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
Rule *PolicyRuleBody `json:"rule,omitempty"`
|
||||||
|
NotBefore string `json:"not_before,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
ClearNotBefore bool `json:"clear_not_before,omitempty"`
|
||||||
|
ClearExpiresAt bool `json:"clear_expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Options and Client struct
|
// Options and Client struct
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Options configures the MCIAS client.
|
// Options configures the MCIAS client.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
// CACertPath is an optional path to a PEM-encoded CA certificate for TLS
|
// CACertPath is an optional path to a PEM-encoded CA certificate for TLS
|
||||||
@@ -85,6 +186,7 @@ type Options struct {
|
|||||||
// Token is an optional pre-existing bearer token.
|
// Token is an optional pre-existing bearer token.
|
||||||
Token string
|
Token string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client is a thread-safe MCIAS REST API client.
|
// Client is a thread-safe MCIAS REST API client.
|
||||||
// Security: the bearer token is guarded by a sync.RWMutex; it is never
|
// Security: the bearer token is guarded by a sync.RWMutex; it is never
|
||||||
// written to logs or included in error messages in this library.
|
// written to logs or included in error messages in this library.
|
||||||
@@ -94,9 +196,11 @@ type Client struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
token string
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constructor
|
// Constructor
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// New creates a new Client for the given serverURL.
|
// New creates a new Client for the given serverURL.
|
||||||
// TLS 1.2 is the minimum version enforced on all connections.
|
// TLS 1.2 is the minimum version enforced on all connections.
|
||||||
// If opts.CACertPath is set, that CA certificate is added to the trust pool.
|
// If opts.CACertPath is set, that CA certificate is added to the trust pool.
|
||||||
@@ -126,20 +230,24 @@ func New(serverURL string, opts Options) (*Client, error) {
|
|||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token returns the current bearer token (empty string if not logged in).
|
// Token returns the current bearer token (empty string if not logged in).
|
||||||
func (c *Client) Token() string {
|
func (c *Client) Token() string {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return c.token
|
return c.token
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Internal helpers
|
// Internal helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func (c *Client) setToken(tok string) {
|
func (c *Client) setToken(tok string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.token = tok
|
c.token = tok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) do(method, path string, body interface{}, out interface{}) error {
|
func (c *Client) do(method, path string, body interface{}, out interface{}) error {
|
||||||
var reqBody io.Reader
|
var reqBody io.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
@@ -195,6 +303,7 @@ func (c *Client) do(method, path string, body interface{}, out interface{}) erro
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeError(status int, msg string) error {
|
func makeError(status int, msg string) error {
|
||||||
base := MciasError{StatusCode: status, Message: msg}
|
base := MciasError{StatusCode: status, Message: msg}
|
||||||
switch {
|
switch {
|
||||||
@@ -212,13 +321,16 @@ func makeError(status int, msg string) error {
|
|||||||
return &MciasServerError{base}
|
return &MciasServerError{base}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// API methods
|
// API methods — Public
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Health calls GET /v1/health. Returns nil if the server is healthy.
|
// Health calls GET /v1/health. Returns nil if the server is healthy.
|
||||||
func (c *Client) Health() error {
|
func (c *Client) Health() error {
|
||||||
return c.do(http.MethodGet, "/v1/health", nil, nil)
|
return c.do(http.MethodGet, "/v1/health", nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicKey returns the server's Ed25519 public key in JWK format.
|
// GetPublicKey returns the server's Ed25519 public key in JWK format.
|
||||||
func (c *Client) GetPublicKey() (*PublicKey, error) {
|
func (c *Client) GetPublicKey() (*PublicKey, error) {
|
||||||
var pk PublicKey
|
var pk PublicKey
|
||||||
@@ -227,6 +339,7 @@ func (c *Client) GetPublicKey() (*PublicKey, error) {
|
|||||||
}
|
}
|
||||||
return &pk, nil
|
return &pk, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login authenticates with username and password. On success the token is
|
// Login authenticates with username and password. On success the token is
|
||||||
// stored in the Client and returned along with the expiry timestamp.
|
// stored in the Client and returned along with the expiry timestamp.
|
||||||
// totpCode may be empty for accounts without TOTP.
|
// totpCode may be empty for accounts without TOTP.
|
||||||
@@ -245,6 +358,23 @@ func (c *Client) Login(username, password, totpCode string) (token, expiresAt st
|
|||||||
c.setToken(resp.Token)
|
c.setToken(resp.Token)
|
||||||
return resp.Token, resp.ExpiresAt, nil
|
return resp.Token, resp.ExpiresAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a token string against the server.
|
||||||
|
// Returns claims; Valid is false (not an error) if the token is expired or
|
||||||
|
// revoked.
|
||||||
|
func (c *Client) ValidateToken(token string) (*TokenClaims, error) {
|
||||||
|
var claims TokenClaims
|
||||||
|
if err := c.do(http.MethodPost, "/v1/token/validate",
|
||||||
|
map[string]string{"token": token}, &claims); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API methods — Authenticated
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Logout revokes the current token on the server and clears it from the client.
|
// Logout revokes the current token on the server and clears it from the client.
|
||||||
func (c *Client) Logout() error {
|
func (c *Client) Logout() error {
|
||||||
if err := c.do(http.MethodPost, "/v1/auth/logout", nil, nil); err != nil {
|
if err := c.do(http.MethodPost, "/v1/auth/logout", nil, nil); err != nil {
|
||||||
@@ -253,6 +383,7 @@ func (c *Client) Logout() error {
|
|||||||
c.setToken("")
|
c.setToken("")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenewToken exchanges the current token for a fresh one.
|
// RenewToken exchanges the current token for a fresh one.
|
||||||
// The new token is stored in the client and returned.
|
// The new token is stored in the client and returned.
|
||||||
func (c *Client) RenewToken() (token, expiresAt string, err error) {
|
func (c *Client) RenewToken() (token, expiresAt string, err error) {
|
||||||
@@ -266,17 +397,69 @@ func (c *Client) RenewToken() (token, expiresAt string, err error) {
|
|||||||
c.setToken(resp.Token)
|
c.setToken(resp.Token)
|
||||||
return resp.Token, resp.ExpiresAt, nil
|
return resp.Token, resp.ExpiresAt, nil
|
||||||
}
|
}
|
||||||
// ValidateToken validates a token string against the server.
|
|
||||||
// Returns claims; Valid is false (not an error) if the token is expired or
|
// EnrollTOTP begins TOTP enrollment for the authenticated account.
|
||||||
// revoked.
|
// Returns a base32 secret and an otpauth:// URI for QR-code generation.
|
||||||
func (c *Client) ValidateToken(token string) (*TokenClaims, error) {
|
// The secret is shown once; it is not retrievable after this call.
|
||||||
var claims TokenClaims
|
// TOTP is not enforced until confirmed via ConfirmTOTP.
|
||||||
if err := c.do(http.MethodPost, "/v1/token/validate",
|
//
|
||||||
map[string]string{"token": token}, &claims); err != nil {
|
// Security (SEC-01): the current password is required to prevent a stolen
|
||||||
|
// session token from being used to enroll attacker-controlled TOTP.
|
||||||
|
func (c *Client) EnrollTOTP(password string) (*TOTPEnrollResponse, error) {
|
||||||
|
var resp TOTPEnrollResponse
|
||||||
|
body := struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}{Password: password}
|
||||||
|
if err := c.do(http.MethodPost, "/v1/auth/totp/enroll", body, &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &claims, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfirmTOTP completes TOTP enrollment by verifying the current code against
|
||||||
|
// the pending secret. On success, TOTP becomes required for all future logins.
|
||||||
|
func (c *Client) ConfirmTOTP(code string) error {
|
||||||
|
return c.do(http.MethodPost, "/v1/auth/totp/confirm",
|
||||||
|
map[string]string{"code": code}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword changes the password of the currently authenticated human
|
||||||
|
// account. currentPassword is required to prevent token-theft attacks.
|
||||||
|
// On success, all active sessions except the caller's are revoked.
|
||||||
|
//
|
||||||
|
// Security: both passwords are transmitted over TLS only; the server verifies
|
||||||
|
// currentPassword with constant-time comparison before accepting the change.
|
||||||
|
func (c *Client) ChangePassword(currentPassword, newPassword string) error {
|
||||||
|
return c.do(http.MethodPut, "/v1/auth/password", map[string]string{
|
||||||
|
"current_password": currentPassword,
|
||||||
|
"new_password": newPassword,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API methods — Admin: Auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RemoveTOTP clears TOTP enrollment for the given account (admin).
|
||||||
|
// Use for account recovery when a user has lost their TOTP device.
|
||||||
|
func (c *Client) RemoveTOTP(accountID string) error {
|
||||||
|
return c.do(http.MethodDelete, "/v1/auth/totp",
|
||||||
|
map[string]string{"account_id": accountID}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API methods — Admin: Accounts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ListAccounts returns all accounts. Requires admin role.
|
||||||
|
func (c *Client) ListAccounts() ([]Account, error) {
|
||||||
|
var accounts []Account
|
||||||
|
if err := c.do(http.MethodGet, "/v1/accounts", nil, &accounts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return accounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateAccount creates a new account. accountType is "human" or "system".
|
// CreateAccount creates a new account. accountType is "human" or "system".
|
||||||
// password is required for human accounts.
|
// password is required for human accounts.
|
||||||
func (c *Client) CreateAccount(username, accountType, password string) (*Account, error) {
|
func (c *Client) CreateAccount(username, accountType, password string) (*Account, error) {
|
||||||
@@ -293,14 +476,7 @@ func (c *Client) CreateAccount(username, accountType, password string) (*Account
|
|||||||
}
|
}
|
||||||
return &acct, nil
|
return &acct, nil
|
||||||
}
|
}
|
||||||
// ListAccounts returns all accounts. Requires admin role.
|
|
||||||
func (c *Client) ListAccounts() ([]Account, error) {
|
|
||||||
var accounts []Account
|
|
||||||
if err := c.do(http.MethodGet, "/v1/accounts", nil, &accounts); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return accounts, nil
|
|
||||||
}
|
|
||||||
// GetAccount returns the account with the given ID. Requires admin role.
|
// GetAccount returns the account with the given ID. Requires admin role.
|
||||||
func (c *Client) GetAccount(id string) (*Account, error) {
|
func (c *Client) GetAccount(id string) (*Account, error) {
|
||||||
var acct Account
|
var acct Account
|
||||||
@@ -309,23 +485,22 @@ func (c *Client) GetAccount(id string) (*Account, error) {
|
|||||||
}
|
}
|
||||||
return &acct, nil
|
return &acct, nil
|
||||||
}
|
}
|
||||||
// UpdateAccount updates mutable account fields. Requires admin role.
|
|
||||||
// Pass an empty string for fields that should not be changed.
|
// UpdateAccount updates mutable account fields (currently only status).
|
||||||
func (c *Client) UpdateAccount(id, status string) (*Account, error) {
|
// Requires admin role. Returns nil on success (HTTP 204).
|
||||||
|
func (c *Client) UpdateAccount(id, status string) error {
|
||||||
req := map[string]string{}
|
req := map[string]string{}
|
||||||
if status != "" {
|
if status != "" {
|
||||||
req["status"] = status
|
req["status"] = status
|
||||||
}
|
}
|
||||||
var acct Account
|
return c.do(http.MethodPatch, "/v1/accounts/"+id, req, nil)
|
||||||
if err := c.do(http.MethodPatch, "/v1/accounts/"+id, req, &acct); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &acct, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAccount soft-deletes the account with the given ID. Requires admin.
|
// DeleteAccount soft-deletes the account with the given ID. Requires admin.
|
||||||
func (c *Client) DeleteAccount(id string) error {
|
func (c *Client) DeleteAccount(id string) error {
|
||||||
return c.do(http.MethodDelete, "/v1/accounts/"+id, nil, nil)
|
return c.do(http.MethodDelete, "/v1/accounts/"+id, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRoles returns the roles for accountID. Requires admin.
|
// GetRoles returns the roles for accountID. Requires admin.
|
||||||
func (c *Client) GetRoles(accountID string) ([]string, error) {
|
func (c *Client) GetRoles(accountID string) ([]string, error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
@@ -336,11 +511,49 @@ func (c *Client) GetRoles(accountID string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
return resp.Roles, nil
|
return resp.Roles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRoles replaces the role set for accountID. Requires admin.
|
// SetRoles replaces the role set for accountID. Requires admin.
|
||||||
func (c *Client) SetRoles(accountID string, roles []string) error {
|
func (c *Client) SetRoles(accountID string, roles []string) error {
|
||||||
return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/roles",
|
return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/roles",
|
||||||
map[string][]string{"roles": roles}, nil)
|
map[string][]string{"roles": roles}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminSetPassword resets a human account's password without requiring the
|
||||||
|
// current password. Requires admin. All active sessions for the target account
|
||||||
|
// are revoked on success.
|
||||||
|
func (c *Client) AdminSetPassword(accountID, newPassword string) error {
|
||||||
|
return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/password",
|
||||||
|
map[string]string{"new_password": newPassword}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccountTags returns the current tag set for an account. Requires admin.
|
||||||
|
func (c *Client) GetAccountTags(accountID string) ([]string, error) {
|
||||||
|
var resp struct {
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
if err := c.do(http.MethodGet, "/v1/accounts/"+accountID+"/tags", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAccountTags replaces the full tag set for an account atomically.
|
||||||
|
// Pass an empty slice to clear all tags. Requires admin.
|
||||||
|
func (c *Client) SetAccountTags(accountID string, tags []string) ([]string, error) {
|
||||||
|
var resp struct {
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
if err := c.do(http.MethodPut, "/v1/accounts/"+accountID+"/tags",
|
||||||
|
map[string][]string{"tags": tags}, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API methods — Admin: Tokens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// IssueServiceToken issues a long-lived token for a system account. Requires admin.
|
// IssueServiceToken issues a long-lived token for a system account. Requires admin.
|
||||||
func (c *Client) IssueServiceToken(accountID string) (token, expiresAt string, err error) {
|
func (c *Client) IssueServiceToken(accountID string) (token, expiresAt string, err error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
@@ -353,10 +566,16 @@ func (c *Client) IssueServiceToken(accountID string) (token, expiresAt string, e
|
|||||||
}
|
}
|
||||||
return resp.Token, resp.ExpiresAt, nil
|
return resp.Token, resp.ExpiresAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeToken revokes a token by JTI. Requires admin.
|
// RevokeToken revokes a token by JTI. Requires admin.
|
||||||
func (c *Client) RevokeToken(jti string) error {
|
func (c *Client) RevokeToken(jti string) error {
|
||||||
return c.do(http.MethodDelete, "/v1/token/"+jti, nil, nil)
|
return c.do(http.MethodDelete, "/v1/token/"+jti, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API methods — Admin: Credentials
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// GetPGCreds returns Postgres credentials for accountID. Requires admin.
|
// GetPGCreds returns Postgres credentials for accountID. Requires admin.
|
||||||
func (c *Client) GetPGCreds(accountID string) (*PGCreds, error) {
|
func (c *Client) GetPGCreds(accountID string) (*PGCreds, error) {
|
||||||
var creds PGCreds
|
var creds PGCreds
|
||||||
@@ -365,6 +584,7 @@ func (c *Client) GetPGCreds(accountID string) (*PGCreds, error) {
|
|||||||
}
|
}
|
||||||
return &creds, nil
|
return &creds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPGCreds stores Postgres credentials for accountID. Requires admin.
|
// SetPGCreds stores Postgres credentials for accountID. Requires admin.
|
||||||
// The password is sent over TLS and encrypted at rest server-side.
|
// The password is sent over TLS and encrypted at rest server-side.
|
||||||
func (c *Client) SetPGCreds(accountID, host string, port int, database, username, password string) error {
|
func (c *Client) SetPGCreds(accountID, host string, port int, database, username, password string) error {
|
||||||
@@ -376,3 +596,78 @@ func (c *Client) SetPGCreds(accountID, host string, port int, database, username
|
|||||||
"password": password,
|
"password": password,
|
||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API methods — Admin: Audit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ListAudit retrieves audit log entries, newest first. Requires admin.
|
||||||
|
// f may be zero-valued to use defaults (limit=50, offset=0, no filter).
|
||||||
|
func (c *Client) ListAudit(f AuditFilter) (*AuditListResponse, error) {
|
||||||
|
path := "/v1/audit?"
|
||||||
|
if f.Limit > 0 {
|
||||||
|
path += fmt.Sprintf("limit=%d&", f.Limit)
|
||||||
|
}
|
||||||
|
if f.Offset > 0 {
|
||||||
|
path += fmt.Sprintf("offset=%d&", f.Offset)
|
||||||
|
}
|
||||||
|
if f.EventType != "" {
|
||||||
|
path += fmt.Sprintf("event_type=%s&", f.EventType)
|
||||||
|
}
|
||||||
|
if f.ActorID != "" {
|
||||||
|
path += fmt.Sprintf("actor_id=%s&", f.ActorID)
|
||||||
|
}
|
||||||
|
path = strings.TrimRight(path, "&?")
|
||||||
|
var resp AuditListResponse
|
||||||
|
if err := c.do(http.MethodGet, path, nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API methods — Admin: Policy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ListPolicyRules returns all operator-defined policy rules ordered by
|
||||||
|
// priority (ascending). Requires admin.
|
||||||
|
func (c *Client) ListPolicyRules() ([]PolicyRule, error) {
|
||||||
|
var rules []PolicyRule
|
||||||
|
if err := c.do(http.MethodGet, "/v1/policy/rules", nil, &rules); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePolicyRule creates a new policy rule. Requires admin.
|
||||||
|
func (c *Client) CreatePolicyRule(req CreatePolicyRuleRequest) (*PolicyRule, error) {
|
||||||
|
var rule PolicyRule
|
||||||
|
if err := c.do(http.MethodPost, "/v1/policy/rules", req, &rule); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPolicyRule returns a single policy rule by integer ID. Requires admin.
|
||||||
|
func (c *Client) GetPolicyRule(id int) (*PolicyRule, error) {
|
||||||
|
var rule PolicyRule
|
||||||
|
if err := c.do(http.MethodGet, fmt.Sprintf("/v1/policy/rules/%d", id), nil, &rule); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePolicyRule updates one or more fields of an existing policy rule.
|
||||||
|
// Requires admin.
|
||||||
|
func (c *Client) UpdatePolicyRule(id int, req UpdatePolicyRuleRequest) (*PolicyRule, error) {
|
||||||
|
var rule PolicyRule
|
||||||
|
if err := c.do(http.MethodPatch, fmt.Sprintf("/v1/policy/rules/%d", id), req, &rule); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePolicyRule permanently deletes a policy rule. Requires admin.
|
||||||
|
func (c *Client) DeletePolicyRule(id int) error {
|
||||||
|
return c.do(http.MethodDelete, fmt.Sprintf("/v1/policy/rules/%d", id), nil, nil)
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,10 @@ from ._errors import (
|
|||||||
MciasForbiddenError,
|
MciasForbiddenError,
|
||||||
MciasInputError,
|
MciasInputError,
|
||||||
MciasNotFoundError,
|
MciasNotFoundError,
|
||||||
|
MciasRateLimitError,
|
||||||
MciasServerError,
|
MciasServerError,
|
||||||
)
|
)
|
||||||
from ._models import Account, PGCreds, PublicKey, TokenClaims
|
from ._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Client",
|
"Client",
|
||||||
@@ -19,9 +20,12 @@ __all__ = [
|
|||||||
"MciasNotFoundError",
|
"MciasNotFoundError",
|
||||||
"MciasInputError",
|
"MciasInputError",
|
||||||
"MciasConflictError",
|
"MciasConflictError",
|
||||||
|
"MciasRateLimitError",
|
||||||
"MciasServerError",
|
"MciasServerError",
|
||||||
"Account",
|
"Account",
|
||||||
"PublicKey",
|
"PublicKey",
|
||||||
"TokenClaims",
|
"TokenClaims",
|
||||||
"PGCreds",
|
"PGCreds",
|
||||||
|
"PolicyRule",
|
||||||
|
"RuleBody",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Any
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from ._errors import raise_for_status
|
from ._errors import raise_for_status
|
||||||
from ._models import Account, PGCreds, PublicKey, TokenClaims
|
from ._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
@@ -76,6 +76,29 @@ class Client:
|
|||||||
if status == 204 or not response.content:
|
if status == 204 or not response.content:
|
||||||
return None
|
return None
|
||||||
return response.json() # type: ignore[no-any-return]
|
return response.json() # type: ignore[no-any-return]
|
||||||
|
def _request_list(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Send a request that returns a JSON array at the top level."""
|
||||||
|
url = f"{self._base_url}{path}"
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if self.token is not None:
|
||||||
|
headers["Authorization"] = f"Bearer {self.token}"
|
||||||
|
response = self._http.request(method, url, json=json, headers=headers)
|
||||||
|
status = response.status_code
|
||||||
|
if status >= 400:
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
message = str(body.get("error", response.text))
|
||||||
|
except Exception:
|
||||||
|
message = response.text
|
||||||
|
raise_for_status(status, message)
|
||||||
|
return response.json() # type: ignore[no-any-return]
|
||||||
|
# ── Public ────────────────────────────────────────────────────────────────
|
||||||
def health(self) -> None:
|
def health(self) -> None:
|
||||||
"""GET /v1/health — liveness check."""
|
"""GET /v1/health — liveness check."""
|
||||||
self._request("GET", "/v1/health")
|
self._request("GET", "/v1/health")
|
||||||
@@ -105,6 +128,12 @@ class Client:
|
|||||||
expires_at = str(data["expires_at"])
|
expires_at = str(data["expires_at"])
|
||||||
self.token = token
|
self.token = token
|
||||||
return token, expires_at
|
return token, expires_at
|
||||||
|
def validate_token(self, token: str) -> TokenClaims:
|
||||||
|
"""POST /v1/token/validate — check whether a token is valid."""
|
||||||
|
data = self._request("POST", "/v1/token/validate", json={"token": token})
|
||||||
|
assert data is not None
|
||||||
|
return TokenClaims.from_dict(data)
|
||||||
|
# ── Authenticated ──────────────────────────────────────────────────────────
|
||||||
def logout(self) -> None:
|
def logout(self) -> None:
|
||||||
"""POST /v1/auth/logout — invalidate the current token."""
|
"""POST /v1/auth/logout — invalidate the current token."""
|
||||||
self._request("POST", "/v1/auth/logout")
|
self._request("POST", "/v1/auth/logout")
|
||||||
@@ -119,11 +148,49 @@ class Client:
|
|||||||
expires_at = str(data["expires_at"])
|
expires_at = str(data["expires_at"])
|
||||||
self.token = token
|
self.token = token
|
||||||
return token, expires_at
|
return token, expires_at
|
||||||
def validate_token(self, token: str) -> TokenClaims:
|
def enroll_totp(self, password: str) -> tuple[str, str]:
|
||||||
"""POST /v1/token/validate — check whether a token is valid."""
|
"""POST /v1/auth/totp/enroll — begin TOTP enrollment.
|
||||||
data = self._request("POST", "/v1/token/validate", json={"token": token})
|
|
||||||
|
Security (SEC-01): current password is required to prevent session-theft
|
||||||
|
escalation to persistent account takeover.
|
||||||
|
|
||||||
|
Returns (secret, otpauth_uri). The secret is shown only once.
|
||||||
|
"""
|
||||||
|
data = self._request("POST", "/v1/auth/totp/enroll", json={"password": password})
|
||||||
assert data is not None
|
assert data is not None
|
||||||
return TokenClaims.from_dict(data)
|
return str(data["secret"]), str(data["otpauth_uri"])
|
||||||
|
def confirm_totp(self, code: str) -> None:
|
||||||
|
"""POST /v1/auth/totp/confirm — confirm TOTP enrollment with a code."""
|
||||||
|
self._request("POST", "/v1/auth/totp/confirm", json={"code": code})
|
||||||
|
def change_password(self, current_password: str, new_password: str) -> None:
|
||||||
|
"""PUT /v1/auth/password — change own password (self-service)."""
|
||||||
|
self._request(
|
||||||
|
"PUT",
|
||||||
|
"/v1/auth/password",
|
||||||
|
json={"current_password": current_password, "new_password": new_password},
|
||||||
|
)
|
||||||
|
# ── Admin — Auth ──────────────────────────────────────────────────────────
|
||||||
|
def remove_totp(self, account_id: str) -> None:
|
||||||
|
"""DELETE /v1/auth/totp — remove TOTP from an account (admin)."""
|
||||||
|
self._request("DELETE", "/v1/auth/totp", json={"account_id": account_id})
|
||||||
|
# ── Admin — Tokens ────────────────────────────────────────────────────────
|
||||||
|
def issue_service_token(self, account_id: str) -> tuple[str, str]:
|
||||||
|
"""POST /v1/token/issue — issue a long-lived service token (admin).
|
||||||
|
Returns (token, expires_at).
|
||||||
|
"""
|
||||||
|
data = self._request("POST", "/v1/token/issue", json={"account_id": account_id})
|
||||||
|
assert data is not None
|
||||||
|
return str(data["token"]), str(data["expires_at"])
|
||||||
|
def revoke_token(self, jti: str) -> None:
|
||||||
|
"""DELETE /v1/token/{jti} — revoke a token by JTI (admin)."""
|
||||||
|
self._request("DELETE", f"/v1/token/{jti}")
|
||||||
|
# ── Admin — Accounts ──────────────────────────────────────────────────────
|
||||||
|
def list_accounts(self) -> list[Account]:
|
||||||
|
"""GET /v1/accounts — list all accounts (admin).
|
||||||
|
The API returns a JSON array directly (no wrapper object).
|
||||||
|
"""
|
||||||
|
items = self._request_list("GET", "/v1/accounts")
|
||||||
|
return [Account.from_dict(a) for a in items]
|
||||||
def create_account(
|
def create_account(
|
||||||
self,
|
self,
|
||||||
username: str,
|
username: str,
|
||||||
@@ -131,7 +198,7 @@ class Client:
|
|||||||
*,
|
*,
|
||||||
password: str | None = None,
|
password: str | None = None,
|
||||||
) -> Account:
|
) -> Account:
|
||||||
"""POST /v1/accounts — create a new account."""
|
"""POST /v1/accounts — create a new account (admin)."""
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"username": username,
|
"username": username,
|
||||||
"account_type": account_type,
|
"account_type": account_type,
|
||||||
@@ -141,14 +208,8 @@ class Client:
|
|||||||
data = self._request("POST", "/v1/accounts", json=payload)
|
data = self._request("POST", "/v1/accounts", json=payload)
|
||||||
assert data is not None
|
assert data is not None
|
||||||
return Account.from_dict(data)
|
return Account.from_dict(data)
|
||||||
def list_accounts(self) -> list[Account]:
|
|
||||||
"""GET /v1/accounts — list all accounts."""
|
|
||||||
data = self._request("GET", "/v1/accounts")
|
|
||||||
assert data is not None
|
|
||||||
accounts_raw = data.get("accounts") or []
|
|
||||||
return [Account.from_dict(a) for a in accounts_raw]
|
|
||||||
def get_account(self, account_id: str) -> Account:
|
def get_account(self, account_id: str) -> Account:
|
||||||
"""GET /v1/accounts/{id} — retrieve a single account."""
|
"""GET /v1/accounts/{id} — retrieve a single account (admin)."""
|
||||||
data = self._request("GET", f"/v1/accounts/{account_id}")
|
data = self._request("GET", f"/v1/accounts/{account_id}")
|
||||||
assert data is not None
|
assert data is not None
|
||||||
return Account.from_dict(data)
|
return Account.from_dict(data)
|
||||||
@@ -157,42 +218,40 @@ class Client:
|
|||||||
account_id: str,
|
account_id: str,
|
||||||
*,
|
*,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
) -> Account:
|
) -> None:
|
||||||
"""PATCH /v1/accounts/{id} — update account fields."""
|
"""PATCH /v1/accounts/{id} — update account fields (admin).
|
||||||
|
Currently only `status` is patchable. Returns None (204 No Content).
|
||||||
|
"""
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
if status is not None:
|
if status is not None:
|
||||||
payload["status"] = status
|
payload["status"] = status
|
||||||
data = self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
|
self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
|
||||||
assert data is not None
|
|
||||||
return Account.from_dict(data)
|
|
||||||
def delete_account(self, account_id: str) -> None:
|
def delete_account(self, account_id: str) -> None:
|
||||||
"""DELETE /v1/accounts/{id} — permanently remove an account."""
|
"""DELETE /v1/accounts/{id} — soft-delete an account (admin)."""
|
||||||
self._request("DELETE", f"/v1/accounts/{account_id}")
|
self._request("DELETE", f"/v1/accounts/{account_id}")
|
||||||
def get_roles(self, account_id: str) -> list[str]:
|
def get_roles(self, account_id: str) -> list[str]:
|
||||||
"""GET /v1/accounts/{id}/roles — list roles for an account."""
|
"""GET /v1/accounts/{id}/roles — list roles for an account (admin)."""
|
||||||
data = self._request("GET", f"/v1/accounts/{account_id}/roles")
|
data = self._request("GET", f"/v1/accounts/{account_id}/roles")
|
||||||
assert data is not None
|
assert data is not None
|
||||||
roles_raw = data.get("roles") or []
|
roles_raw = data.get("roles") or []
|
||||||
return [str(r) for r in roles_raw]
|
return [str(r) for r in roles_raw]
|
||||||
def set_roles(self, account_id: str, roles: list[str]) -> None:
|
def set_roles(self, account_id: str, roles: list[str]) -> None:
|
||||||
"""PUT /v1/accounts/{id}/roles — replace the full role set."""
|
"""PUT /v1/accounts/{id}/roles — replace the full role set (admin)."""
|
||||||
self._request(
|
self._request(
|
||||||
"PUT",
|
"PUT",
|
||||||
f"/v1/accounts/{account_id}/roles",
|
f"/v1/accounts/{account_id}/roles",
|
||||||
json={"roles": roles},
|
json={"roles": roles},
|
||||||
)
|
)
|
||||||
def issue_service_token(self, account_id: str) -> tuple[str, str]:
|
def admin_set_password(self, account_id: str, new_password: str) -> None:
|
||||||
"""POST /v1/accounts/{id}/token — issue a long-lived service token.
|
"""PUT /v1/accounts/{id}/password — reset a password without the old one (admin)."""
|
||||||
Returns (token, expires_at).
|
self._request(
|
||||||
"""
|
"PUT",
|
||||||
data = self._request("POST", f"/v1/accounts/{account_id}/token")
|
f"/v1/accounts/{account_id}/password",
|
||||||
assert data is not None
|
json={"new_password": new_password},
|
||||||
return str(data["token"]), str(data["expires_at"])
|
)
|
||||||
def revoke_token(self, jti: str) -> None:
|
# ── Admin — Credentials ───────────────────────────────────────────────────
|
||||||
"""DELETE /v1/token/{jti} — revoke a token by JTI."""
|
|
||||||
self._request("DELETE", f"/v1/token/{jti}")
|
|
||||||
def get_pg_creds(self, account_id: str) -> PGCreds:
|
def get_pg_creds(self, account_id: str) -> PGCreds:
|
||||||
"""GET /v1/accounts/{id}/pgcreds — retrieve Postgres credentials."""
|
"""GET /v1/accounts/{id}/pgcreds — retrieve Postgres credentials (admin)."""
|
||||||
data = self._request("GET", f"/v1/accounts/{account_id}/pgcreds")
|
data = self._request("GET", f"/v1/accounts/{account_id}/pgcreds")
|
||||||
assert data is not None
|
assert data is not None
|
||||||
return PGCreds.from_dict(data)
|
return PGCreds.from_dict(data)
|
||||||
@@ -205,7 +264,7 @@ class Client:
|
|||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials."""
|
"""PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials (admin)."""
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": port,
|
"port": port,
|
||||||
@@ -214,3 +273,89 @@ class Client:
|
|||||||
"password": password,
|
"password": password,
|
||||||
}
|
}
|
||||||
self._request("PUT", f"/v1/accounts/{account_id}/pgcreds", json=payload)
|
self._request("PUT", f"/v1/accounts/{account_id}/pgcreds", json=payload)
|
||||||
|
# ── Admin — Policy ────────────────────────────────────────────────────────
|
||||||
|
def get_account_tags(self, account_id: str) -> list[str]:
|
||||||
|
"""GET /v1/accounts/{id}/tags — get account tags (admin)."""
|
||||||
|
data = self._request("GET", f"/v1/accounts/{account_id}/tags")
|
||||||
|
assert data is not None
|
||||||
|
return [str(t) for t in (data.get("tags") or [])]
|
||||||
|
def set_account_tags(self, account_id: str, tags: list[str]) -> list[str]:
|
||||||
|
"""PUT /v1/accounts/{id}/tags — replace the full tag set (admin).
|
||||||
|
Returns the updated tag list.
|
||||||
|
"""
|
||||||
|
data = self._request(
|
||||||
|
"PUT",
|
||||||
|
f"/v1/accounts/{account_id}/tags",
|
||||||
|
json={"tags": tags},
|
||||||
|
)
|
||||||
|
assert data is not None
|
||||||
|
return [str(t) for t in (data.get("tags") or [])]
|
||||||
|
def list_policy_rules(self) -> list[PolicyRule]:
|
||||||
|
"""GET /v1/policy/rules — list all operator policy rules (admin)."""
|
||||||
|
items = self._request_list("GET", "/v1/policy/rules")
|
||||||
|
return [PolicyRule.from_dict(r) for r in items]
|
||||||
|
def create_policy_rule(
|
||||||
|
self,
|
||||||
|
description: str,
|
||||||
|
rule: RuleBody,
|
||||||
|
*,
|
||||||
|
priority: int | None = None,
|
||||||
|
not_before: str | None = None,
|
||||||
|
expires_at: str | None = None,
|
||||||
|
) -> PolicyRule:
|
||||||
|
"""POST /v1/policy/rules — create a policy rule (admin)."""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"description": description,
|
||||||
|
"rule": rule.to_dict(),
|
||||||
|
}
|
||||||
|
if priority is not None:
|
||||||
|
payload["priority"] = priority
|
||||||
|
if not_before is not None:
|
||||||
|
payload["not_before"] = not_before
|
||||||
|
if expires_at is not None:
|
||||||
|
payload["expires_at"] = expires_at
|
||||||
|
data = self._request("POST", "/v1/policy/rules", json=payload)
|
||||||
|
assert data is not None
|
||||||
|
return PolicyRule.from_dict(data)
|
||||||
|
def get_policy_rule(self, rule_id: int) -> PolicyRule:
|
||||||
|
"""GET /v1/policy/rules/{id} — get a policy rule (admin)."""
|
||||||
|
data = self._request("GET", f"/v1/policy/rules/{rule_id}")
|
||||||
|
assert data is not None
|
||||||
|
return PolicyRule.from_dict(data)
|
||||||
|
def update_policy_rule(
|
||||||
|
self,
|
||||||
|
rule_id: int,
|
||||||
|
*,
|
||||||
|
description: str | None = None,
|
||||||
|
priority: int | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
rule: RuleBody | None = None,
|
||||||
|
not_before: str | None = None,
|
||||||
|
expires_at: str | None = None,
|
||||||
|
clear_not_before: bool | None = None,
|
||||||
|
clear_expires_at: bool | None = None,
|
||||||
|
) -> PolicyRule:
|
||||||
|
"""PATCH /v1/policy/rules/{id} — update a policy rule (admin)."""
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if description is not None:
|
||||||
|
payload["description"] = description
|
||||||
|
if priority is not None:
|
||||||
|
payload["priority"] = priority
|
||||||
|
if enabled is not None:
|
||||||
|
payload["enabled"] = enabled
|
||||||
|
if rule is not None:
|
||||||
|
payload["rule"] = rule.to_dict()
|
||||||
|
if not_before is not None:
|
||||||
|
payload["not_before"] = not_before
|
||||||
|
if expires_at is not None:
|
||||||
|
payload["expires_at"] = expires_at
|
||||||
|
if clear_not_before is not None:
|
||||||
|
payload["clear_not_before"] = clear_not_before
|
||||||
|
if clear_expires_at is not None:
|
||||||
|
payload["clear_expires_at"] = clear_expires_at
|
||||||
|
data = self._request("PATCH", f"/v1/policy/rules/{rule_id}", json=payload)
|
||||||
|
assert data is not None
|
||||||
|
return PolicyRule.from_dict(data)
|
||||||
|
def delete_policy_rule(self, rule_id: int) -> None:
|
||||||
|
"""DELETE /v1/policy/rules/{id} — delete a policy rule (admin)."""
|
||||||
|
self._request("DELETE", f"/v1/policy/rules/{rule_id}")
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class MciasInputError(MciasError):
|
|||||||
"""400 Bad Request — malformed request."""
|
"""400 Bad Request — malformed request."""
|
||||||
class MciasConflictError(MciasError):
|
class MciasConflictError(MciasError):
|
||||||
"""409 Conflict — e.g. duplicate username."""
|
"""409 Conflict — e.g. duplicate username."""
|
||||||
|
class MciasRateLimitError(MciasError):
|
||||||
|
"""429 Too Many Requests — rate limit exceeded."""
|
||||||
class MciasServerError(MciasError):
|
class MciasServerError(MciasError):
|
||||||
"""5xx — unexpected server error."""
|
"""5xx — unexpected server error."""
|
||||||
def raise_for_status(status_code: int, message: str) -> None:
|
def raise_for_status(status_code: int, message: str) -> None:
|
||||||
@@ -25,6 +27,7 @@ def raise_for_status(status_code: int, message: str) -> None:
|
|||||||
403: MciasForbiddenError,
|
403: MciasForbiddenError,
|
||||||
404: MciasNotFoundError,
|
404: MciasNotFoundError,
|
||||||
409: MciasConflictError,
|
409: MciasConflictError,
|
||||||
|
429: MciasRateLimitError,
|
||||||
}
|
}
|
||||||
cls = exc_map.get(status_code, MciasServerError)
|
cls = exc_map.get(status_code, MciasServerError)
|
||||||
raise cls(status_code, message)
|
raise cls(status_code, message)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Data models for MCIAS API responses."""
|
"""Data models for MCIAS API responses."""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import cast
|
from typing import Any, cast
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -74,3 +74,73 @@ class PGCreds:
|
|||||||
username=str(d["username"]),
|
username=str(d["username"]),
|
||||||
password=str(d["password"]),
|
password=str(d["password"]),
|
||||||
)
|
)
|
||||||
|
@dataclass
|
||||||
|
class RuleBody:
|
||||||
|
"""Match conditions and effect of a policy rule."""
|
||||||
|
effect: str
|
||||||
|
roles: list[str] = field(default_factory=list)
|
||||||
|
account_types: list[str] = field(default_factory=list)
|
||||||
|
subject_uuid: str | None = None
|
||||||
|
actions: list[str] = field(default_factory=list)
|
||||||
|
resource_type: str | None = None
|
||||||
|
owner_matches_subject: bool | None = None
|
||||||
|
service_names: list[str] = field(default_factory=list)
|
||||||
|
required_tags: list[str] = field(default_factory=list)
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict[str, object]) -> "RuleBody":
|
||||||
|
return cls(
|
||||||
|
effect=str(d["effect"]),
|
||||||
|
roles=[str(r) for r in cast(list[Any], d.get("roles") or [])],
|
||||||
|
account_types=[str(t) for t in cast(list[Any], d.get("account_types") or [])],
|
||||||
|
subject_uuid=str(d["subject_uuid"]) if d.get("subject_uuid") is not None else None,
|
||||||
|
actions=[str(a) for a in cast(list[Any], d.get("actions") or [])],
|
||||||
|
resource_type=str(d["resource_type"]) if d.get("resource_type") is not None else None,
|
||||||
|
owner_matches_subject=bool(d["owner_matches_subject"]) if d.get("owner_matches_subject") is not None else None,
|
||||||
|
service_names=[str(s) for s in cast(list[Any], d.get("service_names") or [])],
|
||||||
|
required_tags=[str(t) for t in cast(list[Any], d.get("required_tags") or [])],
|
||||||
|
)
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Serialise to a JSON-compatible dict, omitting None/empty fields."""
|
||||||
|
out: dict[str, Any] = {"effect": self.effect}
|
||||||
|
if self.roles:
|
||||||
|
out["roles"] = self.roles
|
||||||
|
if self.account_types:
|
||||||
|
out["account_types"] = self.account_types
|
||||||
|
if self.subject_uuid is not None:
|
||||||
|
out["subject_uuid"] = self.subject_uuid
|
||||||
|
if self.actions:
|
||||||
|
out["actions"] = self.actions
|
||||||
|
if self.resource_type is not None:
|
||||||
|
out["resource_type"] = self.resource_type
|
||||||
|
if self.owner_matches_subject is not None:
|
||||||
|
out["owner_matches_subject"] = self.owner_matches_subject
|
||||||
|
if self.service_names:
|
||||||
|
out["service_names"] = self.service_names
|
||||||
|
if self.required_tags:
|
||||||
|
out["required_tags"] = self.required_tags
|
||||||
|
return out
|
||||||
|
@dataclass
|
||||||
|
class PolicyRule:
|
||||||
|
"""An operator-defined policy rule."""
|
||||||
|
id: int
|
||||||
|
priority: int
|
||||||
|
description: str
|
||||||
|
rule: RuleBody
|
||||||
|
enabled: bool
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
not_before: str | None = None
|
||||||
|
expires_at: str | None = None
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict[str, object]) -> "PolicyRule":
|
||||||
|
return cls(
|
||||||
|
id=int(cast(int, d["id"])),
|
||||||
|
priority=int(cast(int, d["priority"])),
|
||||||
|
description=str(d["description"]),
|
||||||
|
rule=RuleBody.from_dict(cast(dict[str, object], d["rule"])),
|
||||||
|
enabled=bool(d["enabled"]),
|
||||||
|
created_at=str(d["created_at"]),
|
||||||
|
updated_at=str(d["updated_at"]),
|
||||||
|
not_before=str(d["not_before"]) if d.get("not_before") is not None else None,
|
||||||
|
expires_at=str(d["expires_at"]) if d.get("expires_at") is not None else None,
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,15 +13,16 @@ from mcias_client import (
|
|||||||
MciasForbiddenError,
|
MciasForbiddenError,
|
||||||
MciasInputError,
|
MciasInputError,
|
||||||
MciasNotFoundError,
|
MciasNotFoundError,
|
||||||
|
MciasRateLimitError,
|
||||||
MciasServerError,
|
MciasServerError,
|
||||||
)
|
)
|
||||||
from mcias_client._models import Account, PGCreds, PublicKey, TokenClaims
|
from mcias_client._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
|
||||||
|
|
||||||
BASE_URL = "https://auth.example.com"
|
BASE_URL = "https://auth.example.com"
|
||||||
SAMPLE_ACCOUNT: dict[str, object] = {
|
SAMPLE_ACCOUNT: dict[str, object] = {
|
||||||
"id": "acc-001",
|
"id": "acc-001",
|
||||||
"username": "alice",
|
"username": "alice",
|
||||||
"account_type": "user",
|
"account_type": "human",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"created_at": "2024-01-01T00:00:00Z",
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
"updated_at": "2024-01-01T00:00:00Z",
|
"updated_at": "2024-01-01T00:00:00Z",
|
||||||
@@ -34,6 +35,24 @@ SAMPLE_PK: dict[str, object] = {
|
|||||||
"use": "sig",
|
"use": "sig",
|
||||||
"alg": "EdDSA",
|
"alg": "EdDSA",
|
||||||
}
|
}
|
||||||
|
SAMPLE_RULE_BODY: dict[str, object] = {
|
||||||
|
"effect": "allow",
|
||||||
|
"roles": ["svc:payments-api"],
|
||||||
|
"actions": ["pgcreds:read"],
|
||||||
|
"resource_type": "pgcreds",
|
||||||
|
"owner_matches_subject": True,
|
||||||
|
}
|
||||||
|
SAMPLE_POLICY_RULE: dict[str, object] = {
|
||||||
|
"id": 1,
|
||||||
|
"priority": 100,
|
||||||
|
"description": "Allow payments-api to read its own pgcreds",
|
||||||
|
"rule": SAMPLE_RULE_BODY,
|
||||||
|
"enabled": True,
|
||||||
|
"not_before": None,
|
||||||
|
"expires_at": None,
|
||||||
|
"created_at": "2026-03-11T09:00:00Z",
|
||||||
|
"updated_at": "2026-03-11T09:00:00Z",
|
||||||
|
}
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client() -> Client:
|
def client() -> Client:
|
||||||
return Client(BASE_URL)
|
return Client(BASE_URL)
|
||||||
@@ -88,6 +107,16 @@ def test_login_success(client: Client) -> None:
|
|||||||
assert expires_at == "2099-01-01T00:00:00Z"
|
assert expires_at == "2099-01-01T00:00:00Z"
|
||||||
assert client.token == "jwt-token-abc"
|
assert client.token == "jwt-token-abc"
|
||||||
@respx.mock
|
@respx.mock
|
||||||
|
def test_login_with_totp(client: Client) -> None:
|
||||||
|
respx.post(f"{BASE_URL}/v1/auth/login").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"token": "jwt-token-totp", "expires_at": "2099-01-01T00:00:00Z"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
token, _ = client.login("alice", "s3cr3t", totp_code="123456")
|
||||||
|
assert token == "jwt-token-totp"
|
||||||
|
@respx.mock
|
||||||
def test_login_unauthorized(client: Client) -> None:
|
def test_login_unauthorized(client: Client) -> None:
|
||||||
respx.post(f"{BASE_URL}/v1/auth/login").mock(
|
respx.post(f"{BASE_URL}/v1/auth/login").mock(
|
||||||
return_value=httpx.Response(
|
return_value=httpx.Response(
|
||||||
@@ -98,6 +127,14 @@ def test_login_unauthorized(client: Client) -> None:
|
|||||||
client.login("alice", "wrong")
|
client.login("alice", "wrong")
|
||||||
assert exc_info.value.status_code == 401
|
assert exc_info.value.status_code == 401
|
||||||
@respx.mock
|
@respx.mock
|
||||||
|
def test_login_rate_limited(client: Client) -> None:
|
||||||
|
respx.post(f"{BASE_URL}/v1/auth/login").mock(
|
||||||
|
return_value=httpx.Response(429, json={"error": "rate limit exceeded", "code": "rate_limited"})
|
||||||
|
)
|
||||||
|
with pytest.raises(MciasRateLimitError) as exc_info:
|
||||||
|
client.login("alice", "s3cr3t")
|
||||||
|
assert exc_info.value.status_code == 429
|
||||||
|
@respx.mock
|
||||||
def test_logout_clears_token(admin_client: Client) -> None:
|
def test_logout_clears_token(admin_client: Client) -> None:
|
||||||
respx.post(f"{BASE_URL}/v1/auth/logout").mock(
|
respx.post(f"{BASE_URL}/v1/auth/logout").mock(
|
||||||
return_value=httpx.Response(204)
|
return_value=httpx.Response(204)
|
||||||
@@ -147,11 +184,58 @@ def test_validate_token_invalid(admin_client: Client) -> None:
|
|||||||
claims = admin_client.validate_token("expired-token")
|
claims = admin_client.validate_token("expired-token")
|
||||||
assert claims.valid is False
|
assert claims.valid is False
|
||||||
@respx.mock
|
@respx.mock
|
||||||
|
def test_enroll_totp(admin_client: Client) -> None:
|
||||||
|
respx.post(f"{BASE_URL}/v1/auth/totp/enroll").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
secret, uri = admin_client.enroll_totp("testpass123")
|
||||||
|
assert secret == "JBSWY3DPEHPK3PXP"
|
||||||
|
assert "otpauth://totp/" in uri
|
||||||
|
@respx.mock
|
||||||
|
def test_confirm_totp(admin_client: Client) -> None:
|
||||||
|
respx.post(f"{BASE_URL}/v1/auth/totp/confirm").mock(
|
||||||
|
return_value=httpx.Response(204)
|
||||||
|
)
|
||||||
|
admin_client.confirm_totp("123456") # should not raise
|
||||||
|
@respx.mock
|
||||||
|
def test_change_password(admin_client: Client) -> None:
|
||||||
|
respx.put(f"{BASE_URL}/v1/auth/password").mock(
|
||||||
|
return_value=httpx.Response(204)
|
||||||
|
)
|
||||||
|
admin_client.change_password("old-pass", "new-pass-long-enough") # should not raise
|
||||||
|
@respx.mock
|
||||||
|
def test_remove_totp(admin_client: Client) -> None:
|
||||||
|
respx.delete(f"{BASE_URL}/v1/auth/totp").mock(
|
||||||
|
return_value=httpx.Response(204)
|
||||||
|
)
|
||||||
|
admin_client.remove_totp("acc-001") # should not raise
|
||||||
|
@respx.mock
|
||||||
|
def test_issue_service_token(admin_client: Client) -> None:
|
||||||
|
respx.post(f"{BASE_URL}/v1/token/issue").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"token": "svc-token-xyz", "expires_at": "2099-12-31T00:00:00Z"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
token, expires_at = admin_client.issue_service_token("acc-001")
|
||||||
|
assert token == "svc-token-xyz"
|
||||||
|
assert expires_at == "2099-12-31T00:00:00Z"
|
||||||
|
@respx.mock
|
||||||
|
def test_revoke_token(admin_client: Client) -> None:
|
||||||
|
jti = "some-jti-uuid"
|
||||||
|
respx.delete(f"{BASE_URL}/v1/token/{jti}").mock(
|
||||||
|
return_value=httpx.Response(204)
|
||||||
|
)
|
||||||
|
admin_client.revoke_token(jti) # should not raise
|
||||||
|
@respx.mock
|
||||||
def test_create_account(admin_client: Client) -> None:
|
def test_create_account(admin_client: Client) -> None:
|
||||||
respx.post(f"{BASE_URL}/v1/accounts").mock(
|
respx.post(f"{BASE_URL}/v1/accounts").mock(
|
||||||
return_value=httpx.Response(201, json=SAMPLE_ACCOUNT)
|
return_value=httpx.Response(201, json=SAMPLE_ACCOUNT)
|
||||||
)
|
)
|
||||||
acc = admin_client.create_account("alice", "user", password="pass123")
|
acc = admin_client.create_account("alice", "human", password="pass123")
|
||||||
assert isinstance(acc, Account)
|
assert isinstance(acc, Account)
|
||||||
assert acc.id == "acc-001"
|
assert acc.id == "acc-001"
|
||||||
assert acc.username == "alice"
|
assert acc.username == "alice"
|
||||||
@@ -161,15 +245,14 @@ def test_create_account_conflict(admin_client: Client) -> None:
|
|||||||
return_value=httpx.Response(409, json={"error": "username already exists"})
|
return_value=httpx.Response(409, json={"error": "username already exists"})
|
||||||
)
|
)
|
||||||
with pytest.raises(MciasConflictError) as exc_info:
|
with pytest.raises(MciasConflictError) as exc_info:
|
||||||
admin_client.create_account("alice", "user")
|
admin_client.create_account("alice", "human")
|
||||||
assert exc_info.value.status_code == 409
|
assert exc_info.value.status_code == 409
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_list_accounts(admin_client: Client) -> None:
|
def test_list_accounts(admin_client: Client) -> None:
|
||||||
second = {**SAMPLE_ACCOUNT, "id": "acc-002"}
|
second = {**SAMPLE_ACCOUNT, "id": "acc-002"}
|
||||||
|
# API returns a plain JSON array, not a wrapper object
|
||||||
respx.get(f"{BASE_URL}/v1/accounts").mock(
|
respx.get(f"{BASE_URL}/v1/accounts").mock(
|
||||||
return_value=httpx.Response(
|
return_value=httpx.Response(200, json=[SAMPLE_ACCOUNT, second])
|
||||||
200, json={"accounts": [SAMPLE_ACCOUNT, second]}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
accounts = admin_client.list_accounts()
|
accounts = admin_client.list_accounts()
|
||||||
assert len(accounts) == 2
|
assert len(accounts) == 2
|
||||||
@@ -183,12 +266,12 @@ def test_get_account(admin_client: Client) -> None:
|
|||||||
assert acc.id == "acc-001"
|
assert acc.id == "acc-001"
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_update_account(admin_client: Client) -> None:
|
def test_update_account(admin_client: Client) -> None:
|
||||||
updated = {**SAMPLE_ACCOUNT, "status": "suspended"}
|
# PATCH /v1/accounts/{id} returns 204 No Content
|
||||||
respx.patch(f"{BASE_URL}/v1/accounts/acc-001").mock(
|
respx.patch(f"{BASE_URL}/v1/accounts/acc-001").mock(
|
||||||
return_value=httpx.Response(200, json=updated)
|
return_value=httpx.Response(204)
|
||||||
)
|
)
|
||||||
acc = admin_client.update_account("acc-001", status="suspended")
|
result = admin_client.update_account("acc-001", status="inactive")
|
||||||
assert acc.status == "suspended"
|
assert result is None
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_delete_account(admin_client: Client) -> None:
|
def test_delete_account(admin_client: Client) -> None:
|
||||||
respx.delete(f"{BASE_URL}/v1/accounts/acc-001").mock(
|
respx.delete(f"{BASE_URL}/v1/accounts/acc-001").mock(
|
||||||
@@ -209,23 +292,11 @@ def test_set_roles(admin_client: Client) -> None:
|
|||||||
)
|
)
|
||||||
admin_client.set_roles("acc-001", ["viewer"]) # should not raise
|
admin_client.set_roles("acc-001", ["viewer"]) # should not raise
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_issue_service_token(admin_client: Client) -> None:
|
def test_admin_set_password(admin_client: Client) -> None:
|
||||||
respx.post(f"{BASE_URL}/v1/accounts/acc-001/token").mock(
|
respx.put(f"{BASE_URL}/v1/accounts/acc-001/password").mock(
|
||||||
return_value=httpx.Response(
|
|
||||||
200,
|
|
||||||
json={"token": "svc-token-xyz", "expires_at": "2099-12-31T00:00:00Z"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
token, expires_at = admin_client.issue_service_token("acc-001")
|
|
||||||
assert token == "svc-token-xyz"
|
|
||||||
assert expires_at == "2099-12-31T00:00:00Z"
|
|
||||||
@respx.mock
|
|
||||||
def test_revoke_token(admin_client: Client) -> None:
|
|
||||||
jti = "some-jti-uuid"
|
|
||||||
respx.delete(f"{BASE_URL}/v1/token/{jti}").mock(
|
|
||||||
return_value=httpx.Response(204)
|
return_value=httpx.Response(204)
|
||||||
)
|
)
|
||||||
admin_client.revoke_token(jti) # should not raise
|
admin_client.admin_set_password("acc-001", "new-secure-password") # should not raise
|
||||||
SAMPLE_PG_CREDS: dict[str, object] = {
|
SAMPLE_PG_CREDS: dict[str, object] = {
|
||||||
"host": "db.example.com",
|
"host": "db.example.com",
|
||||||
"port": 5432,
|
"port": 5432,
|
||||||
@@ -256,6 +327,68 @@ def test_set_pg_creds(admin_client: Client) -> None:
|
|||||||
username="appuser",
|
username="appuser",
|
||||||
password="s3cr3t",
|
password="s3cr3t",
|
||||||
) # should not raise
|
) # should not raise
|
||||||
|
@respx.mock
|
||||||
|
def test_get_account_tags(admin_client: Client) -> None:
|
||||||
|
respx.get(f"{BASE_URL}/v1/accounts/acc-001/tags").mock(
|
||||||
|
return_value=httpx.Response(200, json={"tags": ["env:production", "svc:payments-api"]})
|
||||||
|
)
|
||||||
|
tags = admin_client.get_account_tags("acc-001")
|
||||||
|
assert tags == ["env:production", "svc:payments-api"]
|
||||||
|
@respx.mock
|
||||||
|
def test_set_account_tags(admin_client: Client) -> None:
|
||||||
|
respx.put(f"{BASE_URL}/v1/accounts/acc-001/tags").mock(
|
||||||
|
return_value=httpx.Response(200, json={"tags": ["env:staging"]})
|
||||||
|
)
|
||||||
|
tags = admin_client.set_account_tags("acc-001", ["env:staging"])
|
||||||
|
assert tags == ["env:staging"]
|
||||||
|
@respx.mock
|
||||||
|
def test_list_policy_rules(admin_client: Client) -> None:
|
||||||
|
respx.get(f"{BASE_URL}/v1/policy/rules").mock(
|
||||||
|
return_value=httpx.Response(200, json=[SAMPLE_POLICY_RULE])
|
||||||
|
)
|
||||||
|
rules = admin_client.list_policy_rules()
|
||||||
|
assert len(rules) == 1
|
||||||
|
assert isinstance(rules[0], PolicyRule)
|
||||||
|
assert rules[0].id == 1
|
||||||
|
assert rules[0].rule.effect == "allow"
|
||||||
|
@respx.mock
|
||||||
|
def test_create_policy_rule(admin_client: Client) -> None:
|
||||||
|
respx.post(f"{BASE_URL}/v1/policy/rules").mock(
|
||||||
|
return_value=httpx.Response(201, json=SAMPLE_POLICY_RULE)
|
||||||
|
)
|
||||||
|
rule_body = RuleBody(effect="allow", actions=["pgcreds:read"], resource_type="pgcreds")
|
||||||
|
rule = admin_client.create_policy_rule(
|
||||||
|
"Allow payments-api to read its own pgcreds",
|
||||||
|
rule_body,
|
||||||
|
priority=50,
|
||||||
|
)
|
||||||
|
assert isinstance(rule, PolicyRule)
|
||||||
|
assert rule.id == 1
|
||||||
|
assert rule.description == "Allow payments-api to read its own pgcreds"
|
||||||
|
@respx.mock
|
||||||
|
def test_get_policy_rule(admin_client: Client) -> None:
|
||||||
|
respx.get(f"{BASE_URL}/v1/policy/rules/1").mock(
|
||||||
|
return_value=httpx.Response(200, json=SAMPLE_POLICY_RULE)
|
||||||
|
)
|
||||||
|
rule = admin_client.get_policy_rule(1)
|
||||||
|
assert isinstance(rule, PolicyRule)
|
||||||
|
assert rule.id == 1
|
||||||
|
assert rule.enabled is True
|
||||||
|
@respx.mock
|
||||||
|
def test_update_policy_rule(admin_client: Client) -> None:
|
||||||
|
updated = {**SAMPLE_POLICY_RULE, "enabled": False}
|
||||||
|
respx.patch(f"{BASE_URL}/v1/policy/rules/1").mock(
|
||||||
|
return_value=httpx.Response(200, json=updated)
|
||||||
|
)
|
||||||
|
rule = admin_client.update_policy_rule(1, enabled=False)
|
||||||
|
assert isinstance(rule, PolicyRule)
|
||||||
|
assert rule.enabled is False
|
||||||
|
@respx.mock
|
||||||
|
def test_delete_policy_rule(admin_client: Client) -> None:
|
||||||
|
respx.delete(f"{BASE_URL}/v1/policy/rules/1").mock(
|
||||||
|
return_value=httpx.Response(204)
|
||||||
|
)
|
||||||
|
admin_client.delete_policy_rule(1) # should not raise
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("status_code", "exc_class"),
|
("status_code", "exc_class"),
|
||||||
[
|
[
|
||||||
@@ -264,6 +397,7 @@ def test_set_pg_creds(admin_client: Client) -> None:
|
|||||||
(403, MciasForbiddenError),
|
(403, MciasForbiddenError),
|
||||||
(404, MciasNotFoundError),
|
(404, MciasNotFoundError),
|
||||||
(409, MciasConflictError),
|
(409, MciasConflictError),
|
||||||
|
(429, MciasRateLimitError),
|
||||||
(500, MciasServerError),
|
(500, MciasServerError),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ pub enum MciasError {
|
|||||||
Decode(String),
|
Decode(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Data types ----
|
// ---- Public data types ----
|
||||||
|
|
||||||
/// Account information returned by the server.
|
/// Account information returned by the server.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -101,6 +101,11 @@ pub struct TokenClaims {
|
|||||||
pub struct PublicKey {
|
pub struct PublicKey {
|
||||||
pub kty: String,
|
pub kty: String,
|
||||||
pub crv: String,
|
pub crv: String,
|
||||||
|
/// Key use — always `"sig"` for the MCIAS signing key.
|
||||||
|
#[serde(rename = "use")]
|
||||||
|
pub key_use: Option<String>,
|
||||||
|
/// Algorithm — always `"EdDSA"`. Validate this before trusting the key.
|
||||||
|
pub alg: Option<String>,
|
||||||
pub x: String,
|
pub x: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +119,106 @@ pub struct PgCreds {
|
|||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Audit log entry returned by `GET /v1/audit`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct AuditEvent {
|
||||||
|
pub id: i64,
|
||||||
|
pub event_type: String,
|
||||||
|
pub event_time: String,
|
||||||
|
pub ip_address: String,
|
||||||
|
pub actor_id: Option<String>,
|
||||||
|
pub target_id: Option<String>,
|
||||||
|
pub details: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paginated response from `GET /v1/audit`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct AuditPage {
|
||||||
|
pub events: Vec<AuditEvent>,
|
||||||
|
pub total: i64,
|
||||||
|
pub limit: i64,
|
||||||
|
pub offset: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for `GET /v1/audit`.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AuditQuery {
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
pub event_type: Option<String>,
|
||||||
|
pub actor_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single operator-defined policy rule.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct PolicyRule {
|
||||||
|
pub id: i64,
|
||||||
|
pub priority: i64,
|
||||||
|
pub description: String,
|
||||||
|
pub rule: RuleBody,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub not_before: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The match conditions and effect of a policy rule.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RuleBody {
|
||||||
|
pub effect: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub roles: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub account_types: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub subject_uuid: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub actions: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resource_type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub owner_matches_subject: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub service_names: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub required_tags: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `POST /v1/policy/rules`.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CreatePolicyRuleRequest {
|
||||||
|
pub description: String,
|
||||||
|
pub rule: RuleBody,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub priority: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub not_before: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `PATCH /v1/policy/rules/{id}`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Default)]
|
||||||
|
pub struct UpdatePolicyRuleRequest {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub priority: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rule: Option<RuleBody>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub not_before: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub clear_not_before: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub clear_expires_at: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Internal request/response types ----
|
// ---- Internal request/response types ----
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -136,6 +241,22 @@ struct ErrorResponse {
|
|||||||
error: String,
|
error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RolesResponse {
|
||||||
|
roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TagsResponse {
|
||||||
|
tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TotpEnrollResponse {
|
||||||
|
secret: String,
|
||||||
|
otpauth_uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Client options ----
|
// ---- Client options ----
|
||||||
|
|
||||||
/// Configuration options for the MCIAS client.
|
/// Configuration options for the MCIAS client.
|
||||||
@@ -160,6 +281,7 @@ pub struct Client {
|
|||||||
base_url: String,
|
base_url: String,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
|
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
|
||||||
|
/// Security: the token is never logged or included in error messages.
|
||||||
token: Arc<RwLock<Option<String>>>,
|
token: Arc<RwLock<Option<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,9 +407,9 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update an account's status. Allowed values: `"active"`, `"inactive"`.
|
/// Update an account's status. Allowed values: `"active"`, `"inactive"`.
|
||||||
pub async fn update_account(&self, id: &str, status: &str) -> Result<Account, MciasError> {
|
pub async fn update_account(&self, id: &str, status: &str) -> Result<(), MciasError> {
|
||||||
let body = serde_json::json!({ "status": status });
|
let body = serde_json::json!({ "status": status });
|
||||||
self.patch(&format!("/v1/accounts/{id}"), &body).await
|
self.patch_no_content(&format!("/v1/accounts/{id}"), &body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Soft-delete an account and revoke all its tokens.
|
/// Soft-delete an account and revoke all its tokens.
|
||||||
@@ -299,13 +421,17 @@ impl Client {
|
|||||||
|
|
||||||
/// Get all roles assigned to an account.
|
/// Get all roles assigned to an account.
|
||||||
pub async fn get_roles(&self, account_id: &str) -> Result<Vec<String>, MciasError> {
|
pub async fn get_roles(&self, account_id: &str) -> Result<Vec<String>, MciasError> {
|
||||||
self.get(&format!("/v1/accounts/{account_id}/roles")).await
|
// Security: spec wraps roles in {"roles": [...]}, unwrap before returning.
|
||||||
|
let resp: RolesResponse = self.get(&format!("/v1/accounts/{account_id}/roles")).await?;
|
||||||
|
Ok(resp.roles)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the complete role set for an account.
|
/// Replace the complete role set for an account.
|
||||||
pub async fn set_roles(&self, account_id: &str, roles: &[&str]) -> Result<(), MciasError> {
|
pub async fn set_roles(&self, account_id: &str, roles: &[&str]) -> Result<(), MciasError> {
|
||||||
let url = format!("/v1/accounts/{account_id}/roles");
|
let url = format!("/v1/accounts/{account_id}/roles");
|
||||||
self.put_no_content(&url, roles).await
|
// Spec requires {"roles": [...]} wrapper.
|
||||||
|
let body = serde_json::json!({ "roles": roles });
|
||||||
|
self.put_no_content(&url, &body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Token management (admin only) ----
|
// ---- Token management (admin only) ----
|
||||||
@@ -354,10 +480,145 @@ impl Client {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- TOTP enrollment (authenticated) ----
|
||||||
|
|
||||||
|
/// Begin TOTP enrollment. Returns `(secret, otpauth_uri)`.
|
||||||
|
/// The secret is shown once; store it in an authenticator app immediately.
|
||||||
|
///
|
||||||
|
/// Security (SEC-01): current password is required to prevent session-theft
|
||||||
|
/// escalation to persistent account takeover.
|
||||||
|
pub async fn enroll_totp(&self, password: &str) -> Result<(String, String), MciasError> {
|
||||||
|
let resp: TotpEnrollResponse =
|
||||||
|
self.post("/v1/auth/totp/enroll", &serde_json::json!({"password": password})).await?;
|
||||||
|
Ok((resp.secret, resp.otpauth_uri))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm TOTP enrollment with the current 6-digit code.
|
||||||
|
/// On success, TOTP becomes required for all future logins.
|
||||||
|
pub async fn confirm_totp(&self, code: &str) -> Result<(), MciasError> {
|
||||||
|
let body = serde_json::json!({ "code": code });
|
||||||
|
self.post_empty_body("/v1/auth/totp/confirm", &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Password management ----
|
||||||
|
|
||||||
|
/// Change the caller's own password (self-service). Requires the current
|
||||||
|
/// password to guard against token-theft attacks.
|
||||||
|
pub async fn change_password(
|
||||||
|
&self,
|
||||||
|
current_password: &str,
|
||||||
|
new_password: &str,
|
||||||
|
) -> Result<(), MciasError> {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"current_password": current_password,
|
||||||
|
"new_password": new_password,
|
||||||
|
});
|
||||||
|
self.put_no_content("/v1/auth/password", &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Admin: TOTP removal ----
|
||||||
|
|
||||||
|
/// Remove TOTP enrollment from an account (admin). Use for recovery when
|
||||||
|
/// a user loses their TOTP device.
|
||||||
|
pub async fn remove_totp(&self, account_id: &str) -> Result<(), MciasError> {
|
||||||
|
let body = serde_json::json!({ "account_id": account_id });
|
||||||
|
self.delete_with_body("/v1/auth/totp", &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Admin: password reset ----
|
||||||
|
|
||||||
|
/// Reset an account's password without requiring the current password.
|
||||||
|
pub async fn admin_set_password(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
new_password: &str,
|
||||||
|
) -> Result<(), MciasError> {
|
||||||
|
let body = serde_json::json!({ "new_password": new_password });
|
||||||
|
self.put_no_content(&format!("/v1/accounts/{account_id}/password"), &body)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Account tags (admin) ----
|
||||||
|
|
||||||
|
/// Get all tags for an account.
|
||||||
|
pub async fn get_tags(&self, account_id: &str) -> Result<Vec<String>, MciasError> {
|
||||||
|
let resp: TagsResponse =
|
||||||
|
self.get(&format!("/v1/accounts/{account_id}/tags")).await?;
|
||||||
|
Ok(resp.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the full tag set for an account atomically. Pass an empty slice
|
||||||
|
/// to clear all tags. Returns the updated tag list.
|
||||||
|
pub async fn set_tags(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
tags: &[&str],
|
||||||
|
) -> Result<Vec<String>, MciasError> {
|
||||||
|
let body = serde_json::json!({ "tags": tags });
|
||||||
|
let resp: TagsResponse =
|
||||||
|
self.put_with_response(&format!("/v1/accounts/{account_id}/tags"), &body).await?;
|
||||||
|
Ok(resp.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Audit log (admin) ----
|
||||||
|
|
||||||
|
/// Query the audit log. Returns a paginated [`AuditPage`].
|
||||||
|
pub async fn list_audit(&self, query: AuditQuery) -> Result<AuditPage, MciasError> {
|
||||||
|
let mut params: Vec<(&str, String)> = Vec::new();
|
||||||
|
if let Some(limit) = query.limit {
|
||||||
|
params.push(("limit", limit.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(offset) = query.offset {
|
||||||
|
params.push(("offset", offset.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(ref et) = query.event_type {
|
||||||
|
params.push(("event_type", et.clone()));
|
||||||
|
}
|
||||||
|
if let Some(ref aid) = query.actor_id {
|
||||||
|
params.push(("actor_id", aid.clone()));
|
||||||
|
}
|
||||||
|
self.get_with_query("/v1/audit", ¶ms).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Policy rules (admin) ----
|
||||||
|
|
||||||
|
/// List all operator-defined policy rules ordered by priority.
|
||||||
|
pub async fn list_policy_rules(&self) -> Result<Vec<PolicyRule>, MciasError> {
|
||||||
|
self.get("/v1/policy/rules").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new policy rule.
|
||||||
|
pub async fn create_policy_rule(
|
||||||
|
&self,
|
||||||
|
req: CreatePolicyRuleRequest,
|
||||||
|
) -> Result<PolicyRule, MciasError> {
|
||||||
|
self.post_expect_status("/v1/policy/rules", &req, StatusCode::CREATED)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a single policy rule by ID.
|
||||||
|
pub async fn get_policy_rule(&self, id: i64) -> Result<PolicyRule, MciasError> {
|
||||||
|
self.get(&format!("/v1/policy/rules/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a policy rule. Omitted fields are left unchanged.
|
||||||
|
pub async fn update_policy_rule(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
req: UpdatePolicyRuleRequest,
|
||||||
|
) -> Result<PolicyRule, MciasError> {
|
||||||
|
self.patch(&format!("/v1/policy/rules/{id}"), &req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a policy rule permanently.
|
||||||
|
pub async fn delete_policy_rule(&self, id: i64) -> Result<(), MciasError> {
|
||||||
|
self.delete(&format!("/v1/policy/rules/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
// ---- HTTP helpers ----
|
// ---- HTTP helpers ----
|
||||||
|
|
||||||
/// Build a request with the Authorization header set from the stored token.
|
/// Build the Authorization header value from the stored token.
|
||||||
/// Security: the token is read under a read-lock and is not logged.
|
/// Security: the token is read under a read-lock and is never logged.
|
||||||
async fn auth_header(&self) -> Option<header::HeaderValue> {
|
async fn auth_header(&self) -> Option<header::HeaderValue> {
|
||||||
let guard = self.token.read().await;
|
let guard = self.token.read().await;
|
||||||
guard.as_deref().and_then(|tok| {
|
guard.as_deref().and_then(|tok| {
|
||||||
@@ -383,6 +644,22 @@ impl Client {
|
|||||||
self.expect_success(resp).await
|
self.expect_success(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_with_query<T: for<'de> Deserialize<'de>>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
params: &[(&str, String)],
|
||||||
|
) -> Result<T, MciasError> {
|
||||||
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.get(format!("{}{path}", self.base_url))
|
||||||
|
.query(params);
|
||||||
|
if let Some(auth) = self.auth_header().await {
|
||||||
|
req = req.header(header::AUTHORIZATION, auth);
|
||||||
|
}
|
||||||
|
let resp = req.send().await?;
|
||||||
|
self.decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn post<B: Serialize, T: for<'de> Deserialize<'de>>(
|
async fn post<B: Serialize, T: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -434,6 +711,19 @@ impl Client {
|
|||||||
self.expect_success(resp).await
|
self.expect_success(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST with a JSON body that expects a 2xx (no body) response.
|
||||||
|
async fn post_empty_body<B: Serialize>(&self, path: &str, body: &B) -> Result<(), MciasError> {
|
||||||
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.post(format!("{}{path}", self.base_url))
|
||||||
|
.json(body);
|
||||||
|
if let Some(auth) = self.auth_header().await {
|
||||||
|
req = req.header(header::AUTHORIZATION, auth);
|
||||||
|
}
|
||||||
|
let resp = req.send().await?;
|
||||||
|
self.expect_success(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn patch<B: Serialize, T: for<'de> Deserialize<'de>>(
|
async fn patch<B: Serialize, T: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -450,6 +740,18 @@ impl Client {
|
|||||||
self.decode(resp).await
|
self.decode(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn patch_no_content<B: Serialize>(&self, path: &str, body: &B) -> Result<(), MciasError> {
|
||||||
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.patch(format!("{}{path}", self.base_url))
|
||||||
|
.json(body);
|
||||||
|
if let Some(auth) = self.auth_header().await {
|
||||||
|
req = req.header(header::AUTHORIZATION, auth);
|
||||||
|
}
|
||||||
|
let resp = req.send().await?;
|
||||||
|
self.expect_success(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn put_no_content<B: Serialize + ?Sized>(&self, path: &str, body: &B) -> Result<(), MciasError> {
|
async fn put_no_content<B: Serialize + ?Sized>(&self, path: &str, body: &B) -> Result<(), MciasError> {
|
||||||
let mut req = self
|
let mut req = self
|
||||||
.http
|
.http
|
||||||
@@ -462,6 +764,22 @@ impl Client {
|
|||||||
self.expect_success(resp).await
|
self.expect_success(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn put_with_response<B: Serialize, T: for<'de> Deserialize<'de>>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<T, MciasError> {
|
||||||
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.put(format!("{}{path}", self.base_url))
|
||||||
|
.json(body);
|
||||||
|
if let Some(auth) = self.auth_header().await {
|
||||||
|
req = req.header(header::AUTHORIZATION, auth);
|
||||||
|
}
|
||||||
|
let resp = req.send().await?;
|
||||||
|
self.decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete(&self, path: &str) -> Result<(), MciasError> {
|
async fn delete(&self, path: &str) -> Result<(), MciasError> {
|
||||||
let mut req = self.http.delete(format!("{}{path}", self.base_url));
|
let mut req = self.http.delete(format!("{}{path}", self.base_url));
|
||||||
if let Some(auth) = self.auth_header().await {
|
if let Some(auth) = self.auth_header().await {
|
||||||
@@ -471,6 +789,19 @@ impl Client {
|
|||||||
self.expect_success(resp).await
|
self.expect_success(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DELETE with a JSON request body (used by `DELETE /v1/auth/totp`).
|
||||||
|
async fn delete_with_body<B: Serialize>(&self, path: &str, body: &B) -> Result<(), MciasError> {
|
||||||
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.delete(format!("{}{path}", self.base_url))
|
||||||
|
.json(body);
|
||||||
|
if let Some(auth) = self.auth_header().await {
|
||||||
|
req = req.header(header::AUTHORIZATION, auth);
|
||||||
|
}
|
||||||
|
let resp = req.send().await?;
|
||||||
|
self.expect_success(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn decode<T: for<'de> Deserialize<'de>>(
|
async fn decode<T: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
resp: reqwest::Response,
|
resp: reqwest::Response,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
use mcias_client::{Client, ClientOptions, MciasError};
|
use mcias_client::{
|
||||||
|
AuditQuery, Client, ClientOptions, CreatePolicyRuleRequest, MciasError, RuleBody,
|
||||||
|
UpdatePolicyRuleRequest,
|
||||||
|
};
|
||||||
use wiremock::matchers::{method, path};
|
use wiremock::matchers::{method, path};
|
||||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
async fn admin_client(server: &MockServer) -> Client {
|
async fn admin_client(server: &MockServer) -> Client {
|
||||||
Client::new(&server.uri(), ClientOptions {
|
Client::new(
|
||||||
|
&server.uri(),
|
||||||
|
ClientOptions {
|
||||||
token: Some("admin-token".to_string()),
|
token: Some("admin-token".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +54,10 @@ async fn test_health_server_error() {
|
|||||||
|
|
||||||
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
|
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
|
||||||
let err = c.health().await.unwrap_err();
|
let err = c.health().await.unwrap_err();
|
||||||
assert!(matches!(err, MciasError::Server { .. }), "expected Server error, got {err:?}");
|
assert!(
|
||||||
|
matches!(err, MciasError::Server { .. }),
|
||||||
|
"expected Server error, got {err:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- public key ----
|
// ---- public key ----
|
||||||
@@ -61,6 +70,8 @@ async fn test_get_public_key() {
|
|||||||
.respond_with(json_body(serde_json::json!({
|
.respond_with(json_body(serde_json::json!({
|
||||||
"kty": "OKP",
|
"kty": "OKP",
|
||||||
"crv": "Ed25519",
|
"crv": "Ed25519",
|
||||||
|
"use": "sig",
|
||||||
|
"alg": "EdDSA",
|
||||||
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
|
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
|
||||||
})))
|
})))
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
@@ -70,6 +81,8 @@ async fn test_get_public_key() {
|
|||||||
let pk = c.get_public_key().await.expect("get_public_key should succeed");
|
let pk = c.get_public_key().await.expect("get_public_key should succeed");
|
||||||
assert_eq!(pk.kty, "OKP");
|
assert_eq!(pk.kty, "OKP");
|
||||||
assert_eq!(pk.crv, "Ed25519");
|
assert_eq!(pk.crv, "Ed25519");
|
||||||
|
assert_eq!(pk.key_use.as_deref(), Some("sig"));
|
||||||
|
assert_eq!(pk.alg.as_deref(), Some("EdDSA"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- login ----
|
// ---- login ----
|
||||||
@@ -99,7 +112,10 @@ async fn test_login_bad_credentials() {
|
|||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
Mock::given(method("POST"))
|
Mock::given(method("POST"))
|
||||||
.and(path("/v1/auth/login"))
|
.and(path("/v1/auth/login"))
|
||||||
.respond_with(json_body_status(401, serde_json::json!({"error": "invalid credentials"})))
|
.respond_with(json_body_status(
|
||||||
|
401,
|
||||||
|
serde_json::json!({"error": "invalid credentials"}),
|
||||||
|
))
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -119,10 +135,13 @@ async fn test_logout_clears_token() {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let c = Client::new(&server.uri(), ClientOptions {
|
let c = Client::new(
|
||||||
|
&server.uri(),
|
||||||
|
ClientOptions {
|
||||||
token: Some("existing-token".to_string()),
|
token: Some("existing-token".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
c.logout().await.unwrap();
|
c.logout().await.unwrap();
|
||||||
assert!(c.token().await.is_none(), "token should be cleared after logout");
|
assert!(c.token().await.is_none(), "token should be cleared after logout");
|
||||||
@@ -142,10 +161,13 @@ async fn test_renew_token() {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let c = Client::new(&server.uri(), ClientOptions {
|
let c = Client::new(
|
||||||
|
&server.uri(),
|
||||||
|
ClientOptions {
|
||||||
token: Some("old-token".to_string()),
|
token: Some("old-token".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let (tok, _) = c.renew_token().await.unwrap();
|
let (tok, _) = c.renew_token().await.unwrap();
|
||||||
assert_eq!(tok, "new-token");
|
assert_eq!(tok, "new-token");
|
||||||
@@ -224,7 +246,10 @@ async fn test_create_account_conflict() {
|
|||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
Mock::given(method("POST"))
|
Mock::given(method("POST"))
|
||||||
.and(path("/v1/accounts"))
|
.and(path("/v1/accounts"))
|
||||||
.respond_with(json_body_status(409, serde_json::json!({"error": "username already exists"})))
|
.respond_with(json_body_status(
|
||||||
|
409,
|
||||||
|
serde_json::json!({"error": "username already exists"}),
|
||||||
|
))
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -259,7 +284,10 @@ async fn test_get_account_not_found() {
|
|||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
Mock::given(method("GET"))
|
Mock::given(method("GET"))
|
||||||
.and(path("/v1/accounts/missing"))
|
.and(path("/v1/accounts/missing"))
|
||||||
.respond_with(json_body_status(404, serde_json::json!({"error": "account not found"})))
|
.respond_with(json_body_status(
|
||||||
|
404,
|
||||||
|
serde_json::json!({"error": "account not found"}),
|
||||||
|
))
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -271,19 +299,15 @@ async fn test_get_account_not_found() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_update_account() {
|
async fn test_update_account() {
|
||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
|
// PATCH /v1/accounts/{id} returns 204 No Content per spec.
|
||||||
Mock::given(method("PATCH"))
|
Mock::given(method("PATCH"))
|
||||||
.and(path("/v1/accounts/uuid-1"))
|
.and(path("/v1/accounts/uuid-1"))
|
||||||
.respond_with(json_body(serde_json::json!({
|
.respond_with(ResponseTemplate::new(204))
|
||||||
"id": "uuid-1", "username": "alice", "account_type": "human",
|
|
||||||
"status": "inactive", "created_at": "2023-11-15T12:00:00Z",
|
|
||||||
"updated_at": "2023-11-15T13:00:00Z", "totp_enabled": false
|
|
||||||
})))
|
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let c = admin_client(&server).await;
|
let c = admin_client(&server).await;
|
||||||
let a = c.update_account("uuid-1", "inactive").await.unwrap();
|
c.update_account("uuid-1", "inactive").await.unwrap();
|
||||||
assert_eq!(a.status, "inactive");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -305,12 +329,14 @@ async fn test_delete_account() {
|
|||||||
async fn test_get_set_roles() {
|
async fn test_get_set_roles() {
|
||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
|
|
||||||
|
// Spec wraps the array: {"roles": [...]}
|
||||||
Mock::given(method("GET"))
|
Mock::given(method("GET"))
|
||||||
.and(path("/v1/accounts/uuid-1/roles"))
|
.and(path("/v1/accounts/uuid-1/roles"))
|
||||||
.respond_with(json_body(serde_json::json!(["admin", "viewer"])))
|
.respond_with(json_body(serde_json::json!({"roles": ["admin", "viewer"]})))
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Spec requires {"roles": [...]} in the PUT body.
|
||||||
Mock::given(method("PUT"))
|
Mock::given(method("PUT"))
|
||||||
.and(path("/v1/accounts/uuid-1/roles"))
|
.and(path("/v1/accounts/uuid-1/roles"))
|
||||||
.respond_with(ResponseTemplate::new(204))
|
.respond_with(ResponseTemplate::new(204))
|
||||||
@@ -363,7 +389,10 @@ async fn test_pg_creds_not_found() {
|
|||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
Mock::given(method("GET"))
|
Mock::given(method("GET"))
|
||||||
.and(path("/v1/accounts/uuid-1/pgcreds"))
|
.and(path("/v1/accounts/uuid-1/pgcreds"))
|
||||||
.respond_with(json_body_status(404, serde_json::json!({"error": "no pg credentials found"})))
|
.respond_with(json_body_status(
|
||||||
|
404,
|
||||||
|
serde_json::json!({"error": "no pg credentials found"}),
|
||||||
|
))
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -405,6 +434,298 @@ async fn test_set_get_pg_creds() {
|
|||||||
assert_eq!(creds.password, "dbpass");
|
assert_eq!(creds.password, "dbpass");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- TOTP ----
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_enroll_totp() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/v1/auth/totp/enroll"))
|
||||||
|
.respond_with(json_body(serde_json::json!({
|
||||||
|
"secret": "JBSWY3DPEHPK3PXP",
|
||||||
|
"otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
let (secret, uri) = c.enroll_totp("testpass123").await.unwrap();
|
||||||
|
assert_eq!(secret, "JBSWY3DPEHPK3PXP");
|
||||||
|
assert!(uri.starts_with("otpauth://totp/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_confirm_totp() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/v1/auth/totp/confirm"))
|
||||||
|
.respond_with(ResponseTemplate::new(204))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
c.confirm_totp("123456").await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_remove_totp() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("DELETE"))
|
||||||
|
.and(path("/v1/auth/totp"))
|
||||||
|
.respond_with(ResponseTemplate::new(204))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
c.remove_totp("some-account-uuid").await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- password management ----
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_change_password() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("PUT"))
|
||||||
|
.and(path("/v1/auth/password"))
|
||||||
|
.respond_with(ResponseTemplate::new(204))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
c.change_password("old-pass", "new-pass-long-enough").await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_change_password_wrong_current() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("PUT"))
|
||||||
|
.and(path("/v1/auth/password"))
|
||||||
|
.respond_with(json_body_status(
|
||||||
|
401,
|
||||||
|
serde_json::json!({"error": "current password is incorrect", "code": "unauthorized"}),
|
||||||
|
))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
let err = c
|
||||||
|
.change_password("wrong", "new-pass-long-enough")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, MciasError::Auth(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_admin_set_password() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("PUT"))
|
||||||
|
.and(path("/v1/accounts/uuid-1/password"))
|
||||||
|
.respond_with(ResponseTemplate::new(204))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
c.admin_set_password("uuid-1", "new-pass-long-enough").await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tags ----
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_set_tags() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/v1/accounts/uuid-1/tags"))
|
||||||
|
.respond_with(json_body(
|
||||||
|
serde_json::json!({"tags": ["env:production", "svc:payments-api"]}),
|
||||||
|
))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Mock::given(method("PUT"))
|
||||||
|
.and(path("/v1/accounts/uuid-1/tags"))
|
||||||
|
.respond_with(json_body(serde_json::json!({"tags": ["env:staging"]})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
let tags = c.get_tags("uuid-1").await.unwrap();
|
||||||
|
assert_eq!(tags, vec!["env:production", "svc:payments-api"]);
|
||||||
|
|
||||||
|
let updated = c.set_tags("uuid-1", &["env:staging"]).await.unwrap();
|
||||||
|
assert_eq!(updated, vec!["env:staging"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- audit log ----
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_audit() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/v1/audit"))
|
||||||
|
.respond_with(json_body(serde_json::json!({
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"event_type": "login_ok",
|
||||||
|
"event_time": "2026-03-11T09:01:23Z",
|
||||||
|
"ip_address": "192.0.2.1",
|
||||||
|
"actor_id": "uuid-1",
|
||||||
|
"target_id": null,
|
||||||
|
"details": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"limit": 50,
|
||||||
|
"offset": 0
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
let page = c.list_audit(AuditQuery::default()).await.unwrap();
|
||||||
|
assert_eq!(page.total, 1);
|
||||||
|
assert_eq!(page.events.len(), 1);
|
||||||
|
assert_eq!(page.events[0].event_type, "login_ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- policy rules ----
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_policy_rules() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/v1/policy/rules"))
|
||||||
|
.respond_with(json_body(serde_json::json!([])))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
let rules = c.list_policy_rules().await.unwrap();
|
||||||
|
assert!(rules.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_policy_rule() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/v1/policy/rules"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(201)
|
||||||
|
.set_body_json(serde_json::json!({
|
||||||
|
"id": 1,
|
||||||
|
"priority": 100,
|
||||||
|
"description": "Allow payments-api to read its own pgcreds",
|
||||||
|
"rule": {"effect": "allow", "roles": ["svc:payments-api"]},
|
||||||
|
"enabled": true,
|
||||||
|
"not_before": null,
|
||||||
|
"expires_at": null,
|
||||||
|
"created_at": "2026-03-11T09:00:00Z",
|
||||||
|
"updated_at": "2026-03-11T09:00:00Z"
|
||||||
|
}))
|
||||||
|
.insert_header("content-type", "application/json"),
|
||||||
|
)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
let rule = c
|
||||||
|
.create_policy_rule(CreatePolicyRuleRequest {
|
||||||
|
description: "Allow payments-api to read its own pgcreds".to_string(),
|
||||||
|
rule: RuleBody {
|
||||||
|
effect: "allow".to_string(),
|
||||||
|
roles: Some(vec!["svc:payments-api".to_string()]),
|
||||||
|
account_types: None,
|
||||||
|
subject_uuid: None,
|
||||||
|
actions: None,
|
||||||
|
resource_type: None,
|
||||||
|
owner_matches_subject: None,
|
||||||
|
service_names: None,
|
||||||
|
required_tags: None,
|
||||||
|
},
|
||||||
|
priority: None,
|
||||||
|
not_before: None,
|
||||||
|
expires_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rule.id, 1);
|
||||||
|
assert_eq!(rule.description, "Allow payments-api to read its own pgcreds");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_policy_rule() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/v1/policy/rules/1"))
|
||||||
|
.respond_with(json_body(serde_json::json!({
|
||||||
|
"id": 1,
|
||||||
|
"priority": 100,
|
||||||
|
"description": "test rule",
|
||||||
|
"rule": {"effect": "deny"},
|
||||||
|
"enabled": true,
|
||||||
|
"not_before": null,
|
||||||
|
"expires_at": null,
|
||||||
|
"created_at": "2026-03-11T09:00:00Z",
|
||||||
|
"updated_at": "2026-03-11T09:00:00Z"
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
let rule = c.get_policy_rule(1).await.unwrap();
|
||||||
|
assert_eq!(rule.id, 1);
|
||||||
|
assert_eq!(rule.rule.effect, "deny");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_update_policy_rule() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("PATCH"))
|
||||||
|
.and(path("/v1/policy/rules/1"))
|
||||||
|
.respond_with(json_body(serde_json::json!({
|
||||||
|
"id": 1,
|
||||||
|
"priority": 75,
|
||||||
|
"description": "updated rule",
|
||||||
|
"rule": {"effect": "allow"},
|
||||||
|
"enabled": false,
|
||||||
|
"not_before": null,
|
||||||
|
"expires_at": null,
|
||||||
|
"created_at": "2026-03-11T09:00:00Z",
|
||||||
|
"updated_at": "2026-03-11T10:00:00Z"
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
let rule = c
|
||||||
|
.update_policy_rule(
|
||||||
|
1,
|
||||||
|
UpdatePolicyRuleRequest {
|
||||||
|
enabled: Some(false),
|
||||||
|
priority: Some(75),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!rule.enabled);
|
||||||
|
assert_eq!(rule.priority, 75);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_delete_policy_rule() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("DELETE"))
|
||||||
|
.and(path("/v1/policy/rules/1"))
|
||||||
|
.respond_with(ResponseTemplate::new(204))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = admin_client(&server).await;
|
||||||
|
c.delete_policy_rule(1).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- error type coverage ----
|
// ---- error type coverage ----
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -416,11 +737,13 @@ async fn test_forbidden_error() {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Use a non-admin token.
|
let c = Client::new(
|
||||||
let c = Client::new(&server.uri(), ClientOptions {
|
&server.uri(),
|
||||||
|
ClientOptions {
|
||||||
token: Some("user-token".to_string()),
|
token: Some("user-token".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let err = c.list_accounts().await.unwrap_err();
|
let err = c.list_accounts().await.unwrap_err();
|
||||||
assert!(matches!(err, MciasError::Forbidden(_)));
|
assert!(matches!(err, MciasError::Forbidden(_)));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
//
|
//
|
||||||
// Global flags:
|
// Global flags:
|
||||||
//
|
//
|
||||||
// -server URL of the mciassrv instance (default: https://localhost:8443)
|
// -server URL of the mciassrv instance (default: https://mcias.metacircular.net:8443)
|
||||||
// -token Bearer token for authentication (or set MCIAS_TOKEN env var)
|
// -token Bearer token for authentication (or set MCIAS_TOKEN env var)
|
||||||
// -cacert Path to CA certificate for TLS verification (optional)
|
// -cacert Path to CA certificate for TLS verification (optional)
|
||||||
//
|
//
|
||||||
@@ -28,10 +28,13 @@
|
|||||||
//
|
//
|
||||||
// role list -id UUID
|
// role list -id UUID
|
||||||
// role set -id UUID -roles role1,role2,...
|
// role set -id UUID -roles role1,role2,...
|
||||||
|
// role grant -id UUID -role ROLE
|
||||||
|
// role revoke -id UUID -role ROLE
|
||||||
//
|
//
|
||||||
// token issue -id UUID
|
// token issue -id UUID
|
||||||
// token revoke -jti JTI
|
// token revoke -jti JTI
|
||||||
//
|
//
|
||||||
|
// pgcreds list
|
||||||
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
|
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
|
||||||
// pgcreds get -id UUID
|
// pgcreds get -id UUID
|
||||||
//
|
//
|
||||||
@@ -61,7 +64,7 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Global flags.
|
// Global flags.
|
||||||
serverURL := flag.String("server", "https://localhost:8443", "mciassrv base URL")
|
serverURL := flag.String("server", "https://mcias.metacircular.net:8443", "mciassrv base URL")
|
||||||
tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)")
|
tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)")
|
||||||
caCert := flag.String("cacert", "", "path to CA certificate for TLS")
|
caCert := flag.String("cacert", "", "path to CA certificate for TLS")
|
||||||
flag.Usage = usage
|
flag.Usage = usage
|
||||||
@@ -386,13 +389,17 @@ func (c *controller) accountSetPassword(args []string) {
|
|||||||
|
|
||||||
func (c *controller) runRole(args []string) {
|
func (c *controller) runRole(args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fatalf("role requires a subcommand: list, set")
|
fatalf("role requires a subcommand: list, set, grant, revoke")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "list":
|
case "list":
|
||||||
c.roleList(args[1:])
|
c.roleList(args[1:])
|
||||||
case "set":
|
case "set":
|
||||||
c.roleSet(args[1:])
|
c.roleSet(args[1:])
|
||||||
|
case "grant":
|
||||||
|
c.roleGrant(args[1:])
|
||||||
|
case "revoke":
|
||||||
|
c.roleRevoke(args[1:])
|
||||||
default:
|
default:
|
||||||
fatalf("unknown role subcommand %q", args[0])
|
fatalf("unknown role subcommand %q", args[0])
|
||||||
}
|
}
|
||||||
@@ -437,6 +444,41 @@ func (c *controller) roleSet(args []string) {
|
|||||||
fmt.Printf("roles set: %v\n", roles)
|
fmt.Printf("roles set: %v\n", roles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *controller) roleGrant(args []string) {
|
||||||
|
fs := flag.NewFlagSet("role grant", flag.ExitOnError)
|
||||||
|
id := fs.String("id", "", "account UUID (required)")
|
||||||
|
role := fs.String("role", "", "role name (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *id == "" {
|
||||||
|
fatalf("role grant: -id is required")
|
||||||
|
}
|
||||||
|
if *role == "" {
|
||||||
|
fatalf("role grant: -role is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]string{"role": *role}
|
||||||
|
c.doRequest("POST", "/v1/accounts/"+*id+"/roles", body, nil)
|
||||||
|
fmt.Printf("role granted: %s\n", *role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) roleRevoke(args []string) {
|
||||||
|
fs := flag.NewFlagSet("role revoke", flag.ExitOnError)
|
||||||
|
id := fs.String("id", "", "account UUID (required)")
|
||||||
|
role := fs.String("role", "", "role name (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *id == "" {
|
||||||
|
fatalf("role revoke: -id is required")
|
||||||
|
}
|
||||||
|
if *role == "" {
|
||||||
|
fatalf("role revoke: -role is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.doRequest("DELETE", "/v1/accounts/"+*id+"/roles/"+*role, nil, nil)
|
||||||
|
fmt.Printf("role revoked: %s\n", *role)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- token subcommands ----
|
// ---- token subcommands ----
|
||||||
|
|
||||||
func (c *controller) runToken(args []string) {
|
func (c *controller) runToken(args []string) {
|
||||||
@@ -485,9 +527,11 @@ func (c *controller) tokenRevoke(args []string) {
|
|||||||
|
|
||||||
func (c *controller) runPGCreds(args []string) {
|
func (c *controller) runPGCreds(args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fatalf("pgcreds requires a subcommand: get, set")
|
fatalf("pgcreds requires a subcommand: list, get, set")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
c.pgCredsList(args[1:])
|
||||||
case "get":
|
case "get":
|
||||||
c.pgCredsGet(args[1:])
|
c.pgCredsGet(args[1:])
|
||||||
case "set":
|
case "set":
|
||||||
@@ -497,6 +541,15 @@ func (c *controller) runPGCreds(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *controller) pgCredsList(args []string) {
|
||||||
|
fs := flag.NewFlagSet("pgcreds list", flag.ExitOnError)
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
var result json.RawMessage
|
||||||
|
c.doRequest("GET", "/v1/pgcreds", nil, &result)
|
||||||
|
printJSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *controller) pgCredsGet(args []string) {
|
func (c *controller) pgCredsGet(args []string) {
|
||||||
fs := flag.NewFlagSet("pgcreds get", flag.ExitOnError)
|
fs := flag.NewFlagSet("pgcreds get", flag.ExitOnError)
|
||||||
id := fs.String("id", "", "account UUID (required)")
|
id := fs.String("id", "", "account UUID (required)")
|
||||||
@@ -871,7 +924,7 @@ func usage() {
|
|||||||
Usage: mciasctl [global flags] <command> [args]
|
Usage: mciasctl [global flags] <command> [args]
|
||||||
|
|
||||||
Global flags:
|
Global flags:
|
||||||
-server URL of the mciassrv instance (default: https://localhost:8443)
|
-server URL of the mciassrv instance (default: https://mcias.metacircular.net:8443)
|
||||||
-token Bearer token (or set MCIAS_TOKEN env var)
|
-token Bearer token (or set MCIAS_TOKEN env var)
|
||||||
-cacert Path to CA certificate for TLS verification
|
-cacert Path to CA certificate for TLS verification
|
||||||
|
|
||||||
@@ -902,6 +955,7 @@ Commands:
|
|||||||
token issue -id UUID
|
token issue -id UUID
|
||||||
token revoke -jti JTI
|
token revoke -jti JTI
|
||||||
|
|
||||||
|
pgcreds list
|
||||||
pgcreds get -id UUID
|
pgcreds get -id UUID
|
||||||
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
|
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,13 @@
|
|||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
//
|
//
|
||||||
// mciasdb --config /etc/mcias/mcias.toml <command> [subcommand] [flags]
|
// mciasdb --config /srv/mcias/mcias.toml <command> [subcommand] [flags]
|
||||||
//
|
//
|
||||||
// Commands:
|
// Commands:
|
||||||
//
|
//
|
||||||
// schema verify
|
// schema verify
|
||||||
// schema migrate
|
// schema migrate
|
||||||
|
// schema force --version N
|
||||||
//
|
//
|
||||||
// account list
|
// account list
|
||||||
// account get --id UUID
|
// account get --id UUID
|
||||||
@@ -38,6 +39,8 @@
|
|||||||
//
|
//
|
||||||
// pgcreds get --id UUID
|
// pgcreds get --id UUID
|
||||||
// pgcreds set --id UUID --host H --port P --db D --user U
|
// pgcreds set --id UUID --host H --port P --db D --user U
|
||||||
|
//
|
||||||
|
// rekey
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -52,7 +55,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "mcias.toml", "path to TOML configuration file")
|
configPath := flag.String("config", "/srv/mcias/mcias.toml", "path to TOML configuration file")
|
||||||
flag.Usage = usage
|
flag.Usage = usage
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -62,7 +65,22 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
database, masterKey, err := openDB(*configPath)
|
command := args[0]
|
||||||
|
subArgs := args[1:]
|
||||||
|
|
||||||
|
// schema subcommands manage migrations themselves and must not trigger
|
||||||
|
// auto-migration on open (a dirty database would prevent the tool from
|
||||||
|
// opening at all, blocking recovery operations like "schema force").
|
||||||
|
var (
|
||||||
|
database *db.DB
|
||||||
|
masterKey []byte
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if command == "schema" {
|
||||||
|
database, masterKey, err = openDBRaw(*configPath)
|
||||||
|
} else {
|
||||||
|
database, masterKey, err = openDB(*configPath)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("%v", err)
|
fatalf("%v", err)
|
||||||
}
|
}
|
||||||
@@ -76,9 +94,6 @@ func main() {
|
|||||||
|
|
||||||
tool := &tool{db: database, masterKey: masterKey}
|
tool := &tool{db: database, masterKey: masterKey}
|
||||||
|
|
||||||
command := args[0]
|
|
||||||
subArgs := args[1:]
|
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "schema":
|
case "schema":
|
||||||
tool.runSchema(subArgs)
|
tool.runSchema(subArgs)
|
||||||
@@ -94,6 +109,8 @@ func main() {
|
|||||||
tool.runAudit(subArgs)
|
tool.runAudit(subArgs)
|
||||||
case "pgcreds":
|
case "pgcreds":
|
||||||
tool.runPGCreds(subArgs)
|
tool.runPGCreds(subArgs)
|
||||||
|
case "rekey":
|
||||||
|
tool.runRekey(subArgs)
|
||||||
default:
|
default:
|
||||||
fatalf("unknown command %q; run with no args for usage", command)
|
fatalf("unknown command %q; run with no args for usage", command)
|
||||||
}
|
}
|
||||||
@@ -111,6 +128,21 @@ type tool struct {
|
|||||||
// the same passphrase always yields the same key and encrypted secrets remain
|
// the same passphrase always yields the same key and encrypted secrets remain
|
||||||
// readable. The passphrase env var is unset immediately after reading.
|
// readable. The passphrase env var is unset immediately after reading.
|
||||||
func openDB(configPath string) (*db.DB, []byte, error) {
|
func openDB(configPath string) (*db.DB, []byte, error) {
|
||||||
|
database, masterKey, err := openDBRaw(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Migrate(database); err != nil {
|
||||||
|
_ = database.Close()
|
||||||
|
return nil, nil, fmt.Errorf("migrate database: %w", err)
|
||||||
|
}
|
||||||
|
return database, masterKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openDBRaw opens the database without running migrations. Used by schema
|
||||||
|
// subcommands so they remain operational even when the database is in a dirty
|
||||||
|
// migration state (e.g. to allow "schema force" to clear a dirty flag).
|
||||||
|
func openDBRaw(configPath string) (*db.DB, []byte, error) {
|
||||||
cfg, err := config.Load(configPath)
|
cfg, err := config.Load(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("load config: %w", err)
|
return nil, nil, fmt.Errorf("load config: %w", err)
|
||||||
@@ -121,11 +153,6 @@ func openDB(configPath string) (*db.DB, []byte, error) {
|
|||||||
return nil, nil, fmt.Errorf("open database %q: %w", cfg.Database.Path, err)
|
return nil, nil, fmt.Errorf("open database %q: %w", cfg.Database.Path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Migrate(database); err != nil {
|
|
||||||
_ = database.Close()
|
|
||||||
return nil, nil, fmt.Errorf("migrate database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
masterKey, err := deriveMasterKey(cfg, database)
|
masterKey, err := deriveMasterKey(cfg, database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = database.Close()
|
_ = database.Close()
|
||||||
@@ -210,6 +237,7 @@ Global flags:
|
|||||||
Commands:
|
Commands:
|
||||||
schema verify Check schema version; exit 1 if migrations pending
|
schema verify Check schema version; exit 1 if migrations pending
|
||||||
schema migrate Apply any pending schema migrations
|
schema migrate Apply any pending schema migrations
|
||||||
|
schema force --version N Force schema version (clears dirty state)
|
||||||
|
|
||||||
account list List all accounts
|
account list List all accounts
|
||||||
account get --id UUID
|
account get --id UUID
|
||||||
@@ -235,6 +263,9 @@ Commands:
|
|||||||
pgcreds set --id UUID --host H [--port P] --db D --user U
|
pgcreds set --id UUID --host H [--port P] --db D --user U
|
||||||
(password is prompted interactively)
|
(password is prompted interactively)
|
||||||
|
|
||||||
|
rekey Re-encrypt all secrets under a new master passphrase
|
||||||
|
(prompts interactively; requires server to be stopped)
|
||||||
|
|
||||||
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
|
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
|
||||||
file. Use it only when the server is unavailable or for break-glass recovery.
|
file. Use it only when the server is unavailable or for break-glass recovery.
|
||||||
All write operations are recorded in the audit log.
|
All write operations are recorded in the audit log.
|
||||||
|
|||||||
@@ -206,12 +206,12 @@ func TestRoleRevoke(t *testing.T) {
|
|||||||
t.Fatalf("create account: %v", err)
|
t.Fatalf("create account: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tool.db.GrantRole(a.ID, "editor", nil); err != nil {
|
if err := tool.db.GrantRole(a.ID, "user", nil); err != nil {
|
||||||
t.Fatalf("grant role: %v", err)
|
t.Fatalf("grant role: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
captureStdout(t, func() {
|
captureStdout(t, func() {
|
||||||
tool.roleRevoke([]string{"--id", a.UUID, "--role", "editor"})
|
tool.roleRevoke([]string{"--id", a.UUID, "--role", "user"})
|
||||||
})
|
})
|
||||||
|
|
||||||
roles, err := tool.db.GetRoles(a.ID)
|
roles, err := tool.db.GetRoles(a.ID)
|
||||||
@@ -438,3 +438,141 @@ func TestPGCredsGetNotFound(t *testing.T) {
|
|||||||
t.Fatal("expected ErrNotFound, got nil")
|
t.Fatal("expected ErrNotFound, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- rekey command tests ----
|
||||||
|
|
||||||
|
// TestRekeyCommandRoundTrip exercises runRekey end-to-end with real AES-256-GCM
|
||||||
|
// encryption and actual Argon2id key derivation. It verifies that all secrets
|
||||||
|
// (signing key, TOTP, pg password) remain accessible after rekey and that the
|
||||||
|
// old master key no longer decrypts the re-encrypted values.
|
||||||
|
//
|
||||||
|
// Note: Argon2id derivation (time=3, memory=128 MiB) makes this test slow (~2 s).
|
||||||
|
func TestRekeyCommandRoundTrip(t *testing.T) {
|
||||||
|
tool := newTestTool(t)
|
||||||
|
|
||||||
|
// ── Setup: signing key encrypted under old master key ──
|
||||||
|
_, privKey, err := crypto.GenerateEd25519KeyPair()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key pair: %v", err)
|
||||||
|
}
|
||||||
|
sigKeyPEM, err := crypto.MarshalPrivateKeyPEM(privKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal key: %v", err)
|
||||||
|
}
|
||||||
|
sigEnc, sigNonce, err := crypto.SealAESGCM(tool.masterKey, sigKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seal signing key: %v", err)
|
||||||
|
}
|
||||||
|
if err := tool.db.WriteServerConfig(sigEnc, sigNonce); err != nil {
|
||||||
|
t.Fatalf("write server config: %v", err)
|
||||||
|
}
|
||||||
|
// WriteMasterKeySalt so ReadServerConfig has a valid salt row.
|
||||||
|
oldSalt, err := crypto.NewSalt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gen salt: %v", err)
|
||||||
|
}
|
||||||
|
if err := tool.db.WriteMasterKeySalt(oldSalt); err != nil {
|
||||||
|
t.Fatalf("write salt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup: account with TOTP ──
|
||||||
|
a, err := tool.db.CreateAccount("rekeyuser", "human", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
totpSecret := []byte("JBSWY3DPEHPK3PXP")
|
||||||
|
totpEnc, totpNonce, err := crypto.SealAESGCM(tool.masterKey, totpSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seal totp: %v", err)
|
||||||
|
}
|
||||||
|
if err := tool.db.SetTOTP(a.ID, totpEnc, totpNonce); err != nil {
|
||||||
|
t.Fatalf("set totp: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup: pg credentials ──
|
||||||
|
pgPass := []byte("pgpassword123")
|
||||||
|
pgEnc, pgNonce, err := crypto.SealAESGCM(tool.masterKey, pgPass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seal pg pass: %v", err)
|
||||||
|
}
|
||||||
|
if err := tool.db.WritePGCredentials(a.ID, "localhost", 5432, "mydb", "myuser", pgEnc, pgNonce); err != nil {
|
||||||
|
t.Fatalf("write pg creds: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pipe new passphrase twice into stdin ──
|
||||||
|
const newPassphrase = "new-master-passphrase-for-test"
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create stdin pipe: %v", err)
|
||||||
|
}
|
||||||
|
origStdin := os.Stdin
|
||||||
|
os.Stdin = r
|
||||||
|
t.Cleanup(func() { os.Stdin = origStdin })
|
||||||
|
if _, err := fmt.Fprintf(w, "%s\n%s\n", newPassphrase, newPassphrase); err != nil {
|
||||||
|
t.Fatalf("write stdin: %v", err)
|
||||||
|
}
|
||||||
|
_ = w.Close()
|
||||||
|
|
||||||
|
// ── Execute rekey ──
|
||||||
|
tool.runRekey(nil)
|
||||||
|
|
||||||
|
// ── Derive new key from stored salt + new passphrase ──
|
||||||
|
newSalt, err := tool.db.ReadMasterKeySalt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read new salt: %v", err)
|
||||||
|
}
|
||||||
|
newKey, err := crypto.DeriveKey(newPassphrase, newSalt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive new key: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
for i := range newKey {
|
||||||
|
newKey[i] = 0
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Signing key must decrypt with new key.
|
||||||
|
newSigEnc, newSigNonce, err := tool.db.ReadServerConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read server config after rekey: %v", err)
|
||||||
|
}
|
||||||
|
decPEM, err := crypto.OpenAESGCM(newKey, newSigNonce, newSigEnc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt signing key with new key: %v", err)
|
||||||
|
}
|
||||||
|
if string(decPEM) != string(sigKeyPEM) {
|
||||||
|
t.Error("signing key PEM mismatch after rekey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old key must NOT decrypt the re-encrypted signing key.
|
||||||
|
// Security: adversarial check that old key is invalidated.
|
||||||
|
if _, err := crypto.OpenAESGCM(tool.masterKey, newSigNonce, newSigEnc); err == nil {
|
||||||
|
t.Error("old key still decrypts signing key after rekey — ciphertext was not replaced")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTP must decrypt with new key.
|
||||||
|
updatedAcct, err := tool.db.GetAccountByUUID(a.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get account after rekey: %v", err)
|
||||||
|
}
|
||||||
|
decTOTP, err := crypto.OpenAESGCM(newKey, updatedAcct.TOTPSecretNonce, updatedAcct.TOTPSecretEnc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt TOTP with new key: %v", err)
|
||||||
|
}
|
||||||
|
if string(decTOTP) != string(totpSecret) {
|
||||||
|
t.Errorf("TOTP mismatch: got %q, want %q", decTOTP, totpSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pg password must decrypt with new key.
|
||||||
|
updatedCred, err := tool.db.ReadPGCredentials(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read pg creds after rekey: %v", err)
|
||||||
|
}
|
||||||
|
decPG, err := crypto.OpenAESGCM(newKey, updatedCred.PGPasswordNonce, updatedCred.PGPasswordEnc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt pg password with new key: %v", err)
|
||||||
|
}
|
||||||
|
if string(decPG) != string(pgPass) {
|
||||||
|
t.Errorf("pg password mismatch: got %q, want %q", decPG, pgPass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
154
cmd/mciasdb/rekey.go
Normal file
154
cmd/mciasdb/rekey.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runRekey re-encrypts all secrets under a new passphrase-derived master key.
|
||||||
|
//
|
||||||
|
// The current master key (already loaded in tool.masterKey by openDB) is used
|
||||||
|
// to decrypt every encrypted secret: the Ed25519 signing key, all TOTP secrets,
|
||||||
|
// and all Postgres credential passwords. The operator is then prompted for a
|
||||||
|
// new passphrase (confirmed), a fresh Argon2id salt is generated, a new 256-bit
|
||||||
|
// master key is derived, and all secrets are re-encrypted and written back in a
|
||||||
|
// single atomic SQLite transaction.
|
||||||
|
//
|
||||||
|
// Security: The entire re-encryption happens in memory first; the database is
|
||||||
|
// only updated once all ciphertext has been produced successfully. The new
|
||||||
|
// salt replaces the old salt atomically within the same transaction so the
|
||||||
|
// database is never left in a mixed state. Both the old and new master keys
|
||||||
|
// are zeroed in deferred cleanup. No secret material is logged or printed.
|
||||||
|
func (t *tool) runRekey(_ []string) {
|
||||||
|
// ── 1. Decrypt signing key under old master key ──────────────────────
|
||||||
|
sigKeyEnc, sigKeyNonce, err := t.db.ReadServerConfig()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read server config: %v", err)
|
||||||
|
}
|
||||||
|
sigKeyPEM, err := crypto.OpenAESGCM(t.masterKey, sigKeyNonce, sigKeyEnc)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("decrypt signing key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Decrypt all TOTP secrets under old master key ─────────────────
|
||||||
|
totpAccounts, err := t.db.ListAccountsWithTOTP()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("list accounts with TOTP: %v", err)
|
||||||
|
}
|
||||||
|
type totpPlain struct {
|
||||||
|
secret []byte
|
||||||
|
accountID int64
|
||||||
|
}
|
||||||
|
totpPlaintexts := make([]totpPlain, 0, len(totpAccounts))
|
||||||
|
for _, a := range totpAccounts {
|
||||||
|
pt, err := crypto.OpenAESGCM(t.masterKey, a.TOTPSecretNonce, a.TOTPSecretEnc)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("decrypt TOTP secret for account %s: %v", a.Username, err)
|
||||||
|
}
|
||||||
|
totpPlaintexts = append(totpPlaintexts, totpPlain{accountID: a.ID, secret: pt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Decrypt all pg_credentials passwords under old master key ──────
|
||||||
|
pgCreds, err := t.db.ListAllPGCredentials()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("list pg credentials: %v", err)
|
||||||
|
}
|
||||||
|
type pgPlain struct {
|
||||||
|
password []byte
|
||||||
|
credID int64
|
||||||
|
}
|
||||||
|
pgPlaintexts := make([]pgPlain, 0, len(pgCreds))
|
||||||
|
for _, c := range pgCreds {
|
||||||
|
pt, err := crypto.OpenAESGCM(t.masterKey, c.PGPasswordNonce, c.PGPasswordEnc)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("decrypt pg password for credential %d: %v", c.ID, err)
|
||||||
|
}
|
||||||
|
pgPlaintexts = append(pgPlaintexts, pgPlain{credID: c.ID, password: pt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Prompt for new passphrase (confirmed) ──────────────────────────
|
||||||
|
fmt.Fprintln(os.Stderr, "Enter new master passphrase (will not echo):")
|
||||||
|
newPassphrase, err := readPassword("New passphrase: ")
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read passphrase: %v", err)
|
||||||
|
}
|
||||||
|
if newPassphrase == "" {
|
||||||
|
fatalf("passphrase must not be empty")
|
||||||
|
}
|
||||||
|
confirm, err := readPassword("Confirm passphrase: ")
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read passphrase confirmation: %v", err)
|
||||||
|
}
|
||||||
|
if newPassphrase != confirm {
|
||||||
|
fatalf("passphrases do not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Derive new master key ──────────────────────────────────────────
|
||||||
|
// Security: a fresh random salt is generated for every rekey so that the
|
||||||
|
// new key is independent of the old key even if the same passphrase is
|
||||||
|
// reused. The new salt is stored atomically with the re-encrypted secrets.
|
||||||
|
newSalt, err := crypto.NewSalt()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("generate new salt: %v", err)
|
||||||
|
}
|
||||||
|
newKey, err := crypto.DeriveKey(newPassphrase, newSalt)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("derive new master key: %v", err)
|
||||||
|
}
|
||||||
|
// Zero both keys when done, regardless of outcome.
|
||||||
|
defer func() {
|
||||||
|
for i := range newKey {
|
||||||
|
newKey[i] = 0
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ── 6. Re-encrypt signing key ─────────────────────────────────────────
|
||||||
|
newSigKeyEnc, newSigKeyNonce, err := crypto.SealAESGCM(newKey, sigKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("re-encrypt signing key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Re-encrypt TOTP secrets ────────────────────────────────────────
|
||||||
|
totpRows := make([]db.TOTPRekeyRow, 0, len(totpPlaintexts))
|
||||||
|
for _, tp := range totpPlaintexts {
|
||||||
|
enc, nonce, err := crypto.SealAESGCM(newKey, tp.secret)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("re-encrypt TOTP secret for account %d: %v", tp.accountID, err)
|
||||||
|
}
|
||||||
|
totpRows = append(totpRows, db.TOTPRekeyRow{
|
||||||
|
AccountID: tp.accountID,
|
||||||
|
Enc: enc,
|
||||||
|
Nonce: nonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. Re-encrypt pg_credentials passwords ────────────────────────────
|
||||||
|
pgRows := make([]db.PGRekeyRow, 0, len(pgPlaintexts))
|
||||||
|
for _, pp := range pgPlaintexts {
|
||||||
|
enc, nonce, err := crypto.SealAESGCM(newKey, pp.password)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("re-encrypt pg password for credential %d: %v", pp.credID, err)
|
||||||
|
}
|
||||||
|
pgRows = append(pgRows, db.PGRekeyRow{
|
||||||
|
CredentialID: pp.credID,
|
||||||
|
Enc: enc,
|
||||||
|
Nonce: nonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 9. Atomic commit ──────────────────────────────────────────────────
|
||||||
|
if err := t.db.Rekey(newSalt, newSigKeyEnc, newSigKeyNonce, totpRows, pgRows); err != nil {
|
||||||
|
fatalf("rekey database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.db.WriteAuditEvent("master_key_rekeyed", nil, nil, "", `{"actor":"mciasdb"}`); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Rekey complete: %d TOTP secrets and %d pg credentials re-encrypted.\n",
|
||||||
|
len(totpRows), len(pgRows))
|
||||||
|
fmt.Fprintln(os.Stderr, "Update your mcias.toml or passphrase environment variable to use the new passphrase.")
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
@@ -8,13 +9,15 @@ import (
|
|||||||
|
|
||||||
func (t *tool) runSchema(args []string) {
|
func (t *tool) runSchema(args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fatalf("schema requires a subcommand: verify, migrate")
|
fatalf("schema requires a subcommand: verify, migrate, force")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "verify":
|
case "verify":
|
||||||
t.schemaVerify()
|
t.schemaVerify()
|
||||||
case "migrate":
|
case "migrate":
|
||||||
t.schemaMigrate()
|
t.schemaMigrate()
|
||||||
|
case "force":
|
||||||
|
t.schemaForce(args[1:])
|
||||||
default:
|
default:
|
||||||
fatalf("unknown schema subcommand %q", args[0])
|
fatalf("unknown schema subcommand %q", args[0])
|
||||||
}
|
}
|
||||||
@@ -39,6 +42,26 @@ func (t *tool) schemaVerify() {
|
|||||||
fmt.Println("schema is up-to-date")
|
fmt.Println("schema is up-to-date")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// schemaForce marks the database as being at a specific migration version
|
||||||
|
// without running any SQL. Use this to clear a dirty migration state after
|
||||||
|
// you have verified that the schema already reflects the target version.
|
||||||
|
//
|
||||||
|
// Example: mciasdb schema force --version 6
|
||||||
|
func (t *tool) schemaForce(args []string) {
|
||||||
|
fs := flag.NewFlagSet("schema force", flag.ExitOnError)
|
||||||
|
version := fs.Int("version", 0, "schema version to force (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *version <= 0 {
|
||||||
|
fatalf("--version must be a positive integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.ForceSchemaVersion(t.db, *version); err != nil {
|
||||||
|
fatalf("force schema version: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("schema version forced to %d; run 'schema migrate' to apply any remaining migrations\n", *version)
|
||||||
|
}
|
||||||
|
|
||||||
// schemaMigrate applies any pending migrations and reports each one.
|
// schemaMigrate applies any pending migrations and reports each one.
|
||||||
func (t *tool) schemaMigrate() {
|
func (t *tool) schemaMigrate() {
|
||||||
before, err := db.SchemaVersion(t.db)
|
before, err := db.SchemaVersion(t.db)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Command mciasgrpcctl is the MCIAS gRPC admin CLI.
|
// Command mciasgrpcctl is the MCIAS gRPC admin CLI.
|
||||||
//
|
//
|
||||||
// It connects to a running mciassrv gRPC listener and provides subcommands for
|
// It connects to a running mciassrv gRPC listener and provides subcommands for
|
||||||
// managing accounts, roles, tokens, and Postgres credentials via the gRPC API.
|
// managing accounts, roles, tokens, Postgres credentials, and policy rules via
|
||||||
|
// the gRPC API.
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
//
|
//
|
||||||
@@ -9,7 +10,7 @@
|
|||||||
//
|
//
|
||||||
// Global flags:
|
// Global flags:
|
||||||
//
|
//
|
||||||
// -server gRPC server address (default: localhost:9443)
|
// -server gRPC server address (default: mcias.metacircular.net:9443)
|
||||||
// -token Bearer token for authentication (or set MCIAS_TOKEN env var)
|
// -token Bearer token for authentication (or set MCIAS_TOKEN env var)
|
||||||
// -cacert Path to CA certificate for TLS verification (optional)
|
// -cacert Path to CA certificate for TLS verification (optional)
|
||||||
//
|
//
|
||||||
@@ -18,6 +19,9 @@
|
|||||||
// health
|
// health
|
||||||
// pubkey
|
// pubkey
|
||||||
//
|
//
|
||||||
|
// auth login -username NAME [-totp CODE]
|
||||||
|
// auth logout
|
||||||
|
//
|
||||||
// account list
|
// account list
|
||||||
// account create -username NAME -password PASS [-type human|system]
|
// account create -username NAME -password PASS [-type human|system]
|
||||||
// account get -id UUID
|
// account get -id UUID
|
||||||
@@ -26,6 +30,8 @@
|
|||||||
//
|
//
|
||||||
// role list -id UUID
|
// role list -id UUID
|
||||||
// role set -id UUID -roles role1,role2,...
|
// role set -id UUID -roles role1,role2,...
|
||||||
|
// role grant -id UUID -role ROLE
|
||||||
|
// role revoke -id UUID -role ROLE
|
||||||
//
|
//
|
||||||
// token validate -token TOKEN
|
// token validate -token TOKEN
|
||||||
// token issue -id UUID
|
// token issue -id UUID
|
||||||
@@ -33,6 +39,12 @@
|
|||||||
//
|
//
|
||||||
// pgcreds get -id UUID
|
// pgcreds get -id UUID
|
||||||
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER -password PASS
|
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER -password PASS
|
||||||
|
//
|
||||||
|
// policy list
|
||||||
|
// policy create -description STR -json FILE [-priority N] [-not-before RFC3339] [-expires-at RFC3339]
|
||||||
|
// policy get -id ID
|
||||||
|
// policy update -id ID [-priority N] [-enabled true|false] [-not-before RFC3339] [-expires-at RFC3339] [-clear-not-before] [-clear-expires-at]
|
||||||
|
// policy delete -id ID
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -43,9 +55,11 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
@@ -55,7 +69,7 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Global flags.
|
// Global flags.
|
||||||
serverAddr := flag.String("server", "localhost:9443", "gRPC server address (host:port)")
|
serverAddr := flag.String("server", "mcias.metacircular.net:9443", "gRPC server address (host:port)")
|
||||||
tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)")
|
tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)")
|
||||||
caCert := flag.String("cacert", "", "path to CA certificate for TLS")
|
caCert := flag.String("cacert", "", "path to CA certificate for TLS")
|
||||||
flag.Usage = usage
|
flag.Usage = usage
|
||||||
@@ -93,6 +107,8 @@ func main() {
|
|||||||
ctl.runHealth()
|
ctl.runHealth()
|
||||||
case "pubkey":
|
case "pubkey":
|
||||||
ctl.runPubKey()
|
ctl.runPubKey()
|
||||||
|
case "auth":
|
||||||
|
ctl.runAuth(subArgs)
|
||||||
case "account":
|
case "account":
|
||||||
ctl.runAccount(subArgs)
|
ctl.runAccount(subArgs)
|
||||||
case "role":
|
case "role":
|
||||||
@@ -101,6 +117,8 @@ func main() {
|
|||||||
ctl.runToken(subArgs)
|
ctl.runToken(subArgs)
|
||||||
case "pgcreds":
|
case "pgcreds":
|
||||||
ctl.runPGCreds(subArgs)
|
ctl.runPGCreds(subArgs)
|
||||||
|
case "policy":
|
||||||
|
ctl.runPolicy(subArgs)
|
||||||
default:
|
default:
|
||||||
fatalf("unknown command %q; run with no args to see usage", command)
|
fatalf("unknown command %q; run with no args to see usage", command)
|
||||||
}
|
}
|
||||||
@@ -162,6 +180,89 @@ func (c *controller) runPubKey() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- auth subcommands ----
|
||||||
|
|
||||||
|
func (c *controller) runAuth(args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("auth requires a subcommand: login, logout")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "login":
|
||||||
|
c.authLogin(args[1:])
|
||||||
|
case "logout":
|
||||||
|
c.authLogout()
|
||||||
|
default:
|
||||||
|
fatalf("unknown auth subcommand %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authLogin authenticates with the gRPC server using username and password,
|
||||||
|
// then prints the resulting bearer token to stdout. The password is always
|
||||||
|
// prompted interactively; it is never accepted as a command-line flag to
|
||||||
|
// prevent it from appearing in shell history, ps output, and process argument
|
||||||
|
// lists.
|
||||||
|
//
|
||||||
|
// Security: terminal echo is disabled during password entry
|
||||||
|
// (golang.org/x/term.ReadPassword); the raw byte slice is zeroed after use.
|
||||||
|
func (c *controller) authLogin(args []string) {
|
||||||
|
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
|
||||||
|
username := fs.String("username", "", "username (required)")
|
||||||
|
totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *username == "" {
|
||||||
|
fatalf("auth login: -username is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: always prompt interactively; never accept password as a flag.
|
||||||
|
// This prevents the credential from appearing in shell history, ps output,
|
||||||
|
// and /proc/PID/cmdline.
|
||||||
|
fmt.Fprint(os.Stderr, "Password: ")
|
||||||
|
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read password: %v", err)
|
||||||
|
}
|
||||||
|
passwd := string(raw)
|
||||||
|
// Zero the raw byte slice once copied into the string.
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
authCl := mciasv1.NewAuthServiceClient(c.conn)
|
||||||
|
// Login is a public RPC — no auth context needed.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := authCl.Login(ctx, &mciasv1.LoginRequest{
|
||||||
|
Username: *username,
|
||||||
|
Password: passwd,
|
||||||
|
TotpCode: *totpCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("auth login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print token to stdout so it can be captured by scripts, e.g.:
|
||||||
|
// export MCIAS_TOKEN=$(mciasgrpcctl auth login -username alice)
|
||||||
|
fmt.Println(resp.Token)
|
||||||
|
if resp.ExpiresAt != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "expires: %s\n", resp.ExpiresAt.AsTime().UTC().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authLogout revokes the caller's current JWT via the gRPC AuthService.
|
||||||
|
func (c *controller) authLogout() {
|
||||||
|
authCl := mciasv1.NewAuthServiceClient(c.conn)
|
||||||
|
ctx, cancel := c.callCtx()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if _, err := authCl.Logout(ctx, &mciasv1.LogoutRequest{}); err != nil {
|
||||||
|
fatalf("auth logout: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("logged out")
|
||||||
|
}
|
||||||
|
|
||||||
// ---- account subcommands ----
|
// ---- account subcommands ----
|
||||||
|
|
||||||
func (c *controller) runAccount(args []string) {
|
func (c *controller) runAccount(args []string) {
|
||||||
@@ -293,13 +394,17 @@ func (c *controller) accountDelete(args []string) {
|
|||||||
|
|
||||||
func (c *controller) runRole(args []string) {
|
func (c *controller) runRole(args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fatalf("role requires a subcommand: list, set")
|
fatalf("role requires a subcommand: list, set, grant, revoke")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "list":
|
case "list":
|
||||||
c.roleList(args[1:])
|
c.roleList(args[1:])
|
||||||
case "set":
|
case "set":
|
||||||
c.roleSet(args[1:])
|
c.roleSet(args[1:])
|
||||||
|
case "grant":
|
||||||
|
c.roleGrant(args[1:])
|
||||||
|
case "revoke":
|
||||||
|
c.roleRevoke(args[1:])
|
||||||
default:
|
default:
|
||||||
fatalf("unknown role subcommand %q", args[0])
|
fatalf("unknown role subcommand %q", args[0])
|
||||||
}
|
}
|
||||||
@@ -356,6 +461,54 @@ func (c *controller) roleSet(args []string) {
|
|||||||
fmt.Printf("roles set: %v\n", roles)
|
fmt.Printf("roles set: %v\n", roles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *controller) roleGrant(args []string) {
|
||||||
|
fs := flag.NewFlagSet("role grant", flag.ExitOnError)
|
||||||
|
id := fs.String("id", "", "account UUID (required)")
|
||||||
|
role := fs.String("role", "", "role name (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *id == "" {
|
||||||
|
fatalf("role grant: -id is required")
|
||||||
|
}
|
||||||
|
if *role == "" {
|
||||||
|
fatalf("role grant: -role is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := mciasv1.NewAccountServiceClient(c.conn)
|
||||||
|
ctx, cancel := c.callCtx()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := cl.GrantRole(ctx, &mciasv1.GrantRoleRequest{Id: *id, Role: *role})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("role grant: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("role granted: %s\n", *role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) roleRevoke(args []string) {
|
||||||
|
fs := flag.NewFlagSet("role revoke", flag.ExitOnError)
|
||||||
|
id := fs.String("id", "", "account UUID (required)")
|
||||||
|
role := fs.String("role", "", "role name (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *id == "" {
|
||||||
|
fatalf("role revoke: -id is required")
|
||||||
|
}
|
||||||
|
if *role == "" {
|
||||||
|
fatalf("role revoke: -role is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := mciasv1.NewAccountServiceClient(c.conn)
|
||||||
|
ctx, cancel := c.callCtx()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := cl.RevokeRole(ctx, &mciasv1.RevokeRoleRequest{Id: *id, Role: *role})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("role revoke: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("role revoked: %s\n", *role)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- token subcommands ----
|
// ---- token subcommands ----
|
||||||
|
|
||||||
func (c *controller) runToken(args []string) {
|
func (c *controller) runToken(args []string) {
|
||||||
@@ -518,6 +671,208 @@ func (c *controller) pgCredsSet(args []string) {
|
|||||||
fmt.Println("credentials stored")
|
fmt.Println("credentials stored")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- policy subcommands ----
|
||||||
|
|
||||||
|
func (c *controller) runPolicy(args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("policy requires a subcommand: list, create, get, update, delete")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
c.policyList()
|
||||||
|
case "create":
|
||||||
|
c.policyCreate(args[1:])
|
||||||
|
case "get":
|
||||||
|
c.policyGet(args[1:])
|
||||||
|
case "update":
|
||||||
|
c.policyUpdate(args[1:])
|
||||||
|
case "delete":
|
||||||
|
c.policyDelete(args[1:])
|
||||||
|
default:
|
||||||
|
fatalf("unknown policy subcommand %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) policyList() {
|
||||||
|
cl := mciasv1.NewPolicyServiceClient(c.conn)
|
||||||
|
ctx, cancel := c.callCtx()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := cl.ListPolicyRules(ctx, &mciasv1.ListPolicyRulesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("policy list: %v", err)
|
||||||
|
}
|
||||||
|
printJSON(resp.Rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) policyCreate(args []string) {
|
||||||
|
fs := flag.NewFlagSet("policy create", flag.ExitOnError)
|
||||||
|
description := fs.String("description", "", "rule description (required)")
|
||||||
|
jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)")
|
||||||
|
priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)")
|
||||||
|
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339, optional)")
|
||||||
|
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339, optional)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *description == "" {
|
||||||
|
fatalf("policy create: -description is required")
|
||||||
|
}
|
||||||
|
if *jsonFile == "" {
|
||||||
|
fatalf("policy create: -json is required (path to rule body JSON file)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// G304: path comes from a CLI flag supplied by the operator.
|
||||||
|
ruleBytes, err := os.ReadFile(*jsonFile) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
fatalf("policy create: read %s: %v", *jsonFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the file contains valid JSON before sending.
|
||||||
|
var ruleBody interface{}
|
||||||
|
if err := json.Unmarshal(ruleBytes, &ruleBody); err != nil {
|
||||||
|
fatalf("policy create: invalid JSON in %s: %v", *jsonFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *notBefore != "" {
|
||||||
|
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
|
||||||
|
fatalf("policy create: -not-before must be RFC3339: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *expiresAt != "" {
|
||||||
|
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
|
||||||
|
fatalf("policy create: -expires-at must be RFC3339: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := mciasv1.NewPolicyServiceClient(c.conn)
|
||||||
|
ctx, cancel := c.callCtx()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := cl.CreatePolicyRule(ctx, &mciasv1.CreatePolicyRuleRequest{
|
||||||
|
Description: *description,
|
||||||
|
RuleJson: string(ruleBytes),
|
||||||
|
Priority: int32(*priority), //nolint:gosec // priority is a small positive integer
|
||||||
|
NotBefore: *notBefore,
|
||||||
|
ExpiresAt: *expiresAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("policy create: %v", err)
|
||||||
|
}
|
||||||
|
printJSON(resp.Rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) policyGet(args []string) {
|
||||||
|
fs := flag.NewFlagSet("policy get", flag.ExitOnError)
|
||||||
|
idStr := fs.String("id", "", "rule ID (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *idStr == "" {
|
||||||
|
fatalf("policy get: -id is required")
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(*idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("policy get: -id must be an integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := mciasv1.NewPolicyServiceClient(c.conn)
|
||||||
|
ctx, cancel := c.callCtx()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := cl.GetPolicyRule(ctx, &mciasv1.GetPolicyRuleRequest{Id: id})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("policy get: %v", err)
|
||||||
|
}
|
||||||
|
printJSON(resp.Rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) policyUpdate(args []string) {
|
||||||
|
fs := flag.NewFlagSet("policy update", flag.ExitOnError)
|
||||||
|
idStr := fs.String("id", "", "rule ID (required)")
|
||||||
|
priority := fs.Int("priority", -1, "new priority (-1 = no change)")
|
||||||
|
enabled := fs.String("enabled", "", "true or false")
|
||||||
|
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339)")
|
||||||
|
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339)")
|
||||||
|
clearNotBefore := fs.Bool("clear-not-before", false, "remove not_before constraint")
|
||||||
|
clearExpiresAt := fs.Bool("clear-expires-at", false, "remove expires_at constraint")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *idStr == "" {
|
||||||
|
fatalf("policy update: -id is required")
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(*idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("policy update: -id must be an integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &mciasv1.UpdatePolicyRuleRequest{
|
||||||
|
Id: id,
|
||||||
|
ClearNotBefore: *clearNotBefore,
|
||||||
|
ClearExpiresAt: *clearExpiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if *priority >= 0 {
|
||||||
|
v := int32(*priority) //nolint:gosec // priority is a small positive integer
|
||||||
|
req.Priority = &v
|
||||||
|
}
|
||||||
|
if *enabled != "" {
|
||||||
|
switch *enabled {
|
||||||
|
case "true":
|
||||||
|
b := true
|
||||||
|
req.Enabled = &b
|
||||||
|
case "false":
|
||||||
|
b := false
|
||||||
|
req.Enabled = &b
|
||||||
|
default:
|
||||||
|
fatalf("policy update: -enabled must be true or false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !*clearNotBefore && *notBefore != "" {
|
||||||
|
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
|
||||||
|
fatalf("policy update: -not-before must be RFC3339: %v", err)
|
||||||
|
}
|
||||||
|
req.NotBefore = *notBefore
|
||||||
|
}
|
||||||
|
if !*clearExpiresAt && *expiresAt != "" {
|
||||||
|
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
|
||||||
|
fatalf("policy update: -expires-at must be RFC3339: %v", err)
|
||||||
|
}
|
||||||
|
req.ExpiresAt = *expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := mciasv1.NewPolicyServiceClient(c.conn)
|
||||||
|
ctx, cancel := c.callCtx()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := cl.UpdatePolicyRule(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("policy update: %v", err)
|
||||||
|
}
|
||||||
|
printJSON(resp.Rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) policyDelete(args []string) {
|
||||||
|
fs := flag.NewFlagSet("policy delete", flag.ExitOnError)
|
||||||
|
idStr := fs.String("id", "", "rule ID (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *idStr == "" {
|
||||||
|
fatalf("policy delete: -id is required")
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(*idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("policy delete: -id must be an integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := mciasv1.NewPolicyServiceClient(c.conn)
|
||||||
|
ctx, cancel := c.callCtx()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if _, err := cl.DeletePolicyRule(ctx, &mciasv1.DeletePolicyRuleRequest{Id: id}); err != nil {
|
||||||
|
fatalf("policy delete: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("policy rule deleted")
|
||||||
|
}
|
||||||
|
|
||||||
// ---- gRPC connection ----
|
// ---- gRPC connection ----
|
||||||
|
|
||||||
// newGRPCConn dials the gRPC server with TLS.
|
// newGRPCConn dials the gRPC server with TLS.
|
||||||
@@ -575,7 +930,7 @@ func usage() {
|
|||||||
Usage: mciasgrpcctl [global flags] <command> [args]
|
Usage: mciasgrpcctl [global flags] <command> [args]
|
||||||
|
|
||||||
Global flags:
|
Global flags:
|
||||||
-server gRPC server address (default: localhost:9443)
|
-server gRPC server address (default: mcias.metacircular.net:9443)
|
||||||
-token Bearer token (or set MCIAS_TOKEN env var)
|
-token Bearer token (or set MCIAS_TOKEN env var)
|
||||||
-cacert Path to CA certificate for TLS verification
|
-cacert Path to CA certificate for TLS verification
|
||||||
|
|
||||||
@@ -583,6 +938,12 @@ Commands:
|
|||||||
health
|
health
|
||||||
pubkey
|
pubkey
|
||||||
|
|
||||||
|
auth login -username NAME [-totp CODE]
|
||||||
|
Obtain a bearer token. Password is always prompted interactively.
|
||||||
|
Token is written to stdout; expiry to stderr.
|
||||||
|
Example: export MCIAS_TOKEN=$(mciasgrpcctl auth login -username alice)
|
||||||
|
auth logout Revoke the current bearer token.
|
||||||
|
|
||||||
account list
|
account list
|
||||||
account create -username NAME -password PASS [-type human|system]
|
account create -username NAME -password PASS [-type human|system]
|
||||||
account get -id UUID
|
account get -id UUID
|
||||||
@@ -598,5 +959,16 @@ Commands:
|
|||||||
|
|
||||||
pgcreds get -id UUID
|
pgcreds get -id UUID
|
||||||
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER -password PASS
|
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER -password PASS
|
||||||
|
|
||||||
|
policy list
|
||||||
|
policy create -description STR -json FILE [-priority N]
|
||||||
|
[-not-before RFC3339] [-expires-at RFC3339]
|
||||||
|
FILE must contain a JSON rule body, e.g.:
|
||||||
|
{"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true}
|
||||||
|
policy get -id ID
|
||||||
|
policy update -id ID [-priority N] [-enabled true|false]
|
||||||
|
[-not-before RFC3339] [-expires-at RFC3339]
|
||||||
|
[-clear-not-before] [-clear-expires-at]
|
||||||
|
policy delete -id ID
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
//
|
//
|
||||||
// mciassrv -config /etc/mcias/mcias.toml
|
// mciassrv -config /srv/mcias/mcias.toml
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -36,10 +36,11 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/grpcserver"
|
"git.wntrmute.dev/kyle/mcias/internal/grpcserver"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/server"
|
"git.wntrmute.dev/kyle/mcias/internal/server"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "mcias.toml", "path to TOML configuration file")
|
configPath := flag.String("config", "/srv/mcias/mcias.toml", "path to TOML configuration file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
@@ -72,30 +73,47 @@ func run(configPath string, logger *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
logger.Info("database ready", "path", cfg.Database.Path)
|
logger.Info("database ready", "path", cfg.Database.Path)
|
||||||
|
|
||||||
// Derive or load the master encryption key.
|
// Derive or load the master encryption key and build the vault.
|
||||||
// Security: The master key encrypts TOTP secrets, Postgres passwords, and
|
// Security: The master key encrypts TOTP secrets, Postgres passwords, and
|
||||||
// the signing key at rest. It is derived from a passphrase via Argon2id
|
// the signing key at rest. It is derived from a passphrase via Argon2id
|
||||||
// (or loaded directly from a key file). The KDF salt is stored in the DB
|
// (or loaded directly from a key file). The KDF salt is stored in the DB
|
||||||
// for stability across restarts. The passphrase env var is cleared after use.
|
// for stability across restarts. The passphrase env var is cleared after use.
|
||||||
masterKey, err := loadMasterKey(cfg, database)
|
//
|
||||||
if err != nil {
|
// When the passphrase is not available (empty env var in passphrase mode
|
||||||
return fmt.Errorf("load master key: %w", err)
|
// with no key file), the server starts in sealed state. The operator must
|
||||||
|
// provide the passphrase via the /v1/vault/unseal API or the /unseal UI page.
|
||||||
|
// First run (no signing key in DB) still requires the passphrase at startup.
|
||||||
|
var v *vault.Vault
|
||||||
|
masterKey, mkErr := loadMasterKey(cfg, database)
|
||||||
|
if mkErr != nil {
|
||||||
|
// Check if we can start sealed (passphrase mode, empty env var).
|
||||||
|
if cfg.MasterKey.KeyFile == "" && os.Getenv(cfg.MasterKey.PassphraseEnv) == "" {
|
||||||
|
// Verify that this is not a first run — the signing key must already exist.
|
||||||
|
enc, nonce, scErr := database.ReadServerConfig()
|
||||||
|
if scErr != nil || enc == nil || nonce == nil {
|
||||||
|
return fmt.Errorf("first run requires passphrase: %w", mkErr)
|
||||||
}
|
}
|
||||||
defer func() {
|
v = vault.NewSealed()
|
||||||
// Zero the master key when done — reduces the window of exposure.
|
logger.Info("vault starting in sealed state")
|
||||||
for i := range masterKey {
|
} else {
|
||||||
masterKey[i] = 0
|
return fmt.Errorf("load master key: %w", mkErr)
|
||||||
}
|
}
|
||||||
}()
|
} else {
|
||||||
|
|
||||||
// Load or generate the Ed25519 signing key.
|
// Load or generate the Ed25519 signing key.
|
||||||
// Security: The private signing key is stored AES-256-GCM encrypted in the
|
// Security: The private signing key is stored AES-256-GCM encrypted in the
|
||||||
// database. On first run it is generated and stored. The key is decrypted
|
// database. On first run it is generated and stored. The key is decrypted
|
||||||
// with the master key each startup.
|
// with the master key each startup.
|
||||||
privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger)
|
privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Zero master key on failure.
|
||||||
|
for i := range masterKey {
|
||||||
|
masterKey[i] = 0
|
||||||
|
}
|
||||||
return fmt.Errorf("signing key: %w", err)
|
return fmt.Errorf("signing key: %w", err)
|
||||||
}
|
}
|
||||||
|
v = vault.NewUnsealed(masterKey, privKey, pubKey)
|
||||||
|
logger.Info("vault unsealed at startup")
|
||||||
|
}
|
||||||
|
|
||||||
// Configure TLS. We require TLS 1.2+ and prefer TLS 1.3.
|
// Configure TLS. We require TLS 1.2+ and prefer TLS 1.3.
|
||||||
// Security: HTTPS/gRPC-TLS is mandatory; no plaintext listener is provided.
|
// Security: HTTPS/gRPC-TLS is mandatory; no plaintext listener is provided.
|
||||||
@@ -108,8 +126,8 @@ func run(configPath string, logger *slog.Logger) error {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the REST handler.
|
// Build the REST handler. All servers share the same vault by pointer.
|
||||||
restSrv := server.New(database, cfg, privKey, pubKey, masterKey, logger)
|
restSrv := server.New(database, cfg, v, logger)
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.ListenAddr,
|
Addr: cfg.Server.ListenAddr,
|
||||||
Handler: restSrv.Handler(),
|
Handler: restSrv.Handler(),
|
||||||
@@ -131,7 +149,7 @@ func run(configPath string, logger *slog.Logger) error {
|
|||||||
return fmt.Errorf("load gRPC TLS credentials: %w", err)
|
return fmt.Errorf("load gRPC TLS credentials: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
grpcSrvImpl := grpcserver.New(database, cfg, privKey, pubKey, masterKey, logger)
|
grpcSrvImpl := grpcserver.New(database, cfg, v, logger)
|
||||||
// Build server directly with TLS credentials. GRPCServerWithCreds builds
|
// Build server directly with TLS credentials. GRPCServerWithCreds builds
|
||||||
// the server with transport credentials at construction time per gRPC idiom.
|
// the server with transport credentials at construction time per gRPC idiom.
|
||||||
grpcSrv = rebuildGRPCServerWithTLS(grpcSrvImpl, grpcTLSCreds)
|
grpcSrv = rebuildGRPCServerWithTLS(grpcSrvImpl, grpcTLSCreds)
|
||||||
|
|||||||
51
dist/install.sh
vendored
51
dist/install.sh
vendored
@@ -6,7 +6,7 @@
|
|||||||
# This script must be run as root. It:
|
# This script must be run as root. It:
|
||||||
# 1. Creates the mcias system user and group (idempotent).
|
# 1. Creates the mcias system user and group (idempotent).
|
||||||
# 2. Copies binaries to /usr/local/bin/.
|
# 2. Copies binaries to /usr/local/bin/.
|
||||||
# 3. Creates /etc/mcias/ and /var/lib/mcias/ with correct permissions.
|
# 3. Creates /srv/mcias/ with correct permissions.
|
||||||
# 4. Installs the systemd service unit.
|
# 4. Installs the systemd service unit.
|
||||||
# 5. Prints post-install instructions.
|
# 5. Prints post-install instructions.
|
||||||
#
|
#
|
||||||
@@ -25,8 +25,7 @@ set -eu
|
|||||||
# Configuration
|
# Configuration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
BIN_DIR="/usr/local/bin"
|
BIN_DIR="/usr/local/bin"
|
||||||
CONF_DIR="/etc/mcias"
|
SRV_DIR="/srv/mcias"
|
||||||
DATA_DIR="/var/lib/mcias"
|
|
||||||
MAN_DIR="/usr/share/man/man1"
|
MAN_DIR="/usr/share/man/man1"
|
||||||
SYSTEMD_DIR="/etc/systemd/system"
|
SYSTEMD_DIR="/etc/systemd/system"
|
||||||
SERVICE_USER="mcias"
|
SERVICE_USER="mcias"
|
||||||
@@ -114,23 +113,19 @@ for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
|
|||||||
install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin"
|
install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Step 3: Create configuration directory.
|
# Step 3: Create service directory.
|
||||||
info "Creating $CONF_DIR"
|
info "Creating $SRV_DIR"
|
||||||
install -d -m 0750 -o root -g "$SERVICE_GROUP" "$CONF_DIR"
|
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR"
|
||||||
|
|
||||||
# Install example config files; never overwrite existing configs.
|
# Install example config files; never overwrite existing configs.
|
||||||
for f in mcias.conf.example mcias.env.example; do
|
for f in mcias.conf.example mcias.env.example; do
|
||||||
src="$SCRIPT_DIR/$f"
|
src="$SCRIPT_DIR/$f"
|
||||||
dst="$CONF_DIR/$f"
|
dst="$SRV_DIR/$f"
|
||||||
if [ -f "$src" ]; then
|
if [ -f "$src" ]; then
|
||||||
install -m 0640 -o root -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
|
install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Step 4: Create data directory.
|
|
||||||
info "Creating $DATA_DIR"
|
|
||||||
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$DATA_DIR"
|
|
||||||
|
|
||||||
# Step 5: Install systemd service unit.
|
# Step 5: Install systemd service unit.
|
||||||
if [ -d "$SYSTEMD_DIR" ]; then
|
if [ -d "$SYSTEMD_DIR" ]; then
|
||||||
info "Installing systemd service unit to $SYSTEMD_DIR"
|
info "Installing systemd service unit to $SYSTEMD_DIR"
|
||||||
@@ -175,26 +170,26 @@ Next steps:
|
|||||||
|
|
||||||
# Self-signed (development / personal use):
|
# Self-signed (development / personal use):
|
||||||
openssl req -x509 -newkey ed25519 -days 3650 \\
|
openssl req -x509 -newkey ed25519 -days 3650 \\
|
||||||
-keyout /etc/mcias/server.key \\
|
-keyout /srv/mcias/server.key \\
|
||||||
-out /etc/mcias/server.crt \\
|
-out /srv/mcias/server.crt \\
|
||||||
-subj "/CN=auth.example.com" \\
|
-subj "/CN=auth.example.com" \\
|
||||||
-nodes
|
-nodes
|
||||||
chmod 0640 /etc/mcias/server.key
|
chmod 0640 /srv/mcias/server.key
|
||||||
chown root:mcias /etc/mcias/server.key
|
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
|
||||||
|
|
||||||
2. Copy and edit the configuration file:
|
2. Copy and edit the configuration file:
|
||||||
|
|
||||||
cp /etc/mcias/mcias.conf.example /etc/mcias/mcias.conf
|
cp /srv/mcias/mcias.conf.example /srv/mcias/mcias.toml
|
||||||
\$EDITOR /etc/mcias/mcias.conf
|
\$EDITOR /srv/mcias/mcias.toml
|
||||||
chmod 0640 /etc/mcias/mcias.conf
|
chmod 0640 /srv/mcias/mcias.toml
|
||||||
chown root:mcias /etc/mcias/mcias.conf
|
chown mcias:mcias /srv/mcias/mcias.toml
|
||||||
|
|
||||||
3. Set the master key passphrase:
|
3. Set the master key passphrase:
|
||||||
|
|
||||||
cp /etc/mcias/mcias.env.example /etc/mcias/env
|
cp /srv/mcias/mcias.env.example /srv/mcias/env
|
||||||
\$EDITOR /etc/mcias/env # replace the placeholder passphrase
|
\$EDITOR /srv/mcias/env # replace the placeholder passphrase
|
||||||
chmod 0640 /etc/mcias/env
|
chmod 0640 /srv/mcias/env
|
||||||
chown root:mcias /etc/mcias/env
|
chown mcias:mcias /srv/mcias/env
|
||||||
|
|
||||||
IMPORTANT: Back up the passphrase to a secure offline location.
|
IMPORTANT: Back up the passphrase to a secure offline location.
|
||||||
Losing it means losing access to all encrypted data in the database.
|
Losing it means losing access to all encrypted data in the database.
|
||||||
@@ -208,16 +203,16 @@ Next steps:
|
|||||||
5. Create the first admin account using mciasdb (while the server is
|
5. Create the first admin account using mciasdb (while the server is
|
||||||
running, or before first start):
|
running, or before first start):
|
||||||
|
|
||||||
MCIAS_MASTER_PASSPHRASE=\$(grep MCIAS_MASTER_PASSPHRASE /etc/mcias/env | cut -d= -f2) \\
|
MCIAS_MASTER_PASSPHRASE=\$(grep MCIAS_MASTER_PASSPHRASE /srv/mcias/env | cut -d= -f2) \\
|
||||||
mciasdb --config /etc/mcias/mcias.conf account create \\
|
mciasdb --config /srv/mcias/mcias.toml account create \\
|
||||||
--username admin --type human
|
--username admin --type human
|
||||||
|
|
||||||
Then set a password:
|
Then set a password:
|
||||||
MCIAS_MASTER_PASSPHRASE=... mciasdb --config /etc/mcias/mcias.conf \\
|
MCIAS_MASTER_PASSPHRASE=... mciasdb --config /srv/mcias/mcias.toml \\
|
||||||
account set-password --id <UUID>
|
account set-password --id <UUID>
|
||||||
|
|
||||||
And grant the admin role:
|
And grant the admin role:
|
||||||
MCIAS_MASTER_PASSPHRASE=... mciasdb --config /etc/mcias/mcias.conf \\
|
MCIAS_MASTER_PASSPHRASE=... mciasdb --config /srv/mcias/mcias.toml \\
|
||||||
role grant --id <UUID> --role admin
|
role grant --id <UUID> --role admin
|
||||||
|
|
||||||
For full documentation, see: man mciassrv
|
For full documentation, see: man mciassrv
|
||||||
|
|||||||
3
dist/mcias-dev.conf.example
vendored
3
dist/mcias-dev.conf.example
vendored
@@ -15,13 +15,14 @@
|
|||||||
# export MCIAS_MASTER_PASSPHRASE=devpassphrase
|
# export MCIAS_MASTER_PASSPHRASE=devpassphrase
|
||||||
#
|
#
|
||||||
# Start the server:
|
# Start the server:
|
||||||
# mciassrv -config /path/to/mcias-dev.conf
|
# mciassrv -config /path/to/mcias-dev.toml
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
listen_addr = "127.0.0.1:8443"
|
listen_addr = "127.0.0.1:8443"
|
||||||
grpc_addr = "127.0.0.1:9443"
|
grpc_addr = "127.0.0.1:9443"
|
||||||
tls_cert = "/tmp/mcias-dev.crt"
|
tls_cert = "/tmp/mcias-dev.crt"
|
||||||
tls_key = "/tmp/mcias-dev.key"
|
tls_key = "/tmp/mcias-dev.key"
|
||||||
|
# trusted_proxy not set — direct local development, no reverse proxy.
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
path = "/tmp/mcias-dev.db"
|
path = "/tmp/mcias-dev.db"
|
||||||
|
|||||||
24
dist/mcias.conf.docker.example
vendored
24
dist/mcias.conf.docker.example
vendored
@@ -1,38 +1,40 @@
|
|||||||
# mcias.conf.docker.example — Config template for container deployment
|
# mcias.conf.docker.example — Config template for container deployment
|
||||||
#
|
#
|
||||||
# Mount this file into the container at /etc/mcias/mcias.conf:
|
# Mount this file into the container at /srv/mcias/mcias.toml:
|
||||||
#
|
#
|
||||||
# docker run -d \
|
# docker run -d \
|
||||||
# --name mcias \
|
# --name mcias \
|
||||||
# -v /path/to/mcias.conf:/etc/mcias/mcias.conf:ro \
|
# -v /srv/mcias:/srv/mcias \
|
||||||
# -v /path/to/certs:/etc/mcias:ro \
|
|
||||||
# -v mcias-data:/data \
|
|
||||||
# -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
|
# -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
|
||||||
# -p 8443:8443 \
|
# -p 8443:8443 \
|
||||||
# -p 9443:9443 \
|
# -p 9443:9443 \
|
||||||
# mcias:latest
|
# mcias:latest
|
||||||
#
|
#
|
||||||
# The container runs as uid 10001 (mcias). Ensure that:
|
# The container runs as uid 10001 (mcias). Ensure that:
|
||||||
# - /data volume is writable by uid 10001
|
# - /srv/mcias is writable by uid 10001
|
||||||
# - TLS cert and key are readable by uid 10001
|
# - TLS cert and key are readable by uid 10001
|
||||||
#
|
#
|
||||||
# TLS: The server performs TLS termination inside the container; there is no
|
# TLS: The server performs TLS termination inside the container; there is no
|
||||||
# plain-text mode. Mount your certificate and key under /etc/mcias/.
|
# plain-text mode. Place your certificate and key under /srv/mcias/.
|
||||||
# For Let's Encrypt certificates, mount the live/ directory read-only.
|
# For Let's Encrypt certificates, mount the live/ directory read-only.
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
listen_addr = "0.0.0.0:8443"
|
listen_addr = "0.0.0.0:8443"
|
||||||
grpc_addr = "0.0.0.0:9443"
|
grpc_addr = "0.0.0.0:9443"
|
||||||
tls_cert = "/etc/mcias/server.crt"
|
tls_cert = "/srv/mcias/server.crt"
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/srv/mcias/server.key"
|
||||||
|
# If a reverse proxy (nginx, Caddy, Traefik) sits in front of this container,
|
||||||
|
# set trusted_proxy to its container IP so real client IPs are used for rate
|
||||||
|
# limiting and audit logging. Leave commented out for direct exposure.
|
||||||
|
# trusted_proxy = "172.17.0.1"
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# VOLUME /data is declared in the Dockerfile; map a named volume here.
|
# All data lives under /srv/mcias for a single-volume deployment.
|
||||||
path = "/data/mcias.db"
|
path = "/srv/mcias/mcias.db"
|
||||||
|
|
||||||
[tokens]
|
[tokens]
|
||||||
issuer = "https://auth.example.com"
|
issuer = "https://auth.example.com"
|
||||||
default_expiry = "720h"
|
default_expiry = "168h"
|
||||||
admin_expiry = "8h"
|
admin_expiry = "8h"
|
||||||
service_expiry = "8760h"
|
service_expiry = "8760h"
|
||||||
|
|
||||||
|
|||||||
37
dist/mcias.conf.example
vendored
37
dist/mcias.conf.example
vendored
@@ -1,12 +1,12 @@
|
|||||||
# mcias.conf — Reference configuration for mciassrv
|
# mcias.conf — Reference configuration for mciassrv
|
||||||
#
|
#
|
||||||
# Copy this file to /etc/mcias/mcias.conf and adjust the values for your
|
# Copy this file to /srv/mcias/mcias.toml and adjust the values for your
|
||||||
# deployment. All fields marked REQUIRED must be set before the server will
|
# deployment. All fields marked REQUIRED must be set before the server will
|
||||||
# start. Fields marked OPTIONAL can be omitted to use defaults.
|
# start. Fields marked OPTIONAL can be omitted to use defaults.
|
||||||
#
|
#
|
||||||
# File permissions: mode 0640, owner root:mcias.
|
# File permissions: mode 0640, owner root:mcias.
|
||||||
# chmod 0640 /etc/mcias/mcias.conf
|
# chmod 0640 /srv/mcias/mcias.toml
|
||||||
# chown root:mcias /etc/mcias/mcias.conf
|
# chown root:mcias /srv/mcias/mcias.toml
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# [server] — Network listener configuration
|
# [server] — Network listener configuration
|
||||||
@@ -26,11 +26,26 @@ listen_addr = "0.0.0.0:8443"
|
|||||||
# REQUIRED. Path to the TLS certificate (PEM format).
|
# REQUIRED. Path to the TLS certificate (PEM format).
|
||||||
# Self-signed certificates work fine for personal deployments; for
|
# Self-signed certificates work fine for personal deployments; for
|
||||||
# public-facing deployments consider a certificate from Let's Encrypt.
|
# public-facing deployments consider a certificate from Let's Encrypt.
|
||||||
tls_cert = "/etc/mcias/server.crt"
|
tls_cert = "/srv/mcias/server.crt"
|
||||||
|
|
||||||
# REQUIRED. Path to the TLS private key (PEM format).
|
# REQUIRED. Path to the TLS private key (PEM format).
|
||||||
# Permissions: mode 0640, owner root:mcias.
|
# Permissions: mode 0640, owner root:mcias.
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/srv/mcias/server.key"
|
||||||
|
|
||||||
|
# OPTIONAL. IP address of a trusted reverse proxy (e.g. nginx, Caddy, HAProxy).
|
||||||
|
# When set, the rate limiter and audit log extract the real client IP from the
|
||||||
|
# X-Real-IP or X-Forwarded-For header, but ONLY for requests whose TCP source
|
||||||
|
# address matches this exact IP. All other requests use RemoteAddr directly,
|
||||||
|
# preventing IP spoofing by external clients.
|
||||||
|
#
|
||||||
|
# Must be an IP address, not a hostname or CIDR range.
|
||||||
|
# Omit when running without a reverse proxy (direct Internet exposure).
|
||||||
|
#
|
||||||
|
# Example — local nginx proxy:
|
||||||
|
# trusted_proxy = "127.0.0.1"
|
||||||
|
#
|
||||||
|
# Example — Docker network gateway:
|
||||||
|
# trusted_proxy = "172.17.0.1"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# [database] — SQLite database
|
# [database] — SQLite database
|
||||||
@@ -40,7 +55,7 @@ tls_key = "/etc/mcias/server.key"
|
|||||||
# REQUIRED. Path to the SQLite database file.
|
# REQUIRED. Path to the SQLite database file.
|
||||||
# The directory must be writable by the mcias user. WAL mode is enabled
|
# The directory must be writable by the mcias user. WAL mode is enabled
|
||||||
# automatically; expect three files: mcias.db, mcias.db-wal, mcias.db-shm.
|
# automatically; expect three files: mcias.db, mcias.db-wal, mcias.db-shm.
|
||||||
path = "/var/lib/mcias/mcias.db"
|
path = "/srv/mcias/mcias.db"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# [tokens] — JWT issuance policy
|
# [tokens] — JWT issuance policy
|
||||||
@@ -54,8 +69,8 @@ issuer = "https://auth.example.com"
|
|||||||
|
|
||||||
# OPTIONAL. Default token expiry for interactive (human) logins.
|
# OPTIONAL. Default token expiry for interactive (human) logins.
|
||||||
# Go duration string: "h" hours, "m" minutes, "s" seconds.
|
# Go duration string: "h" hours, "m" minutes, "s" seconds.
|
||||||
# Default: 720h (30 days). Reduce for higher-security deployments.
|
# Default: 168h (7 days). The maximum allowed value is 720h (30 days).
|
||||||
default_expiry = "720h"
|
default_expiry = "168h"
|
||||||
|
|
||||||
# OPTIONAL. Expiry for admin tokens (tokens with the "admin" role).
|
# OPTIONAL. Expiry for admin tokens (tokens with the "admin" role).
|
||||||
# Should be shorter than default_expiry to limit the blast radius of
|
# Should be shorter than default_expiry to limit the blast radius of
|
||||||
@@ -98,13 +113,13 @@ threads = 4
|
|||||||
# database on first run and reused on subsequent runs so the same passphrase
|
# database on first run and reused on subsequent runs so the same passphrase
|
||||||
# always produces the same master key.
|
# always produces the same master key.
|
||||||
#
|
#
|
||||||
# Set the passphrase in /etc/mcias/env (loaded by the systemd EnvironmentFile
|
# Set the passphrase in /srv/mcias/env (loaded by the systemd EnvironmentFile
|
||||||
# directive). See dist/mcias.env.example for the template.
|
# directive). See dist/mcias.env.example for the template.
|
||||||
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
||||||
|
|
||||||
# Option B: Key file mode. The file must contain exactly 32 bytes of raw key
|
# Option B: Key file mode. The file must contain exactly 32 bytes of raw key
|
||||||
# material (AES-256). Generate with: openssl rand -out /etc/mcias/master.key 32
|
# material (AES-256). Generate with: openssl rand -out /srv/mcias/master.key 32
|
||||||
# Permissions: mode 0640, owner root:mcias.
|
# Permissions: mode 0640, owner root:mcias.
|
||||||
#
|
#
|
||||||
# Uncomment and comment out passphrase_env to switch modes.
|
# Uncomment and comment out passphrase_env to switch modes.
|
||||||
# keyfile = "/etc/mcias/master.key"
|
# keyfile = "/srv/mcias/master.key"
|
||||||
|
|||||||
6
dist/mcias.env.example
vendored
6
dist/mcias.env.example
vendored
@@ -1,10 +1,10 @@
|
|||||||
# /etc/mcias/env — Environment file for mciassrv (systemd EnvironmentFile).
|
# /srv/mcias/env — Environment file for mciassrv (systemd EnvironmentFile).
|
||||||
#
|
#
|
||||||
# This file is loaded by the mcias.service unit before the server starts.
|
# This file is loaded by the mcias.service unit before the server starts.
|
||||||
# It must be readable only by root and the mcias service account:
|
# It must be readable only by root and the mcias service account:
|
||||||
#
|
#
|
||||||
# chmod 0640 /etc/mcias/env
|
# chmod 0640 /srv/mcias/env
|
||||||
# chown root:mcias /etc/mcias/env
|
# chown root:mcias /srv/mcias/env
|
||||||
#
|
#
|
||||||
# SECURITY: This file contains the master key passphrase. Treat it with
|
# SECURITY: This file contains the master key passphrase. Treat it with
|
||||||
# the same care as a private key. Do not commit it to version control.
|
# the same care as a private key. Do not commit it to version control.
|
||||||
|
|||||||
10
dist/mcias.service
vendored
10
dist/mcias.service
vendored
@@ -11,11 +11,11 @@ User=mcias
|
|||||||
Group=mcias
|
Group=mcias
|
||||||
|
|
||||||
# Configuration and secrets.
|
# Configuration and secrets.
|
||||||
# /etc/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
|
# /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
|
||||||
# See dist/mcias.env.example for the template.
|
# See dist/mcias.env.example for the template.
|
||||||
EnvironmentFile=/etc/mcias/env
|
EnvironmentFile=/srv/mcias/env
|
||||||
|
|
||||||
ExecStart=/usr/local/bin/mciassrv -config /etc/mcias/mcias.conf
|
ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -30,11 +30,11 @@ LimitNOFILE=65536
|
|||||||
CapabilityBoundingSet=
|
CapabilityBoundingSet=
|
||||||
|
|
||||||
# Filesystem restrictions.
|
# Filesystem restrictions.
|
||||||
# mciassrv reads /etc/mcias (config, TLS cert/key) and writes /var/lib/mcias (DB).
|
# mciassrv reads and writes /srv/mcias (config, TLS cert/key, database).
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ReadWritePaths=/var/lib/mcias
|
ReadWritePaths=/srv/mcias
|
||||||
|
|
||||||
# Additional hardening.
|
# Additional hardening.
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|||||||
@@ -654,6 +654,186 @@ func (*SetRolesResponse) Descriptor() ([]byte, []int) {
|
|||||||
return file_mcias_v1_account_proto_rawDescGZIP(), []int{13}
|
return file_mcias_v1_account_proto_rawDescGZIP(), []int{13}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GrantRoleRequest adds a single role to an account.
|
||||||
|
type GrantRoleRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID
|
||||||
|
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` // role name
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GrantRoleRequest) Reset() {
|
||||||
|
*x = GrantRoleRequest{}
|
||||||
|
mi := &file_mcias_v1_account_proto_msgTypes[14]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GrantRoleRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GrantRoleRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GrantRoleRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_account_proto_msgTypes[14]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GrantRoleRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GrantRoleRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_account_proto_rawDescGZIP(), []int{14}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GrantRoleRequest) GetId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GrantRoleRequest) GetRole() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Role
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantRoleResponse confirms the grant.
|
||||||
|
type GrantRoleResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GrantRoleResponse) Reset() {
|
||||||
|
*x = GrantRoleResponse{}
|
||||||
|
mi := &file_mcias_v1_account_proto_msgTypes[15]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GrantRoleResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GrantRoleResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GrantRoleResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_account_proto_msgTypes[15]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GrantRoleResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GrantRoleResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_account_proto_rawDescGZIP(), []int{15}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeRoleRequest removes a single role from an account.
|
||||||
|
type RevokeRoleRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID
|
||||||
|
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` // role name
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RevokeRoleRequest) Reset() {
|
||||||
|
*x = RevokeRoleRequest{}
|
||||||
|
mi := &file_mcias_v1_account_proto_msgTypes[16]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RevokeRoleRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*RevokeRoleRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *RevokeRoleRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_account_proto_msgTypes[16]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use RevokeRoleRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*RevokeRoleRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_account_proto_rawDescGZIP(), []int{16}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RevokeRoleRequest) GetId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RevokeRoleRequest) GetRole() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Role
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeRoleResponse confirms the revocation.
|
||||||
|
type RevokeRoleResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RevokeRoleResponse) Reset() {
|
||||||
|
*x = RevokeRoleResponse{}
|
||||||
|
mi := &file_mcias_v1_account_proto_msgTypes[17]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RevokeRoleResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*RevokeRoleResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *RevokeRoleResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_account_proto_msgTypes[17]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use RevokeRoleResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*RevokeRoleResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_account_proto_rawDescGZIP(), []int{17}
|
||||||
|
}
|
||||||
|
|
||||||
// GetPGCredsRequest identifies an account by UUID.
|
// GetPGCredsRequest identifies an account by UUID.
|
||||||
type GetPGCredsRequest struct {
|
type GetPGCredsRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@@ -664,7 +844,7 @@ type GetPGCredsRequest struct {
|
|||||||
|
|
||||||
func (x *GetPGCredsRequest) Reset() {
|
func (x *GetPGCredsRequest) Reset() {
|
||||||
*x = GetPGCredsRequest{}
|
*x = GetPGCredsRequest{}
|
||||||
mi := &file_mcias_v1_account_proto_msgTypes[14]
|
mi := &file_mcias_v1_account_proto_msgTypes[18]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -676,7 +856,7 @@ func (x *GetPGCredsRequest) String() string {
|
|||||||
func (*GetPGCredsRequest) ProtoMessage() {}
|
func (*GetPGCredsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *GetPGCredsRequest) ProtoReflect() protoreflect.Message {
|
func (x *GetPGCredsRequest) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mcias_v1_account_proto_msgTypes[14]
|
mi := &file_mcias_v1_account_proto_msgTypes[18]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -689,7 +869,7 @@ func (x *GetPGCredsRequest) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use GetPGCredsRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use GetPGCredsRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*GetPGCredsRequest) Descriptor() ([]byte, []int) {
|
func (*GetPGCredsRequest) Descriptor() ([]byte, []int) {
|
||||||
return file_mcias_v1_account_proto_rawDescGZIP(), []int{14}
|
return file_mcias_v1_account_proto_rawDescGZIP(), []int{18}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *GetPGCredsRequest) GetId() string {
|
func (x *GetPGCredsRequest) GetId() string {
|
||||||
@@ -710,7 +890,7 @@ type GetPGCredsResponse struct {
|
|||||||
|
|
||||||
func (x *GetPGCredsResponse) Reset() {
|
func (x *GetPGCredsResponse) Reset() {
|
||||||
*x = GetPGCredsResponse{}
|
*x = GetPGCredsResponse{}
|
||||||
mi := &file_mcias_v1_account_proto_msgTypes[15]
|
mi := &file_mcias_v1_account_proto_msgTypes[19]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -722,7 +902,7 @@ func (x *GetPGCredsResponse) String() string {
|
|||||||
func (*GetPGCredsResponse) ProtoMessage() {}
|
func (*GetPGCredsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *GetPGCredsResponse) ProtoReflect() protoreflect.Message {
|
func (x *GetPGCredsResponse) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mcias_v1_account_proto_msgTypes[15]
|
mi := &file_mcias_v1_account_proto_msgTypes[19]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -735,7 +915,7 @@ func (x *GetPGCredsResponse) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use GetPGCredsResponse.ProtoReflect.Descriptor instead.
|
// Deprecated: Use GetPGCredsResponse.ProtoReflect.Descriptor instead.
|
||||||
func (*GetPGCredsResponse) Descriptor() ([]byte, []int) {
|
func (*GetPGCredsResponse) Descriptor() ([]byte, []int) {
|
||||||
return file_mcias_v1_account_proto_rawDescGZIP(), []int{15}
|
return file_mcias_v1_account_proto_rawDescGZIP(), []int{19}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *GetPGCredsResponse) GetCreds() *PGCreds {
|
func (x *GetPGCredsResponse) GetCreds() *PGCreds {
|
||||||
@@ -756,7 +936,7 @@ type SetPGCredsRequest struct {
|
|||||||
|
|
||||||
func (x *SetPGCredsRequest) Reset() {
|
func (x *SetPGCredsRequest) Reset() {
|
||||||
*x = SetPGCredsRequest{}
|
*x = SetPGCredsRequest{}
|
||||||
mi := &file_mcias_v1_account_proto_msgTypes[16]
|
mi := &file_mcias_v1_account_proto_msgTypes[20]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -768,7 +948,7 @@ func (x *SetPGCredsRequest) String() string {
|
|||||||
func (*SetPGCredsRequest) ProtoMessage() {}
|
func (*SetPGCredsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *SetPGCredsRequest) ProtoReflect() protoreflect.Message {
|
func (x *SetPGCredsRequest) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mcias_v1_account_proto_msgTypes[16]
|
mi := &file_mcias_v1_account_proto_msgTypes[20]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -781,7 +961,7 @@ func (x *SetPGCredsRequest) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use SetPGCredsRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use SetPGCredsRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*SetPGCredsRequest) Descriptor() ([]byte, []int) {
|
func (*SetPGCredsRequest) Descriptor() ([]byte, []int) {
|
||||||
return file_mcias_v1_account_proto_rawDescGZIP(), []int{16}
|
return file_mcias_v1_account_proto_rawDescGZIP(), []int{20}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *SetPGCredsRequest) GetId() string {
|
func (x *SetPGCredsRequest) GetId() string {
|
||||||
@@ -807,7 +987,7 @@ type SetPGCredsResponse struct {
|
|||||||
|
|
||||||
func (x *SetPGCredsResponse) Reset() {
|
func (x *SetPGCredsResponse) Reset() {
|
||||||
*x = SetPGCredsResponse{}
|
*x = SetPGCredsResponse{}
|
||||||
mi := &file_mcias_v1_account_proto_msgTypes[17]
|
mi := &file_mcias_v1_account_proto_msgTypes[21]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -819,7 +999,7 @@ func (x *SetPGCredsResponse) String() string {
|
|||||||
func (*SetPGCredsResponse) ProtoMessage() {}
|
func (*SetPGCredsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *SetPGCredsResponse) ProtoReflect() protoreflect.Message {
|
func (x *SetPGCredsResponse) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_mcias_v1_account_proto_msgTypes[17]
|
mi := &file_mcias_v1_account_proto_msgTypes[21]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -832,7 +1012,7 @@ func (x *SetPGCredsResponse) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use SetPGCredsResponse.ProtoReflect.Descriptor instead.
|
// Deprecated: Use SetPGCredsResponse.ProtoReflect.Descriptor instead.
|
||||||
func (*SetPGCredsResponse) Descriptor() ([]byte, []int) {
|
func (*SetPGCredsResponse) Descriptor() ([]byte, []int) {
|
||||||
return file_mcias_v1_account_proto_rawDescGZIP(), []int{17}
|
return file_mcias_v1_account_proto_rawDescGZIP(), []int{21}
|
||||||
}
|
}
|
||||||
|
|
||||||
var File_mcias_v1_account_proto protoreflect.FileDescriptor
|
var File_mcias_v1_account_proto protoreflect.FileDescriptor
|
||||||
@@ -867,7 +1047,15 @@ const file_mcias_v1_account_proto_rawDesc = "" +
|
|||||||
"\x0fSetRolesRequest\x12\x0e\n" +
|
"\x0fSetRolesRequest\x12\x0e\n" +
|
||||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
|
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
|
||||||
"\x05roles\x18\x02 \x03(\tR\x05roles\"\x12\n" +
|
"\x05roles\x18\x02 \x03(\tR\x05roles\"\x12\n" +
|
||||||
"\x10SetRolesResponse\"#\n" +
|
"\x10SetRolesResponse\"6\n" +
|
||||||
|
"\x10GrantRoleRequest\x12\x0e\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||||
|
"\x04role\x18\x02 \x01(\tR\x04role\"\x13\n" +
|
||||||
|
"\x11GrantRoleResponse\"7\n" +
|
||||||
|
"\x11RevokeRoleRequest\x12\x0e\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||||
|
"\x04role\x18\x02 \x01(\tR\x04role\"\x14\n" +
|
||||||
|
"\x12RevokeRoleResponse\"#\n" +
|
||||||
"\x11GetPGCredsRequest\x12\x0e\n" +
|
"\x11GetPGCredsRequest\x12\x0e\n" +
|
||||||
"\x02id\x18\x01 \x01(\tR\x02id\"=\n" +
|
"\x02id\x18\x01 \x01(\tR\x02id\"=\n" +
|
||||||
"\x12GetPGCredsResponse\x12'\n" +
|
"\x12GetPGCredsResponse\x12'\n" +
|
||||||
@@ -875,7 +1063,7 @@ const file_mcias_v1_account_proto_rawDesc = "" +
|
|||||||
"\x11SetPGCredsRequest\x12\x0e\n" +
|
"\x11SetPGCredsRequest\x12\x0e\n" +
|
||||||
"\x02id\x18\x01 \x01(\tR\x02id\x12'\n" +
|
"\x02id\x18\x01 \x01(\tR\x02id\x12'\n" +
|
||||||
"\x05creds\x18\x02 \x01(\v2\x11.mcias.v1.PGCredsR\x05creds\"\x14\n" +
|
"\x05creds\x18\x02 \x01(\v2\x11.mcias.v1.PGCredsR\x05creds\"\x14\n" +
|
||||||
"\x12SetPGCredsResponse2\xa4\x04\n" +
|
"\x12SetPGCredsResponse2\xb3\x05\n" +
|
||||||
"\x0eAccountService\x12M\n" +
|
"\x0eAccountService\x12M\n" +
|
||||||
"\fListAccounts\x12\x1d.mcias.v1.ListAccountsRequest\x1a\x1e.mcias.v1.ListAccountsResponse\x12P\n" +
|
"\fListAccounts\x12\x1d.mcias.v1.ListAccountsRequest\x1a\x1e.mcias.v1.ListAccountsResponse\x12P\n" +
|
||||||
"\rCreateAccount\x12\x1e.mcias.v1.CreateAccountRequest\x1a\x1f.mcias.v1.CreateAccountResponse\x12G\n" +
|
"\rCreateAccount\x12\x1e.mcias.v1.CreateAccountRequest\x1a\x1f.mcias.v1.CreateAccountResponse\x12G\n" +
|
||||||
@@ -884,7 +1072,10 @@ const file_mcias_v1_account_proto_rawDesc = "" +
|
|||||||
"\rUpdateAccount\x12\x1e.mcias.v1.UpdateAccountRequest\x1a\x1f.mcias.v1.UpdateAccountResponse\x12P\n" +
|
"\rUpdateAccount\x12\x1e.mcias.v1.UpdateAccountRequest\x1a\x1f.mcias.v1.UpdateAccountResponse\x12P\n" +
|
||||||
"\rDeleteAccount\x12\x1e.mcias.v1.DeleteAccountRequest\x1a\x1f.mcias.v1.DeleteAccountResponse\x12A\n" +
|
"\rDeleteAccount\x12\x1e.mcias.v1.DeleteAccountRequest\x1a\x1f.mcias.v1.DeleteAccountResponse\x12A\n" +
|
||||||
"\bGetRoles\x12\x19.mcias.v1.GetRolesRequest\x1a\x1a.mcias.v1.GetRolesResponse\x12A\n" +
|
"\bGetRoles\x12\x19.mcias.v1.GetRolesRequest\x1a\x1a.mcias.v1.GetRolesResponse\x12A\n" +
|
||||||
"\bSetRoles\x12\x19.mcias.v1.SetRolesRequest\x1a\x1a.mcias.v1.SetRolesResponse2\xa5\x01\n" +
|
"\bSetRoles\x12\x19.mcias.v1.SetRolesRequest\x1a\x1a.mcias.v1.SetRolesResponse\x12D\n" +
|
||||||
|
"\tGrantRole\x12\x1a.mcias.v1.GrantRoleRequest\x1a\x1b.mcias.v1.GrantRoleResponse\x12G\n" +
|
||||||
|
"\n" +
|
||||||
|
"RevokeRole\x12\x1b.mcias.v1.RevokeRoleRequest\x1a\x1c.mcias.v1.RevokeRoleResponse2\xa5\x01\n" +
|
||||||
"\x11CredentialService\x12G\n" +
|
"\x11CredentialService\x12G\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" +
|
"GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" +
|
||||||
@@ -903,7 +1094,7 @@ func file_mcias_v1_account_proto_rawDescGZIP() []byte {
|
|||||||
return file_mcias_v1_account_proto_rawDescData
|
return file_mcias_v1_account_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_mcias_v1_account_proto_msgTypes = make([]protoimpl.MessageInfo, 18)
|
var file_mcias_v1_account_proto_msgTypes = make([]protoimpl.MessageInfo, 22)
|
||||||
var file_mcias_v1_account_proto_goTypes = []any{
|
var file_mcias_v1_account_proto_goTypes = []any{
|
||||||
(*ListAccountsRequest)(nil), // 0: mcias.v1.ListAccountsRequest
|
(*ListAccountsRequest)(nil), // 0: mcias.v1.ListAccountsRequest
|
||||||
(*ListAccountsResponse)(nil), // 1: mcias.v1.ListAccountsResponse
|
(*ListAccountsResponse)(nil), // 1: mcias.v1.ListAccountsResponse
|
||||||
@@ -919,19 +1110,23 @@ var file_mcias_v1_account_proto_goTypes = []any{
|
|||||||
(*GetRolesResponse)(nil), // 11: mcias.v1.GetRolesResponse
|
(*GetRolesResponse)(nil), // 11: mcias.v1.GetRolesResponse
|
||||||
(*SetRolesRequest)(nil), // 12: mcias.v1.SetRolesRequest
|
(*SetRolesRequest)(nil), // 12: mcias.v1.SetRolesRequest
|
||||||
(*SetRolesResponse)(nil), // 13: mcias.v1.SetRolesResponse
|
(*SetRolesResponse)(nil), // 13: mcias.v1.SetRolesResponse
|
||||||
(*GetPGCredsRequest)(nil), // 14: mcias.v1.GetPGCredsRequest
|
(*GrantRoleRequest)(nil), // 14: mcias.v1.GrantRoleRequest
|
||||||
(*GetPGCredsResponse)(nil), // 15: mcias.v1.GetPGCredsResponse
|
(*GrantRoleResponse)(nil), // 15: mcias.v1.GrantRoleResponse
|
||||||
(*SetPGCredsRequest)(nil), // 16: mcias.v1.SetPGCredsRequest
|
(*RevokeRoleRequest)(nil), // 16: mcias.v1.RevokeRoleRequest
|
||||||
(*SetPGCredsResponse)(nil), // 17: mcias.v1.SetPGCredsResponse
|
(*RevokeRoleResponse)(nil), // 17: mcias.v1.RevokeRoleResponse
|
||||||
(*Account)(nil), // 18: mcias.v1.Account
|
(*GetPGCredsRequest)(nil), // 18: mcias.v1.GetPGCredsRequest
|
||||||
(*PGCreds)(nil), // 19: mcias.v1.PGCreds
|
(*GetPGCredsResponse)(nil), // 19: mcias.v1.GetPGCredsResponse
|
||||||
|
(*SetPGCredsRequest)(nil), // 20: mcias.v1.SetPGCredsRequest
|
||||||
|
(*SetPGCredsResponse)(nil), // 21: mcias.v1.SetPGCredsResponse
|
||||||
|
(*Account)(nil), // 22: mcias.v1.Account
|
||||||
|
(*PGCreds)(nil), // 23: mcias.v1.PGCreds
|
||||||
}
|
}
|
||||||
var file_mcias_v1_account_proto_depIdxs = []int32{
|
var file_mcias_v1_account_proto_depIdxs = []int32{
|
||||||
18, // 0: mcias.v1.ListAccountsResponse.accounts:type_name -> mcias.v1.Account
|
22, // 0: mcias.v1.ListAccountsResponse.accounts:type_name -> mcias.v1.Account
|
||||||
18, // 1: mcias.v1.CreateAccountResponse.account:type_name -> mcias.v1.Account
|
22, // 1: mcias.v1.CreateAccountResponse.account:type_name -> mcias.v1.Account
|
||||||
18, // 2: mcias.v1.GetAccountResponse.account:type_name -> mcias.v1.Account
|
22, // 2: mcias.v1.GetAccountResponse.account:type_name -> mcias.v1.Account
|
||||||
19, // 3: mcias.v1.GetPGCredsResponse.creds:type_name -> mcias.v1.PGCreds
|
23, // 3: mcias.v1.GetPGCredsResponse.creds:type_name -> mcias.v1.PGCreds
|
||||||
19, // 4: mcias.v1.SetPGCredsRequest.creds:type_name -> mcias.v1.PGCreds
|
23, // 4: mcias.v1.SetPGCredsRequest.creds:type_name -> mcias.v1.PGCreds
|
||||||
0, // 5: mcias.v1.AccountService.ListAccounts:input_type -> mcias.v1.ListAccountsRequest
|
0, // 5: mcias.v1.AccountService.ListAccounts:input_type -> mcias.v1.ListAccountsRequest
|
||||||
2, // 6: mcias.v1.AccountService.CreateAccount:input_type -> mcias.v1.CreateAccountRequest
|
2, // 6: mcias.v1.AccountService.CreateAccount:input_type -> mcias.v1.CreateAccountRequest
|
||||||
4, // 7: mcias.v1.AccountService.GetAccount:input_type -> mcias.v1.GetAccountRequest
|
4, // 7: mcias.v1.AccountService.GetAccount:input_type -> mcias.v1.GetAccountRequest
|
||||||
@@ -939,19 +1134,23 @@ var file_mcias_v1_account_proto_depIdxs = []int32{
|
|||||||
8, // 9: mcias.v1.AccountService.DeleteAccount:input_type -> mcias.v1.DeleteAccountRequest
|
8, // 9: mcias.v1.AccountService.DeleteAccount:input_type -> mcias.v1.DeleteAccountRequest
|
||||||
10, // 10: mcias.v1.AccountService.GetRoles:input_type -> mcias.v1.GetRolesRequest
|
10, // 10: mcias.v1.AccountService.GetRoles:input_type -> mcias.v1.GetRolesRequest
|
||||||
12, // 11: mcias.v1.AccountService.SetRoles:input_type -> mcias.v1.SetRolesRequest
|
12, // 11: mcias.v1.AccountService.SetRoles:input_type -> mcias.v1.SetRolesRequest
|
||||||
14, // 12: mcias.v1.CredentialService.GetPGCreds:input_type -> mcias.v1.GetPGCredsRequest
|
14, // 12: mcias.v1.AccountService.GrantRole:input_type -> mcias.v1.GrantRoleRequest
|
||||||
16, // 13: mcias.v1.CredentialService.SetPGCreds:input_type -> mcias.v1.SetPGCredsRequest
|
16, // 13: mcias.v1.AccountService.RevokeRole:input_type -> mcias.v1.RevokeRoleRequest
|
||||||
1, // 14: mcias.v1.AccountService.ListAccounts:output_type -> mcias.v1.ListAccountsResponse
|
18, // 14: mcias.v1.CredentialService.GetPGCreds:input_type -> mcias.v1.GetPGCredsRequest
|
||||||
3, // 15: mcias.v1.AccountService.CreateAccount:output_type -> mcias.v1.CreateAccountResponse
|
20, // 15: mcias.v1.CredentialService.SetPGCreds:input_type -> mcias.v1.SetPGCredsRequest
|
||||||
5, // 16: mcias.v1.AccountService.GetAccount:output_type -> mcias.v1.GetAccountResponse
|
1, // 16: mcias.v1.AccountService.ListAccounts:output_type -> mcias.v1.ListAccountsResponse
|
||||||
7, // 17: mcias.v1.AccountService.UpdateAccount:output_type -> mcias.v1.UpdateAccountResponse
|
3, // 17: mcias.v1.AccountService.CreateAccount:output_type -> mcias.v1.CreateAccountResponse
|
||||||
9, // 18: mcias.v1.AccountService.DeleteAccount:output_type -> mcias.v1.DeleteAccountResponse
|
5, // 18: mcias.v1.AccountService.GetAccount:output_type -> mcias.v1.GetAccountResponse
|
||||||
11, // 19: mcias.v1.AccountService.GetRoles:output_type -> mcias.v1.GetRolesResponse
|
7, // 19: mcias.v1.AccountService.UpdateAccount:output_type -> mcias.v1.UpdateAccountResponse
|
||||||
13, // 20: mcias.v1.AccountService.SetRoles:output_type -> mcias.v1.SetRolesResponse
|
9, // 20: mcias.v1.AccountService.DeleteAccount:output_type -> mcias.v1.DeleteAccountResponse
|
||||||
15, // 21: mcias.v1.CredentialService.GetPGCreds:output_type -> mcias.v1.GetPGCredsResponse
|
11, // 21: mcias.v1.AccountService.GetRoles:output_type -> mcias.v1.GetRolesResponse
|
||||||
17, // 22: mcias.v1.CredentialService.SetPGCreds:output_type -> mcias.v1.SetPGCredsResponse
|
13, // 22: mcias.v1.AccountService.SetRoles:output_type -> mcias.v1.SetRolesResponse
|
||||||
14, // [14:23] is the sub-list for method output_type
|
15, // 23: mcias.v1.AccountService.GrantRole:output_type -> mcias.v1.GrantRoleResponse
|
||||||
5, // [5:14] is the sub-list for method input_type
|
17, // 24: mcias.v1.AccountService.RevokeRole:output_type -> mcias.v1.RevokeRoleResponse
|
||||||
|
19, // 25: mcias.v1.CredentialService.GetPGCreds:output_type -> mcias.v1.GetPGCredsResponse
|
||||||
|
21, // 26: mcias.v1.CredentialService.SetPGCreds:output_type -> mcias.v1.SetPGCredsResponse
|
||||||
|
16, // [16:27] is the sub-list for method output_type
|
||||||
|
5, // [5:16] is the sub-list for method input_type
|
||||||
5, // [5:5] is the sub-list for extension type_name
|
5, // [5:5] is the sub-list for extension type_name
|
||||||
5, // [5:5] is the sub-list for extension extendee
|
5, // [5:5] is the sub-list for extension extendee
|
||||||
0, // [0:5] is the sub-list for field type_name
|
0, // [0:5] is the sub-list for field type_name
|
||||||
@@ -969,7 +1168,7 @@ func file_mcias_v1_account_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_account_proto_rawDesc), len(file_mcias_v1_account_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_account_proto_rawDesc), len(file_mcias_v1_account_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 18,
|
NumMessages: 22,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 2,
|
NumServices: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const (
|
|||||||
AccountService_DeleteAccount_FullMethodName = "/mcias.v1.AccountService/DeleteAccount"
|
AccountService_DeleteAccount_FullMethodName = "/mcias.v1.AccountService/DeleteAccount"
|
||||||
AccountService_GetRoles_FullMethodName = "/mcias.v1.AccountService/GetRoles"
|
AccountService_GetRoles_FullMethodName = "/mcias.v1.AccountService/GetRoles"
|
||||||
AccountService_SetRoles_FullMethodName = "/mcias.v1.AccountService/SetRoles"
|
AccountService_SetRoles_FullMethodName = "/mcias.v1.AccountService/SetRoles"
|
||||||
|
AccountService_GrantRole_FullMethodName = "/mcias.v1.AccountService/GrantRole"
|
||||||
|
AccountService_RevokeRole_FullMethodName = "/mcias.v1.AccountService/RevokeRole"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AccountServiceClient is the client API for AccountService service.
|
// AccountServiceClient is the client API for AccountService service.
|
||||||
@@ -44,6 +46,8 @@ type AccountServiceClient interface {
|
|||||||
DeleteAccount(ctx context.Context, in *DeleteAccountRequest, opts ...grpc.CallOption) (*DeleteAccountResponse, error)
|
DeleteAccount(ctx context.Context, in *DeleteAccountRequest, opts ...grpc.CallOption) (*DeleteAccountResponse, error)
|
||||||
GetRoles(ctx context.Context, in *GetRolesRequest, opts ...grpc.CallOption) (*GetRolesResponse, error)
|
GetRoles(ctx context.Context, in *GetRolesRequest, opts ...grpc.CallOption) (*GetRolesResponse, error)
|
||||||
SetRoles(ctx context.Context, in *SetRolesRequest, opts ...grpc.CallOption) (*SetRolesResponse, error)
|
SetRoles(ctx context.Context, in *SetRolesRequest, opts ...grpc.CallOption) (*SetRolesResponse, error)
|
||||||
|
GrantRole(ctx context.Context, in *GrantRoleRequest, opts ...grpc.CallOption) (*GrantRoleResponse, error)
|
||||||
|
RevokeRole(ctx context.Context, in *RevokeRoleRequest, opts ...grpc.CallOption) (*RevokeRoleResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type accountServiceClient struct {
|
type accountServiceClient struct {
|
||||||
@@ -124,6 +128,26 @@ func (c *accountServiceClient) SetRoles(ctx context.Context, in *SetRolesRequest
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *accountServiceClient) GrantRole(ctx context.Context, in *GrantRoleRequest, opts ...grpc.CallOption) (*GrantRoleResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GrantRoleResponse)
|
||||||
|
err := c.cc.Invoke(ctx, AccountService_GrantRole_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *accountServiceClient) RevokeRole(ctx context.Context, in *RevokeRoleRequest, opts ...grpc.CallOption) (*RevokeRoleResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(RevokeRoleResponse)
|
||||||
|
err := c.cc.Invoke(ctx, AccountService_RevokeRole_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AccountServiceServer is the server API for AccountService service.
|
// AccountServiceServer is the server API for AccountService service.
|
||||||
// All implementations must embed UnimplementedAccountServiceServer
|
// All implementations must embed UnimplementedAccountServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -137,6 +161,8 @@ type AccountServiceServer interface {
|
|||||||
DeleteAccount(context.Context, *DeleteAccountRequest) (*DeleteAccountResponse, error)
|
DeleteAccount(context.Context, *DeleteAccountRequest) (*DeleteAccountResponse, error)
|
||||||
GetRoles(context.Context, *GetRolesRequest) (*GetRolesResponse, error)
|
GetRoles(context.Context, *GetRolesRequest) (*GetRolesResponse, error)
|
||||||
SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error)
|
SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error)
|
||||||
|
GrantRole(context.Context, *GrantRoleRequest) (*GrantRoleResponse, error)
|
||||||
|
RevokeRole(context.Context, *RevokeRoleRequest) (*RevokeRoleResponse, error)
|
||||||
mustEmbedUnimplementedAccountServiceServer()
|
mustEmbedUnimplementedAccountServiceServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +194,12 @@ func (UnimplementedAccountServiceServer) GetRoles(context.Context, *GetRolesRequ
|
|||||||
func (UnimplementedAccountServiceServer) SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error) {
|
func (UnimplementedAccountServiceServer) SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method SetRoles not implemented")
|
return nil, status.Error(codes.Unimplemented, "method SetRoles not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedAccountServiceServer) GrantRole(context.Context, *GrantRoleRequest) (*GrantRoleResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GrantRole not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedAccountServiceServer) RevokeRole(context.Context, *RevokeRoleRequest) (*RevokeRoleResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method RevokeRole not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedAccountServiceServer) mustEmbedUnimplementedAccountServiceServer() {}
|
func (UnimplementedAccountServiceServer) mustEmbedUnimplementedAccountServiceServer() {}
|
||||||
func (UnimplementedAccountServiceServer) testEmbeddedByValue() {}
|
func (UnimplementedAccountServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -315,6 +347,42 @@ func _AccountService_SetRoles_Handler(srv interface{}, ctx context.Context, dec
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _AccountService_GrantRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GrantRoleRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(AccountServiceServer).GrantRole(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: AccountService_GrantRole_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(AccountServiceServer).GrantRole(ctx, req.(*GrantRoleRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _AccountService_RevokeRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RevokeRoleRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(AccountServiceServer).RevokeRole(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: AccountService_RevokeRole_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(AccountServiceServer).RevokeRole(ctx, req.(*RevokeRoleRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// AccountService_ServiceDesc is the grpc.ServiceDesc for AccountService service.
|
// AccountService_ServiceDesc is the grpc.ServiceDesc for AccountService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -350,6 +418,14 @@ var AccountService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "SetRoles",
|
MethodName: "SetRoles",
|
||||||
Handler: _AccountService_SetRoles_Handler,
|
Handler: _AccountService_SetRoles_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GrantRole",
|
||||||
|
Handler: _AccountService_GrantRole_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RevokeRole",
|
||||||
|
Handler: _AccountService_RevokeRole_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "mcias/v1/account.proto",
|
Metadata: "mcias/v1/account.proto",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v6.33.4
|
// protoc v3.20.3
|
||||||
// source: mcias/v1/admin.proto
|
// source: mcias/v1/admin.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc v6.33.4
|
// - protoc v3.20.3
|
||||||
// source: mcias/v1/admin.proto
|
// source: mcias/v1/admin.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v6.33.4
|
// protoc v3.20.3
|
||||||
// source: mcias/v1/auth.proto
|
// source: mcias/v1/auth.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
@@ -304,9 +304,12 @@ func (x *RenewTokenResponse) GetExpiresAt() *timestamppb.Timestamp {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnrollTOTPRequest carries no body; the acting account is from the JWT.
|
// EnrollTOTPRequest carries the current password for re-authentication.
|
||||||
|
// Security (SEC-01): password is required to prevent a stolen session token
|
||||||
|
// from being used to enroll attacker-controlled TOTP on the victim's account.
|
||||||
type EnrollTOTPRequest struct {
|
type EnrollTOTPRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` // security: current password required; never logged
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -341,6 +344,13 @@ func (*EnrollTOTPRequest) Descriptor() ([]byte, []int) {
|
|||||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{6}
|
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{6}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *EnrollTOTPRequest) GetPassword() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Password
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// EnrollTOTPResponse returns the TOTP secret and otpauth URI for display.
|
// EnrollTOTPResponse returns the TOTP secret and otpauth URI for display.
|
||||||
// Security: the secret is shown once; it is stored only in encrypted form.
|
// Security: the secret is shown once; it is stored only in encrypted form.
|
||||||
type EnrollTOTPResponse struct {
|
type EnrollTOTPResponse struct {
|
||||||
@@ -578,8 +588,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
|
|||||||
"\x12RenewTokenResponse\x12\x14\n" +
|
"\x12RenewTokenResponse\x12\x14\n" +
|
||||||
"\x05token\x18\x01 \x01(\tR\x05token\x129\n" +
|
"\x05token\x18\x01 \x01(\tR\x05token\x129\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\x13\n" +
|
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"/\n" +
|
||||||
"\x11EnrollTOTPRequest\"M\n" +
|
"\x11EnrollTOTPRequest\x12\x1a\n" +
|
||||||
|
"\bpassword\x18\x01 \x01(\tR\bpassword\"M\n" +
|
||||||
"\x12EnrollTOTPResponse\x12\x16\n" +
|
"\x12EnrollTOTPResponse\x12\x16\n" +
|
||||||
"\x06secret\x18\x01 \x01(\tR\x06secret\x12\x1f\n" +
|
"\x06secret\x18\x01 \x01(\tR\x06secret\x12\x1f\n" +
|
||||||
"\votpauth_uri\x18\x02 \x01(\tR\n" +
|
"\votpauth_uri\x18\x02 \x01(\tR\n" +
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc v6.33.4
|
// - protoc v3.20.3
|
||||||
// source: mcias/v1/auth.proto
|
// source: mcias/v1/auth.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v6.33.4
|
// protoc v3.20.3
|
||||||
// source: mcias/v1/common.proto
|
// source: mcias/v1/common.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
|
|||||||
779
gen/mcias/v1/policy.pb.go
Normal file
779
gen/mcias/v1/policy.pb.go
Normal file
@@ -0,0 +1,779 @@
|
|||||||
|
// PolicyService: CRUD management of policy rules.
|
||||||
|
|
||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.11
|
||||||
|
// protoc v3.20.3
|
||||||
|
// source: mcias/v1/policy.proto
|
||||||
|
|
||||||
|
package mciasv1
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
// PolicyRule is the wire representation of a policy rule record.
|
||||||
|
type PolicyRule struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
|
||||||
|
Priority int32 `protobuf:"varint,3,opt,name=priority,proto3" json:"priority,omitempty"`
|
||||||
|
Enabled bool `protobuf:"varint,4,opt,name=enabled,proto3" json:"enabled,omitempty"`
|
||||||
|
RuleJson string `protobuf:"bytes,5,opt,name=rule_json,json=ruleJson,proto3" json:"rule_json,omitempty"` // JSON-encoded RuleBody
|
||||||
|
CreatedAt string `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // RFC3339
|
||||||
|
UpdatedAt string `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // RFC3339
|
||||||
|
NotBefore string `protobuf:"bytes,8,opt,name=not_before,json=notBefore,proto3" json:"not_before,omitempty"` // RFC3339; empty if unset
|
||||||
|
ExpiresAt string `protobuf:"bytes,9,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // RFC3339; empty if unset
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) Reset() {
|
||||||
|
*x = PolicyRule{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*PolicyRule) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *PolicyRule) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use PolicyRule.ProtoReflect.Descriptor instead.
|
||||||
|
func (*PolicyRule) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) GetId() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) GetDescription() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Description
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) GetPriority() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Priority
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) GetEnabled() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Enabled
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) GetRuleJson() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.RuleJson
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) GetCreatedAt() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.CreatedAt
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) GetUpdatedAt() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.UpdatedAt
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) GetNotBefore() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.NotBefore
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PolicyRule) GetExpiresAt() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ExpiresAt
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListPolicyRulesRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListPolicyRulesRequest) Reset() {
|
||||||
|
*x = ListPolicyRulesRequest{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListPolicyRulesRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ListPolicyRulesRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ListPolicyRulesRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ListPolicyRulesRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ListPolicyRulesRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListPolicyRulesResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Rules []*PolicyRule `protobuf:"bytes,1,rep,name=rules,proto3" json:"rules,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListPolicyRulesResponse) Reset() {
|
||||||
|
*x = ListPolicyRulesResponse{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListPolicyRulesResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ListPolicyRulesResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ListPolicyRulesResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ListPolicyRulesResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ListPolicyRulesResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListPolicyRulesResponse) GetRules() []*PolicyRule {
|
||||||
|
if x != nil {
|
||||||
|
return x.Rules
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePolicyRuleRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"` // required
|
||||||
|
RuleJson string `protobuf:"bytes,2,opt,name=rule_json,json=ruleJson,proto3" json:"rule_json,omitempty"` // required; JSON-encoded RuleBody
|
||||||
|
Priority int32 `protobuf:"varint,3,opt,name=priority,proto3" json:"priority,omitempty"` // default 100 when zero
|
||||||
|
NotBefore string `protobuf:"bytes,4,opt,name=not_before,json=notBefore,proto3" json:"not_before,omitempty"` // RFC3339; optional
|
||||||
|
ExpiresAt string `protobuf:"bytes,5,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // RFC3339; optional
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleRequest) Reset() {
|
||||||
|
*x = CreatePolicyRuleRequest{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CreatePolicyRuleRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CreatePolicyRuleRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*CreatePolicyRuleRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleRequest) GetDescription() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Description
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleRequest) GetRuleJson() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.RuleJson
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleRequest) GetPriority() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Priority
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleRequest) GetNotBefore() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.NotBefore
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleRequest) GetExpiresAt() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ExpiresAt
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePolicyRuleResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Rule *PolicyRule `protobuf:"bytes,1,opt,name=rule,proto3" json:"rule,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleResponse) Reset() {
|
||||||
|
*x = CreatePolicyRuleResponse{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CreatePolicyRuleResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CreatePolicyRuleResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*CreatePolicyRuleResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreatePolicyRuleResponse) GetRule() *PolicyRule {
|
||||||
|
if x != nil {
|
||||||
|
return x.Rule
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetPolicyRuleRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetPolicyRuleRequest) Reset() {
|
||||||
|
*x = GetPolicyRuleRequest{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[5]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetPolicyRuleRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetPolicyRuleRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetPolicyRuleRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[5]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetPolicyRuleRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetPolicyRuleRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{5}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetPolicyRuleRequest) GetId() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetPolicyRuleResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Rule *PolicyRule `protobuf:"bytes,1,opt,name=rule,proto3" json:"rule,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetPolicyRuleResponse) Reset() {
|
||||||
|
*x = GetPolicyRuleResponse{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[6]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetPolicyRuleResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetPolicyRuleResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetPolicyRuleResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[6]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetPolicyRuleResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetPolicyRuleResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{6}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetPolicyRuleResponse) GetRule() *PolicyRule {
|
||||||
|
if x != nil {
|
||||||
|
return x.Rule
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePolicyRuleRequest carries partial updates.
|
||||||
|
// Fields left at their zero value are not changed on the server, except:
|
||||||
|
// - clear_not_before=true removes the not_before constraint
|
||||||
|
// - clear_expires_at=true removes the expires_at constraint
|
||||||
|
//
|
||||||
|
// has_priority / has_enabled use proto3 optional (field presence) so the
|
||||||
|
// server can distinguish "not supplied" from "set to zero/false".
|
||||||
|
type UpdatePolicyRuleRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
Priority *int32 `protobuf:"varint,2,opt,name=priority,proto3,oneof" json:"priority,omitempty"` // omit to leave unchanged
|
||||||
|
Enabled *bool `protobuf:"varint,3,opt,name=enabled,proto3,oneof" json:"enabled,omitempty"` // omit to leave unchanged
|
||||||
|
NotBefore string `protobuf:"bytes,4,opt,name=not_before,json=notBefore,proto3" json:"not_before,omitempty"` // RFC3339; ignored when clear_not_before=true
|
||||||
|
ExpiresAt string `protobuf:"bytes,5,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // RFC3339; ignored when clear_expires_at=true
|
||||||
|
ClearNotBefore bool `protobuf:"varint,6,opt,name=clear_not_before,json=clearNotBefore,proto3" json:"clear_not_before,omitempty"`
|
||||||
|
ClearExpiresAt bool `protobuf:"varint,7,opt,name=clear_expires_at,json=clearExpiresAt,proto3" json:"clear_expires_at,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleRequest) Reset() {
|
||||||
|
*x = UpdatePolicyRuleRequest{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[7]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*UpdatePolicyRuleRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[7]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use UpdatePolicyRuleRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*UpdatePolicyRuleRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{7}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleRequest) GetId() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleRequest) GetPriority() int32 {
|
||||||
|
if x != nil && x.Priority != nil {
|
||||||
|
return *x.Priority
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleRequest) GetEnabled() bool {
|
||||||
|
if x != nil && x.Enabled != nil {
|
||||||
|
return *x.Enabled
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleRequest) GetNotBefore() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.NotBefore
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleRequest) GetExpiresAt() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ExpiresAt
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleRequest) GetClearNotBefore() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.ClearNotBefore
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleRequest) GetClearExpiresAt() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.ClearExpiresAt
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePolicyRuleResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Rule *PolicyRule `protobuf:"bytes,1,opt,name=rule,proto3" json:"rule,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleResponse) Reset() {
|
||||||
|
*x = UpdatePolicyRuleResponse{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[8]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*UpdatePolicyRuleResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[8]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use UpdatePolicyRuleResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*UpdatePolicyRuleResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{8}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePolicyRuleResponse) GetRule() *PolicyRule {
|
||||||
|
if x != nil {
|
||||||
|
return x.Rule
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeletePolicyRuleRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeletePolicyRuleRequest) Reset() {
|
||||||
|
*x = DeletePolicyRuleRequest{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[9]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeletePolicyRuleRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DeletePolicyRuleRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *DeletePolicyRuleRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[9]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use DeletePolicyRuleRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*DeletePolicyRuleRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{9}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeletePolicyRuleRequest) GetId() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeletePolicyRuleResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeletePolicyRuleResponse) Reset() {
|
||||||
|
*x = DeletePolicyRuleResponse{}
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[10]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeletePolicyRuleResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DeletePolicyRuleResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *DeletePolicyRuleResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mcias_v1_policy_proto_msgTypes[10]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use DeletePolicyRuleResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*DeletePolicyRuleResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mcias_v1_policy_proto_rawDescGZIP(), []int{10}
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_mcias_v1_policy_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
const file_mcias_v1_policy_proto_rawDesc = "" +
|
||||||
|
"\n" +
|
||||||
|
"\x15mcias/v1/policy.proto\x12\bmcias.v1\"\x8d\x02\n" +
|
||||||
|
"\n" +
|
||||||
|
"PolicyRule\x12\x0e\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\x03R\x02id\x12 \n" +
|
||||||
|
"\vdescription\x18\x02 \x01(\tR\vdescription\x12\x1a\n" +
|
||||||
|
"\bpriority\x18\x03 \x01(\x05R\bpriority\x12\x18\n" +
|
||||||
|
"\aenabled\x18\x04 \x01(\bR\aenabled\x12\x1b\n" +
|
||||||
|
"\trule_json\x18\x05 \x01(\tR\bruleJson\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"created_at\x18\x06 \x01(\tR\tcreatedAt\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"updated_at\x18\a \x01(\tR\tupdatedAt\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"not_before\x18\b \x01(\tR\tnotBefore\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"expires_at\x18\t \x01(\tR\texpiresAt\"\x18\n" +
|
||||||
|
"\x16ListPolicyRulesRequest\"E\n" +
|
||||||
|
"\x17ListPolicyRulesResponse\x12*\n" +
|
||||||
|
"\x05rules\x18\x01 \x03(\v2\x14.mcias.v1.PolicyRuleR\x05rules\"\xb2\x01\n" +
|
||||||
|
"\x17CreatePolicyRuleRequest\x12 \n" +
|
||||||
|
"\vdescription\x18\x01 \x01(\tR\vdescription\x12\x1b\n" +
|
||||||
|
"\trule_json\x18\x02 \x01(\tR\bruleJson\x12\x1a\n" +
|
||||||
|
"\bpriority\x18\x03 \x01(\x05R\bpriority\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"not_before\x18\x04 \x01(\tR\tnotBefore\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"expires_at\x18\x05 \x01(\tR\texpiresAt\"D\n" +
|
||||||
|
"\x18CreatePolicyRuleResponse\x12(\n" +
|
||||||
|
"\x04rule\x18\x01 \x01(\v2\x14.mcias.v1.PolicyRuleR\x04rule\"&\n" +
|
||||||
|
"\x14GetPolicyRuleRequest\x12\x0e\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\x03R\x02id\"A\n" +
|
||||||
|
"\x15GetPolicyRuleResponse\x12(\n" +
|
||||||
|
"\x04rule\x18\x01 \x01(\v2\x14.mcias.v1.PolicyRuleR\x04rule\"\x94\x02\n" +
|
||||||
|
"\x17UpdatePolicyRuleRequest\x12\x0e\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x1f\n" +
|
||||||
|
"\bpriority\x18\x02 \x01(\x05H\x00R\bpriority\x88\x01\x01\x12\x1d\n" +
|
||||||
|
"\aenabled\x18\x03 \x01(\bH\x01R\aenabled\x88\x01\x01\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"not_before\x18\x04 \x01(\tR\tnotBefore\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"expires_at\x18\x05 \x01(\tR\texpiresAt\x12(\n" +
|
||||||
|
"\x10clear_not_before\x18\x06 \x01(\bR\x0eclearNotBefore\x12(\n" +
|
||||||
|
"\x10clear_expires_at\x18\a \x01(\bR\x0eclearExpiresAtB\v\n" +
|
||||||
|
"\t_priorityB\n" +
|
||||||
|
"\n" +
|
||||||
|
"\b_enabled\"D\n" +
|
||||||
|
"\x18UpdatePolicyRuleResponse\x12(\n" +
|
||||||
|
"\x04rule\x18\x01 \x01(\v2\x14.mcias.v1.PolicyRuleR\x04rule\")\n" +
|
||||||
|
"\x17DeletePolicyRuleRequest\x12\x0e\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\x03R\x02id\"\x1a\n" +
|
||||||
|
"\x18DeletePolicyRuleResponse2\xca\x03\n" +
|
||||||
|
"\rPolicyService\x12V\n" +
|
||||||
|
"\x0fListPolicyRules\x12 .mcias.v1.ListPolicyRulesRequest\x1a!.mcias.v1.ListPolicyRulesResponse\x12Y\n" +
|
||||||
|
"\x10CreatePolicyRule\x12!.mcias.v1.CreatePolicyRuleRequest\x1a\".mcias.v1.CreatePolicyRuleResponse\x12P\n" +
|
||||||
|
"\rGetPolicyRule\x12\x1e.mcias.v1.GetPolicyRuleRequest\x1a\x1f.mcias.v1.GetPolicyRuleResponse\x12Y\n" +
|
||||||
|
"\x10UpdatePolicyRule\x12!.mcias.v1.UpdatePolicyRuleRequest\x1a\".mcias.v1.UpdatePolicyRuleResponse\x12Y\n" +
|
||||||
|
"\x10DeletePolicyRule\x12!.mcias.v1.DeletePolicyRuleRequest\x1a\".mcias.v1.DeletePolicyRuleResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_mcias_v1_policy_proto_rawDescOnce sync.Once
|
||||||
|
file_mcias_v1_policy_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_mcias_v1_policy_proto_rawDescGZIP() []byte {
|
||||||
|
file_mcias_v1_policy_proto_rawDescOnce.Do(func() {
|
||||||
|
file_mcias_v1_policy_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mcias_v1_policy_proto_rawDesc), len(file_mcias_v1_policy_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_mcias_v1_policy_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_mcias_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
|
||||||
|
var file_mcias_v1_policy_proto_goTypes = []any{
|
||||||
|
(*PolicyRule)(nil), // 0: mcias.v1.PolicyRule
|
||||||
|
(*ListPolicyRulesRequest)(nil), // 1: mcias.v1.ListPolicyRulesRequest
|
||||||
|
(*ListPolicyRulesResponse)(nil), // 2: mcias.v1.ListPolicyRulesResponse
|
||||||
|
(*CreatePolicyRuleRequest)(nil), // 3: mcias.v1.CreatePolicyRuleRequest
|
||||||
|
(*CreatePolicyRuleResponse)(nil), // 4: mcias.v1.CreatePolicyRuleResponse
|
||||||
|
(*GetPolicyRuleRequest)(nil), // 5: mcias.v1.GetPolicyRuleRequest
|
||||||
|
(*GetPolicyRuleResponse)(nil), // 6: mcias.v1.GetPolicyRuleResponse
|
||||||
|
(*UpdatePolicyRuleRequest)(nil), // 7: mcias.v1.UpdatePolicyRuleRequest
|
||||||
|
(*UpdatePolicyRuleResponse)(nil), // 8: mcias.v1.UpdatePolicyRuleResponse
|
||||||
|
(*DeletePolicyRuleRequest)(nil), // 9: mcias.v1.DeletePolicyRuleRequest
|
||||||
|
(*DeletePolicyRuleResponse)(nil), // 10: mcias.v1.DeletePolicyRuleResponse
|
||||||
|
}
|
||||||
|
var file_mcias_v1_policy_proto_depIdxs = []int32{
|
||||||
|
0, // 0: mcias.v1.ListPolicyRulesResponse.rules:type_name -> mcias.v1.PolicyRule
|
||||||
|
0, // 1: mcias.v1.CreatePolicyRuleResponse.rule:type_name -> mcias.v1.PolicyRule
|
||||||
|
0, // 2: mcias.v1.GetPolicyRuleResponse.rule:type_name -> mcias.v1.PolicyRule
|
||||||
|
0, // 3: mcias.v1.UpdatePolicyRuleResponse.rule:type_name -> mcias.v1.PolicyRule
|
||||||
|
1, // 4: mcias.v1.PolicyService.ListPolicyRules:input_type -> mcias.v1.ListPolicyRulesRequest
|
||||||
|
3, // 5: mcias.v1.PolicyService.CreatePolicyRule:input_type -> mcias.v1.CreatePolicyRuleRequest
|
||||||
|
5, // 6: mcias.v1.PolicyService.GetPolicyRule:input_type -> mcias.v1.GetPolicyRuleRequest
|
||||||
|
7, // 7: mcias.v1.PolicyService.UpdatePolicyRule:input_type -> mcias.v1.UpdatePolicyRuleRequest
|
||||||
|
9, // 8: mcias.v1.PolicyService.DeletePolicyRule:input_type -> mcias.v1.DeletePolicyRuleRequest
|
||||||
|
2, // 9: mcias.v1.PolicyService.ListPolicyRules:output_type -> mcias.v1.ListPolicyRulesResponse
|
||||||
|
4, // 10: mcias.v1.PolicyService.CreatePolicyRule:output_type -> mcias.v1.CreatePolicyRuleResponse
|
||||||
|
6, // 11: mcias.v1.PolicyService.GetPolicyRule:output_type -> mcias.v1.GetPolicyRuleResponse
|
||||||
|
8, // 12: mcias.v1.PolicyService.UpdatePolicyRule:output_type -> mcias.v1.UpdatePolicyRuleResponse
|
||||||
|
10, // 13: mcias.v1.PolicyService.DeletePolicyRule:output_type -> mcias.v1.DeletePolicyRuleResponse
|
||||||
|
9, // [9:14] is the sub-list for method output_type
|
||||||
|
4, // [4:9] is the sub-list for method input_type
|
||||||
|
4, // [4:4] is the sub-list for extension type_name
|
||||||
|
4, // [4:4] is the sub-list for extension extendee
|
||||||
|
0, // [0:4] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_mcias_v1_policy_proto_init() }
|
||||||
|
func file_mcias_v1_policy_proto_init() {
|
||||||
|
if File_mcias_v1_policy_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file_mcias_v1_policy_proto_msgTypes[7].OneofWrappers = []any{}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_policy_proto_rawDesc), len(file_mcias_v1_policy_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 11,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_mcias_v1_policy_proto_goTypes,
|
||||||
|
DependencyIndexes: file_mcias_v1_policy_proto_depIdxs,
|
||||||
|
MessageInfos: file_mcias_v1_policy_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_mcias_v1_policy_proto = out.File
|
||||||
|
file_mcias_v1_policy_proto_goTypes = nil
|
||||||
|
file_mcias_v1_policy_proto_depIdxs = nil
|
||||||
|
}
|
||||||
299
gen/mcias/v1/policy_grpc.pb.go
Normal file
299
gen/mcias/v1/policy_grpc.pb.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
// PolicyService: CRUD management of policy rules.
|
||||||
|
|
||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
|
// - protoc v3.20.3
|
||||||
|
// source: mcias/v1/policy.proto
|
||||||
|
|
||||||
|
package mciasv1
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
PolicyService_ListPolicyRules_FullMethodName = "/mcias.v1.PolicyService/ListPolicyRules"
|
||||||
|
PolicyService_CreatePolicyRule_FullMethodName = "/mcias.v1.PolicyService/CreatePolicyRule"
|
||||||
|
PolicyService_GetPolicyRule_FullMethodName = "/mcias.v1.PolicyService/GetPolicyRule"
|
||||||
|
PolicyService_UpdatePolicyRule_FullMethodName = "/mcias.v1.PolicyService/UpdatePolicyRule"
|
||||||
|
PolicyService_DeletePolicyRule_FullMethodName = "/mcias.v1.PolicyService/DeletePolicyRule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PolicyServiceClient is the client API for PolicyService service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// PolicyService manages policy rules (admin only).
|
||||||
|
type PolicyServiceClient interface {
|
||||||
|
// ListPolicyRules returns all policy rules.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
ListPolicyRules(ctx context.Context, in *ListPolicyRulesRequest, opts ...grpc.CallOption) (*ListPolicyRulesResponse, error)
|
||||||
|
// CreatePolicyRule creates a new policy rule.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
CreatePolicyRule(ctx context.Context, in *CreatePolicyRuleRequest, opts ...grpc.CallOption) (*CreatePolicyRuleResponse, error)
|
||||||
|
// GetPolicyRule returns a single policy rule by ID.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
GetPolicyRule(ctx context.Context, in *GetPolicyRuleRequest, opts ...grpc.CallOption) (*GetPolicyRuleResponse, error)
|
||||||
|
// UpdatePolicyRule applies a partial update to a policy rule.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
UpdatePolicyRule(ctx context.Context, in *UpdatePolicyRuleRequest, opts ...grpc.CallOption) (*UpdatePolicyRuleResponse, error)
|
||||||
|
// DeletePolicyRule permanently removes a policy rule.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
DeletePolicyRule(ctx context.Context, in *DeletePolicyRuleRequest, opts ...grpc.CallOption) (*DeletePolicyRuleResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type policyServiceClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPolicyServiceClient(cc grpc.ClientConnInterface) PolicyServiceClient {
|
||||||
|
return &policyServiceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *policyServiceClient) ListPolicyRules(ctx context.Context, in *ListPolicyRulesRequest, opts ...grpc.CallOption) (*ListPolicyRulesResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListPolicyRulesResponse)
|
||||||
|
err := c.cc.Invoke(ctx, PolicyService_ListPolicyRules_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *policyServiceClient) CreatePolicyRule(ctx context.Context, in *CreatePolicyRuleRequest, opts ...grpc.CallOption) (*CreatePolicyRuleResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CreatePolicyRuleResponse)
|
||||||
|
err := c.cc.Invoke(ctx, PolicyService_CreatePolicyRule_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *policyServiceClient) GetPolicyRule(ctx context.Context, in *GetPolicyRuleRequest, opts ...grpc.CallOption) (*GetPolicyRuleResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetPolicyRuleResponse)
|
||||||
|
err := c.cc.Invoke(ctx, PolicyService_GetPolicyRule_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *policyServiceClient) UpdatePolicyRule(ctx context.Context, in *UpdatePolicyRuleRequest, opts ...grpc.CallOption) (*UpdatePolicyRuleResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(UpdatePolicyRuleResponse)
|
||||||
|
err := c.cc.Invoke(ctx, PolicyService_UpdatePolicyRule_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *policyServiceClient) DeletePolicyRule(ctx context.Context, in *DeletePolicyRuleRequest, opts ...grpc.CallOption) (*DeletePolicyRuleResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(DeletePolicyRuleResponse)
|
||||||
|
err := c.cc.Invoke(ctx, PolicyService_DeletePolicyRule_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyServiceServer is the server API for PolicyService service.
|
||||||
|
// All implementations must embed UnimplementedPolicyServiceServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// PolicyService manages policy rules (admin only).
|
||||||
|
type PolicyServiceServer interface {
|
||||||
|
// ListPolicyRules returns all policy rules.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
ListPolicyRules(context.Context, *ListPolicyRulesRequest) (*ListPolicyRulesResponse, error)
|
||||||
|
// CreatePolicyRule creates a new policy rule.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
CreatePolicyRule(context.Context, *CreatePolicyRuleRequest) (*CreatePolicyRuleResponse, error)
|
||||||
|
// GetPolicyRule returns a single policy rule by ID.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
GetPolicyRule(context.Context, *GetPolicyRuleRequest) (*GetPolicyRuleResponse, error)
|
||||||
|
// UpdatePolicyRule applies a partial update to a policy rule.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
UpdatePolicyRule(context.Context, *UpdatePolicyRuleRequest) (*UpdatePolicyRuleResponse, error)
|
||||||
|
// DeletePolicyRule permanently removes a policy rule.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
DeletePolicyRule(context.Context, *DeletePolicyRuleRequest) (*DeletePolicyRuleResponse, error)
|
||||||
|
mustEmbedUnimplementedPolicyServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedPolicyServiceServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedPolicyServiceServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedPolicyServiceServer) ListPolicyRules(context.Context, *ListPolicyRulesRequest) (*ListPolicyRulesResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method ListPolicyRules not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedPolicyServiceServer) CreatePolicyRule(context.Context, *CreatePolicyRuleRequest) (*CreatePolicyRuleResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method CreatePolicyRule not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedPolicyServiceServer) GetPolicyRule(context.Context, *GetPolicyRuleRequest) (*GetPolicyRuleResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GetPolicyRule not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedPolicyServiceServer) UpdatePolicyRule(context.Context, *UpdatePolicyRuleRequest) (*UpdatePolicyRuleResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method UpdatePolicyRule not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedPolicyServiceServer) DeletePolicyRule(context.Context, *DeletePolicyRuleRequest) (*DeletePolicyRuleResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method DeletePolicyRule not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedPolicyServiceServer) mustEmbedUnimplementedPolicyServiceServer() {}
|
||||||
|
func (UnimplementedPolicyServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafePolicyServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to PolicyServiceServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafePolicyServiceServer interface {
|
||||||
|
mustEmbedUnimplementedPolicyServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterPolicyServiceServer(s grpc.ServiceRegistrar, srv PolicyServiceServer) {
|
||||||
|
// If the following call panics, it indicates UnimplementedPolicyServiceServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&PolicyService_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _PolicyService_ListPolicyRules_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListPolicyRulesRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(PolicyServiceServer).ListPolicyRules(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: PolicyService_ListPolicyRules_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(PolicyServiceServer).ListPolicyRules(ctx, req.(*ListPolicyRulesRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _PolicyService_CreatePolicyRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CreatePolicyRuleRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(PolicyServiceServer).CreatePolicyRule(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: PolicyService_CreatePolicyRule_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(PolicyServiceServer).CreatePolicyRule(ctx, req.(*CreatePolicyRuleRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _PolicyService_GetPolicyRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetPolicyRuleRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(PolicyServiceServer).GetPolicyRule(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: PolicyService_GetPolicyRule_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(PolicyServiceServer).GetPolicyRule(ctx, req.(*GetPolicyRuleRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _PolicyService_UpdatePolicyRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(UpdatePolicyRuleRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(PolicyServiceServer).UpdatePolicyRule(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: PolicyService_UpdatePolicyRule_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(PolicyServiceServer).UpdatePolicyRule(ctx, req.(*UpdatePolicyRuleRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _PolicyService_DeletePolicyRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(DeletePolicyRuleRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(PolicyServiceServer).DeletePolicyRule(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: PolicyService_DeletePolicyRule_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(PolicyServiceServer).DeletePolicyRule(ctx, req.(*DeletePolicyRuleRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyService_ServiceDesc is the grpc.ServiceDesc for PolicyService service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var PolicyService_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "mcias.v1.PolicyService",
|
||||||
|
HandlerType: (*PolicyServiceServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "ListPolicyRules",
|
||||||
|
Handler: _PolicyService_ListPolicyRules_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CreatePolicyRule",
|
||||||
|
Handler: _PolicyService_CreatePolicyRule_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetPolicyRule",
|
||||||
|
Handler: _PolicyService_GetPolicyRule_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "UpdatePolicyRule",
|
||||||
|
Handler: _PolicyService_UpdatePolicyRule_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "DeletePolicyRule",
|
||||||
|
Handler: _PolicyService_DeletePolicyRule_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "mcias/v1/policy.proto",
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v6.33.4
|
// protoc v3.20.3
|
||||||
// source: mcias/v1/token.proto
|
// source: mcias/v1/token.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc v6.33.4
|
// - protoc v3.20.3
|
||||||
// source: mcias/v1/token.proto
|
// source: mcias/v1/token.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
|
|||||||
33
internal/audit/detail.go
Normal file
33
internal/audit/detail.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Package audit provides helpers for constructing audit log detail strings.
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// JSON builds a JSON details string from key-value pairs for audit logging.
|
||||||
|
// Uses json.Marshal for safe encoding rather than fmt.Sprintf with %q,
|
||||||
|
// which is fragile for edge-case Unicode.
|
||||||
|
func JSON(pairs ...string) string {
|
||||||
|
if len(pairs)%2 != 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
m := make(map[string]string, len(pairs)/2)
|
||||||
|
for i := 0; i < len(pairs); i += 2 {
|
||||||
|
m[pairs[i]] = pairs[i+1]
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONWithRoles builds a JSON details string that includes a "roles" key
|
||||||
|
// mapped to a string slice. This produces a proper JSON array for the value.
|
||||||
|
func JSONWithRoles(roles []string) string {
|
||||||
|
m := map[string][]string{"roles": roles}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
163
internal/audit/detail_test.go
Normal file
163
internal/audit/detail_test.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJSON(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pairs []string
|
||||||
|
verify func(t *testing.T, result string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single pair",
|
||||||
|
pairs: []string{"username", "alice"},
|
||||||
|
verify: func(t *testing.T, result string) {
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if m["username"] != "alice" {
|
||||||
|
t.Fatalf("expected alice, got %s", m["username"])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple pairs",
|
||||||
|
pairs: []string{"jti", "abc-123", "reason", "logout"},
|
||||||
|
verify: func(t *testing.T, result string) {
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if m["jti"] != "abc-123" {
|
||||||
|
t.Fatalf("expected abc-123, got %s", m["jti"])
|
||||||
|
}
|
||||||
|
if m["reason"] != "logout" {
|
||||||
|
t.Fatalf("expected logout, got %s", m["reason"])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "special characters in values",
|
||||||
|
pairs: []string{"username", "user\"with\\quotes"},
|
||||||
|
verify: func(t *testing.T, result string) {
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||||
|
t.Fatalf("invalid JSON for special chars: %v", err)
|
||||||
|
}
|
||||||
|
if m["username"] != "user\"with\\quotes" {
|
||||||
|
t.Fatalf("unexpected value: %s", m["username"])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unicode edge cases",
|
||||||
|
pairs: []string{"username", "user\u2028line\u2029sep"},
|
||||||
|
verify: func(t *testing.T, result string) {
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||||
|
t.Fatalf("invalid JSON for unicode: %v", err)
|
||||||
|
}
|
||||||
|
if m["username"] != "user\u2028line\u2029sep" {
|
||||||
|
t.Fatalf("unexpected value: %s", m["username"])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null bytes in value",
|
||||||
|
pairs: []string{"data", "before\x00after"},
|
||||||
|
verify: func(t *testing.T, result string) {
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||||
|
t.Fatalf("invalid JSON for null bytes: %v", err)
|
||||||
|
}
|
||||||
|
if m["data"] != "before\x00after" {
|
||||||
|
t.Fatalf("unexpected value: %q", m["data"])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "odd number of args returns empty object",
|
||||||
|
pairs: []string{"key"},
|
||||||
|
verify: func(t *testing.T, result string) {
|
||||||
|
if result != "{}" {
|
||||||
|
t.Fatalf("expected {}, got %s", result)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no args returns empty object",
|
||||||
|
pairs: nil,
|
||||||
|
verify: func(t *testing.T, result string) {
|
||||||
|
if result != "{}" {
|
||||||
|
t.Fatalf("expected {}, got %s", result)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := JSON(tc.pairs...)
|
||||||
|
tc.verify(t, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONWithRoles(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
roles []string
|
||||||
|
verify func(t *testing.T, result string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "multiple roles",
|
||||||
|
roles: []string{"admin", "editor"},
|
||||||
|
verify: func(t *testing.T, result string) {
|
||||||
|
var m map[string][]string
|
||||||
|
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(m["roles"]) != 2 || m["roles"][0] != "admin" || m["roles"][1] != "editor" {
|
||||||
|
t.Fatalf("unexpected roles: %v", m["roles"])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty roles",
|
||||||
|
roles: []string{},
|
||||||
|
verify: func(t *testing.T, result string) {
|
||||||
|
var m map[string][]string
|
||||||
|
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(m["roles"]) != 0 {
|
||||||
|
t.Fatalf("expected empty roles, got %v", m["roles"])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "roles with special characters",
|
||||||
|
roles: []string{"role\"special"},
|
||||||
|
verify: func(t *testing.T, result string) {
|
||||||
|
var m map[string][]string
|
||||||
|
if err := json.Unmarshal([]byte(result), &m); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if m["roles"][0] != "role\"special" {
|
||||||
|
t.Fatalf("unexpected role: %s", m["roles"][0])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := JSONWithRoles(tc.roles)
|
||||||
|
tc.verify(t, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,19 +200,31 @@ func parsePHC(phc string) (ArgonParams, []byte, []byte, error) {
|
|||||||
// ValidateTOTP checks a 6-digit TOTP code against a raw TOTP secret (bytes).
|
// ValidateTOTP checks a 6-digit TOTP code against a raw TOTP secret (bytes).
|
||||||
// A ±1 time-step window (±30s) is allowed to accommodate clock skew.
|
// A ±1 time-step window (±30s) is allowed to accommodate clock skew.
|
||||||
//
|
//
|
||||||
|
// Returns (true, counter, nil) on a valid code where counter is the HOTP
|
||||||
|
// counter value that matched. The caller MUST pass this counter to
|
||||||
|
// db.CheckAndUpdateTOTPCounter to prevent replay attacks within the validity
|
||||||
|
// window (CRIT-01).
|
||||||
|
//
|
||||||
// Security:
|
// Security:
|
||||||
// - Comparison uses crypto/subtle.ConstantTimeCompare to resist timing attacks.
|
// - Comparison uses crypto/subtle.ConstantTimeCompare to resist timing attacks.
|
||||||
// - Only RFC 6238-compliant HOTP (HMAC-SHA1) is implemented; no custom crypto.
|
// - Only RFC 6238-compliant HOTP (HMAC-SHA1) is implemented; no custom crypto.
|
||||||
// - A ±1 window is the RFC 6238 recommendation; wider windows increase
|
// - A ±1 window is the RFC 6238 recommendation; wider windows increase
|
||||||
// exposure to code interception between generation and submission.
|
// exposure to code interception between generation and submission.
|
||||||
func ValidateTOTP(secret []byte, code string) (bool, error) {
|
// - The returned counter enables replay prevention: callers store it and
|
||||||
|
// reject any future code that does not advance past it (RFC 6238 §5.2).
|
||||||
|
func ValidateTOTP(secret []byte, code string) (bool, int64, error) {
|
||||||
if len(code) != 6 {
|
if len(code) != 6 {
|
||||||
return false, nil
|
return false, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
step := int64(30) // RFC 6238 default time step in seconds
|
step := int64(30) // RFC 6238 default time step in seconds
|
||||||
|
|
||||||
|
// Security: evaluate all three counters with constant-time comparisons
|
||||||
|
// before returning. Early-exit would leak which counter matched via
|
||||||
|
// timing; we instead record the match and continue, returning at the end.
|
||||||
|
var matched bool
|
||||||
|
var matchedCounter int64
|
||||||
for _, counter := range []int64{
|
for _, counter := range []int64{
|
||||||
now/step - 1,
|
now/step - 1,
|
||||||
now / step,
|
now / step,
|
||||||
@@ -220,14 +232,21 @@ func ValidateTOTP(secret []byte, code string) (bool, error) {
|
|||||||
} {
|
} {
|
||||||
expected, err := hotp(secret, uint64(counter)) //nolint:gosec // G115: counter is Unix time / step, always non-negative
|
expected, err := hotp(secret, uint64(counter)) //nolint:gosec // G115: counter is Unix time / step, always non-negative
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("auth: compute TOTP: %w", err)
|
return false, 0, fmt.Errorf("auth: compute TOTP: %w", err)
|
||||||
}
|
}
|
||||||
// Security: constant-time comparison to prevent timing attack.
|
// Security: constant-time comparison to prevent timing attack.
|
||||||
|
// We deliberately do NOT break early so that all three comparisons
|
||||||
|
// always execute, preventing a timing side-channel on which counter
|
||||||
|
// slot matched.
|
||||||
if subtle.ConstantTimeCompare([]byte(code), []byte(expected)) == 1 {
|
if subtle.ConstantTimeCompare([]byte(code), []byte(expected)) == 1 {
|
||||||
return true, nil
|
matched = true
|
||||||
|
matchedCounter = counter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
if matched {
|
||||||
|
return true, matchedCounter, nil
|
||||||
|
}
|
||||||
|
return false, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hotp computes an HMAC-SHA1-based OTP for a given counter value.
|
// hotp computes an HMAC-SHA1-based OTP for a given counter value.
|
||||||
|
|||||||
@@ -101,13 +101,16 @@ func TestValidateTOTP(t *testing.T) {
|
|||||||
t.Fatalf("hotp: %v", err)
|
t.Fatalf("hotp: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err := ValidateTOTP(rawSecret, code)
|
ok, counter, err := ValidateTOTP(rawSecret, code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateTOTP: %v", err)
|
t.Fatalf("ValidateTOTP: %v", err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Errorf("ValidateTOTP rejected a valid code %q", code)
|
t.Errorf("ValidateTOTP rejected a valid code %q", code)
|
||||||
}
|
}
|
||||||
|
if ok && counter == 0 {
|
||||||
|
t.Errorf("ValidateTOTP returned zero counter for valid code")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestValidateTOTPWrongCode verifies that an incorrect code is rejected.
|
// TestValidateTOTPWrongCode verifies that an incorrect code is rejected.
|
||||||
@@ -117,7 +120,7 @@ func TestValidateTOTPWrongCode(t *testing.T) {
|
|||||||
t.Fatalf("GenerateTOTPSecret: %v", err)
|
t.Fatalf("GenerateTOTPSecret: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err := ValidateTOTP(rawSecret, "000000")
|
ok, _, err := ValidateTOTP(rawSecret, "000000")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateTOTP: %v", err)
|
t.Fatalf("ValidateTOTP: %v", err)
|
||||||
}
|
}
|
||||||
@@ -135,7 +138,7 @@ func TestValidateTOTPWrongLength(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, code := range []string{"", "12345", "1234567", "abcdef"} {
|
for _, code := range []string{"", "12345", "1234567", "abcdef"} {
|
||||||
ok, err := ValidateTOTP(rawSecret, code)
|
ok, _, err := ValidateTOTP(rawSecret, code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ValidateTOTP(%q): unexpected error: %v", code, err)
|
t.Errorf("ValidateTOTP(%q): unexpected error: %v", code, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -30,6 +31,17 @@ type ServerConfig struct {
|
|||||||
GRPCAddr string `toml:"grpc_addr"`
|
GRPCAddr string `toml:"grpc_addr"`
|
||||||
TLSCert string `toml:"tls_cert"`
|
TLSCert string `toml:"tls_cert"`
|
||||||
TLSKey string `toml:"tls_key"`
|
TLSKey string `toml:"tls_key"`
|
||||||
|
// TrustedProxy is the IP address (not a range) of a reverse proxy that
|
||||||
|
// sits in front of the server and sets X-Forwarded-For or X-Real-IP
|
||||||
|
// headers. When set, the rate limiter and audit log extract the real
|
||||||
|
// client IP from these headers instead of r.RemoteAddr.
|
||||||
|
//
|
||||||
|
// Security: only requests whose r.RemoteAddr matches TrustedProxy are
|
||||||
|
// trusted to carry a valid forwarded-IP header. All other requests use
|
||||||
|
// r.RemoteAddr directly, so this field cannot be exploited for IP
|
||||||
|
// spoofing by external clients. Omit or leave empty when running
|
||||||
|
// without a reverse proxy.
|
||||||
|
TrustedProxy string `toml:"trusted_proxy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseConfig holds SQLite database settings.
|
// DatabaseConfig holds SQLite database settings.
|
||||||
@@ -63,7 +75,7 @@ type MasterKeyConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// duration is a wrapper around time.Duration that supports TOML string parsing
|
// duration is a wrapper around time.Duration that supports TOML string parsing
|
||||||
// (e.g. "720h", "8h").
|
// (e.g. "168h", "8h").
|
||||||
type duration struct {
|
type duration struct {
|
||||||
time.Duration
|
time.Duration
|
||||||
}
|
}
|
||||||
@@ -137,6 +149,14 @@ func (c *Config) validate() error {
|
|||||||
if c.Server.TLSKey == "" {
|
if c.Server.TLSKey == "" {
|
||||||
errs = append(errs, errors.New("server.tls_key is required"))
|
errs = append(errs, errors.New("server.tls_key is required"))
|
||||||
}
|
}
|
||||||
|
// Security (DEF-03): if trusted_proxy is set it must be a valid IP address
|
||||||
|
// (not a hostname or CIDR) so the middleware can compare it to the parsed
|
||||||
|
// host part of r.RemoteAddr using a reliable byte-level equality check.
|
||||||
|
if c.Server.TrustedProxy != "" {
|
||||||
|
if net.ParseIP(c.Server.TrustedProxy) == nil {
|
||||||
|
errs = append(errs, fmt.Errorf("server.trusted_proxy %q is not a valid IP address", c.Server.TrustedProxy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
if c.Database.Path == "" {
|
if c.Database.Path == "" {
|
||||||
@@ -147,14 +167,31 @@ func (c *Config) validate() error {
|
|||||||
if c.Tokens.Issuer == "" {
|
if c.Tokens.Issuer == "" {
|
||||||
errs = append(errs, errors.New("tokens.issuer is required"))
|
errs = append(errs, errors.New("tokens.issuer is required"))
|
||||||
}
|
}
|
||||||
|
// Security (DEF-05): enforce both lower and upper bounds on token expiry
|
||||||
|
// durations. An operator misconfiguration could otherwise produce tokens
|
||||||
|
// valid for centuries, which would be irrevocable (bar explicit JTI
|
||||||
|
// revocation) if a token were stolen. Upper bounds are intentionally
|
||||||
|
// generous to accommodate a range of legitimate deployments while
|
||||||
|
// catching obvious typos (e.g. "876000h" instead of "8760h").
|
||||||
|
const (
|
||||||
|
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
|
||||||
|
maxAdminExpiry = 24 * time.Hour // 24 hours
|
||||||
|
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
|
||||||
|
)
|
||||||
if c.Tokens.DefaultExpiry.Duration <= 0 {
|
if c.Tokens.DefaultExpiry.Duration <= 0 {
|
||||||
errs = append(errs, errors.New("tokens.default_expiry must be positive"))
|
errs = append(errs, errors.New("tokens.default_expiry must be positive"))
|
||||||
|
} else if c.Tokens.DefaultExpiry.Duration > maxDefaultExpiry {
|
||||||
|
errs = append(errs, fmt.Errorf("tokens.default_expiry must be <= %s (got %s)", maxDefaultExpiry, c.Tokens.DefaultExpiry.Duration))
|
||||||
}
|
}
|
||||||
if c.Tokens.AdminExpiry.Duration <= 0 {
|
if c.Tokens.AdminExpiry.Duration <= 0 {
|
||||||
errs = append(errs, errors.New("tokens.admin_expiry must be positive"))
|
errs = append(errs, errors.New("tokens.admin_expiry must be positive"))
|
||||||
|
} else if c.Tokens.AdminExpiry.Duration > maxAdminExpiry {
|
||||||
|
errs = append(errs, fmt.Errorf("tokens.admin_expiry must be <= %s (got %s)", maxAdminExpiry, c.Tokens.AdminExpiry.Duration))
|
||||||
}
|
}
|
||||||
if c.Tokens.ServiceExpiry.Duration <= 0 {
|
if c.Tokens.ServiceExpiry.Duration <= 0 {
|
||||||
errs = append(errs, errors.New("tokens.service_expiry must be positive"))
|
errs = append(errs, errors.New("tokens.service_expiry must be positive"))
|
||||||
|
} else if c.Tokens.ServiceExpiry.Duration > maxServiceExpiry {
|
||||||
|
errs = append(errs, fmt.Errorf("tokens.service_expiry must be <= %s (got %s)", maxServiceExpiry, c.Tokens.ServiceExpiry.Duration))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Argon2 — enforce OWASP 2023 minimums (time=2, memory=65536 KiB).
|
// Argon2 — enforce OWASP 2023 minimums (time=2, memory=65536 KiB).
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ func validConfig() string {
|
|||||||
return `
|
return `
|
||||||
[server]
|
[server]
|
||||||
listen_addr = "0.0.0.0:8443"
|
listen_addr = "0.0.0.0:8443"
|
||||||
tls_cert = "/etc/mcias/server.crt"
|
tls_cert = "/srv/mcias/server.crt"
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/srv/mcias/server.key"
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
path = "/var/lib/mcias/mcias.db"
|
path = "/srv/mcias/mcias.db"
|
||||||
|
|
||||||
[tokens]
|
[tokens]
|
||||||
issuer = "https://auth.example.com"
|
issuer = "https://auth.example.com"
|
||||||
@@ -154,11 +154,11 @@ func TestValidateMasterKeyBothSet(t *testing.T) {
|
|||||||
path := writeTempConfig(t, `
|
path := writeTempConfig(t, `
|
||||||
[server]
|
[server]
|
||||||
listen_addr = "0.0.0.0:8443"
|
listen_addr = "0.0.0.0:8443"
|
||||||
tls_cert = "/etc/mcias/server.crt"
|
tls_cert = "/srv/mcias/server.crt"
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/srv/mcias/server.key"
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
path = "/var/lib/mcias/mcias.db"
|
path = "/srv/mcias/mcias.db"
|
||||||
|
|
||||||
[tokens]
|
[tokens]
|
||||||
issuer = "https://auth.example.com"
|
issuer = "https://auth.example.com"
|
||||||
@@ -173,7 +173,7 @@ threads = 4
|
|||||||
|
|
||||||
[master_key]
|
[master_key]
|
||||||
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
||||||
keyfile = "/etc/mcias/master.key"
|
keyfile = "/srv/mcias/master.key"
|
||||||
`)
|
`)
|
||||||
_, err := Load(path)
|
_, err := Load(path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -185,11 +185,11 @@ func TestValidateMasterKeyNoneSet(t *testing.T) {
|
|||||||
path := writeTempConfig(t, `
|
path := writeTempConfig(t, `
|
||||||
[server]
|
[server]
|
||||||
listen_addr = "0.0.0.0:8443"
|
listen_addr = "0.0.0.0:8443"
|
||||||
tls_cert = "/etc/mcias/server.crt"
|
tls_cert = "/srv/mcias/server.crt"
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/srv/mcias/server.key"
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
path = "/var/lib/mcias/mcias.db"
|
path = "/srv/mcias/mcias.db"
|
||||||
|
|
||||||
[tokens]
|
[tokens]
|
||||||
issuer = "https://auth.example.com"
|
issuer = "https://auth.example.com"
|
||||||
@@ -210,6 +210,40 @@ threads = 4
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTrustedProxyValidation verifies that trusted_proxy must be a valid IP.
|
||||||
|
func TestTrustedProxyValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
proxy string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"empty is valid (disabled)", "", false},
|
||||||
|
{"valid IPv4", "127.0.0.1", false},
|
||||||
|
{"valid IPv6 loopback", "::1", false},
|
||||||
|
{"valid private IPv4", "10.0.0.1", false},
|
||||||
|
{"hostname rejected", "proxy.example.com", true},
|
||||||
|
{"CIDR rejected", "10.0.0.0/8", true},
|
||||||
|
{"garbage rejected", "not-an-ip", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cfg, _ := Load(writeTempConfig(t, validConfig()))
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("baseline config load failed")
|
||||||
|
}
|
||||||
|
cfg.Server.TrustedProxy = tc.proxy
|
||||||
|
err := cfg.validate()
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Errorf("expected validation error for proxy=%q, got nil", tc.proxy)
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Errorf("unexpected error for proxy=%q: %v", tc.proxy, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDurationParsing(t *testing.T) {
|
func TestDurationParsing(t *testing.T) {
|
||||||
var d duration
|
var d duration
|
||||||
if err := d.UnmarshalText([]byte("1h30m")); err != nil {
|
if err := d.UnmarshalText([]byte("1h30m")); err != nil {
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ func (db *DB) GetAccountByID(id int64) (*model.Account, error) {
|
|||||||
`, id))
|
`, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccountByUsername retrieves an account by username (case-insensitive).
|
// GetAccountByUsername retrieves an account by username.
|
||||||
|
// Matching is case-sensitive: SQLite uses BINARY collation by default, so
|
||||||
|
// "admin" and "Admin" are distinct usernames. This is intentional for an
|
||||||
|
// SSO system where usernames should be treated as opaque identifiers.
|
||||||
// Returns ErrNotFound if no matching account exists.
|
// Returns ErrNotFound if no matching account exists.
|
||||||
func (db *DB) GetAccountByUsername(username string) (*model.Account, error) {
|
func (db *DB) GetAccountByUsername(username string) (*model.Account, error) {
|
||||||
return db.scanAccount(db.sql.QueryRow(`
|
return db.scanAccount(db.sql.QueryRow(`
|
||||||
@@ -184,6 +187,46 @@ func (db *DB) SetTOTP(accountID int64, secretEnc, secretNonce []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckAndUpdateTOTPCounter atomically verifies that counter is strictly
|
||||||
|
// greater than the last accepted TOTP counter for the account, and if so,
|
||||||
|
// stores counter as the new last accepted value.
|
||||||
|
//
|
||||||
|
// Returns ErrTOTPReplay if counter ≤ the stored value, preventing a replay
|
||||||
|
// of a previously accepted code within the ±1 time-step validity window.
|
||||||
|
// On the first successful TOTP login (stored value NULL) any counter is
|
||||||
|
// accepted.
|
||||||
|
//
|
||||||
|
// Security (CRIT-01): RFC 6238 §5.2 recommends recording the last OTP
|
||||||
|
// counter used and rejecting any code that does not advance it. Without
|
||||||
|
// this, an intercepted code remains valid for up to 90 seconds. The update
|
||||||
|
// is performed in a single parameterized SQL statement, so there is no
|
||||||
|
// TOCTOU window between the check and the write.
|
||||||
|
func (db *DB) CheckAndUpdateTOTPCounter(accountID int64, counter int64) error {
|
||||||
|
result, err := db.sql.Exec(`
|
||||||
|
UPDATE accounts
|
||||||
|
SET last_totp_counter = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND (last_totp_counter IS NULL OR last_totp_counter < ?)
|
||||||
|
`, counter, now(), accountID, counter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: check-and-update TOTP counter: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: check-and-update TOTP counter rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
// Security: the counter was not advanced — this code has already been
|
||||||
|
// used within its validity window. Treat as authentication failure.
|
||||||
|
return ErrTOTPReplay
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTOTPReplay is returned by CheckAndUpdateTOTPCounter when the submitted
|
||||||
|
// TOTP code corresponds to a counter value that has already been accepted.
|
||||||
|
var ErrTOTPReplay = errors.New("db: TOTP code already used (replay)")
|
||||||
|
|
||||||
// ClearTOTP removes the TOTP secret and disables TOTP requirement.
|
// ClearTOTP removes the TOTP secret and disables TOTP requirement.
|
||||||
func (db *DB) ClearTOTP(accountID int64) error {
|
func (db *DB) ClearTOTP(accountID int64) error {
|
||||||
_, err := db.sql.Exec(`
|
_, err := db.sql.Exec(`
|
||||||
@@ -300,6 +343,12 @@ func (db *DB) GetRoles(accountID int64) ([]string, error) {
|
|||||||
|
|
||||||
// GrantRole adds a role to an account. If the role already exists, it is a no-op.
|
// GrantRole adds a role to an account. If the role already exists, it is a no-op.
|
||||||
func (db *DB) GrantRole(accountID int64, role string, grantedBy *int64) error {
|
func (db *DB) GrantRole(accountID int64, role string, grantedBy *int64) error {
|
||||||
|
// Security (DEF-10): reject unknown roles before writing to the DB so
|
||||||
|
// that typos (e.g. "admim") are caught immediately rather than silently
|
||||||
|
// creating an unmatchable role.
|
||||||
|
if err := model.ValidateRole(role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
_, err := db.sql.Exec(`
|
_, err := db.sql.Exec(`
|
||||||
INSERT OR IGNORE INTO account_roles (account_id, role, granted_by, granted_at)
|
INSERT OR IGNORE INTO account_roles (account_id, role, granted_by, granted_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
@@ -323,6 +372,14 @@ func (db *DB) RevokeRole(accountID int64, role string) error {
|
|||||||
|
|
||||||
// SetRoles replaces the full role set for an account atomically.
|
// SetRoles replaces the full role set for an account atomically.
|
||||||
func (db *DB) SetRoles(accountID int64, roles []string, grantedBy *int64) error {
|
func (db *DB) SetRoles(accountID int64, roles []string, grantedBy *int64) error {
|
||||||
|
// Security (DEF-10): validate all roles before opening the transaction so
|
||||||
|
// we fail fast without touching the database on an invalid input.
|
||||||
|
for _, role := range roles {
|
||||||
|
if err := model.ValidateRole(role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := db.sql.Begin()
|
tx, err := db.sql.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("db: set roles begin tx: %w", err)
|
return fmt.Errorf("db: set roles begin tx: %w", err)
|
||||||
@@ -635,6 +692,70 @@ func (db *DB) RenewToken(oldJTI, reason, newJTI string, accountID int64, issuedA
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IssueSystemToken atomically revokes an existing system token (if oldJTI is
|
||||||
|
// non-empty), tracks the new token in token_revocation, and upserts the
|
||||||
|
// system_tokens table — all within a single SQLite transaction.
|
||||||
|
//
|
||||||
|
// Security: these three operations must be atomic so that a crash between them
|
||||||
|
// cannot leave the database in an inconsistent state (e.g., old token revoked
|
||||||
|
// but new token not tracked, or token tracked but system_tokens not updated).
|
||||||
|
// With MaxOpenConns(1) and SQLite's serialised write path, BEGIN IMMEDIATE
|
||||||
|
// acquires the write lock immediately and prevents any other writer from
|
||||||
|
// interleaving.
|
||||||
|
func (db *DB) IssueSystemToken(oldJTI, newJTI string, accountID int64, issuedAt, expiresAt time.Time) error {
|
||||||
|
tx, err := db.sql.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: issue system token begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
n := now()
|
||||||
|
|
||||||
|
// If there is an existing token, revoke it.
|
||||||
|
if oldJTI != "" {
|
||||||
|
_, err := tx.Exec(`
|
||||||
|
UPDATE token_revocation
|
||||||
|
SET revoked_at = ?, revoke_reason = ?
|
||||||
|
WHERE jti = ? AND revoked_at IS NULL
|
||||||
|
`, n, nullString("rotated"), oldJTI)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: issue system token revoke old %q: %w", oldJTI, err)
|
||||||
|
}
|
||||||
|
// We do not require rows affected > 0 because the old token may
|
||||||
|
// already be revoked or expired; the important thing is that we
|
||||||
|
// proceed to track the new token regardless.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the new token in token_revocation.
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO token_revocation (jti, account_id, issued_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`, newJTI, accountID,
|
||||||
|
issuedAt.UTC().Format(time.RFC3339),
|
||||||
|
expiresAt.UTC().Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: issue system token track new %q: %w", newJTI, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the system_tokens table so GetSystemToken returns the new JTI.
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO system_tokens (account_id, jti, expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(account_id) DO UPDATE SET
|
||||||
|
jti = excluded.jti,
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
created_at = excluded.created_at
|
||||||
|
`, accountID, newJTI, expiresAt.UTC().Format(time.RFC3339), n)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: issue system token set system token for account %d: %w", accountID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("db: issue system token commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RevokeAllUserTokens revokes all non-expired, non-revoked tokens for an account.
|
// RevokeAllUserTokens revokes all non-expired, non-revoked tokens for an account.
|
||||||
func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error {
|
func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error {
|
||||||
n := now()
|
n := now()
|
||||||
@@ -1124,3 +1245,268 @@ func (db *DB) ClearLoginFailures(accountID int64) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAccountsWithTOTP returns all accounts (including deleted) that have a
|
||||||
|
// non-null TOTP secret stored, so that rekey can re-encrypt every secret even
|
||||||
|
// for inactive or deleted accounts.
|
||||||
|
func (db *DB) ListAccountsWithTOTP() ([]*model.Account, error) {
|
||||||
|
rows, err := db.sql.Query(`
|
||||||
|
SELECT id, uuid, username, account_type, COALESCE(password_hash,''),
|
||||||
|
status, totp_required,
|
||||||
|
totp_secret_enc, totp_secret_nonce,
|
||||||
|
created_at, updated_at, deleted_at
|
||||||
|
FROM accounts
|
||||||
|
WHERE totp_secret_enc IS NOT NULL
|
||||||
|
ORDER BY id ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: list accounts with TOTP: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var accounts []*model.Account
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := db.scanAccountRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts = append(accounts, a)
|
||||||
|
}
|
||||||
|
return accounts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllPGCredentials returns every row in pg_credentials. Used by rekey
|
||||||
|
// to re-encrypt all stored passwords under a new master key.
|
||||||
|
func (db *DB) ListAllPGCredentials() ([]*model.PGCredential, error) {
|
||||||
|
rows, err := db.sql.Query(`
|
||||||
|
SELECT id, account_id, pg_host, pg_port, pg_database, pg_username,
|
||||||
|
pg_password_enc, pg_password_nonce, created_at, updated_at, owner_id
|
||||||
|
FROM pg_credentials
|
||||||
|
ORDER BY id ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: list all pg credentials: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var creds []*model.PGCredential
|
||||||
|
for rows.Next() {
|
||||||
|
var cred model.PGCredential
|
||||||
|
var createdAtStr, updatedAtStr string
|
||||||
|
var ownerID sql.NullInt64
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
|
||||||
|
&cred.PGDatabase, &cred.PGUsername,
|
||||||
|
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
|
||||||
|
&createdAtStr, &updatedAtStr, &ownerID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: scan pg credential: %w", err)
|
||||||
|
}
|
||||||
|
var parseErr error
|
||||||
|
cred.CreatedAt, parseErr = parseTime(createdAtStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, parseErr
|
||||||
|
}
|
||||||
|
cred.UpdatedAt, parseErr = parseTime(updatedAtStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, parseErr
|
||||||
|
}
|
||||||
|
if ownerID.Valid {
|
||||||
|
v := ownerID.Int64
|
||||||
|
cred.OwnerID = &v
|
||||||
|
}
|
||||||
|
creds = append(creds, &cred)
|
||||||
|
}
|
||||||
|
return creds, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTPRekeyRow carries a re-encrypted TOTP secret for a single account.
|
||||||
|
type TOTPRekeyRow struct {
|
||||||
|
Enc []byte
|
||||||
|
Nonce []byte
|
||||||
|
AccountID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PGRekeyRow carries a re-encrypted Postgres password for a single credential row.
|
||||||
|
type PGRekeyRow struct {
|
||||||
|
Enc []byte
|
||||||
|
Nonce []byte
|
||||||
|
CredentialID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rekey atomically replaces the master-key salt and all secrets encrypted
|
||||||
|
// under the old master key with values encrypted under the new master key.
|
||||||
|
//
|
||||||
|
// Security: The entire replacement is performed inside a single SQLite
|
||||||
|
// transaction so that a crash mid-way leaves the database either fully on the
|
||||||
|
// old key or fully on the new key — never in a mixed state. The caller is
|
||||||
|
// responsible for zeroing the old and new master keys after this call returns.
|
||||||
|
func (db *DB) Rekey(newSalt, newSigningKeyEnc, newSigningKeyNonce []byte, totpRows []TOTPRekeyRow, pgRows []PGRekeyRow) error {
|
||||||
|
tx, err := db.sql.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: rekey begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
n := now()
|
||||||
|
|
||||||
|
// Replace master key salt and signing key atomically.
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE server_config
|
||||||
|
SET master_key_salt = ?,
|
||||||
|
signing_key_enc = ?,
|
||||||
|
signing_key_nonce = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = 1
|
||||||
|
`, newSalt, newSigningKeyEnc, newSigningKeyNonce, n)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: rekey update server_config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-encrypt each TOTP secret.
|
||||||
|
for _, row := range totpRows {
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE accounts
|
||||||
|
SET totp_secret_enc = ?,
|
||||||
|
totp_secret_nonce = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, row.Enc, row.Nonce, n, row.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: rekey update TOTP for account %d: %w", row.AccountID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-encrypt each pg_credentials password.
|
||||||
|
for _, row := range pgRows {
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE pg_credentials
|
||||||
|
SET pg_password_enc = ?,
|
||||||
|
pg_password_nonce = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, row.Enc, row.Nonce, n, row.CredentialID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: rekey update pg credential %d: %w", row.CredentialID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("db: rekey commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantTokenIssueAccess records that granteeID may issue tokens for the system
|
||||||
|
// account identified by accountID. Idempotent: a second call for the same
|
||||||
|
// (account, grantee) pair is silently ignored via INSERT OR IGNORE.
|
||||||
|
func (db *DB) GrantTokenIssueAccess(accountID, granteeID int64, grantedBy *int64) error {
|
||||||
|
_, err := db.sql.Exec(`
|
||||||
|
INSERT OR IGNORE INTO service_account_delegates
|
||||||
|
(account_id, grantee_id, granted_by, granted_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`, accountID, granteeID, grantedBy, now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: grant token issue access: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeTokenIssueAccess removes the delegate grant for granteeID on accountID.
|
||||||
|
// Returns ErrNotFound if no such grant exists.
|
||||||
|
func (db *DB) RevokeTokenIssueAccess(accountID, granteeID int64) error {
|
||||||
|
result, err := db.sql.Exec(`
|
||||||
|
DELETE FROM service_account_delegates
|
||||||
|
WHERE account_id = ? AND grantee_id = ?
|
||||||
|
`, accountID, granteeID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: revoke token issue access: %w", err)
|
||||||
|
}
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: revoke token issue access rows: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokenIssueDelegates returns all delegate grants for the given system account.
|
||||||
|
func (db *DB) ListTokenIssueDelegates(accountID int64) ([]*model.ServiceAccountDelegate, error) {
|
||||||
|
rows, err := db.sql.Query(`
|
||||||
|
SELECT d.id, d.account_id, d.grantee_id, d.granted_by, d.granted_at,
|
||||||
|
a.uuid, a.username
|
||||||
|
FROM service_account_delegates d
|
||||||
|
JOIN accounts a ON a.id = d.grantee_id
|
||||||
|
WHERE d.account_id = ?
|
||||||
|
ORDER BY d.granted_at ASC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: list token issue delegates: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []*model.ServiceAccountDelegate
|
||||||
|
for rows.Next() {
|
||||||
|
var d model.ServiceAccountDelegate
|
||||||
|
var grantedAt string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&d.ID, &d.AccountID, &d.GranteeID, &d.GrantedBy, &grantedAt,
|
||||||
|
&d.GranteeUUID, &d.GranteeName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: scan token issue delegate: %w", err)
|
||||||
|
}
|
||||||
|
t, err := parseTime(grantedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.GrantedAt = t
|
||||||
|
out = append(out, &d)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasTokenIssueAccess reports whether actorID has been granted permission to
|
||||||
|
// issue tokens for the system account identified by accountID.
|
||||||
|
func (db *DB) HasTokenIssueAccess(accountID, actorID int64) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := db.sql.QueryRow(`
|
||||||
|
SELECT COUNT(1) FROM service_account_delegates
|
||||||
|
WHERE account_id = ? AND grantee_id = ?
|
||||||
|
`, accountID, actorID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("db: has token issue access: %w", err)
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDelegatedServiceAccounts returns system accounts for which actorID has
|
||||||
|
// been granted token-issue access.
|
||||||
|
func (db *DB) ListDelegatedServiceAccounts(actorID int64) ([]*model.Account, error) {
|
||||||
|
rows, err := db.sql.Query(`
|
||||||
|
SELECT a.id, a.uuid, a.username, a.account_type, COALESCE(a.password_hash,''),
|
||||||
|
a.status, a.totp_required,
|
||||||
|
a.totp_secret_enc, a.totp_secret_nonce,
|
||||||
|
a.created_at, a.updated_at, a.deleted_at
|
||||||
|
FROM service_account_delegates d
|
||||||
|
JOIN accounts a ON a.id = d.account_id
|
||||||
|
WHERE d.grantee_id = ? AND a.status != 'deleted'
|
||||||
|
ORDER BY a.username ASC
|
||||||
|
`, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: list delegated service accounts: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []*model.Account
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := db.scanAccountRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,7 +65,14 @@ func (db *DB) configure() error {
|
|||||||
"PRAGMA journal_mode=WAL",
|
"PRAGMA journal_mode=WAL",
|
||||||
"PRAGMA foreign_keys=ON",
|
"PRAGMA foreign_keys=ON",
|
||||||
"PRAGMA busy_timeout=5000",
|
"PRAGMA busy_timeout=5000",
|
||||||
"PRAGMA synchronous=NORMAL",
|
// Security (DEF-07): FULL synchronous mode ensures every write is
|
||||||
|
// flushed to disk before SQLite considers it committed. With WAL
|
||||||
|
// mode + NORMAL, a power failure between a write and the next
|
||||||
|
// checkpoint could lose the most recent committed transactions,
|
||||||
|
// including token issuance and revocation records — which must be
|
||||||
|
// durable. The performance cost is negligible for a single-node
|
||||||
|
// personal SSO server.
|
||||||
|
"PRAGMA synchronous=FULL",
|
||||||
}
|
}
|
||||||
for _, p := range pragmas {
|
for _, p := range pragmas {
|
||||||
if _, err := db.sql.Exec(p); err != nil {
|
if _, err := db.sql.Exec(p); err != nil {
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ func TestRoleOperations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetRoles
|
// SetRoles
|
||||||
if err := db.SetRoles(acct.ID, []string{"reader", "writer"}, nil); err != nil {
|
if err := db.SetRoles(acct.ID, []string{"admin", "user"}, nil); err != nil {
|
||||||
t.Fatalf("SetRoles: %v", err)
|
t.Fatalf("SetRoles: %v", err)
|
||||||
}
|
}
|
||||||
roles, err = db.GetRoles(acct.ID)
|
roles, err = db.GetRoles(acct.ID)
|
||||||
@@ -445,6 +445,79 @@ func TestSystemTokenRotationRevokesOld(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIssueSystemTokenAtomic verifies that IssueSystemToken atomically
|
||||||
|
// revokes an old token, tracks the new token, and upserts system_tokens.
|
||||||
|
func TestIssueSystemTokenAtomic(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
acct, err := db.CreateAccount("svc-atomic", model.AccountTypeSystem, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
exp := now.Add(time.Hour)
|
||||||
|
|
||||||
|
// Issue first system token with no old JTI.
|
||||||
|
jti1 := "atomic-sys-tok-1"
|
||||||
|
if err := db.IssueSystemToken("", jti1, acct.ID, now, exp); err != nil {
|
||||||
|
t.Fatalf("IssueSystemToken first: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the first token is tracked and not revoked.
|
||||||
|
rec1, err := db.GetTokenRecord(jti1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTokenRecord jti1: %v", err)
|
||||||
|
}
|
||||||
|
if rec1.IsRevoked() {
|
||||||
|
t.Error("first token should not be revoked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify system_tokens points to the first token.
|
||||||
|
st1, err := db.GetSystemToken(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSystemToken after first issue: %v", err)
|
||||||
|
}
|
||||||
|
if st1.JTI != jti1 {
|
||||||
|
t.Errorf("system token JTI = %q, want %q", st1.JTI, jti1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue second token, which should atomically revoke the first.
|
||||||
|
jti2 := "atomic-sys-tok-2"
|
||||||
|
if err := db.IssueSystemToken(jti1, jti2, acct.ID, now, exp); err != nil {
|
||||||
|
t.Fatalf("IssueSystemToken second: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First token must be revoked.
|
||||||
|
rec1After, err := db.GetTokenRecord(jti1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTokenRecord jti1 after rotation: %v", err)
|
||||||
|
}
|
||||||
|
if !rec1After.IsRevoked() {
|
||||||
|
t.Error("first token should be revoked after second issue")
|
||||||
|
}
|
||||||
|
if rec1After.RevokeReason != "rotated" {
|
||||||
|
t.Errorf("revoke reason = %q, want %q", rec1After.RevokeReason, "rotated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second token must be tracked and not revoked.
|
||||||
|
rec2, err := db.GetTokenRecord(jti2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTokenRecord jti2: %v", err)
|
||||||
|
}
|
||||||
|
if rec2.IsRevoked() {
|
||||||
|
t.Error("second token should not be revoked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// system_tokens must point to the second token.
|
||||||
|
st2, err := db.GetSystemToken(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSystemToken after second issue: %v", err)
|
||||||
|
}
|
||||||
|
if st2.JTI != jti2 {
|
||||||
|
t.Errorf("system token JTI = %q, want %q", st2.JTI, jti2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRevokeAllUserTokens(t *testing.T) {
|
func TestRevokeAllUserTokens(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
acct, err := db.CreateAccount("ivan", model.AccountTypeHuman, "hash")
|
acct, err := db.CreateAccount("ivan", model.AccountTypeHuman, "hash")
|
||||||
|
|||||||
@@ -194,3 +194,210 @@ func TestListAuditEventsCombinedFilters(t *testing.T) {
|
|||||||
t.Fatalf("expected 0 events, got %d", len(events))
|
t.Fatalf("expected 0 events, got %d", len(events))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- rekey helper tests ----
|
||||||
|
|
||||||
|
func TestListAccountsWithTOTP(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
// No accounts with TOTP yet.
|
||||||
|
accounts, err := database.ListAccountsWithTOTP()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAccountsWithTOTP (empty): %v", err)
|
||||||
|
}
|
||||||
|
if len(accounts) != 0 {
|
||||||
|
t.Fatalf("expected 0 accounts, got %d", len(accounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an account and store a TOTP secret.
|
||||||
|
a, err := database.CreateAccount("totpuser", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.SetTOTP(a.ID, []byte("enc"), []byte("nonce")); err != nil {
|
||||||
|
t.Fatalf("set TOTP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create another account without TOTP.
|
||||||
|
if _, err := database.CreateAccount("nototp", model.AccountTypeHuman, "hash"); err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err = database.ListAccountsWithTOTP()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAccountsWithTOTP: %v", err)
|
||||||
|
}
|
||||||
|
if len(accounts) != 1 {
|
||||||
|
t.Fatalf("expected 1 account with TOTP, got %d", len(accounts))
|
||||||
|
}
|
||||||
|
if accounts[0].ID != a.ID {
|
||||||
|
t.Errorf("expected account ID %d, got %d", a.ID, accounts[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAllPGCredentials(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
creds, err := database.ListAllPGCredentials()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAllPGCredentials (empty): %v", err)
|
||||||
|
}
|
||||||
|
if len(creds) != 0 {
|
||||||
|
t.Fatalf("expected 0 creds, got %d", len(creds))
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := database.CreateAccount("pguser", model.AccountTypeSystem, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.WritePGCredentials(a.ID, "host", 5432, "db", "user", []byte("enc"), []byte("nonce")); err != nil {
|
||||||
|
t.Fatalf("write pg credentials: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err = database.ListAllPGCredentials()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAllPGCredentials: %v", err)
|
||||||
|
}
|
||||||
|
if len(creds) != 1 {
|
||||||
|
t.Fatalf("expected 1 credential, got %d", len(creds))
|
||||||
|
}
|
||||||
|
if creds[0].AccountID != a.ID {
|
||||||
|
t.Errorf("expected account ID %d, got %d", a.ID, creds[0].AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRekey(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
// Set up: salt + signing key.
|
||||||
|
oldSalt := []byte("oldsaltoldsaltoldsaltoldsaltoldt") // 32 bytes
|
||||||
|
if err := database.WriteMasterKeySalt(oldSalt); err != nil {
|
||||||
|
t.Fatalf("write salt: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.WriteServerConfig([]byte("oldenc"), []byte("oldnonce")); err != nil {
|
||||||
|
t.Fatalf("write server config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up: account with TOTP.
|
||||||
|
a, err := database.CreateAccount("rekeyuser", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.SetTOTP(a.ID, []byte("totpenc"), []byte("totpnonce")); err != nil {
|
||||||
|
t.Fatalf("set TOTP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up: pg credential.
|
||||||
|
if err := database.WritePGCredentials(a.ID, "host", 5432, "db", "user", []byte("pgenc"), []byte("pgnonce")); err != nil {
|
||||||
|
t.Fatalf("write pg creds: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute Rekey.
|
||||||
|
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
|
||||||
|
totpRows := []TOTPRekeyRow{{AccountID: a.ID, Enc: []byte("newtotpenc"), Nonce: []byte("newtotpnonce")}}
|
||||||
|
pgCred, err := database.ReadPGCredentials(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read pg creds: %v", err)
|
||||||
|
}
|
||||||
|
pgRows := []PGRekeyRow{{CredentialID: pgCred.ID, Enc: []byte("newpgenc"), Nonce: []byte("newpgnonce")}}
|
||||||
|
|
||||||
|
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), totpRows, pgRows); err != nil {
|
||||||
|
t.Fatalf("Rekey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: salt replaced.
|
||||||
|
gotSalt, err := database.ReadMasterKeySalt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read salt after rekey: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotSalt) != string(newSalt) {
|
||||||
|
t.Errorf("salt mismatch: got %q, want %q", gotSalt, newSalt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: signing key replaced.
|
||||||
|
gotEnc, gotNonce, err := database.ReadServerConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read server config after rekey: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotEnc) != "newenc" || string(gotNonce) != "newnonce" {
|
||||||
|
t.Errorf("signing key enc/nonce mismatch after rekey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: TOTP replaced.
|
||||||
|
updatedAcct, err := database.GetAccountByID(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get account after rekey: %v", err)
|
||||||
|
}
|
||||||
|
if string(updatedAcct.TOTPSecretEnc) != "newtotpenc" || string(updatedAcct.TOTPSecretNonce) != "newtotpnonce" {
|
||||||
|
t.Errorf("TOTP enc/nonce mismatch after rekey: enc=%q nonce=%q",
|
||||||
|
updatedAcct.TOTPSecretEnc, updatedAcct.TOTPSecretNonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: pg credential replaced.
|
||||||
|
updatedCred, err := database.ReadPGCredentials(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read pg creds after rekey: %v", err)
|
||||||
|
}
|
||||||
|
if string(updatedCred.PGPasswordEnc) != "newpgenc" || string(updatedCred.PGPasswordNonce) != "newpgnonce" {
|
||||||
|
t.Errorf("pg enc/nonce mismatch after rekey: enc=%q nonce=%q",
|
||||||
|
updatedCred.PGPasswordEnc, updatedCred.PGPasswordNonce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRekeyEmptyDatabase(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
// Minimal setup: salt and signing key only; no TOTP, no pg creds.
|
||||||
|
salt := []byte("saltsaltsaltsaltsaltsaltsaltsalt") // 32 bytes
|
||||||
|
if err := database.WriteMasterKeySalt(salt); err != nil {
|
||||||
|
t.Fatalf("write salt: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.WriteServerConfig([]byte("enc"), []byte("nonce")); err != nil {
|
||||||
|
t.Fatalf("write server config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
|
||||||
|
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), nil, nil); err != nil {
|
||||||
|
t.Fatalf("Rekey (empty): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotSalt, err := database.ReadMasterKeySalt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read salt: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotSalt) != string(newSalt) {
|
||||||
|
t.Errorf("salt mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRekeyOldSaltUnchangedOnQueryError verifies the salt and encrypted data
|
||||||
|
// is only present under the new values after a successful Rekey — the old
|
||||||
|
// values must be gone. Uses the same approach as TestRekey but reads the
|
||||||
|
// stored salt before and confirms it changes.
|
||||||
|
func TestRekeyReplacesSalt(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
oldSalt := []byte("oldsaltoldsaltoldsaltoldsaltoldt") // 32 bytes
|
||||||
|
if err := database.WriteMasterKeySalt(oldSalt); err != nil {
|
||||||
|
t.Fatalf("write salt: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.WriteServerConfig([]byte("enc"), []byte("nonce")); err != nil {
|
||||||
|
t.Fatalf("write server config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
|
||||||
|
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), nil, nil); err != nil {
|
||||||
|
t.Fatalf("Rekey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotSalt, err := database.ReadMasterKeySalt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read salt: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotSalt) == string(oldSalt) {
|
||||||
|
t.Error("old salt still present after rekey")
|
||||||
|
}
|
||||||
|
if string(gotSalt) != string(newSalt) {
|
||||||
|
t.Errorf("expected new salt %q, got %q", newSalt, gotSalt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
sqlitedriver "github.com/golang-migrate/migrate/v4/database/sqlite"
|
sqlitedriver "github.com/golang-migrate/migrate/v4/database/sqlite"
|
||||||
@@ -21,7 +22,7 @@ var migrationsFS embed.FS
|
|||||||
// LatestSchemaVersion is the highest migration version defined in the
|
// LatestSchemaVersion is the highest migration version defined in the
|
||||||
// migrations/ directory. Update this constant whenever a new migration file
|
// migrations/ directory. Update this constant whenever a new migration file
|
||||||
// is added.
|
// is added.
|
||||||
const LatestSchemaVersion = 6
|
const LatestSchemaVersion = 7
|
||||||
|
|
||||||
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
|
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
|
||||||
// files. It opens a dedicated *sql.DB using the same DSN as the main
|
// files. It opens a dedicated *sql.DB using the same DSN as the main
|
||||||
@@ -93,19 +94,65 @@ func Migrate(database *DB) error {
|
|||||||
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
|
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
|
||||||
|
|
||||||
if legacyVersion > 0 {
|
if legacyVersion > 0 {
|
||||||
// Force the migrator to treat the database as already at
|
// Only fast-forward from the legacy version when golang-migrate has no
|
||||||
// legacyVersion so Up only applies newer migrations.
|
// version record of its own yet (ErrNilVersion). If schema_migrations
|
||||||
|
// already has an entry — including a dirty entry from a previously
|
||||||
|
// failed migration — leave it alone and let golang-migrate handle it.
|
||||||
|
// Overriding a non-nil version would discard progress (or a dirty
|
||||||
|
// state that needs idempotent re-application) and cause migrations to
|
||||||
|
// be retried unnecessarily.
|
||||||
|
_, _, versionErr := m.Version()
|
||||||
|
if errors.Is(versionErr, migrate.ErrNilVersion) {
|
||||||
if err := m.Force(legacyVersion); err != nil {
|
if err := m.Force(legacyVersion); err != nil {
|
||||||
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err)
|
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
// A "duplicate column name" error means the failing migration is an
|
||||||
|
// ADD COLUMN that was already applied outside the migration runner
|
||||||
|
// (common during development before a migration file existed).
|
||||||
|
// If this is the last migration and its version matches LatestSchemaVersion,
|
||||||
|
// force it clean so subsequent starts succeed.
|
||||||
|
//
|
||||||
|
// This is intentionally narrow: we only suppress the error when the
|
||||||
|
// dirty version equals the latest known version, preventing accidental
|
||||||
|
// masking of errors in intermediate migrations.
|
||||||
|
if strings.Contains(err.Error(), "duplicate column name") {
|
||||||
|
v, dirty, verErr := m.Version()
|
||||||
|
if verErr == nil && dirty && int(v) == LatestSchemaVersion { //nolint:gosec // G115: safe conversion
|
||||||
|
if forceErr := m.Force(LatestSchemaVersion); forceErr != nil {
|
||||||
|
return fmt.Errorf("db: force after duplicate column: %w", forceErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return fmt.Errorf("db: apply migrations: %w", err)
|
return fmt.Errorf("db: apply migrations: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceSchemaVersion marks the database as being at the given version without
|
||||||
|
// running any SQL. This is a break-glass operation: use it to clear a dirty
|
||||||
|
// migration state after verifying (or manually applying) the migration SQL.
|
||||||
|
//
|
||||||
|
// Passing a version that has never been recorded by golang-migrate is safe;
|
||||||
|
// it simply sets the version and clears the dirty flag. The next call to
|
||||||
|
// Migrate will apply any versions higher than the forced one.
|
||||||
|
func ForceSchemaVersion(database *DB, version int) error {
|
||||||
|
m, err := newMigrate(database)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
|
||||||
|
|
||||||
|
if err := m.Force(version); err != nil {
|
||||||
|
return fmt.Errorf("db: force schema version %d: %w", version, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SchemaVersion returns the current applied schema version of the database.
|
// SchemaVersion returns the current applied schema version of the database.
|
||||||
// Returns 0 if no migrations have been applied yet.
|
// Returns 0 if no migrations have been applied yet.
|
||||||
func SchemaVersion(database *DB) (int, error) {
|
func SchemaVersion(database *DB) (int, error) {
|
||||||
|
|||||||
9
internal/db/migrations/000007_totp_counter.up.sql
Normal file
9
internal/db/migrations/000007_totp_counter.up.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Add last_totp_counter to track the most recently accepted TOTP counter value
|
||||||
|
-- per account. This is used to prevent TOTP replay attacks within the ±1
|
||||||
|
-- time-step validity window. NULL means no TOTP code has ever been accepted
|
||||||
|
-- for this account (fresh enrollment or TOTP not yet used).
|
||||||
|
--
|
||||||
|
-- Security (CRIT-01): RFC 6238 §5.2 recommends recording the last OTP counter
|
||||||
|
-- used and rejecting codes that do not advance it, eliminating the ~90-second
|
||||||
|
-- replay window that would otherwise be exploitable.
|
||||||
|
ALTER TABLE accounts ADD COLUMN last_totp_counter INTEGER DEFAULT NULL;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- service_account_delegates tracks which human accounts are permitted to issue
|
||||||
|
-- tokens for a given system account without holding the global admin role.
|
||||||
|
-- Admins manage delegates; delegates can issue/rotate tokens for the specific
|
||||||
|
-- system account only and cannot modify any other account settings.
|
||||||
|
CREATE TABLE IF NOT EXISTS service_account_delegates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(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 (account_id, grantee_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sa_delegates_account ON service_account_delegates (account_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sa_delegates_grantee ON service_account_delegates (grantee_id);
|
||||||
@@ -227,3 +227,73 @@ func (a *accountServiceServer) SetRoles(ctx context.Context, req *mciasv1.SetRol
|
|||||||
fmt.Sprintf(`{"roles":%v}`, req.Roles))
|
fmt.Sprintf(`{"roles":%v}`, req.Roles))
|
||||||
return &mciasv1.SetRolesResponse{}, nil
|
return &mciasv1.SetRolesResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GrantRole adds a single role to an account. Admin only.
|
||||||
|
func (a *accountServiceServer) GrantRole(ctx context.Context, req *mciasv1.GrantRoleRequest) (*mciasv1.GrantRoleResponse, error) {
|
||||||
|
if err := a.s.requireAdmin(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Id == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "id is required")
|
||||||
|
}
|
||||||
|
if req.Role == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "role is required")
|
||||||
|
}
|
||||||
|
acct, err := a.s.db.GetAccountByUUID(req.Id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, "account not found")
|
||||||
|
}
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
actorClaims := claimsFromContext(ctx)
|
||||||
|
var grantedBy *int64
|
||||||
|
if actorClaims != nil {
|
||||||
|
if actor, err := a.s.db.GetAccountByUUID(actorClaims.Subject); err == nil {
|
||||||
|
grantedBy = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.s.db.GrantRole(acct.ID, req.Role, grantedBy); err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "invalid role")
|
||||||
|
}
|
||||||
|
a.s.db.WriteAuditEvent(model.EventRoleGranted, grantedBy, &acct.ID, peerIP(ctx), //nolint:errcheck
|
||||||
|
fmt.Sprintf(`{"role":"%s"}`, req.Role))
|
||||||
|
return &mciasv1.GrantRoleResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeRole removes a single role from an account. Admin only.
|
||||||
|
func (a *accountServiceServer) RevokeRole(ctx context.Context, req *mciasv1.RevokeRoleRequest) (*mciasv1.RevokeRoleResponse, error) {
|
||||||
|
if err := a.s.requireAdmin(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Id == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "id is required")
|
||||||
|
}
|
||||||
|
if req.Role == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "role is required")
|
||||||
|
}
|
||||||
|
acct, err := a.s.db.GetAccountByUUID(req.Id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, "account not found")
|
||||||
|
}
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
actorClaims := claimsFromContext(ctx)
|
||||||
|
var revokedBy *int64
|
||||||
|
if actorClaims != nil {
|
||||||
|
if actor, err := a.s.db.GetAccountByUUID(actorClaims.Subject); err == nil {
|
||||||
|
revokedBy = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.s.db.RevokeRole(acct.ID, req.Role); err != nil {
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
a.s.db.WriteAuditEvent(model.EventRoleRevoked, revokedBy, &acct.ID, peerIP(ctx), //nolint:errcheck
|
||||||
|
fmt.Sprintf(`{"role":"%s"}`, req.Role))
|
||||||
|
return &mciasv1.RevokeRoleResponse{}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ type adminServiceServer struct {
|
|||||||
s *Server
|
s *Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health returns {"status":"ok"} to signal the server is operational.
|
// Health returns {"status":"ok"} to signal the server is operational, or
|
||||||
|
// {"status":"sealed"} when the vault is sealed.
|
||||||
func (a *adminServiceServer) Health(_ context.Context, _ *mciasv1.HealthRequest) (*mciasv1.HealthResponse, error) {
|
func (a *adminServiceServer) Health(_ context.Context, _ *mciasv1.HealthRequest) (*mciasv1.HealthResponse, error) {
|
||||||
|
if a.s.vault.IsSealed() {
|
||||||
|
return &mciasv1.HealthResponse{Status: "sealed"}, nil
|
||||||
|
}
|
||||||
return &mciasv1.HealthResponse{Status: "ok"}, nil
|
return &mciasv1.HealthResponse{Status: "ok"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,11 +30,12 @@ func (a *adminServiceServer) Health(_ context.Context, _ *mciasv1.HealthRequest)
|
|||||||
// The "x" field is the raw 32-byte public key base64url-encoded without padding,
|
// The "x" field is the raw 32-byte public key base64url-encoded without padding,
|
||||||
// matching the REST /v1/keys/public response format.
|
// matching the REST /v1/keys/public response format.
|
||||||
func (a *adminServiceServer) GetPublicKey(_ context.Context, _ *mciasv1.GetPublicKeyRequest) (*mciasv1.GetPublicKeyResponse, error) {
|
func (a *adminServiceServer) GetPublicKey(_ context.Context, _ *mciasv1.GetPublicKeyRequest) (*mciasv1.GetPublicKeyResponse, error) {
|
||||||
if len(a.s.pubKey) == 0 {
|
pubKey, err := a.s.vault.PubKey()
|
||||||
return nil, status.Error(codes.Internal, "public key not available")
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
}
|
}
|
||||||
// Encode as base64url without padding — identical to the REST handler.
|
// Encode as base64url without padding — identical to the REST handler.
|
||||||
x := base64.RawURLEncoding.EncodeToString(a.s.pubKey)
|
x := base64.RawURLEncoding.EncodeToString(pubKey)
|
||||||
return &mciasv1.GetPublicKeyResponse{
|
return &mciasv1.GetPublicKeyResponse{
|
||||||
Kty: "OKP",
|
Kty: "OKP",
|
||||||
Crv: "Ed25519",
|
Crv: "Ed25519",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/peer"
|
"google.golang.org/grpc/peer"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
@@ -42,7 +44,7 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
|||||||
// Security: run dummy Argon2 to equalise timing for unknown users.
|
// Security: run dummy Argon2 to equalise timing for unknown users.
|
||||||
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
||||||
a.s.db.WriteAuditEvent(model.EventLoginFail, nil, nil, ip, //nolint:errcheck // audit failure is non-fatal
|
a.s.db.WriteAuditEvent(model.EventLoginFail, nil, nil, ip, //nolint:errcheck // audit failure is non-fatal
|
||||||
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, req.Username))
|
audit.JSON("username", req.Username, "reason", "unknown_user"))
|
||||||
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +62,9 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
|||||||
if locked {
|
if locked {
|
||||||
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
||||||
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"account_locked"}`) //nolint:errcheck
|
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"account_locked"}`) //nolint:errcheck
|
||||||
return nil, status.Error(codes.ResourceExhausted, "account temporarily locked")
|
// Security: return the same Unauthenticated / "invalid credentials" as wrong-password
|
||||||
|
// to prevent user-enumeration via lockout differentiation (SEC-02).
|
||||||
|
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
||||||
@@ -72,21 +76,38 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
|||||||
|
|
||||||
if acct.TOTPRequired {
|
if acct.TOTPRequired {
|
||||||
if req.TotpCode == "" {
|
if req.TotpCode == "" {
|
||||||
|
// Security (DEF-08): password was already verified, so a missing
|
||||||
|
// TOTP code means the gRPC client needs to re-prompt the user —
|
||||||
|
// it is not a credential failure. Do NOT increment the lockout
|
||||||
|
// counter here; doing so would lock out well-behaved clients that
|
||||||
|
// call Login in two steps (password first, TOTP second) and would
|
||||||
|
// also let an attacker trigger account lockout by omitting the
|
||||||
|
// code after a successful password guess.
|
||||||
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck
|
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck
|
||||||
_ = a.s.db.RecordLoginFailure(acct.ID)
|
|
||||||
return nil, status.Error(codes.Unauthenticated, "TOTP code required")
|
return nil, status.Error(codes.Unauthenticated, "TOTP code required")
|
||||||
}
|
}
|
||||||
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
masterKey, mkErr := a.s.vault.MasterKey()
|
||||||
|
if mkErr != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
|
}
|
||||||
|
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
a.s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
valid, err := auth.ValidateTOTP(secret, req.TotpCode)
|
valid, counter, err := auth.ValidateTOTP(secret, req.TotpCode)
|
||||||
if err != nil || !valid {
|
if err != nil || !valid {
|
||||||
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"wrong_totp"}`) //nolint:errcheck
|
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"wrong_totp"}`) //nolint:errcheck
|
||||||
_ = a.s.db.RecordLoginFailure(acct.ID)
|
_ = a.s.db.RecordLoginFailure(acct.ID)
|
||||||
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
||||||
}
|
}
|
||||||
|
// Security (CRIT-01): reject replay of a code already used within
|
||||||
|
// its ±30-second validity window.
|
||||||
|
if err := a.s.db.CheckAndUpdateTOTPCounter(acct.ID, counter); err != nil {
|
||||||
|
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"totp_replay"}`) //nolint:errcheck
|
||||||
|
_ = a.s.db.RecordLoginFailure(acct.ID)
|
||||||
|
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login succeeded: clear any outstanding failure counter.
|
// Login succeeded: clear any outstanding failure counter.
|
||||||
@@ -104,7 +125,11 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenStr, claims, err := token.IssueToken(a.s.privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
privKey, pkErr := a.s.vault.PrivKey()
|
||||||
|
if pkErr != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
|
}
|
||||||
|
tokenStr, claims, err := token.IssueToken(privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.s.logger.Error("issue token", "error", err)
|
a.s.logger.Error("issue token", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
@@ -116,7 +141,7 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
|||||||
|
|
||||||
a.s.db.WriteAuditEvent(model.EventLoginOK, &acct.ID, nil, ip, "") //nolint:errcheck
|
a.s.db.WriteAuditEvent(model.EventLoginOK, &acct.ID, nil, ip, "") //nolint:errcheck
|
||||||
a.s.db.WriteAuditEvent(model.EventTokenIssued, &acct.ID, nil, ip, //nolint:errcheck
|
a.s.db.WriteAuditEvent(model.EventTokenIssued, &acct.ID, nil, ip, //nolint:errcheck
|
||||||
fmt.Sprintf(`{"jti":%q}`, claims.JTI))
|
audit.JSON("jti", claims.JTI))
|
||||||
|
|
||||||
return &mciasv1.LoginResponse{
|
return &mciasv1.LoginResponse{
|
||||||
Token: tokenStr,
|
Token: tokenStr,
|
||||||
@@ -132,7 +157,7 @@ func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest
|
|||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
a.s.db.WriteAuditEvent(model.EventTokenRevoked, nil, nil, peerIP(ctx), //nolint:errcheck
|
a.s.db.WriteAuditEvent(model.EventTokenRevoked, nil, nil, peerIP(ctx), //nolint:errcheck
|
||||||
fmt.Sprintf(`{"jti":%q,"reason":"logout"}`, claims.JTI))
|
audit.JSON("jti", claims.JTI, "reason", "logout"))
|
||||||
return &mciasv1.LogoutResponse{}, nil
|
return &mciasv1.LogoutResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +165,14 @@ func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest
|
|||||||
func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewTokenRequest) (*mciasv1.RenewTokenResponse, error) {
|
func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewTokenRequest) (*mciasv1.RenewTokenResponse, error) {
|
||||||
claims := claimsFromContext(ctx)
|
claims := claimsFromContext(ctx)
|
||||||
|
|
||||||
|
// Security: only allow renewal when the token has consumed at least 50% of
|
||||||
|
// its lifetime. This prevents indefinite renewal of stolen tokens (SEC-03).
|
||||||
|
totalLifetime := claims.ExpiresAt.Sub(claims.IssuedAt)
|
||||||
|
elapsed := time.Since(claims.IssuedAt)
|
||||||
|
if elapsed < totalLifetime/2 {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "token is not yet eligible for renewal")
|
||||||
|
}
|
||||||
|
|
||||||
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
|
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Unauthenticated, "account not found")
|
return nil, status.Error(codes.Unauthenticated, "account not found")
|
||||||
@@ -161,7 +194,11 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newTokenStr, newClaims, err := token.IssueToken(a.s.privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
privKey, pkErr := a.s.vault.PrivKey()
|
||||||
|
if pkErr != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
|
}
|
||||||
|
newTokenStr, newClaims, err := token.IssueToken(privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
@@ -173,7 +210,7 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.s.db.WriteAuditEvent(model.EventTokenRenewed, &acct.ID, nil, peerIP(ctx), //nolint:errcheck
|
a.s.db.WriteAuditEvent(model.EventTokenRenewed, &acct.ID, nil, peerIP(ctx), //nolint:errcheck
|
||||||
fmt.Sprintf(`{"old_jti":%q,"new_jti":%q}`, claims.JTI, newClaims.JTI))
|
audit.JSON("old_jti", claims.JTI, "new_jti", newClaims.JTI))
|
||||||
|
|
||||||
return &mciasv1.RenewTokenResponse{
|
return &mciasv1.RenewTokenResponse{
|
||||||
Token: newTokenStr,
|
Token: newTokenStr,
|
||||||
@@ -182,24 +219,59 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EnrollTOTP begins TOTP enrollment for the calling account.
|
// EnrollTOTP begins TOTP enrollment for the calling account.
|
||||||
func (a *authServiceServer) EnrollTOTP(ctx context.Context, _ *mciasv1.EnrollTOTPRequest) (*mciasv1.EnrollTOTPResponse, error) {
|
//
|
||||||
|
// Security (SEC-01): the current password is required to prevent a stolen
|
||||||
|
// session token from being used to enroll attacker-controlled TOTP on the
|
||||||
|
// victim's account. Lockout is checked and failures are recorded.
|
||||||
|
func (a *authServiceServer) EnrollTOTP(ctx context.Context, req *mciasv1.EnrollTOTPRequest) (*mciasv1.EnrollTOTPResponse, error) {
|
||||||
claims := claimsFromContext(ctx)
|
claims := claimsFromContext(ctx)
|
||||||
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
|
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Unauthenticated, "account not found")
|
return nil, status.Error(codes.Unauthenticated, "account not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Password == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: check lockout before verifying (same as login flow).
|
||||||
|
locked, lockErr := a.s.db.IsLockedOut(acct.ID)
|
||||||
|
if lockErr != nil {
|
||||||
|
a.s.logger.Error("lockout check (gRPC TOTP enroll)", "error", lockErr)
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
a.s.db.WriteAuditEvent(model.EventTOTPEnrolled, &acct.ID, &acct.ID, peerIP(ctx), `{"result":"locked"}`) //nolint:errcheck
|
||||||
|
return nil, status.Error(codes.ResourceExhausted, "account temporarily locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: verify the current password with Argon2id (constant-time).
|
||||||
|
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
||||||
|
if verifyErr != nil || !ok {
|
||||||
|
_ = a.s.db.RecordLoginFailure(acct.ID)
|
||||||
|
a.s.db.WriteAuditEvent(model.EventTOTPEnrolled, &acct.ID, &acct.ID, peerIP(ctx), `{"result":"wrong_password"}`) //nolint:errcheck
|
||||||
|
return nil, status.Error(codes.Unauthenticated, "password is incorrect")
|
||||||
|
}
|
||||||
|
|
||||||
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
|
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
|
|
||||||
secretEnc, secretNonce, err := crypto.SealAESGCM(a.s.masterKey, rawSecret)
|
masterKey, mkErr := a.s.vault.MasterKey()
|
||||||
|
if mkErr != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
|
}
|
||||||
|
secretEnc, secretNonce, err := crypto.SealAESGCM(masterKey, rawSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.s.db.SetTOTP(acct.ID, secretEnc, secretNonce); err != nil {
|
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required is
|
||||||
|
// not set to 1 until the user confirms the code via ConfirmTOTP. Calling
|
||||||
|
// SetTOTP here would immediately lock the account behind TOTP before the
|
||||||
|
// user has had a chance to configure their authenticator app — matching the
|
||||||
|
// behaviour of the REST EnrollTOTP handler at internal/server/server.go.
|
||||||
|
if err := a.s.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,15 +299,24 @@ func (a *authServiceServer) ConfirmTOTP(ctx context.Context, req *mciasv1.Confir
|
|||||||
return nil, status.Error(codes.FailedPrecondition, "TOTP enrollment not started")
|
return nil, status.Error(codes.FailedPrecondition, "TOTP enrollment not started")
|
||||||
}
|
}
|
||||||
|
|
||||||
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
masterKey, mkErr := a.s.vault.MasterKey()
|
||||||
|
if mkErr != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
|
}
|
||||||
|
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
|
|
||||||
valid, err := auth.ValidateTOTP(secret, req.Code)
|
valid, counter, err := auth.ValidateTOTP(secret, req.Code)
|
||||||
if err != nil || !valid {
|
if err != nil || !valid {
|
||||||
return nil, status.Error(codes.Unauthenticated, "invalid TOTP code")
|
return nil, status.Error(codes.Unauthenticated, "invalid TOTP code")
|
||||||
}
|
}
|
||||||
|
// Security (CRIT-01): record the counter even during enrollment confirmation
|
||||||
|
// so the same code cannot be replayed immediately after confirming.
|
||||||
|
if err := a.s.db.CheckAndUpdateTOTPCounter(acct.ID, counter); err != nil {
|
||||||
|
return nil, status.Error(codes.Unauthenticated, "invalid TOTP code")
|
||||||
|
}
|
||||||
|
|
||||||
// SetTOTP with existing enc/nonce sets totp_required=1, confirming enrollment.
|
// SetTOTP with existing enc/nonce sets totp_required=1, confirming enrollment.
|
||||||
if err := a.s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
|
if err := a.s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ func (c *credentialServiceServer) GetPGCreds(ctx context.Context, req *mciasv1.G
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the password for admin retrieval.
|
// Decrypt the password for admin retrieval.
|
||||||
password, err := crypto.OpenAESGCM(c.s.masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc)
|
masterKey, mkErr := c.s.vault.MasterKey()
|
||||||
|
if mkErr != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
|
}
|
||||||
|
password, err := crypto.OpenAESGCM(masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
@@ -94,7 +98,11 @@ func (c *credentialServiceServer) SetPGCreds(ctx context.Context, req *mciasv1.S
|
|||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
|
|
||||||
enc, nonce, err := crypto.SealAESGCM(c.s.masterKey, []byte(cr.Password))
|
masterKey, mkErr := c.s.vault.MasterKey()
|
||||||
|
if mkErr != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
|
}
|
||||||
|
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(cr.Password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ package grpcserver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -35,6 +34,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// contextKey is the unexported context key type for this package.
|
// contextKey is the unexported context key type for this package.
|
||||||
@@ -57,21 +57,17 @@ type Server struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
rateLimiter *grpcRateLimiter
|
rateLimiter *grpcRateLimiter
|
||||||
privKey ed25519.PrivateKey
|
vault *vault.Vault
|
||||||
pubKey ed25519.PublicKey
|
|
||||||
masterKey []byte
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Server with the given dependencies (same as the REST Server).
|
// New creates a Server with the given dependencies (same as the REST Server).
|
||||||
// A fresh per-IP rate limiter (10 req/s, burst 10) is allocated per Server
|
// A fresh per-IP rate limiter (10 req/s, burst 10) is allocated per Server
|
||||||
// instance so that tests do not share state across test cases.
|
// instance so that tests do not share state across test cases.
|
||||||
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) *Server {
|
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
db: database,
|
db: database,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
privKey: priv,
|
vault: v,
|
||||||
pubKey: pub,
|
|
||||||
masterKey: masterKey,
|
|
||||||
logger: logger,
|
logger: logger,
|
||||||
rateLimiter: newGRPCRateLimiter(10, 10),
|
rateLimiter: newGRPCRateLimiter(10, 10),
|
||||||
}
|
}
|
||||||
@@ -106,6 +102,7 @@ func (s *Server) buildServer(extra ...grpc.ServerOption) *grpc.Server {
|
|||||||
[]grpc.ServerOption{
|
[]grpc.ServerOption{
|
||||||
grpc.ChainUnaryInterceptor(
|
grpc.ChainUnaryInterceptor(
|
||||||
s.loggingInterceptor,
|
s.loggingInterceptor,
|
||||||
|
s.sealedInterceptor,
|
||||||
s.authInterceptor,
|
s.authInterceptor,
|
||||||
s.rateLimitInterceptor,
|
s.rateLimitInterceptor,
|
||||||
),
|
),
|
||||||
@@ -120,6 +117,7 @@ func (s *Server) buildServer(extra ...grpc.ServerOption) *grpc.Server {
|
|||||||
mciasv1.RegisterTokenServiceServer(srv, &tokenServiceServer{s: s})
|
mciasv1.RegisterTokenServiceServer(srv, &tokenServiceServer{s: s})
|
||||||
mciasv1.RegisterAccountServiceServer(srv, &accountServiceServer{s: s})
|
mciasv1.RegisterAccountServiceServer(srv, &accountServiceServer{s: s})
|
||||||
mciasv1.RegisterCredentialServiceServer(srv, &credentialServiceServer{s: s})
|
mciasv1.RegisterCredentialServiceServer(srv, &credentialServiceServer{s: s})
|
||||||
|
mciasv1.RegisterPolicyServiceServer(srv, &policyServiceServer{s: s})
|
||||||
|
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
@@ -161,14 +159,36 @@ func (s *Server) loggingInterceptor(
|
|||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sealedInterceptor rejects all RPCs (except Health) when the vault is sealed.
|
||||||
|
//
|
||||||
|
// Security: This is the first interceptor in the chain (after logging). It
|
||||||
|
// prevents any authenticated or data-serving handler from running while the
|
||||||
|
// vault is sealed and key material is unavailable.
|
||||||
|
func (s *Server) sealedInterceptor(
|
||||||
|
ctx context.Context,
|
||||||
|
req interface{},
|
||||||
|
info *grpc.UnaryServerInfo,
|
||||||
|
handler grpc.UnaryHandler,
|
||||||
|
) (interface{}, error) {
|
||||||
|
if !s.vault.IsSealed() {
|
||||||
|
return handler(ctx, req)
|
||||||
|
}
|
||||||
|
// Health is always allowed — returns sealed status.
|
||||||
|
if info.FullMethod == "/mcias.v1.AdminService/Health" {
|
||||||
|
return handler(ctx, req)
|
||||||
|
}
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
|
}
|
||||||
|
|
||||||
// authInterceptor validates the Bearer JWT from gRPC metadata and injects
|
// authInterceptor validates the Bearer JWT from gRPC metadata and injects
|
||||||
// claims into the context. Public methods bypass this check.
|
// claims into the context. Public methods bypass this check.
|
||||||
//
|
//
|
||||||
// Security: Same validation path as the REST RequireAuth middleware:
|
// Security: Same validation path as the REST RequireAuth middleware:
|
||||||
// 1. Extract "authorization" metadata value (case-insensitive key lookup).
|
// 1. Extract "authorization" metadata value (case-insensitive key lookup).
|
||||||
// 2. Validate JWT (alg-first, then signature, then expiry/issuer).
|
// 2. Read public key from vault (fail closed if sealed).
|
||||||
// 3. Check JTI against revocation table.
|
// 3. Validate JWT (alg-first, then signature, then expiry/issuer).
|
||||||
// 4. Inject claims into context.
|
// 4. Check JTI against revocation table.
|
||||||
|
// 5. Inject claims into context.
|
||||||
func (s *Server) authInterceptor(
|
func (s *Server) authInterceptor(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req interface{},
|
req interface{},
|
||||||
@@ -185,7 +205,13 @@ func (s *Server) authInterceptor(
|
|||||||
return nil, status.Error(codes.Unauthenticated, "missing or invalid authorization")
|
return nil, status.Error(codes.Unauthenticated, "missing or invalid authorization")
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := token.ValidateToken(s.pubKey, tokenStr, s.cfg.Tokens.Issuer)
|
// Security: read the public key from vault at request time.
|
||||||
|
pubKey, err := s.vault.PubKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := token.ValidateToken(pubKey, tokenStr, s.cfg.Tokens.Issuer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Unauthenticated, "invalid or expired token")
|
return nil, status.Error(codes.Unauthenticated, "invalid or expired token")
|
||||||
}
|
}
|
||||||
@@ -288,28 +314,75 @@ func (l *grpcRateLimiter) cleanup() {
|
|||||||
|
|
||||||
// rateLimitInterceptor applies per-IP rate limiting using the same token-bucket
|
// rateLimitInterceptor applies per-IP rate limiting using the same token-bucket
|
||||||
// parameters as the REST rate limiter (10 req/s, burst 10).
|
// parameters as the REST rate limiter (10 req/s, burst 10).
|
||||||
|
//
|
||||||
|
// Security (SEC-06): uses grpcClientIP to extract the real client IP when
|
||||||
|
// behind a trusted reverse proxy, matching the REST middleware behaviour.
|
||||||
func (s *Server) rateLimitInterceptor(
|
func (s *Server) rateLimitInterceptor(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req interface{},
|
req interface{},
|
||||||
info *grpc.UnaryServerInfo,
|
info *grpc.UnaryServerInfo,
|
||||||
handler grpc.UnaryHandler,
|
handler grpc.UnaryHandler,
|
||||||
) (interface{}, error) {
|
) (interface{}, error) {
|
||||||
ip := ""
|
var trustedProxy net.IP
|
||||||
if p, ok := peer.FromContext(ctx); ok {
|
if s.cfg.Server.TrustedProxy != "" {
|
||||||
host, _, err := net.SplitHostPort(p.Addr.String())
|
trustedProxy = net.ParseIP(s.cfg.Server.TrustedProxy)
|
||||||
if err == nil {
|
|
||||||
ip = host
|
|
||||||
} else {
|
|
||||||
ip = p.Addr.String()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ip := grpcClientIP(ctx, trustedProxy)
|
||||||
|
|
||||||
if ip != "" && !s.rateLimiter.allow(ip) {
|
if ip != "" && !s.rateLimiter.allow(ip) {
|
||||||
return nil, status.Error(codes.ResourceExhausted, "rate limit exceeded")
|
return nil, status.Error(codes.ResourceExhausted, "rate limit exceeded")
|
||||||
}
|
}
|
||||||
return handler(ctx, req)
|
return handler(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// grpcClientIP extracts the real client IP from gRPC context, optionally
|
||||||
|
// honouring proxy headers when the peer matches the trusted proxy.
|
||||||
|
//
|
||||||
|
// Security (SEC-06): mirrors middleware.ClientIP for the REST server.
|
||||||
|
// X-Forwarded-For and X-Real-IP metadata are only trusted when the immediate
|
||||||
|
// peer address matches trustedProxy exactly, preventing IP-spoofing attacks.
|
||||||
|
// Only the first (leftmost) value in x-forwarded-for is used (original client).
|
||||||
|
// gRPC lowercases all metadata keys, so we look up "x-forwarded-for" and
|
||||||
|
// "x-real-ip".
|
||||||
|
func grpcClientIP(ctx context.Context, trustedProxy net.IP) string {
|
||||||
|
peerIP := ""
|
||||||
|
if p, ok := peer.FromContext(ctx); ok {
|
||||||
|
host, _, err := net.SplitHostPort(p.Addr.String())
|
||||||
|
if err == nil {
|
||||||
|
peerIP = host
|
||||||
|
} else {
|
||||||
|
peerIP = p.Addr.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if trustedProxy != nil && peerIP != "" {
|
||||||
|
remoteIP := net.ParseIP(peerIP)
|
||||||
|
if remoteIP != nil && remoteIP.Equal(trustedProxy) {
|
||||||
|
// Peer is the trusted proxy — extract real client IP from metadata.
|
||||||
|
// Prefer x-real-ip (single value) over x-forwarded-for (may be a
|
||||||
|
// comma-separated list when multiple proxies are chained).
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if ok {
|
||||||
|
if vals := md.Get("x-real-ip"); len(vals) > 0 {
|
||||||
|
if ip := net.ParseIP(strings.TrimSpace(vals[0])); ip != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vals := md.Get("x-forwarded-for"); len(vals) > 0 {
|
||||||
|
// Take the first (leftmost) address — the original client.
|
||||||
|
first, _, _ := strings.Cut(vals[0], ",")
|
||||||
|
if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return peerIP
|
||||||
|
}
|
||||||
|
|
||||||
// extractBearerFromMD extracts the Bearer token from gRPC metadata.
|
// extractBearerFromMD extracts the Bearer token from gRPC metadata.
|
||||||
// The key lookup is case-insensitive per gRPC metadata convention (all keys
|
// The key lookup is case-insensitive per gRPC metadata convention (all keys
|
||||||
// are lowercased by the framework; we match on "authorization").
|
// are lowercased by the framework; we match on "authorization").
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/peer"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/grpc/test/bufconn"
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ import (
|
|||||||
"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"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -71,7 +74,8 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
cfg := config.NewTestConfig(testIssuer)
|
cfg := config.NewTestConfig(testIssuer)
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
|
||||||
srv := New(database, cfg, priv, pub, masterKey, logger)
|
v := vault.NewUnsealed(masterKey, priv, pub)
|
||||||
|
srv := New(database, cfg, v, logger)
|
||||||
grpcSrv := srv.GRPCServer()
|
grpcSrv := srv.GRPCServer()
|
||||||
|
|
||||||
lis := bufconn.Listen(bufConnSize)
|
lis := bufconn.Listen(bufConnSize)
|
||||||
@@ -143,7 +147,12 @@ func (e *testEnv) issueAdminToken(t *testing.T, username string) (string, *model
|
|||||||
// issueUserToken issues a regular (non-admin) token for an account.
|
// issueUserToken issues a regular (non-admin) token for an account.
|
||||||
func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string {
|
func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, time.Hour)
|
return e.issueShortToken(t, acct, time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testEnv) issueShortToken(t *testing.T, acct *model.Account, expiry time.Duration) string {
|
||||||
|
t.Helper()
|
||||||
|
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("issue token: %v", err)
|
t.Fatalf("issue token: %v", err)
|
||||||
}
|
}
|
||||||
@@ -357,11 +366,17 @@ func TestLogout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRenewToken verifies that a valid token can be renewed.
|
// TestRenewToken verifies that a valid token can be renewed after 50% of its
|
||||||
|
// lifetime has elapsed (SEC-03).
|
||||||
func TestRenewToken(t *testing.T) {
|
func TestRenewToken(t *testing.T) {
|
||||||
e := newTestEnv(t)
|
e := newTestEnv(t)
|
||||||
acct := e.createHumanAccount(t, "renewuser")
|
acct := e.createHumanAccount(t, "renewuser")
|
||||||
tok := e.issueUserToken(t, acct)
|
|
||||||
|
// Issue a short-lived token (4s) so we can wait past the 50% threshold.
|
||||||
|
tok := e.issueShortToken(t, acct, 4*time.Second)
|
||||||
|
|
||||||
|
// Wait for >50% of lifetime to elapse.
|
||||||
|
time.Sleep(2100 * time.Millisecond)
|
||||||
|
|
||||||
cl := mciasv1.NewAuthServiceClient(e.conn)
|
cl := mciasv1.NewAuthServiceClient(e.conn)
|
||||||
ctx := authCtx(tok)
|
ctx := authCtx(tok)
|
||||||
@@ -377,6 +392,28 @@ func TestRenewToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRenewTokenTooEarly verifies that a token cannot be renewed before 50%
|
||||||
|
// of its lifetime has elapsed (SEC-03).
|
||||||
|
func TestRenewTokenTooEarly(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
acct := e.createHumanAccount(t, "renewearlyuser")
|
||||||
|
tok := e.issueUserToken(t, acct)
|
||||||
|
|
||||||
|
cl := mciasv1.NewAuthServiceClient(e.conn)
|
||||||
|
ctx := authCtx(tok)
|
||||||
|
_, err := cl.RenewToken(ctx, &mciasv1.RenewTokenRequest{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("RenewToken: expected error for early renewal, got nil")
|
||||||
|
}
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
if !ok || st.Code() != codes.InvalidArgument {
|
||||||
|
t.Fatalf("RenewToken: expected InvalidArgument, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(st.Message(), "not yet eligible for renewal") {
|
||||||
|
t.Errorf("RenewToken: expected eligibility message, got: %s", st.Message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- TokenService tests ----
|
// ---- TokenService tests ----
|
||||||
|
|
||||||
// TestValidateToken verifies the public ValidateToken RPC returns valid=true for
|
// TestValidateToken verifies the public ValidateToken RPC returns valid=true for
|
||||||
@@ -542,7 +579,7 @@ func TestSetAndGetRoles(t *testing.T) {
|
|||||||
|
|
||||||
_, err = cl.SetRoles(authCtx(adminTok), &mciasv1.SetRolesRequest{
|
_, err = cl.SetRoles(authCtx(adminTok), &mciasv1.SetRolesRequest{
|
||||||
Id: id,
|
Id: id,
|
||||||
Roles: []string{"editor", "viewer"},
|
Roles: []string{"admin", "user"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SetRoles: %v", err)
|
t.Fatalf("SetRoles: %v", err)
|
||||||
@@ -650,3 +687,196 @@ func TestCredentialFieldsAbsentFromAccountResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- grpcClientIP tests (SEC-06) ----
|
||||||
|
|
||||||
|
// fakeAddr implements net.Addr for testing peer contexts.
|
||||||
|
type fakeAddr struct {
|
||||||
|
addr string
|
||||||
|
network string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a fakeAddr) String() string { return a.addr }
|
||||||
|
func (a fakeAddr) Network() string { return a.network }
|
||||||
|
|
||||||
|
// TestGRPCClientIP_NoProxy verifies that when no trusted proxy is configured
|
||||||
|
// the function returns the peer IP directly.
|
||||||
|
func TestGRPCClientIP_NoProxy(t *testing.T) {
|
||||||
|
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||||
|
Addr: fakeAddr{addr: "10.0.0.5:54321", network: "tcp"},
|
||||||
|
})
|
||||||
|
|
||||||
|
got := grpcClientIP(ctx, nil)
|
||||||
|
if got != "10.0.0.5" {
|
||||||
|
t.Errorf("grpcClientIP(no proxy) = %q, want %q", got, "10.0.0.5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGRPCClientIP_TrustedProxy_XForwardedFor verifies that when the peer
|
||||||
|
// matches the trusted proxy, the real client IP is extracted from
|
||||||
|
// x-forwarded-for metadata.
|
||||||
|
func TestGRPCClientIP_TrustedProxy_XForwardedFor(t *testing.T) {
|
||||||
|
proxyIP := net.ParseIP("192.168.1.1")
|
||||||
|
|
||||||
|
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||||
|
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
|
||||||
|
})
|
||||||
|
md := metadata.Pairs("x-forwarded-for", "203.0.113.50, 10.0.0.1")
|
||||||
|
ctx = metadata.NewIncomingContext(ctx, md)
|
||||||
|
|
||||||
|
got := grpcClientIP(ctx, proxyIP)
|
||||||
|
if got != "203.0.113.50" {
|
||||||
|
t.Errorf("grpcClientIP(xff) = %q, want %q", got, "203.0.113.50")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGRPCClientIP_TrustedProxy_XRealIP verifies that x-real-ip is preferred
|
||||||
|
// over x-forwarded-for when both are present.
|
||||||
|
func TestGRPCClientIP_TrustedProxy_XRealIP(t *testing.T) {
|
||||||
|
proxyIP := net.ParseIP("192.168.1.1")
|
||||||
|
|
||||||
|
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||||
|
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
|
||||||
|
})
|
||||||
|
md := metadata.Pairs(
|
||||||
|
"x-real-ip", "198.51.100.10",
|
||||||
|
"x-forwarded-for", "203.0.113.50",
|
||||||
|
)
|
||||||
|
ctx = metadata.NewIncomingContext(ctx, md)
|
||||||
|
|
||||||
|
got := grpcClientIP(ctx, proxyIP)
|
||||||
|
if got != "198.51.100.10" {
|
||||||
|
t.Errorf("grpcClientIP(x-real-ip preferred) = %q, want %q", got, "198.51.100.10")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGRPCClientIP_UntrustedPeer_IgnoresHeaders verifies that forwarded
|
||||||
|
// headers are ignored when the peer does NOT match the trusted proxy.
|
||||||
|
// Security: This prevents IP-spoofing by untrusted clients.
|
||||||
|
func TestGRPCClientIP_UntrustedPeer_IgnoresHeaders(t *testing.T) {
|
||||||
|
proxyIP := net.ParseIP("192.168.1.1")
|
||||||
|
|
||||||
|
// Peer is NOT the trusted proxy.
|
||||||
|
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||||
|
Addr: fakeAddr{addr: "10.0.0.99:54321", network: "tcp"},
|
||||||
|
})
|
||||||
|
md := metadata.Pairs(
|
||||||
|
"x-forwarded-for", "203.0.113.50",
|
||||||
|
"x-real-ip", "198.51.100.10",
|
||||||
|
)
|
||||||
|
ctx = metadata.NewIncomingContext(ctx, md)
|
||||||
|
|
||||||
|
got := grpcClientIP(ctx, proxyIP)
|
||||||
|
if got != "10.0.0.99" {
|
||||||
|
t.Errorf("grpcClientIP(untrusted peer) = %q, want %q", got, "10.0.0.99")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGRPCClientIP_TrustedProxy_NoHeaders verifies that when the peer matches
|
||||||
|
// the proxy but no forwarded headers are set, the peer IP is returned as fallback.
|
||||||
|
func TestGRPCClientIP_TrustedProxy_NoHeaders(t *testing.T) {
|
||||||
|
proxyIP := net.ParseIP("192.168.1.1")
|
||||||
|
|
||||||
|
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||||
|
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
|
||||||
|
})
|
||||||
|
|
||||||
|
got := grpcClientIP(ctx, proxyIP)
|
||||||
|
if got != "192.168.1.1" {
|
||||||
|
t.Errorf("grpcClientIP(proxy, no headers) = %q, want %q", got, "192.168.1.1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGRPCClientIP_TrustedProxy_InvalidHeader verifies that invalid IPs in
|
||||||
|
// headers are ignored and the peer IP is returned.
|
||||||
|
func TestGRPCClientIP_TrustedProxy_InvalidHeader(t *testing.T) {
|
||||||
|
proxyIP := net.ParseIP("192.168.1.1")
|
||||||
|
|
||||||
|
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||||
|
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
|
||||||
|
})
|
||||||
|
md := metadata.Pairs("x-forwarded-for", "not-an-ip")
|
||||||
|
ctx = metadata.NewIncomingContext(ctx, md)
|
||||||
|
|
||||||
|
got := grpcClientIP(ctx, proxyIP)
|
||||||
|
if got != "192.168.1.1" {
|
||||||
|
t.Errorf("grpcClientIP(invalid header) = %q, want %q", got, "192.168.1.1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGRPCClientIP_NoPeer verifies that an empty string is returned when
|
||||||
|
// there is no peer in the context.
|
||||||
|
func TestGRPCClientIP_NoPeer(t *testing.T) {
|
||||||
|
got := grpcClientIP(context.Background(), nil)
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("grpcClientIP(no peer) = %q, want %q", got, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginLockedAccountReturnsUnauthenticated verifies that a locked-out
|
||||||
|
// account gets the same gRPC Unauthenticated / "invalid credentials" as a
|
||||||
|
// wrong-password attempt, preventing user-enumeration via lockout
|
||||||
|
// differentiation (SEC-02).
|
||||||
|
func TestLoginLockedAccountReturnsUnauthenticated(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
acct := e.createHumanAccount(t, "lockgrpc")
|
||||||
|
|
||||||
|
// Lower the lockout threshold so we don't need 10 failures.
|
||||||
|
origThreshold := db.LockoutThreshold
|
||||||
|
db.LockoutThreshold = 3
|
||||||
|
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
|
||||||
|
|
||||||
|
for range db.LockoutThreshold {
|
||||||
|
if err := e.db.RecordLoginFailure(acct.ID); err != nil {
|
||||||
|
t.Fatalf("RecordLoginFailure: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, err := e.db.IsLockedOut(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsLockedOut: %v", err)
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
t.Fatal("expected account to be locked out after threshold failures")
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := mciasv1.NewAuthServiceClient(e.conn)
|
||||||
|
|
||||||
|
// Attempt login on the locked account.
|
||||||
|
_, lockedErr := cl.Login(context.Background(), &mciasv1.LoginRequest{
|
||||||
|
Username: "lockgrpc",
|
||||||
|
Password: "testpass123",
|
||||||
|
})
|
||||||
|
if lockedErr == nil {
|
||||||
|
t.Fatal("Login on locked account: expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt login with wrong password for comparison.
|
||||||
|
_, wrongErr := cl.Login(context.Background(), &mciasv1.LoginRequest{
|
||||||
|
Username: "lockgrpc",
|
||||||
|
Password: "wrongpassword",
|
||||||
|
})
|
||||||
|
if wrongErr == nil {
|
||||||
|
t.Fatal("Login with wrong password: expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
lockedSt, _ := status.FromError(lockedErr)
|
||||||
|
wrongSt, _ := status.FromError(wrongErr)
|
||||||
|
|
||||||
|
// Both must return Unauthenticated, not ResourceExhausted.
|
||||||
|
if lockedSt.Code() != codes.Unauthenticated {
|
||||||
|
t.Errorf("locked: got code %v, want Unauthenticated", lockedSt.Code())
|
||||||
|
}
|
||||||
|
if wrongSt.Code() != codes.Unauthenticated {
|
||||||
|
t.Errorf("wrong password: got code %v, want Unauthenticated", wrongSt.Code())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages must be identical.
|
||||||
|
if lockedSt.Message() != wrongSt.Message() {
|
||||||
|
t.Errorf("locked message %q differs from wrong-password message %q",
|
||||||
|
lockedSt.Message(), wrongSt.Message())
|
||||||
|
}
|
||||||
|
if lockedSt.Message() != "invalid credentials" {
|
||||||
|
t.Errorf("locked message = %q, want %q", lockedSt.Message(), "invalid credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
278
internal/grpcserver/policyservice.go
Normal file
278
internal/grpcserver/policyservice.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
// policyServiceServer implements mciasv1.PolicyServiceServer.
|
||||||
|
// All handlers are admin-only and delegate to the same db package used by
|
||||||
|
// the REST policy handlers in internal/server/handlers_policy.go.
|
||||||
|
package grpcserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type policyServiceServer struct {
|
||||||
|
mciasv1.UnimplementedPolicyServiceServer
|
||||||
|
s *Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// policyRuleToProto converts a model.PolicyRuleRecord to the wire representation.
|
||||||
|
func policyRuleToProto(rec *model.PolicyRuleRecord) *mciasv1.PolicyRule {
|
||||||
|
r := &mciasv1.PolicyRule{
|
||||||
|
Id: rec.ID,
|
||||||
|
Description: rec.Description,
|
||||||
|
Priority: int32(rec.Priority), //nolint:gosec // priority is a small positive integer
|
||||||
|
Enabled: rec.Enabled,
|
||||||
|
RuleJson: rec.RuleJSON,
|
||||||
|
CreatedAt: rec.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
UpdatedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if rec.NotBefore != nil {
|
||||||
|
r.NotBefore = rec.NotBefore.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if rec.ExpiresAt != nil {
|
||||||
|
r.ExpiresAt = rec.ExpiresAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRuleJSON ensures the JSON string is valid and contains a recognised
|
||||||
|
// effect. It mirrors the validation in the REST handleCreatePolicyRule handler.
|
||||||
|
func validateRuleJSON(ruleJSON string) error {
|
||||||
|
var body policy.RuleBody
|
||||||
|
if err := json.Unmarshal([]byte(ruleJSON), &body); err != nil {
|
||||||
|
return fmt.Errorf("rule_json is not valid JSON: %w", err)
|
||||||
|
}
|
||||||
|
if body.Effect != policy.Allow && body.Effect != policy.Deny {
|
||||||
|
return fmt.Errorf("rule.effect must be %q or %q", policy.Allow, policy.Deny)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPolicyRules returns all policy rules. Admin only.
|
||||||
|
func (p *policyServiceServer) ListPolicyRules(ctx context.Context, _ *mciasv1.ListPolicyRulesRequest) (*mciasv1.ListPolicyRulesResponse, error) {
|
||||||
|
if err := p.s.requireAdmin(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := p.s.db.ListPolicyRules(false)
|
||||||
|
if err != nil {
|
||||||
|
p.s.logger.Error("list policy rules", "error", err)
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &mciasv1.ListPolicyRulesResponse{
|
||||||
|
Rules: make([]*mciasv1.PolicyRule, 0, len(rules)),
|
||||||
|
}
|
||||||
|
for _, rec := range rules {
|
||||||
|
resp.Rules = append(resp.Rules, policyRuleToProto(rec))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePolicyRule creates a new policy rule. Admin only.
|
||||||
|
func (p *policyServiceServer) CreatePolicyRule(ctx context.Context, req *mciasv1.CreatePolicyRuleRequest) (*mciasv1.CreatePolicyRuleResponse, error) {
|
||||||
|
if err := p.s.requireAdmin(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Description == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "description is required")
|
||||||
|
}
|
||||||
|
if req.RuleJson == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "rule_json is required")
|
||||||
|
}
|
||||||
|
if err := validateRuleJSON(req.RuleJson); err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
priority := int(req.Priority)
|
||||||
|
if priority == 0 {
|
||||||
|
priority = 100 // default, matching REST handler
|
||||||
|
}
|
||||||
|
|
||||||
|
var notBefore, expiresAt *time.Time
|
||||||
|
if req.NotBefore != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, req.NotBefore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "not_before must be RFC3339")
|
||||||
|
}
|
||||||
|
notBefore = &t
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, req.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "expires_at must be RFC3339")
|
||||||
|
}
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "expires_at must be after not_before")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(ctx)
|
||||||
|
var createdBy *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := p.s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
createdBy = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := p.s.db.CreatePolicyRule(req.Description, priority, req.RuleJson, createdBy, notBefore, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
p.s.logger.Error("create policy rule", "error", err)
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.s.db.WriteAuditEvent(model.EventPolicyRuleCreated, createdBy, nil, peerIP(ctx), //nolint:errcheck
|
||||||
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||||
|
|
||||||
|
return &mciasv1.CreatePolicyRuleResponse{Rule: policyRuleToProto(rec)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPolicyRule returns a single policy rule by ID. Admin only.
|
||||||
|
func (p *policyServiceServer) GetPolicyRule(ctx context.Context, req *mciasv1.GetPolicyRuleRequest) (*mciasv1.GetPolicyRuleResponse, error) {
|
||||||
|
if err := p.s.requireAdmin(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Id == 0 {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := p.s.db.GetPolicyRule(req.Id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, "policy rule not found")
|
||||||
|
}
|
||||||
|
p.s.logger.Error("get policy rule", "error", err)
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mciasv1.GetPolicyRuleResponse{Rule: policyRuleToProto(rec)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePolicyRule applies a partial update to a policy rule. Admin only.
|
||||||
|
func (p *policyServiceServer) UpdatePolicyRule(ctx context.Context, req *mciasv1.UpdatePolicyRuleRequest) (*mciasv1.UpdatePolicyRuleResponse, error) {
|
||||||
|
if err := p.s.requireAdmin(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Id == 0 {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the rule exists before applying updates.
|
||||||
|
if _, err := p.s.db.GetPolicyRule(req.Id); err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, "policy rule not found")
|
||||||
|
}
|
||||||
|
p.s.logger.Error("get policy rule for update", "error", err)
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build optional update fields — nil means "do not change".
|
||||||
|
var priority *int
|
||||||
|
if req.Priority != nil {
|
||||||
|
v := int(req.GetPriority())
|
||||||
|
priority = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-pointer semantics for time fields: nil outer = no change;
|
||||||
|
// non-nil outer with nil inner = set to NULL; non-nil both = set value.
|
||||||
|
var notBefore, expiresAt **time.Time
|
||||||
|
if req.ClearNotBefore {
|
||||||
|
var nilTime *time.Time
|
||||||
|
notBefore = &nilTime
|
||||||
|
} else if req.NotBefore != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, req.NotBefore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "not_before must be RFC3339")
|
||||||
|
}
|
||||||
|
tp := &t
|
||||||
|
notBefore = &tp
|
||||||
|
}
|
||||||
|
if req.ClearExpiresAt {
|
||||||
|
var nilTime *time.Time
|
||||||
|
expiresAt = &nilTime
|
||||||
|
} else if req.ExpiresAt != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, req.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "expires_at must be RFC3339")
|
||||||
|
}
|
||||||
|
tp := &t
|
||||||
|
expiresAt = &tp
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.s.db.UpdatePolicyRule(req.Id, nil, priority, nil, notBefore, expiresAt); err != nil {
|
||||||
|
p.s.logger.Error("update policy rule", "error", err)
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Enabled != nil {
|
||||||
|
if err := p.s.db.SetPolicyRuleEnabled(req.Id, req.GetEnabled()); err != nil {
|
||||||
|
p.s.logger.Error("set policy rule enabled", "error", err)
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(ctx)
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := p.s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.s.db.WriteAuditEvent(model.EventPolicyRuleUpdated, actorID, nil, peerIP(ctx), //nolint:errcheck
|
||||||
|
fmt.Sprintf(`{"rule_id":%d}`, req.Id))
|
||||||
|
|
||||||
|
updated, err := p.s.db.GetPolicyRule(req.Id)
|
||||||
|
if err != nil {
|
||||||
|
p.s.logger.Error("get updated policy rule", "error", err)
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mciasv1.UpdatePolicyRuleResponse{Rule: policyRuleToProto(updated)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePolicyRule permanently removes a policy rule. Admin only.
|
||||||
|
func (p *policyServiceServer) DeletePolicyRule(ctx context.Context, req *mciasv1.DeletePolicyRuleRequest) (*mciasv1.DeletePolicyRuleResponse, error) {
|
||||||
|
if err := p.s.requireAdmin(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Id == 0 {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := p.s.db.GetPolicyRule(req.Id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, "policy rule not found")
|
||||||
|
}
|
||||||
|
p.s.logger.Error("get policy rule for delete", "error", err)
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.s.db.DeletePolicyRule(req.Id); err != nil {
|
||||||
|
p.s.logger.Error("delete policy rule", "error", err)
|
||||||
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(ctx)
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := p.s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.s.db.WriteAuditEvent(model.EventPolicyRuleDeleted, actorID, nil, peerIP(ctx), //nolint:errcheck
|
||||||
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||||
|
|
||||||
|
return &mciasv1.DeletePolicyRuleResponse{}, nil
|
||||||
|
}
|
||||||
@@ -32,7 +32,11 @@ func (t *tokenServiceServer) ValidateToken(_ context.Context, req *mciasv1.Valid
|
|||||||
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
|
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := token.ValidateToken(t.s.pubKey, tokenStr, t.s.cfg.Tokens.Issuer)
|
pubKey, pkErr := t.s.vault.PubKey()
|
||||||
|
if pkErr != nil {
|
||||||
|
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
|
||||||
|
}
|
||||||
|
claims, err := token.ValidateToken(pubKey, tokenStr, t.s.cfg.Tokens.Issuer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
|
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
|
||||||
}
|
}
|
||||||
@@ -67,21 +71,24 @@ func (ts *tokenServiceServer) IssueServiceToken(ctx context.Context, req *mciasv
|
|||||||
return nil, status.Error(codes.InvalidArgument, "token issue is only for system accounts")
|
return nil, status.Error(codes.InvalidArgument, "token issue is only for system accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenStr, claims, err := token.IssueToken(ts.s.privKey, ts.s.cfg.Tokens.Issuer, acct.UUID, nil, ts.s.cfg.ServiceExpiry())
|
privKey, pkErr := ts.s.vault.PrivKey()
|
||||||
|
if pkErr != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
|
}
|
||||||
|
tokenStr, claims, err := token.IssueToken(privKey, ts.s.cfg.Tokens.Issuer, acct.UUID, nil, ts.s.cfg.ServiceExpiry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revoke existing system token if any.
|
// Atomically revoke existing system token (if any), track the new token,
|
||||||
|
// and update system_tokens — all in a single transaction.
|
||||||
|
// Security: prevents inconsistent state if a crash occurs mid-operation.
|
||||||
|
var oldJTI string
|
||||||
existing, err := ts.s.db.GetSystemToken(acct.ID)
|
existing, err := ts.s.db.GetSystemToken(acct.ID)
|
||||||
if err == nil && existing != nil {
|
if err == nil && existing != nil {
|
||||||
_ = ts.s.db.RevokeToken(existing.JTI, "rotated")
|
oldJTI = existing.JTI
|
||||||
}
|
}
|
||||||
|
if err := ts.s.db.IssueSystemToken(oldJTI, claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
if err := ts.s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
|
||||||
}
|
|
||||||
if err := ts.s.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -27,6 +26,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// contextKey is the unexported type for context keys in this package, preventing
|
// contextKey is the unexported type for context keys in this package, preventing
|
||||||
@@ -90,12 +90,18 @@ func (rw *responseWriter) WriteHeader(code int) {
|
|||||||
// RequireAuth returns middleware that validates a Bearer JWT and injects the
|
// RequireAuth returns middleware that validates a Bearer JWT and injects the
|
||||||
// claims into the request context. Returns 401 on any auth failure.
|
// claims into the request context. Returns 401 on any auth failure.
|
||||||
//
|
//
|
||||||
|
// The public key is read from the vault at request time so that the middleware
|
||||||
|
// works correctly across seal/unseal transitions. When the vault is sealed,
|
||||||
|
// the sealed middleware (RequireUnsealed) prevents reaching this handler, but
|
||||||
|
// the vault check here provides defense in depth (fail closed).
|
||||||
|
//
|
||||||
// Security: Token validation order:
|
// Security: Token validation order:
|
||||||
// 1. Extract Bearer token from Authorization header.
|
// 1. Extract Bearer token from Authorization header.
|
||||||
// 2. Validate the JWT (alg=EdDSA, signature, expiry, issuer).
|
// 2. Read public key from vault (fail closed if sealed).
|
||||||
// 3. Check the JTI against the revocation table in the database.
|
// 3. Validate the JWT (alg=EdDSA, signature, expiry, issuer).
|
||||||
// 4. Inject validated claims into context for downstream handlers.
|
// 4. Check the JTI against the revocation table in the database.
|
||||||
func RequireAuth(pubKey ed25519.PublicKey, database *db.DB, issuer string) func(http.Handler) http.Handler {
|
// 5. Inject validated claims into context for downstream handlers.
|
||||||
|
func RequireAuth(v *vault.Vault, database *db.DB, issuer string) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
tokenStr, err := extractBearerToken(r)
|
tokenStr, err := extractBearerToken(r)
|
||||||
@@ -104,6 +110,14 @@ func RequireAuth(pubKey ed25519.PublicKey, database *db.DB, issuer string) func(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: read the public key from vault at request time.
|
||||||
|
// If the vault is sealed, fail closed with 503.
|
||||||
|
pubKey, err := v.PubKey()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
claims, err := token.ValidateToken(pubKey, tokenStr, issuer)
|
claims, err := token.ValidateToken(pubKey, tokenStr, issuer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Security: Map all token errors to a generic 401; do not
|
// Security: Map all token errors to a generic 401; do not
|
||||||
@@ -176,15 +190,62 @@ type ipRateLimiter struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClientIP returns the real client IP for a request, optionally trusting a
|
||||||
|
// single reverse-proxy address.
|
||||||
|
//
|
||||||
|
// Security (DEF-03): X-Forwarded-For and X-Real-IP headers can be forged by
|
||||||
|
// any client. This function only honours them when the immediate TCP peer
|
||||||
|
// (r.RemoteAddr) matches trustedProxy exactly. When trustedProxy is nil or
|
||||||
|
// the peer address does not match, r.RemoteAddr is used unconditionally.
|
||||||
|
//
|
||||||
|
// This prevents IP-spoofing attacks: an attacker who sends a fake
|
||||||
|
// X-Forwarded-For header from their own connection still has their real IP
|
||||||
|
// used for rate limiting, because their RemoteAddr will not match the proxy.
|
||||||
|
//
|
||||||
|
// Only the first (leftmost) value in X-Forwarded-For is used, as that is the
|
||||||
|
// client-supplied address as appended by the outermost proxy. If neither
|
||||||
|
// header is present, RemoteAddr is used as a fallback even when the request
|
||||||
|
// comes from the proxy.
|
||||||
|
func ClientIP(r *http.Request, trustedProxy net.IP) string {
|
||||||
|
remoteHost, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
remoteHost = r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
if trustedProxy != nil {
|
||||||
|
remoteIP := net.ParseIP(remoteHost)
|
||||||
|
if remoteIP != nil && remoteIP.Equal(trustedProxy) {
|
||||||
|
// Request is from the trusted proxy; extract the real client IP.
|
||||||
|
// Prefer X-Real-IP (single value) over X-Forwarded-For (may be a
|
||||||
|
// comma-separated list when multiple proxies are chained).
|
||||||
|
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||||
|
if ip := net.ParseIP(strings.TrimSpace(xri)); ip != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
// Take the first (leftmost) address — the original client.
|
||||||
|
first, _, _ := strings.Cut(xff, ",")
|
||||||
|
if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remoteHost
|
||||||
|
}
|
||||||
|
|
||||||
// RateLimit returns middleware implementing a per-IP token bucket.
|
// RateLimit returns middleware implementing a per-IP token bucket.
|
||||||
// rps is the sustained request rate (tokens refilled per second).
|
// rps is the sustained request rate (tokens refilled per second).
|
||||||
// burst is the maximum burst size (initial and maximum token count).
|
// burst is the maximum burst size (initial and maximum token count).
|
||||||
|
// trustedProxy, if non-nil, enables proxy-aware client IP extraction via
|
||||||
|
// ClientIP; pass nil when not running behind a reverse proxy.
|
||||||
//
|
//
|
||||||
// Security: Rate limiting is applied at the IP level. In production, the
|
// Security (DEF-03): when trustedProxy is set, real client IPs are extracted
|
||||||
// server should be behind a reverse proxy that sets X-Forwarded-For; this
|
// from X-Forwarded-For/X-Real-IP headers but only for requests whose
|
||||||
// middleware uses RemoteAddr directly which may be the proxy IP. For single-
|
// RemoteAddr matches the trusted proxy, preventing IP-spoofing.
|
||||||
// instance deployment without a proxy, RemoteAddr is the client IP.
|
func RateLimit(rps float64, burst int, trustedProxy net.IP) func(http.Handler) http.Handler {
|
||||||
func RateLimit(rps float64, burst int) func(http.Handler) http.Handler {
|
|
||||||
limiter := &ipRateLimiter{
|
limiter := &ipRateLimiter{
|
||||||
rps: rps,
|
rps: rps,
|
||||||
burst: float64(burst),
|
burst: float64(burst),
|
||||||
@@ -197,10 +258,7 @@ func RateLimit(rps float64, burst int) func(http.Handler) http.Handler {
|
|||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
ip := ClientIP(r, trustedProxy)
|
||||||
if err != nil {
|
|
||||||
ip = r.RemoteAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
if !limiter.allow(ip) {
|
if !limiter.allow(ip) {
|
||||||
w.Header().Set("Retry-After", "60")
|
w.Header().Set("Retry-After", "60")
|
||||||
@@ -393,3 +451,47 @@ func RequirePolicy(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequireUnsealed returns middleware that blocks requests when the vault is sealed.
|
||||||
|
//
|
||||||
|
// Exempt paths (served normally even when sealed):
|
||||||
|
// - GET /v1/health, GET /v1/vault/status, POST /v1/vault/unseal
|
||||||
|
// - GET /unseal, POST /unseal
|
||||||
|
// - GET /static/* (CSS/JS needed by the unseal page)
|
||||||
|
//
|
||||||
|
// API paths (/v1/*) receive a JSON 503 response. All other paths (UI) receive
|
||||||
|
// a 302 redirect to /unseal.
|
||||||
|
//
|
||||||
|
// Security: This middleware is the first in the chain (after global security
|
||||||
|
// headers). It ensures no authenticated or data-serving handler runs while the
|
||||||
|
// vault is sealed and key material is unavailable.
|
||||||
|
func RequireUnsealed(v *vault.Vault) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !v.IsSealed() {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
// Exempt paths that must work while sealed.
|
||||||
|
if path == "/v1/health" || path == "/v1/vault/status" ||
|
||||||
|
path == "/v1/vault/unseal" ||
|
||||||
|
path == "/unseal" ||
|
||||||
|
strings.HasPrefix(path, "/static/") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// API paths: JSON 503.
|
||||||
|
if strings.HasPrefix(path, "/v1/") {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI paths: redirect to unseal page.
|
||||||
|
http.Redirect(w, r, "/unseal", http.StatusFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"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"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||||
@@ -25,6 +27,15 @@ func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
|||||||
return pub, priv
|
return pub, priv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testVault(t *testing.T, priv ed25519.PrivateKey, pub ed25519.PublicKey) *vault.Vault {
|
||||||
|
t.Helper()
|
||||||
|
mk := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(mk); err != nil {
|
||||||
|
t.Fatalf("generate master key: %v", err)
|
||||||
|
}
|
||||||
|
return vault.NewUnsealed(mk, priv, pub)
|
||||||
|
}
|
||||||
|
|
||||||
func openTestDB(t *testing.T) *db.DB {
|
func openTestDB(t *testing.T) *db.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
database, err := db.Open(":memory:")
|
database, err := db.Open(":memory:")
|
||||||
@@ -95,7 +106,7 @@ func TestRequireAuthValid(t *testing.T) {
|
|||||||
tokenStr := issueAndTrackToken(t, priv, database, acct.ID, []string{"reader"})
|
tokenStr := issueAndTrackToken(t, priv, database, acct.ID, []string{"reader"})
|
||||||
|
|
||||||
reached := false
|
reached := false
|
||||||
handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
reached = true
|
reached = true
|
||||||
claims := ClaimsFromContext(r.Context())
|
claims := ClaimsFromContext(r.Context())
|
||||||
if claims == nil {
|
if claims == nil {
|
||||||
@@ -122,7 +133,7 @@ func TestRequireAuthMissingHeader(t *testing.T) {
|
|||||||
_ = priv
|
_ = priv
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
|
|
||||||
handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
t.Error("handler should not be reached without auth")
|
t.Error("handler should not be reached without auth")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
@@ -137,10 +148,10 @@ func TestRequireAuthMissingHeader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRequireAuthInvalidToken(t *testing.T) {
|
func TestRequireAuthInvalidToken(t *testing.T) {
|
||||||
pub, _ := generateTestKey(t)
|
pub, priv := generateTestKey(t)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
|
|
||||||
handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
t.Error("handler should not be reached with invalid token")
|
t.Error("handler should not be reached with invalid token")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
@@ -175,7 +186,7 @@ func TestRequireAuthRevokedToken(t *testing.T) {
|
|||||||
t.Fatalf("RevokeToken: %v", err)
|
t.Fatalf("RevokeToken: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
t.Error("handler should not be reached with revoked token")
|
t.Error("handler should not be reached with revoked token")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
@@ -200,7 +211,7 @@ func TestRequireAuthExpiredToken(t *testing.T) {
|
|||||||
t.Fatalf("IssueToken: %v", err)
|
t.Fatalf("IssueToken: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
t.Error("handler should not be reached with expired token")
|
t.Error("handler should not be reached with expired token")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
@@ -271,7 +282,7 @@ func TestRequireRoleNoClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitAllows(t *testing.T) {
|
func TestRateLimitAllows(t *testing.T) {
|
||||||
handler := RateLimit(10, 5)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := RateLimit(10, 5, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -289,7 +300,7 @@ func TestRateLimitAllows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitBlocks(t *testing.T) {
|
func TestRateLimitBlocks(t *testing.T) {
|
||||||
handler := RateLimit(0.1, 2)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := RateLimit(0.1, 2, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -340,3 +351,124 @@ func TestExtractBearerToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestClientIP verifies the proxy-aware IP extraction logic.
|
||||||
|
func TestClientIP(t *testing.T) {
|
||||||
|
proxy := net.ParseIP("10.0.0.1")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
xForwardedFor string
|
||||||
|
xRealIP string
|
||||||
|
trustedProxy net.IP
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no proxy configured: uses RemoteAddr",
|
||||||
|
remoteAddr: "203.0.113.5:54321",
|
||||||
|
want: "203.0.113.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "proxy configured but request not from proxy: uses RemoteAddr",
|
||||||
|
remoteAddr: "198.51.100.9:12345",
|
||||||
|
xForwardedFor: "203.0.113.99",
|
||||||
|
trustedProxy: proxy,
|
||||||
|
want: "198.51.100.9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
|
||||||
|
remoteAddr: "10.0.0.1:8080",
|
||||||
|
xRealIP: "203.0.113.42",
|
||||||
|
trustedProxy: proxy,
|
||||||
|
want: "203.0.113.42",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "request from trusted proxy with X-Forwarded-For: uses first entry",
|
||||||
|
remoteAddr: "10.0.0.1:8080",
|
||||||
|
xForwardedFor: "203.0.113.77, 10.0.0.2",
|
||||||
|
trustedProxy: proxy,
|
||||||
|
want: "203.0.113.77",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Real-IP takes precedence over X-Forwarded-For",
|
||||||
|
remoteAddr: "10.0.0.1:8080",
|
||||||
|
xRealIP: "203.0.113.11",
|
||||||
|
xForwardedFor: "203.0.113.22",
|
||||||
|
trustedProxy: proxy,
|
||||||
|
want: "203.0.113.11",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "proxy request with invalid X-Real-IP falls back to X-Forwarded-For",
|
||||||
|
remoteAddr: "10.0.0.1:8080",
|
||||||
|
xRealIP: "not-an-ip",
|
||||||
|
xForwardedFor: "203.0.113.55",
|
||||||
|
trustedProxy: proxy,
|
||||||
|
want: "203.0.113.55",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
|
||||||
|
remoteAddr: "10.0.0.1:8080",
|
||||||
|
trustedProxy: proxy,
|
||||||
|
want: "10.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Security: attacker fakes X-Forwarded-For but connects directly.
|
||||||
|
name: "spoofed X-Forwarded-For from non-proxy IP is ignored",
|
||||||
|
remoteAddr: "198.51.100.99:9999",
|
||||||
|
xForwardedFor: "127.0.0.1",
|
||||||
|
trustedProxy: proxy,
|
||||||
|
want: "198.51.100.99",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = tc.remoteAddr
|
||||||
|
if tc.xForwardedFor != "" {
|
||||||
|
req.Header.Set("X-Forwarded-For", tc.xForwardedFor)
|
||||||
|
}
|
||||||
|
if tc.xRealIP != "" {
|
||||||
|
req.Header.Set("X-Real-IP", tc.xRealIP)
|
||||||
|
}
|
||||||
|
got := ClientIP(req, tc.trustedProxy)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("ClientIP = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimitTrustedProxy verifies that rate limiting uses the forwarded IP
|
||||||
|
// when the request originates from a trusted proxy.
|
||||||
|
func TestRateLimitTrustedProxy(t *testing.T) {
|
||||||
|
proxy := net.ParseIP("10.0.0.1")
|
||||||
|
// Very low rps and burst=1 so any two requests from the same IP are blocked.
|
||||||
|
handler := RateLimit(0.001, 1, proxy)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Two requests from the same real client IP, forwarded by the proxy.
|
||||||
|
// Both carry the same X-Real-IP; the second should be rate-limited.
|
||||||
|
for i, wantStatus := range []int{http.StatusOK, http.StatusTooManyRequests} {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.1:5000" // from the trusted proxy
|
||||||
|
req.Header.Set("X-Real-IP", "203.0.113.5")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != wantStatus {
|
||||||
|
t.Errorf("request %d: status = %d, want %d", i+1, rr.Code, wantStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different real client (different X-Real-IP) should still be allowed.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.1:5001"
|
||||||
|
req.Header.Set("X-Real-IP", "203.0.113.99")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("distinct client: status = %d, want 200 (separate bucket)", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
// These are pure data definitions with no external dependencies.
|
// These are pure data definitions with no external dependencies.
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// AccountType distinguishes human interactive accounts from non-interactive
|
// AccountType distinguishes human interactive accounts from non-interactive
|
||||||
// service accounts.
|
// service accounts.
|
||||||
@@ -43,6 +46,41 @@ type Account struct {
|
|||||||
TOTPRequired bool `json:"totp_required"`
|
TOTPRequired bool `json:"totp_required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allowlisted role names (DEF-10).
|
||||||
|
// Only these strings may be stored in account_roles. Extending the set of
|
||||||
|
// valid roles requires a code change, ensuring that typos such as "admim"
|
||||||
|
// are caught at grant time rather than silently creating a useless role.
|
||||||
|
const (
|
||||||
|
RoleAdmin = "admin"
|
||||||
|
RoleUser = "user"
|
||||||
|
RoleGuest = "guest"
|
||||||
|
RoleViewer = "viewer"
|
||||||
|
RoleEditor = "editor"
|
||||||
|
RoleCommenter = "commenter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allowedRoles is the compile-time set of recognised role names.
|
||||||
|
var allowedRoles = map[string]struct{}{
|
||||||
|
RoleAdmin: {},
|
||||||
|
RoleUser: {},
|
||||||
|
RoleGuest: {},
|
||||||
|
RoleViewer: {},
|
||||||
|
RoleEditor: {},
|
||||||
|
RoleCommenter: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRole returns nil if role is an allowlisted role name, or an error
|
||||||
|
// describing the problem. Call this before writing to account_roles.
|
||||||
|
//
|
||||||
|
// Security (DEF-10): prevents admins from accidentally creating unmatchable
|
||||||
|
// roles (e.g. "admim") by enforcing a compile-time allowlist.
|
||||||
|
func ValidateRole(role string) error {
|
||||||
|
if _, ok := allowedRoles[role]; !ok {
|
||||||
|
return fmt.Errorf("model: unknown role %q; allowed roles: admin, user, guest, viewer, editor, commenter", role)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Role is a string label assigned to an account to grant permissions.
|
// Role is a string label assigned to an account to grant permissions.
|
||||||
type Role struct {
|
type Role struct {
|
||||||
GrantedAt time.Time `json:"granted_at"`
|
GrantedAt time.Time `json:"granted_at"`
|
||||||
@@ -140,6 +178,9 @@ const (
|
|||||||
EventPGCredAccessed = "pgcred_accessed"
|
EventPGCredAccessed = "pgcred_accessed"
|
||||||
EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential
|
EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential
|
||||||
|
|
||||||
|
EventVaultSealed = "vault_sealed"
|
||||||
|
EventVaultUnsealed = "vault_unsealed"
|
||||||
|
|
||||||
EventTagAdded = "tag_added"
|
EventTagAdded = "tag_added"
|
||||||
EventTagRemoved = "tag_removed"
|
EventTagRemoved = "tag_removed"
|
||||||
|
|
||||||
@@ -169,8 +210,25 @@ const (
|
|||||||
EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential
|
EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential
|
||||||
|
|
||||||
EventPasswordChanged = "password_changed"
|
EventPasswordChanged = "password_changed"
|
||||||
|
|
||||||
|
EventTokenDelegateGranted = "token_delegate_granted"
|
||||||
|
EventTokenDelegateRevoked = "token_delegate_revoked"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ServiceAccountDelegate records that a specific account has been granted
|
||||||
|
// permission to issue tokens for a given system account. Only admins can
|
||||||
|
// add or remove delegates; delegates can issue/rotate tokens for that specific
|
||||||
|
// system account and nothing else.
|
||||||
|
type ServiceAccountDelegate struct {
|
||||||
|
GrantedAt time.Time `json:"granted_at"`
|
||||||
|
GrantedBy *int64 `json:"-"`
|
||||||
|
GranteeUUID string `json:"grantee_id"`
|
||||||
|
GranteeName string `json:"grantee_username"`
|
||||||
|
ID int64 `json:"-"`
|
||||||
|
AccountID int64 `json:"-"`
|
||||||
|
GranteeID int64 `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ var defaultRules = []Rule{
|
|||||||
Actions: []Action{ActionEnrollTOTP},
|
Actions: []Action{ActionEnrollTOTP},
|
||||||
Effect: Allow,
|
Effect: Allow,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Self-service password change: any authenticated human account may
|
||||||
|
// change their own password. The handler derives the target exclusively
|
||||||
|
// from the JWT subject (claims.Subject) and requires the current
|
||||||
|
// password, so a non-admin caller can only affect their own account.
|
||||||
|
ID: -7,
|
||||||
|
Description: "Self-service: any human account may change their own password",
|
||||||
|
Priority: 0,
|
||||||
|
AccountTypes: []string{"human"},
|
||||||
|
Actions: []Action{ActionChangePassword},
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// System accounts reading their own pgcreds: a service that has already
|
// System accounts reading their own pgcreds: a service that has already
|
||||||
// authenticated (e.g. via its bearer service token) may retrieve its own
|
// authenticated (e.g. via its bearer service token) may retrieve its own
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const (
|
|||||||
|
|
||||||
ActionLogin Action = "auth:login" // public
|
ActionLogin Action = "auth:login" // public
|
||||||
ActionLogout Action = "auth:logout" // self-service
|
ActionLogout Action = "auth:logout" // self-service
|
||||||
|
ActionChangePassword Action = "auth:change_password" // self-service
|
||||||
|
|
||||||
ActionListRules Action = "policy:list"
|
ActionListRules Action = "policy:list"
|
||||||
ActionManageRules Action = "policy:manage"
|
ActionManageRules Action = "policy:manage"
|
||||||
|
|||||||
@@ -217,6 +217,9 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
|
|||||||
s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil,
|
s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil,
|
||||||
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||||
|
|
||||||
|
// Reload the in-memory engine so the new rule takes effect immediately.
|
||||||
|
s.reloadPolicyEngine()
|
||||||
|
|
||||||
rv, err := policyRuleToResponse(rec)
|
rv, err := policyRuleToResponse(rec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
@@ -325,6 +328,9 @@ func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request)
|
|||||||
s.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
|
s.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
|
||||||
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
|
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
|
||||||
|
|
||||||
|
// Reload the in-memory engine so rule changes take effect immediately.
|
||||||
|
s.reloadPolicyEngine()
|
||||||
|
|
||||||
updated, err := s.db.GetPolicyRule(rec.ID)
|
updated, err := s.db.GetPolicyRule(rec.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
@@ -358,6 +364,9 @@ func (s *Server) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request)
|
|||||||
s.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
|
s.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
|
||||||
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||||
|
|
||||||
|
// Reload the in-memory engine so the deleted rule is removed immediately.
|
||||||
|
s.reloadPolicyEngine()
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,15 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha1" //nolint:gosec // G505: SHA1 required by RFC 6238 TOTP (HMAC-SHA1)
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,9 +23,31 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
"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"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// generateTOTPCode computes a valid RFC 6238 TOTP code for the current time
|
||||||
|
// using the given raw secret bytes. Used in tests to confirm TOTP enrollment.
|
||||||
|
func generateTOTPCode(t *testing.T, secret []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
counter := uint64(time.Now().Unix() / 30) //nolint:gosec // G115: always non-negative
|
||||||
|
counterBytes := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(counterBytes, counter)
|
||||||
|
mac := hmac.New(sha1.New, secret)
|
||||||
|
if _, err := mac.Write(counterBytes); err != nil {
|
||||||
|
t.Fatalf("generateTOTPCode: HMAC write: %v", err)
|
||||||
|
}
|
||||||
|
h := mac.Sum(nil)
|
||||||
|
offset := h[len(h)-1] & 0x0F
|
||||||
|
binCode := (int(h[offset]&0x7F)<<24 |
|
||||||
|
int(h[offset+1])<<16 |
|
||||||
|
int(h[offset+2])<<8 |
|
||||||
|
int(h[offset+3])) % int(math.Pow10(6))
|
||||||
|
return fmt.Sprintf("%06d", binCode)
|
||||||
|
}
|
||||||
|
|
||||||
const testIssuer = "https://auth.example.com"
|
const testIssuer = "https://auth.example.com"
|
||||||
|
|
||||||
func newTestServer(t *testing.T) (*Server, ed25519.PublicKey, ed25519.PrivateKey, *db.DB) {
|
func newTestServer(t *testing.T) (*Server, ed25519.PublicKey, ed25519.PrivateKey, *db.DB) {
|
||||||
@@ -47,8 +74,9 @@ func newTestServer(t *testing.T) (*Server, ed25519.PublicKey, ed25519.PrivateKey
|
|||||||
|
|
||||||
cfg := config.NewTestConfig(testIssuer)
|
cfg := config.NewTestConfig(testIssuer)
|
||||||
|
|
||||||
|
v := vault.NewUnsealed(masterKey, priv, pub)
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
srv := New(database, cfg, priv, pub, masterKey, logger)
|
srv := New(database, cfg, v, logger)
|
||||||
return srv, pub, priv, database
|
return srv, pub, priv, database
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +404,7 @@ func TestSetAndGetRoles(t *testing.T) {
|
|||||||
|
|
||||||
// Set roles.
|
// Set roles.
|
||||||
rr := doRequest(t, handler, "PUT", "/v1/accounts/"+target.UUID+"/roles", map[string][]string{
|
rr := doRequest(t, handler, "PUT", "/v1/accounts/"+target.UUID+"/roles", map[string][]string{
|
||||||
"roles": {"reader", "writer"},
|
"roles": {"admin", "user"},
|
||||||
}, adminToken)
|
}, adminToken)
|
||||||
if rr.Code != http.StatusNoContent {
|
if rr.Code != http.StatusNoContent {
|
||||||
t.Errorf("set roles status = %d, want 204; body: %s", rr.Code, rr.Body.String())
|
t.Errorf("set roles status = %d, want 204; body: %s", rr.Code, rr.Body.String())
|
||||||
@@ -519,8 +547,10 @@ func TestTOTPEnrollDoesNotRequireTOTP(t *testing.T) {
|
|||||||
t.Fatalf("TrackToken: %v", err)
|
t.Fatalf("TrackToken: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start enrollment.
|
// Start enrollment (password required since SEC-01 fix).
|
||||||
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", nil, tokenStr)
|
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
|
||||||
|
Password: "testpass123",
|
||||||
|
}, tokenStr)
|
||||||
if rr.Code != http.StatusOK {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("enroll status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
t.Fatalf("enroll status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
@@ -558,12 +588,69 @@ func TestTOTPEnrollDoesNotRequireTOTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTOTPEnrollRequiresPassword verifies that TOTP enrollment (SEC-01)
|
||||||
|
// requires the current password. A stolen session token alone must not be
|
||||||
|
// sufficient to add attacker-controlled MFA to the victim's account.
|
||||||
|
func TestTOTPEnrollRequiresPassword(t *testing.T) {
|
||||||
|
srv, _, priv, _ := newTestServer(t)
|
||||||
|
acct := createTestHumanAccount(t, srv, "totp-pw-check")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueToken: %v", err)
|
||||||
|
}
|
||||||
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
|
t.Fatalf("TrackToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no password", func(t *testing.T) {
|
||||||
|
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{}, tokenStr)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("enroll without password: status = %d, want %d; body: %s",
|
||||||
|
rr.Code, http.StatusBadRequest, rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong password", func(t *testing.T) {
|
||||||
|
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
|
||||||
|
Password: "wrong-password",
|
||||||
|
}, tokenStr)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("enroll with wrong password: status = %d, want %d; body: %s",
|
||||||
|
rr.Code, http.StatusUnauthorized, rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("correct password", func(t *testing.T) {
|
||||||
|
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
|
||||||
|
Password: "testpass123",
|
||||||
|
}, tokenStr)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("enroll with correct password: status = %d, want 200; body: %s",
|
||||||
|
rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var resp totpEnrollResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Secret == "" {
|
||||||
|
t.Error("expected non-empty TOTP secret")
|
||||||
|
}
|
||||||
|
if resp.OTPAuthURI == "" {
|
||||||
|
t.Error("expected non-empty otpauth URI")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenewToken(t *testing.T) {
|
func TestRenewToken(t *testing.T) {
|
||||||
srv, _, priv, _ := newTestServer(t)
|
srv, _, priv, _ := newTestServer(t)
|
||||||
acct := createTestHumanAccount(t, srv, "renew-user")
|
acct := createTestHumanAccount(t, srv, "renew-user")
|
||||||
handler := srv.Handler()
|
handler := srv.Handler()
|
||||||
|
|
||||||
oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
// Issue a short-lived token (4s) so we can wait past the 50% threshold
|
||||||
|
// while leaving enough headroom before expiry to avoid flakiness.
|
||||||
|
oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, 4*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("IssueToken: %v", err)
|
t.Fatalf("IssueToken: %v", err)
|
||||||
}
|
}
|
||||||
@@ -572,6 +659,9 @@ func TestRenewToken(t *testing.T) {
|
|||||||
t.Fatalf("TrackToken: %v", err)
|
t.Fatalf("TrackToken: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for >50% of the 4s lifetime to elapse.
|
||||||
|
time.Sleep(2100 * time.Millisecond)
|
||||||
|
|
||||||
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, oldTokenStr)
|
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, oldTokenStr)
|
||||||
if rr.Code != http.StatusOK {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("renew status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
t.Fatalf("renew status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
@@ -594,3 +684,438 @@ func TestRenewToken(t *testing.T) {
|
|||||||
t.Error("old token should be revoked after renewal")
|
t.Error("old token should be revoked after renewal")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOversizedJSONBodyRejected(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
// Build a JSON body larger than 1 MiB.
|
||||||
|
oversized := bytes.Repeat([]byte("A"), (1<<20)+1)
|
||||||
|
body := []byte(`{"username":"admin","password":"` + string(oversized) + `"}`)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/v1/auth/login", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for oversized body, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecurityHeadersOnAPIResponses verifies that the global security-headers
|
||||||
|
// middleware (SEC-04) sets X-Content-Type-Options, Strict-Transport-Security,
|
||||||
|
// and Cache-Control on all API responses, not just the UI.
|
||||||
|
func TestSecurityHeadersOnAPIResponses(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
wantHeaders := map[string]string{
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("GET /v1/health", func(t *testing.T) {
|
||||||
|
rr := doRequest(t, handler, "GET", "/v1/health", nil, "")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
for header, want := range wantHeaders {
|
||||||
|
got := rr.Header().Get(header)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s = %q, want %q", header, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST /v1/auth/login", func(t *testing.T) {
|
||||||
|
createTestHumanAccount(t, srv, "sec04-user")
|
||||||
|
rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
||||||
|
"username": "sec04-user",
|
||||||
|
"password": "testpass123",
|
||||||
|
}, "")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
for header, want := range wantHeaders {
|
||||||
|
got := rr.Header().Get(header)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s = %q, want %q", header, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginLockedAccountReturns401 verifies that a locked-out account gets the
|
||||||
|
// same HTTP 401 / "invalid credentials" response as a wrong-password attempt,
|
||||||
|
// preventing user-enumeration via lockout differentiation (SEC-02).
|
||||||
|
func TestLoginLockedAccountReturns401(t *testing.T) {
|
||||||
|
srv, _, _, database := newTestServer(t)
|
||||||
|
acct := createTestHumanAccount(t, srv, "lockuser")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
// Lower the lockout threshold so we don't need 10 failures.
|
||||||
|
origThreshold := db.LockoutThreshold
|
||||||
|
db.LockoutThreshold = 3
|
||||||
|
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
|
||||||
|
|
||||||
|
// Record enough failures to trigger lockout.
|
||||||
|
for range db.LockoutThreshold {
|
||||||
|
if err := database.RecordLoginFailure(acct.ID); err != nil {
|
||||||
|
t.Fatalf("RecordLoginFailure: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the account is locked.
|
||||||
|
locked, err := database.IsLockedOut(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsLockedOut: %v", err)
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
t.Fatal("expected account to be locked out after threshold failures")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt login on the locked account.
|
||||||
|
lockedRR := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
||||||
|
"username": "lockuser",
|
||||||
|
"password": "testpass123",
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
// Also attempt login with a wrong password (not locked) for comparison.
|
||||||
|
wrongRR := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
||||||
|
"username": "lockuser",
|
||||||
|
"password": "wrongpassword",
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
// Both must return 401, not 429.
|
||||||
|
if lockedRR.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("locked account: status = %d, want %d", lockedRR.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
if wrongRR.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("wrong password: status = %d, want %d", wrongRR.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON bodies and compare — they must be identical.
|
||||||
|
type errResp struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
var lockedBody, wrongBody errResp
|
||||||
|
if err := json.Unmarshal(lockedRR.Body.Bytes(), &lockedBody); err != nil {
|
||||||
|
t.Fatalf("unmarshal locked body: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(wrongRR.Body.Bytes(), &wrongBody); err != nil {
|
||||||
|
t.Fatalf("unmarshal wrong body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lockedBody != wrongBody {
|
||||||
|
t.Errorf("locked response %+v differs from wrong-password response %+v", lockedBody, wrongBody)
|
||||||
|
}
|
||||||
|
if lockedBody.Code != "unauthorized" {
|
||||||
|
t.Errorf("locked response code = %q, want %q", lockedBody.Code, "unauthorized")
|
||||||
|
}
|
||||||
|
if lockedBody.Error != "invalid credentials" {
|
||||||
|
t.Errorf("locked response error = %q, want %q", lockedBody.Error, "invalid credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenewTokenTooEarly verifies that a token cannot be renewed before 50%
|
||||||
|
// of its lifetime has elapsed (SEC-03).
|
||||||
|
// TestExtractBearerFromRequest verifies that extractBearerFromRequest correctly
|
||||||
|
// validates the "Bearer" prefix before extracting the token string.
|
||||||
|
// Security (PEN-01): the previous implementation sliced at a fixed offset
|
||||||
|
// without checking the prefix, accepting any 8+ character Authorization value.
|
||||||
|
func TestExtractBearerFromRequest(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", "Bearer mytoken123", "mytoken123", false},
|
||||||
|
{"missing header", "", "", true},
|
||||||
|
{"no bearer prefix", "Token mytoken123", "", true},
|
||||||
|
{"basic auth scheme", "Basic dXNlcjpwYXNz", "", true},
|
||||||
|
{"empty token", "Bearer ", "", true},
|
||||||
|
{"bearer only no space", "Bearer", "", true},
|
||||||
|
{"case insensitive", "bearer mytoken123", "mytoken123", false},
|
||||||
|
{"mixed case", "BEARER mytoken123", "mytoken123", false},
|
||||||
|
{"garbage 8 chars", "XXXXXXXX", "", true},
|
||||||
|
{"token with spaces", "Bearer token with spaces", "token with spaces", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header.Set("Authorization", tc.header)
|
||||||
|
}
|
||||||
|
got, err := extractBearerFromRequest(req)
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Errorf("wantErr=%v, got err=%v", tc.wantErr, err)
|
||||||
|
}
|
||||||
|
if !tc.wantErr && got != tc.want {
|
||||||
|
t.Errorf("token = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenewTokenTooEarly(t *testing.T) {
|
||||||
|
srv, _, priv, _ := newTestServer(t)
|
||||||
|
acct := createTestHumanAccount(t, srv, "renew-early-user")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
// Issue a long-lived token so 50% is far in the future.
|
||||||
|
tokStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueToken: %v", err)
|
||||||
|
}
|
||||||
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
|
t.Fatalf("TrackToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediately try to renew — should be rejected.
|
||||||
|
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, tokStr)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("renew status = %d, want 400; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "not yet eligible for renewal") {
|
||||||
|
t.Errorf("expected eligibility message, got: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTOTPMissingDoesNotIncrementLockout verifies that a login attempt with
|
||||||
|
// a correct password but missing TOTP code does NOT increment the account
|
||||||
|
// lockout counter (PEN-06 / DEF-08).
|
||||||
|
//
|
||||||
|
// Security: incrementing the lockout counter for a missing TOTP code would
|
||||||
|
// allow an attacker to lock out a TOTP-enrolled account by repeatedly sending
|
||||||
|
// the correct password with no TOTP code — without needing to guess TOTP.
|
||||||
|
// It would also penalise well-behaved two-step clients.
|
||||||
|
func TestTOTPMissingDoesNotIncrementLockout(t *testing.T) {
|
||||||
|
srv, _, priv, database := newTestServer(t)
|
||||||
|
acct := createTestHumanAccount(t, srv, "totp-lockout-user")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
// Issue a token so we can call the TOTP enroll and confirm endpoints.
|
||||||
|
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueToken: %v", err)
|
||||||
|
}
|
||||||
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
|
t.Fatalf("TrackToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enroll TOTP — get back the base32 secret.
|
||||||
|
enrollRR := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
|
||||||
|
Password: "testpass123",
|
||||||
|
}, tokenStr)
|
||||||
|
if enrollRR.Code != http.StatusOK {
|
||||||
|
t.Fatalf("enroll status = %d, want 200; body: %s", enrollRR.Code, enrollRR.Body.String())
|
||||||
|
}
|
||||||
|
var enrollResp totpEnrollResponse
|
||||||
|
if err := json.Unmarshal(enrollRR.Body.Bytes(), &enrollResp); err != nil {
|
||||||
|
t.Fatalf("unmarshal enroll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the secret and generate a valid TOTP code to confirm enrollment.
|
||||||
|
// We compute the TOTP code inline using the same RFC 6238 algorithm used
|
||||||
|
// by auth.ValidateTOTP, since auth.hotp is not exported.
|
||||||
|
secretBytes, err := auth.DecodeTOTPSecret(enrollResp.Secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeTOTPSecret: %v", err)
|
||||||
|
}
|
||||||
|
currentCode := generateTOTPCode(t, secretBytes)
|
||||||
|
|
||||||
|
// Confirm enrollment.
|
||||||
|
confirmRR := doRequest(t, handler, "POST", "/v1/auth/totp/confirm", map[string]string{
|
||||||
|
"code": currentCode,
|
||||||
|
}, tokenStr)
|
||||||
|
if confirmRR.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("confirm status = %d, want 204; body: %s", confirmRR.Code, confirmRR.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account should now require TOTP. Lower the lockout threshold to 1 so
|
||||||
|
// that a single RecordLoginFailure call would immediately lock the account.
|
||||||
|
origThreshold := db.LockoutThreshold
|
||||||
|
db.LockoutThreshold = 1
|
||||||
|
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
|
||||||
|
|
||||||
|
// Attempt login with the correct password but no TOTP code.
|
||||||
|
rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
||||||
|
"username": "totp-lockout-user",
|
||||||
|
"password": "testpass123",
|
||||||
|
}, "")
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401 for missing TOTP, got %d; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
// The error code must be totp_required, not unauthorized.
|
||||||
|
var errResp struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &errResp); err != nil {
|
||||||
|
t.Fatalf("unmarshal error response: %v", err)
|
||||||
|
}
|
||||||
|
if errResp.Code != "totp_required" {
|
||||||
|
t.Errorf("error code = %q, want %q", errResp.Code, "totp_required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security (PEN-06): the lockout counter must NOT have been incremented.
|
||||||
|
// With threshold=1, if it had been incremented the account would now be
|
||||||
|
// locked and a subsequent login with correct credentials would fail.
|
||||||
|
locked, err := database.IsLockedOut(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsLockedOut: %v", err)
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
t.Error("account was locked after TOTP-missing login — lockout counter was incorrectly incremented (PEN-06)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueSystemToken creates a system account, issues a JWT with the given roles,
|
||||||
|
// tracks it in the database, and returns the token string and account.
|
||||||
|
func issueSystemToken(t *testing.T, srv *Server, priv ed25519.PrivateKey, username string, roles []string) (string, *model.Account) {
|
||||||
|
t.Helper()
|
||||||
|
acct, err := srv.db.CreateAccount(username, model.AccountTypeSystem, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create system account: %v", err)
|
||||||
|
}
|
||||||
|
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, roles, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue token: %v", err)
|
||||||
|
}
|
||||||
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
|
t.Fatalf("track token: %v", err)
|
||||||
|
}
|
||||||
|
return tokenStr, acct
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPolicyEnforcement verifies that the policy engine gates access:
|
||||||
|
// - Admin role is always allowed (built-in wildcard rule).
|
||||||
|
// - Unauthenticated requests are rejected.
|
||||||
|
// - Non-admin accounts are denied by default.
|
||||||
|
// - A non-admin account gains access once an operator policy rule is created.
|
||||||
|
// - Deleting the rule reverts to denial.
|
||||||
|
func TestPolicyEnforcement(t *testing.T) {
|
||||||
|
srv, _, priv, _ := newTestServer(t)
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
adminToken, _ := issueAdminToken(t, srv, priv, "admin-pol")
|
||||||
|
|
||||||
|
// 1. Admin can list accounts (built-in wildcard rule -1).
|
||||||
|
rr := doRequest(t, handler, "GET", "/v1/accounts", nil, adminToken)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("admin list accounts status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Unauthenticated request is rejected.
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, "")
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("unauth list accounts status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. System account with no operator rules is denied by default.
|
||||||
|
svcToken, svcAcct := issueSystemToken(t, srv, priv, "metacrypt", []string{"user"})
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("system account (no policy) list accounts status = %d, want 403; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create an operator policy rule granting the system account accounts:list.
|
||||||
|
rule := createPolicyRuleRequest{
|
||||||
|
Description: "allow metacrypt to list accounts",
|
||||||
|
Priority: 50,
|
||||||
|
Rule: policy.RuleBody{
|
||||||
|
SubjectUUID: svcAcct.UUID,
|
||||||
|
AccountTypes: []string{"system"},
|
||||||
|
Actions: []policy.Action{policy.ActionListAccounts},
|
||||||
|
Effect: policy.Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rr = doRequest(t, handler, "POST", "/v1/policy/rules", rule, adminToken)
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create policy rule status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var created policyRuleResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatalf("unmarshal created rule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. The same system account can now list accounts.
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("system account (with policy) list accounts status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. The system account is still denied other actions (accounts:read).
|
||||||
|
rr = doRequest(t, handler, "POST", "/v1/accounts", map[string]string{
|
||||||
|
"username": "newuser", "password": "newpassword123", "account_type": "human",
|
||||||
|
}, svcToken)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("system account (list-only policy) create account status = %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Delete the rule and verify the account is denied again.
|
||||||
|
rr = doRequest(t, handler, "DELETE", fmt.Sprintf("/v1/policy/rules/%d", created.ID), nil, adminToken)
|
||||||
|
if rr.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete policy rule status = %d, want 204; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("system account (rule deleted) list accounts status = %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPolicyDenyRule verifies that an explicit Deny rule blocks access even
|
||||||
|
// when an Allow rule would otherwise permit it.
|
||||||
|
func TestPolicyDenyRule(t *testing.T) {
|
||||||
|
srv, _, priv, _ := newTestServer(t)
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
adminToken, _ := issueAdminToken(t, srv, priv, "admin-deny")
|
||||||
|
|
||||||
|
// Create an Allow rule for the system account.
|
||||||
|
svcToken, svcAcct := issueSystemToken(t, srv, priv, "svc-deny", []string{"user"})
|
||||||
|
allow := createPolicyRuleRequest{
|
||||||
|
Description: "allow svc-deny to list accounts",
|
||||||
|
Priority: 50,
|
||||||
|
Rule: policy.RuleBody{
|
||||||
|
SubjectUUID: svcAcct.UUID,
|
||||||
|
Actions: []policy.Action{policy.ActionListAccounts},
|
||||||
|
Effect: policy.Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rr := doRequest(t, handler, "POST", "/v1/policy/rules", allow, adminToken)
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create allow rule status = %d; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access is granted.
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("with allow rule, list accounts status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a higher-priority Deny rule for the same account.
|
||||||
|
deny := createPolicyRuleRequest{
|
||||||
|
Description: "deny svc-deny accounts:list",
|
||||||
|
Priority: 10, // lower number = higher precedence
|
||||||
|
Rule: policy.RuleBody{
|
||||||
|
SubjectUUID: svcAcct.UUID,
|
||||||
|
Actions: []policy.Action{policy.ActionListAccounts},
|
||||||
|
Effect: policy.Deny,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rr = doRequest(t, handler, "POST", "/v1/policy/rules", deny, adminToken)
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create deny rule status = %d; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny-wins: access must now be blocked despite the Allow rule.
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("deny-wins: list accounts status = %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
102
internal/server/vault.go
Normal file
102
internal/server/vault.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Vault seal/unseal REST handlers for MCIAS.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// unsealRequest is the request body for POST /v1/vault/unseal.
|
||||||
|
type unsealRequest struct {
|
||||||
|
Passphrase string `json:"passphrase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnseal accepts a passphrase, derives the master key, decrypts the
|
||||||
|
// signing key, and unseals the vault. Rate-limited to 3/s burst 5.
|
||||||
|
//
|
||||||
|
// Security: The passphrase is never logged. A generic error is returned on
|
||||||
|
// any failure to prevent information leakage about the vault state.
|
||||||
|
func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.vault.IsSealed() {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "already unsealed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req unsealRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Passphrase == "" {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "passphrase is required", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive master key from passphrase.
|
||||||
|
masterKey, err := vault.DeriveFromPassphrase(req.Passphrase, s.db)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("vault unseal: derive key", "error", err)
|
||||||
|
middleware.WriteError(w, http.StatusUnauthorized, "unseal failed", "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the signing key.
|
||||||
|
privKey, pubKey, err := vault.DecryptSigningKey(s.db, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
// Zero derived master key on failure.
|
||||||
|
for i := range masterKey {
|
||||||
|
masterKey[i] = 0
|
||||||
|
}
|
||||||
|
s.logger.Error("vault unseal: decrypt signing key", "error", err)
|
||||||
|
middleware.WriteError(w, http.StatusUnauthorized, "unseal failed", "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.vault.Unseal(masterKey, privKey, pubKey); err != nil {
|
||||||
|
s.logger.Error("vault unseal: state transition", "error", err)
|
||||||
|
middleware.WriteError(w, http.StatusConflict, "vault is already unsealed", "conflict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := middleware.ClientIP(r, nil)
|
||||||
|
s.writeAudit(r, model.EventVaultUnsealed, nil, nil, audit.JSON("source", "api", "ip", ip))
|
||||||
|
s.logger.Info("vault unsealed via API", "ip", ip)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "unsealed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSeal seals the vault, zeroing all key material. Admin-only.
|
||||||
|
//
|
||||||
|
// Security: The caller's token becomes invalid after sealing because the
|
||||||
|
// public key needed to validate it is no longer available.
|
||||||
|
func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.vault.IsSealed() {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "already sealed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &acct.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.vault.Seal()
|
||||||
|
|
||||||
|
ip := middleware.ClientIP(r, nil)
|
||||||
|
s.writeAudit(r, model.EventVaultSealed, actorID, nil, audit.JSON("source", "api", "ip", ip))
|
||||||
|
s.logger.Info("vault sealed via API", "ip", ip)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "sealed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVaultStatus returns the current seal state of the vault.
|
||||||
|
func (s *Server) handleVaultStatus(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"sealed": s.vault.IsSealed()})
|
||||||
|
}
|
||||||
171
internal/server/vault_test.go
Normal file
171
internal/server/vault_test.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleHealthSealed(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
srv.vault.Seal()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("health status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode health: %v", err)
|
||||||
|
}
|
||||||
|
if resp["status"] != "sealed" {
|
||||||
|
t.Fatalf("health status = %q, want sealed", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleHealthUnsealed(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("health status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode health: %v", err)
|
||||||
|
}
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("health status = %q, want ok", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultStatusEndpoint(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
|
||||||
|
// Unsealed
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/vault/status", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status code = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
var resp map[string]bool
|
||||||
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp["sealed"] {
|
||||||
|
t.Fatal("vault should be unsealed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal and check again
|
||||||
|
srv.vault.Seal()
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/v1/vault/status", nil)
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status code = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
resp = nil
|
||||||
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !resp["sealed"] {
|
||||||
|
t.Fatal("vault should be sealed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSealedMiddlewareAPIReturns503(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
srv.vault.Seal()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/accounts", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("sealed API status = %d, want 503", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp["code"] != "vault_sealed" {
|
||||||
|
t.Fatalf("error code = %q, want vault_sealed", resp["code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSealedMiddlewareUIRedirects(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
srv.vault.Seal()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusFound {
|
||||||
|
t.Fatalf("sealed UI status = %d, want 302", rr.Code)
|
||||||
|
}
|
||||||
|
loc := rr.Header().Get("Location")
|
||||||
|
if loc != "/unseal" {
|
||||||
|
t.Fatalf("redirect location = %q, want /unseal", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsealBadPassphrase(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
// Start sealed.
|
||||||
|
v := vault.NewSealed()
|
||||||
|
srv.vault = v
|
||||||
|
|
||||||
|
body := `{"passphrase":"wrong-passphrase"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/vault/unseal", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("unseal with bad passphrase status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSealAlreadySealedNoop(t *testing.T) {
|
||||||
|
srv, _, priv, _ := newTestServer(t)
|
||||||
|
|
||||||
|
// Seal via API (needs admin token)
|
||||||
|
adminToken, _ := issueAdminToken(t, srv, priv, "admin")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/vault/seal", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("seal status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp["status"] != "sealed" {
|
||||||
|
t.Fatalf("seal response status = %q, want sealed", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vault should be sealed now
|
||||||
|
if !srv.vault.IsSealed() {
|
||||||
|
t.Fatal("vault should be sealed after seal API call")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,11 +70,16 @@ func IssueToken(key ed25519.PrivateKey, issuer, subject string, roles []string,
|
|||||||
exp := now.Add(expiry)
|
exp := now.Add(expiry)
|
||||||
jti := uuid.New().String()
|
jti := uuid.New().String()
|
||||||
|
|
||||||
|
// Security (DEF-04): set NotBefore = now so tokens are not valid before
|
||||||
|
// the instant of issuance. This is a defence-in-depth measure: without
|
||||||
|
// nbf, a clock-skewed client or intermediate could present a token
|
||||||
|
// before its intended validity window.
|
||||||
jc := jwtClaims{
|
jc := jwtClaims{
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Issuer: issuer,
|
Issuer: issuer,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
ExpiresAt: jwt.NewNumericDate(exp),
|
ExpiresAt: jwt.NewNumericDate(exp),
|
||||||
ID: jti,
|
ID: jti,
|
||||||
},
|
},
|
||||||
@@ -127,6 +132,9 @@ func ValidateToken(key ed25519.PublicKey, tokenString, expectedIssuer string) (*
|
|||||||
jwt.WithIssuedAt(),
|
jwt.WithIssuedAt(),
|
||||||
jwt.WithIssuer(expectedIssuer),
|
jwt.WithIssuer(expectedIssuer),
|
||||||
jwt.WithExpirationRequired(),
|
jwt.WithExpirationRequired(),
|
||||||
|
// Security (DEF-04): nbf is validated automatically by the library
|
||||||
|
// when the claim is present; no explicit option is needed. If nbf is
|
||||||
|
// in the future the library returns ErrTokenNotValidYet.
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Map library errors to our typed errors for consistent handling.
|
// Map library errors to our typed errors for consistent handling.
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.
|
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.
|
||||||
@@ -21,17 +24,67 @@ import (
|
|||||||
// - The form/header value is HMAC-SHA256(key, cookieVal); this is what the
|
// - The form/header value is HMAC-SHA256(key, cookieVal); this is what the
|
||||||
// server verifies. An attacker cannot forge the HMAC without the key.
|
// server verifies. An attacker cannot forge the HMAC without the key.
|
||||||
// - Comparison uses crypto/subtle.ConstantTimeCompare to prevent timing attacks.
|
// - Comparison uses crypto/subtle.ConstantTimeCompare to prevent timing attacks.
|
||||||
|
// - When backed by a vault, the key is derived lazily on first use after
|
||||||
|
// unseal. When the vault is re-sealed, the key is invalidated and re-derived
|
||||||
|
// on the next unseal. This is safe because sealed middleware prevents
|
||||||
|
// reaching CSRF-protected routes.
|
||||||
type CSRFManager struct {
|
type CSRFManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
key []byte
|
key []byte
|
||||||
|
vault *vault.Vault
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCSRFManager creates a CSRFManager whose key is derived from masterKey.
|
// newCSRFManager creates a CSRFManager with a static key derived from masterKey.
|
||||||
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
|
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
|
||||||
func newCSRFManager(masterKey []byte) *CSRFManager {
|
func newCSRFManager(masterKey []byte) *CSRFManager {
|
||||||
|
return &CSRFManager{key: deriveCSRFKey(masterKey)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
|
||||||
|
// from the vault's master key. When the vault is sealed, operations fail
|
||||||
|
// gracefully (the sealed middleware prevents reaching CSRF-protected routes).
|
||||||
|
func newCSRFManagerFromVault(v *vault.Vault) *CSRFManager {
|
||||||
|
c := &CSRFManager{vault: v}
|
||||||
|
// If already unsealed, derive immediately.
|
||||||
|
mk, err := v.MasterKey()
|
||||||
|
if err == nil {
|
||||||
|
c.key = deriveCSRFKey(mk)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveCSRFKey computes the HMAC key from a master key.
|
||||||
|
func deriveCSRFKey(masterKey []byte) []byte {
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
h.Write([]byte("mcias-ui-csrf-v1"))
|
h.Write([]byte("mcias-ui-csrf-v1"))
|
||||||
h.Write(masterKey)
|
h.Write(masterKey)
|
||||||
return &CSRFManager{key: h.Sum(nil)}
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// csrfKey returns the current CSRF key, deriving it from vault if needed.
|
||||||
|
func (c *CSRFManager) csrfKey() ([]byte, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
// If we have a vault, re-derive key when sealed state changes.
|
||||||
|
if c.vault != nil {
|
||||||
|
if c.vault.IsSealed() {
|
||||||
|
c.key = nil
|
||||||
|
return nil, fmt.Errorf("csrf: vault is sealed")
|
||||||
|
}
|
||||||
|
if c.key == nil {
|
||||||
|
mk, err := c.vault.MasterKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("csrf: %w", err)
|
||||||
|
}
|
||||||
|
c.key = deriveCSRFKey(mk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.key == nil {
|
||||||
|
return nil, fmt.Errorf("csrf: no key available")
|
||||||
|
}
|
||||||
|
return c.key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewToken generates a fresh CSRF token pair.
|
// NewToken generates a fresh CSRF token pair.
|
||||||
@@ -40,12 +93,16 @@ func newCSRFManager(masterKey []byte) *CSRFManager {
|
|||||||
// - cookieVal: hex(32 random bytes) — stored in the mcias_csrf cookie
|
// - cookieVal: hex(32 random bytes) — stored in the mcias_csrf cookie
|
||||||
// - headerVal: hex(HMAC-SHA256(key, cookieVal)) — embedded in forms / X-CSRF-Token header
|
// - headerVal: hex(HMAC-SHA256(key, cookieVal)) — embedded in forms / X-CSRF-Token header
|
||||||
func (c *CSRFManager) NewToken() (cookieVal, headerVal string, err error) {
|
func (c *CSRFManager) NewToken() (cookieVal, headerVal string, err error) {
|
||||||
|
key, err := c.csrfKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
raw := make([]byte, 32)
|
raw := make([]byte, 32)
|
||||||
if _, err = rand.Read(raw); err != nil {
|
if _, err = rand.Read(raw); err != nil {
|
||||||
return "", "", fmt.Errorf("csrf: generate random bytes: %w", err)
|
return "", "", fmt.Errorf("csrf: generate random bytes: %w", err)
|
||||||
}
|
}
|
||||||
cookieVal = hex.EncodeToString(raw)
|
cookieVal = hex.EncodeToString(raw)
|
||||||
mac := hmac.New(sha256.New, c.key)
|
mac := hmac.New(sha256.New, key)
|
||||||
mac.Write([]byte(cookieVal))
|
mac.Write([]byte(cookieVal))
|
||||||
headerVal = hex.EncodeToString(mac.Sum(nil))
|
headerVal = hex.EncodeToString(mac.Sum(nil))
|
||||||
return cookieVal, headerVal, nil
|
return cookieVal, headerVal, nil
|
||||||
@@ -57,7 +114,11 @@ func (c *CSRFManager) Validate(cookieVal, headerVal string) bool {
|
|||||||
if cookieVal == "" || headerVal == "" {
|
if cookieVal == "" || headerVal == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
mac := hmac.New(sha256.New, c.key)
|
key, err := c.csrfKey()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
mac.Write([]byte(cookieVal))
|
mac.Write([]byte(cookieVal))
|
||||||
expected := hex.EncodeToString(mac.Sum(nil))
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
// Security: constant-time comparison prevents timing oracle attacks.
|
// Security: constant-time comparison prevents timing oracle attacks.
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
|
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
|
||||||
var knownRoles = []string{"admin", "user", "service"}
|
var knownRoles = []string{
|
||||||
|
model.RoleAdmin,
|
||||||
|
model.RoleUser,
|
||||||
|
model.RoleGuest,
|
||||||
|
model.RoleViewer,
|
||||||
|
model.RoleEditor,
|
||||||
|
model.RoleCommenter,
|
||||||
|
}
|
||||||
|
|
||||||
// handleAccountsList renders the accounts list page.
|
// handleAccountsList renders the accounts list page.
|
||||||
func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
|
func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -32,7 +39,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, ActorName: u.actorName(r)},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||||
Accounts: accounts,
|
Accounts: accounts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -175,8 +182,23 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
tags = nil
|
tags = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For system accounts, load token issue delegates and the full account
|
||||||
|
// list so admins can add new ones.
|
||||||
|
var tokenDelegates []*model.ServiceAccountDelegate
|
||||||
|
var delegatableAccounts []*model.Account
|
||||||
|
if acct.AccountType == model.AccountTypeSystem && isAdmin(r) {
|
||||||
|
tokenDelegates, err = u.db.ListTokenIssueDelegates(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list token issue delegates", "error", err)
|
||||||
|
}
|
||||||
|
delegatableAccounts, err = u.db.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list accounts for delegate dropdown", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u.render(w, "account_detail", AccountDetailData{
|
u.render(w, "account_detail", AccountDetailData{
|
||||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||||
Account: acct,
|
Account: acct,
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
AllRoles: knownRoles,
|
AllRoles: knownRoles,
|
||||||
@@ -186,6 +208,9 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
GrantableAccounts: grantableAccounts,
|
GrantableAccounts: grantableAccounts,
|
||||||
ActorID: actorID,
|
ActorID: actorID,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
|
TokenDelegates: tokenDelegates,
|
||||||
|
DelegatableAccounts: delegatableAccounts,
|
||||||
|
CanIssueToken: true, // account_detail is admin-only, so admin can always issue
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +478,12 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Security: encrypt the password with AES-256-GCM before storage.
|
// Security: encrypt the password with AES-256-GCM before storage.
|
||||||
// A fresh random nonce is generated per call by SealAESGCM; nonce reuse
|
// A fresh random nonce is generated per call by SealAESGCM; nonce reuse
|
||||||
// is not possible. The plaintext password is not retained after this call.
|
// is not possible. The plaintext password is not retained after this call.
|
||||||
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
|
masterKey, err := u.vault.MasterKey()
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logger.Error("encrypt pg password", "error", err)
|
u.logger.Error("encrypt pg password", "error", err)
|
||||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
@@ -783,7 +813,7 @@ func (u *UIServer) handlePGCredsList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
u.render(w, "pgcreds", PGCredsData{
|
u.render(w, "pgcreds", PGCredsData{
|
||||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||||
Creds: creds,
|
Creds: creds,
|
||||||
UncredentialedAccounts: uncredentialed,
|
UncredentialedAccounts: uncredentialed,
|
||||||
CredGrants: credGrants,
|
CredGrants: credGrants,
|
||||||
@@ -857,7 +887,12 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Security: encrypt with AES-256-GCM; fresh nonce per call.
|
// Security: encrypt with AES-256-GCM; fresh nonce per call.
|
||||||
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
|
masterKey, err := u.vault.MasterKey()
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logger.Error("encrypt pg password", "error", err)
|
u.logger.Error("encrypt pg password", "error", err)
|
||||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
@@ -901,10 +936,14 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
|
|||||||
// for the target account are revoked so a compromised account is fully
|
// for the target account are revoked so a compromised account is fully
|
||||||
// invalidated.
|
// invalidated.
|
||||||
//
|
//
|
||||||
// Security: new password is validated (minimum 12 chars) and hashed with
|
// Security: caller must hold the admin role; the check is performed server-side
|
||||||
// Argon2id before storage. The plaintext is never logged or included in any
|
// against the JWT claims so it cannot be bypassed by client-side tricks.
|
||||||
// response. Audit event EventPasswordChanged is recorded on success.
|
// 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) {
|
func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Security: admin role is enforced by the requireAdminRole middleware in
|
||||||
|
// the route registration (ui.go); no inline check needed here.
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||||
@@ -988,6 +1027,13 @@ func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleIssueSystemToken issues a long-lived service token for a system account.
|
// handleIssueSystemToken issues a long-lived service token for a system account.
|
||||||
|
// Accessible to admins and to accounts that have been granted delegate access
|
||||||
|
// for this specific service account via service_account_delegates.
|
||||||
|
//
|
||||||
|
// Security: authorization is checked server-side against the JWT claims stored
|
||||||
|
// in the request context — it cannot be bypassed by client-side manipulation.
|
||||||
|
// After issuance the token string is stored in a short-lived single-use
|
||||||
|
// download nonce so the operator can retrieve it exactly once as a file.
|
||||||
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
|
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
acct, err := u.db.GetAccountByUUID(id)
|
acct, err := u.db.GetAccountByUUID(id)
|
||||||
@@ -1000,6 +1046,32 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: require admin role OR an explicit delegate grant for this account.
|
||||||
|
actorClaims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if !isAdmin(r) {
|
||||||
|
if actorClaims == nil {
|
||||||
|
u.renderError(w, r, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actorID = &actor.ID
|
||||||
|
hasAccess, err := u.db.HasTokenIssueAccess(acct.ID, actor.ID)
|
||||||
|
if err != nil || !hasAccess {
|
||||||
|
u.renderError(w, r, http.StatusForbidden, "not authorized to issue tokens for this service account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if actorClaims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
roles, err := u.db.GetRoles(acct.ID)
|
roles, err := u.db.GetRoles(acct.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
|
||||||
@@ -1033,17 +1105,18 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
|
|||||||
u.logger.Warn("set system token record", "error", err)
|
u.logger.Warn("set system token record", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
actorClaims := claimsFromContext(r.Context())
|
|
||||||
var actorID *int64
|
|
||||||
if actorClaims != nil {
|
|
||||||
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
|
||||||
if err == nil {
|
|
||||||
actorID = &actor.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
u.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID,
|
u.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID,
|
||||||
fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI))
|
fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI))
|
||||||
|
|
||||||
|
// Store the raw token in the short-lived download cache so the operator
|
||||||
|
// can retrieve it exactly once via the download endpoint.
|
||||||
|
downloadNonce, err := u.storeTokenDownload(tokenStr, acct.UUID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("store token download nonce", "error", err)
|
||||||
|
// Non-fatal: fall back to showing the token in the flash message.
|
||||||
|
downloadNonce = ""
|
||||||
|
}
|
||||||
|
|
||||||
// Re-fetch token list including the new token.
|
// Re-fetch token list including the new token.
|
||||||
tokens, err := u.db.ListTokensForAccount(acct.ID)
|
tokens, err := u.db.ListTokensForAccount(acct.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1056,13 +1129,209 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
|
|||||||
csrfToken = ""
|
csrfToken = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flash the raw token once at the top so the operator can copy it.
|
var flash string
|
||||||
|
if downloadNonce == "" {
|
||||||
|
// Fallback: show token in flash when download nonce could not be stored.
|
||||||
|
flash = fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr)
|
||||||
|
} else {
|
||||||
|
flash = "Token issued. Download it now — it will not be available again."
|
||||||
|
}
|
||||||
|
|
||||||
u.render(w, "token_list", AccountDetailData{
|
u.render(w, "token_list", AccountDetailData{
|
||||||
PageData: PageData{
|
PageData: PageData{CSRFToken: csrfToken, Flash: flash},
|
||||||
CSRFToken: csrfToken,
|
|
||||||
Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr),
|
|
||||||
},
|
|
||||||
Account: acct,
|
Account: acct,
|
||||||
Tokens: tokens,
|
Tokens: tokens,
|
||||||
|
DownloadNonce: downloadNonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDownloadToken serves the just-issued service token as a file
|
||||||
|
// attachment. The nonce is single-use and expires after tokenDownloadTTL.
|
||||||
|
//
|
||||||
|
// Security: the nonce was generated with crypto/rand (128 bits) at issuance
|
||||||
|
// time and is deleted from the in-memory store on first retrieval, preventing
|
||||||
|
// replay. The response sets Content-Disposition: attachment so the browser
|
||||||
|
// saves the file rather than rendering it, reducing the risk of an XSS vector
|
||||||
|
// if the token were displayed inline.
|
||||||
|
func (u *UIServer) handleDownloadToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nonce := r.PathValue("nonce")
|
||||||
|
if nonce == "" {
|
||||||
|
http.Error(w, "missing nonce", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr, accountID, ok := u.consumeTokenDownload(nonce)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "download link expired or already used", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := "service-account-" + accountID + ".token"
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
// Security: Content-Type is text/plain and Content-Disposition is attachment,
|
||||||
|
// so the browser will save the file rather than render it, mitigating XSS risk.
|
||||||
|
_, _ = fmt.Fprint(w, tokenStr) //nolint:gosec // G705: token served as attachment, not rendered by browser
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGrantTokenDelegate adds a delegate who may issue tokens for a system
|
||||||
|
// account. Only admins may call this endpoint.
|
||||||
|
//
|
||||||
|
// Security: the target system account and grantee are looked up by UUID so the
|
||||||
|
// URL/form fields cannot reference arbitrary row IDs. Audit event
|
||||||
|
// EventTokenDelegateGranted is recorded on success.
|
||||||
|
func (u *UIServer) handleGrantTokenDelegate(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, "service account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if acct.AccountType != model.AccountTypeSystem {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "token issue delegates are only supported for system accounts")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
actorClaims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if actorClaims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.GrantTokenIssueAccess(acct.ID, grantee.ID, actorID); err != nil {
|
||||||
|
u.logger.Error("grant token issue access", "error", err)
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to grant access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventTokenDelegateGranted, actorID, &acct.ID,
|
||||||
|
fmt.Sprintf(`{"grantee":%q}`, grantee.UUID))
|
||||||
|
|
||||||
|
delegates, err := u.db.ListTokenIssueDelegates(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list token issue delegates after grant", "error", err)
|
||||||
|
}
|
||||||
|
allAccounts, err := u.db.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list accounts for delegate grant", "error", err)
|
||||||
|
}
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
csrfToken = ""
|
||||||
|
}
|
||||||
|
u.render(w, "token_delegates", AccountDetailData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Account: acct,
|
||||||
|
TokenDelegates: delegates,
|
||||||
|
DelegatableAccounts: allAccounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeTokenDelegate removes a delegate's permission to issue tokens for
|
||||||
|
// a system account. Only admins may call this endpoint.
|
||||||
|
//
|
||||||
|
// Security: grantee looked up by UUID from the URL path. Audit event
|
||||||
|
// EventTokenDelegateRevoked recorded on success.
|
||||||
|
func (u *UIServer) handleRevokeTokenDelegate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
acct, err := u.db.GetAccountByUUID(id)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "service account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
granteeUUID := r.PathValue("grantee")
|
||||||
|
grantee, err := u.db.GetAccountByUUID(granteeUUID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "grantee not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.RevokeTokenIssueAccess(acct.ID, grantee.ID); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to revoke access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actorClaims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if actorClaims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.writeAudit(r, model.EventTokenDelegateRevoked, actorID, &acct.ID,
|
||||||
|
fmt.Sprintf(`{"grantee":%q}`, grantee.UUID))
|
||||||
|
|
||||||
|
delegates, err := u.db.ListTokenIssueDelegates(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list token issue delegates after revoke", "error", err)
|
||||||
|
}
|
||||||
|
allAccounts, err := u.db.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list accounts for delegate dropdown", "error", err)
|
||||||
|
}
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
csrfToken = ""
|
||||||
|
}
|
||||||
|
u.render(w, "token_delegates", AccountDetailData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Account: acct,
|
||||||
|
TokenDelegates: delegates,
|
||||||
|
DelegatableAccounts: allAccounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceAccountsPage renders the /service-accounts page showing all
|
||||||
|
// system accounts the current user has delegate access to, along with the
|
||||||
|
// ability to issue and download tokens for them.
|
||||||
|
func (u *UIServer) handleServiceAccountsPage(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
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := u.db.ListDelegatedServiceAccounts(actor.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load service accounts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.render(w, "service_accounts", ServiceAccountsData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||||
|
Accounts: accounts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ActorName: u.actorName(r)},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(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, ActorName: u.actorName(r)},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||||
Events: events,
|
Events: events,
|
||||||
EventTypes: auditEventTypes,
|
EventTypes: auditEventTypes,
|
||||||
FilterType: filterType,
|
FilterType: filterType,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleLoginPage renders the login form.
|
// handleLoginPage renders the login form.
|
||||||
@@ -58,7 +59,7 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
|
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
|
||||||
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
|
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
|
||||||
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
||||||
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, username))
|
audit.JSON("username", username, "reason", "unknown_user"))
|
||||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,9 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
if locked {
|
if locked {
|
||||||
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
|
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
|
||||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
|
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
|
||||||
u.render(w, "login", LoginData{Error: "account temporarily locked, please try again later"})
|
// Security: return the same "invalid credentials" as wrong-password
|
||||||
|
// to prevent user-enumeration via lockout differentiation (SEC-02).
|
||||||
|
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
|
|||||||
accountID, ok := u.consumeTOTPNonce(nonce)
|
accountID, ok := u.consumeTOTPNonce(nonce)
|
||||||
if !ok {
|
if !ok {
|
||||||
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
||||||
fmt.Sprintf(`{"username":%q,"reason":"invalid_totp_nonce"}`, username))
|
audit.JSON("username", username, "reason", "invalid_totp_nonce"))
|
||||||
u.render(w, "login", LoginData{Error: "session expired, please log in again"})
|
u.render(w, "login", LoginData{Error: "session expired, please log in again"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -142,13 +145,18 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt and validate TOTP secret.
|
// Decrypt and validate TOTP secret.
|
||||||
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
masterKey, err := u.vault.MasterKey()
|
||||||
|
if err != nil {
|
||||||
|
u.render(w, "login", LoginData{Error: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||||
u.render(w, "login", LoginData{Error: "internal error"})
|
u.render(w, "login", LoginData{Error: "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
valid, err := auth.ValidateTOTP(secret, totpCode)
|
valid, totpCounter, err := auth.ValidateTOTP(secret, totpCode)
|
||||||
if err != nil || !valid {
|
if err != nil || !valid {
|
||||||
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
||||||
_ = u.db.RecordLoginFailure(acct.ID)
|
_ = u.db.RecordLoginFailure(acct.ID)
|
||||||
@@ -165,6 +173,23 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Security (CRIT-01): reject replay of a code already used within its
|
||||||
|
// ±30-second validity window.
|
||||||
|
if err := u.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
|
||||||
|
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"totp_replay"}`)
|
||||||
|
_ = u.db.RecordLoginFailure(acct.ID)
|
||||||
|
newNonce, nonceErr := u.issueTOTPNonce(acct.ID)
|
||||||
|
if nonceErr != nil {
|
||||||
|
u.render(w, "login", LoginData{Error: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.render(w, "totp_step", LoginData{
|
||||||
|
Error: "invalid TOTP code",
|
||||||
|
Username: username,
|
||||||
|
Nonce: newNonce,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
u.finishLogin(w, r, acct)
|
u.finishLogin(w, r, acct)
|
||||||
}
|
}
|
||||||
@@ -188,7 +213,12 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
|
|||||||
// Login succeeded: clear any outstanding failure counter.
|
// Login succeeded: clear any outstanding failure counter.
|
||||||
_ = u.db.ClearLoginFailures(acct.ID)
|
_ = u.db.ClearLoginFailures(acct.ID)
|
||||||
|
|
||||||
tokenStr, claims, err := token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
privKey, err := u.vault.PrivKey()
|
||||||
|
if err != nil {
|
||||||
|
u.render(w, "login", LoginData{Error: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenStr, claims, err := token.IssueToken(privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logger.Error("issue token", "error", err)
|
u.logger.Error("issue token", "error", err)
|
||||||
u.render(w, "login", LoginData{Error: "internal error"})
|
u.render(w, "login", LoginData{Error: "internal error"})
|
||||||
@@ -220,7 +250,7 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
|
|||||||
|
|
||||||
u.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
|
u.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
|
||||||
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
|
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
|
||||||
fmt.Sprintf(`{"jti":%q,"via":"ui"}`, claims.JTI))
|
audit.JSON("jti", claims.JTI, "via", "ui"))
|
||||||
|
|
||||||
// Redirect to dashboard.
|
// Redirect to dashboard.
|
||||||
if isHTMX(r) {
|
if isHTMX(r) {
|
||||||
@@ -235,13 +265,14 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
|
|||||||
func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if err == nil && cookie.Value != "" {
|
if err == nil && cookie.Value != "" {
|
||||||
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
pubKey, _ := u.vault.PubKey()
|
||||||
|
claims, err := validateSessionToken(pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil {
|
if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil {
|
||||||
u.logger.Warn("revoke token on UI logout", "error", revokeErr)
|
u.logger.Warn("revoke token on UI logout", "error", revokeErr)
|
||||||
}
|
}
|
||||||
u.writeAudit(r, model.EventTokenRevoked, nil, nil,
|
u.writeAudit(r, model.EventTokenRevoked, nil, nil,
|
||||||
fmt.Sprintf(`{"jti":%q,"reason":"ui_logout"}`, claims.JTI))
|
audit.JSON("jti", claims.JTI, "reason", "ui_logout"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
u.clearSessionCookie(w)
|
u.clearSessionCookie(w)
|
||||||
@@ -250,8 +281,134 @@ func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// writeAudit is a fire-and-forget audit log helper for the UI package.
|
// writeAudit is a fire-and-forget audit log helper for the UI package.
|
||||||
func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
|
func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
|
||||||
ip := clientIP(r)
|
ip := u.clientIP(r)
|
||||||
if err := u.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
|
if err := u.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
|
||||||
u.logger.Warn("write audit event", "type", eventType, "error", err)
|
u.logger.Warn("write audit event", "type", eventType, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleProfilePage renders the profile page for the currently logged-in user.
|
||||||
|
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
csrfToken, _ := u.setCSRFCookies(w)
|
||||||
|
u.render(w, "profile", ProfileData{
|
||||||
|
PageData: PageData{
|
||||||
|
CSRFToken: csrfToken,
|
||||||
|
ActorName: u.actorName(r),
|
||||||
|
IsAdmin: isAdmin(r),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSelfChangePassword allows an authenticated human user to change their
|
||||||
|
// own password. The current password must be supplied to prevent a stolen
|
||||||
|
// session token from being used to take over an account.
|
||||||
|
//
|
||||||
|
// Security: current password is verified with Argon2id (constant-time) before
|
||||||
|
// the new hash is written. Lockout is checked first so the endpoint cannot
|
||||||
|
// be used to brute-force the existing password. On success all other active
|
||||||
|
// sessions are revoked; the caller's own session is preserved so they remain
|
||||||
|
// logged in. The plaintext passwords are never logged or returned.
|
||||||
|
func (u *UIServer) handleSelfChangePassword(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
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
if claims == nil {
|
||||||
|
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusUnauthorized, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if acct.AccountType != model.AccountTypeHuman {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "password change is only available for human accounts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPassword := r.FormValue("current_password")
|
||||||
|
newPassword := r.FormValue("new_password")
|
||||||
|
confirmPassword := r.FormValue("confirm_password")
|
||||||
|
|
||||||
|
if currentPassword == "" || newPassword == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "current and new password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Server-side confirmation check mirrors the client-side guard; defends
|
||||||
|
// against direct POST requests that bypass the JavaScript validation.
|
||||||
|
if newPassword != confirmPassword {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: check lockout before running Argon2 to prevent brute-force.
|
||||||
|
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||||
|
if lockErr != nil {
|
||||||
|
u.logger.Error("lockout check (UI self-service password change)", "error", lockErr)
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
||||||
|
u.renderError(w, r, http.StatusTooManyRequests, "account temporarily locked, please try again later")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: verify current password with constant-time Argon2id path used
|
||||||
|
// at login so this endpoint cannot serve as a timing oracle.
|
||||||
|
ok, verifyErr := auth.VerifyPassword(currentPassword, acct.PasswordHash)
|
||||||
|
if verifyErr != nil || !ok {
|
||||||
|
_ = u.db.RecordLoginFailure(acct.ID)
|
||||||
|
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
|
||||||
|
u.renderError(w, r, http.StatusUnauthorized, "current password is incorrect")
|
||||||
|
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 (UI self-service)", "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: clear failure counter (user proved knowledge of current
|
||||||
|
// password), then revoke all sessions except the current one so stale
|
||||||
|
// tokens are invalidated while the caller stays logged in.
|
||||||
|
_ = u.db.ClearLoginFailures(acct.ID)
|
||||||
|
if err := u.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
|
||||||
|
u.logger.Error("revoke other tokens on UI password change", "account_id", acct.ID, "error", err)
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"ui_self_service"}`)
|
||||||
|
|
||||||
|
csrfToken, _ := u.setCSRFCookies(w)
|
||||||
|
u.render(w, "password_change_result", ProfileData{
|
||||||
|
PageData: PageData{
|
||||||
|
CSRFToken: csrfToken,
|
||||||
|
ActorName: u.actorName(r),
|
||||||
|
IsAdmin: isAdmin(r),
|
||||||
|
Flash: "Password updated successfully. Other active sessions have been revoked.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleDashboard renders the main dashboard page with account counts and recent events.
|
// handleDashboard renders the main dashboard page. Admin users see account
|
||||||
|
// counts and recent audit events; non-admin users see a welcome page.
|
||||||
func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
csrfToken, err := u.setCSRFCookies(w)
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -16,17 +17,23 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
admin := isAdmin(r)
|
||||||
|
|
||||||
|
data := DashboardData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: admin},
|
||||||
|
}
|
||||||
|
|
||||||
|
if admin {
|
||||||
accounts, err := u.db.ListAccounts()
|
accounts, err := u.db.ListAccounts()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var total, active int
|
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
total++
|
data.TotalAccounts++
|
||||||
if a.Status == model.AccountStatusActive {
|
if a.Status == model.AccountStatusActive {
|
||||||
active++
|
data.ActiveAccounts++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +42,8 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
u.logger.Warn("load recent audit events", "error", err)
|
u.logger.Warn("load recent audit events", "error", err)
|
||||||
events = nil
|
events = nil
|
||||||
}
|
}
|
||||||
|
data.RecentEvents = events
|
||||||
|
}
|
||||||
|
|
||||||
u.render(w, "dashboard", DashboardData{
|
u.render(w, "dashboard", data)
|
||||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
|
||||||
TotalAccounts: total,
|
|
||||||
ActiveAccounts: active,
|
|
||||||
RecentEvents: events,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := PoliciesData{
|
data := PoliciesData{
|
||||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||||
Rules: views,
|
Rules: views,
|
||||||
AllActions: allActionStrings,
|
AllActions: allActionStrings,
|
||||||
}
|
}
|
||||||
|
|||||||
81
internal/ui/handlers_vault.go
Normal file
81
internal/ui/handlers_vault.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// UI handlers for vault unseal page.
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnsealData is the view model for the unseal page.
|
||||||
|
type UnsealData struct {
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnsealPage renders the unseal form, or redirects to login if already unsealed.
|
||||||
|
func (u *UIServer) handleUnsealPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !u.vault.IsSealed() {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.render(w, "unseal", UnsealData{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnsealPost processes the unseal form submission.
|
||||||
|
//
|
||||||
|
// Security: The passphrase is never logged. No CSRF protection is applied
|
||||||
|
// because there is no session to protect (the vault is sealed), and CSRF
|
||||||
|
// token generation depends on the master key (chicken-and-egg).
|
||||||
|
func (u *UIServer) handleUnsealPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !u.vault.IsSealed() {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.render(w, "unseal", UnsealData{Error: "invalid form data"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passphrase := r.FormValue("passphrase")
|
||||||
|
if passphrase == "" {
|
||||||
|
u.render(w, "unseal", UnsealData{Error: "passphrase is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive master key from passphrase.
|
||||||
|
masterKey, err := vault.DeriveFromPassphrase(passphrase, u.db)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("vault unseal (UI): derive key", "error", err)
|
||||||
|
u.render(w, "unseal", UnsealData{Error: "unseal failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the signing key.
|
||||||
|
privKey, pubKey, err := vault.DecryptSigningKey(u.db, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
// Zero derived master key on failure.
|
||||||
|
for i := range masterKey {
|
||||||
|
masterKey[i] = 0
|
||||||
|
}
|
||||||
|
u.logger.Error("vault unseal (UI): decrypt signing key", "error", err)
|
||||||
|
u.render(w, "unseal", UnsealData{Error: "unseal failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.vault.Unseal(masterKey, privKey, pubKey); err != nil {
|
||||||
|
u.logger.Error("vault unseal (UI): state transition", "error", err)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := middleware.ClientIP(r, nil)
|
||||||
|
u.writeAudit(r, model.EventVaultUnsealed, nil, nil, audit.JSON("source", "ui", "ip", ip))
|
||||||
|
u.logger.Info("vault unsealed via UI", "ip", ip)
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
@@ -16,5 +17,9 @@ func validateSessionToken(pubKey ed25519.PublicKey, tokenStr, issuer string) (*t
|
|||||||
|
|
||||||
// issueToken is a convenience method for issuing a signed JWT.
|
// issueToken is a convenience method for issuing a signed JWT.
|
||||||
func (u *UIServer) issueToken(subject string, roles []string, expiry time.Duration) (string, *token.Claims, error) {
|
func (u *UIServer) issueToken(subject string, roles []string, expiry time.Duration) (string, *token.Claims, error) {
|
||||||
return token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, subject, roles, expiry)
|
privKey, err := u.vault.PrivKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("vault sealed: %w", err)
|
||||||
|
}
|
||||||
|
return token.IssueToken(privKey, u.cfg.Tokens.Issuer, subject, roles, expiry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -22,6 +21,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -30,7 +30,9 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
"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/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
"git.wntrmute.dev/kyle/mcias/web"
|
"git.wntrmute.dev/kyle/mcias/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,17 +54,31 @@ type pendingLogin struct {
|
|||||||
accountID int64
|
accountID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tokenDownload is a short-lived record that holds a just-issued service token
|
||||||
|
// string so the operator can download it as a file. It is single-use and
|
||||||
|
// expires after tokenDownloadTTL.
|
||||||
|
//
|
||||||
|
// Security: the token string is stored only for tokenDownloadTTL after
|
||||||
|
// issuance. The nonce is random (128 bits) and single-use: it is deleted from
|
||||||
|
// the map on first retrieval so it cannot be replayed.
|
||||||
|
type tokenDownload struct {
|
||||||
|
expiresAt time.Time
|
||||||
|
token string
|
||||||
|
accountID string // service account UUID (for the filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenDownloadTTL = 5 * time.Minute
|
||||||
|
|
||||||
// UIServer serves the HTMX-based management UI.
|
// UIServer serves the HTMX-based management UI.
|
||||||
type UIServer struct {
|
type UIServer struct {
|
||||||
pendingLogins sync.Map // nonce (string) → *pendingLogin
|
|
||||||
tmpls map[string]*template.Template // page name → template set
|
tmpls map[string]*template.Template // page name → template set
|
||||||
db *db.DB
|
db *db.DB
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
csrf *CSRFManager
|
csrf *CSRFManager
|
||||||
pubKey ed25519.PublicKey
|
vault *vault.Vault
|
||||||
privKey ed25519.PrivateKey
|
pendingLogins sync.Map // nonce (string) → *pendingLogin
|
||||||
masterKey []byte
|
tokenDownloads sync.Map // nonce (string) → *tokenDownload
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
||||||
@@ -106,8 +122,12 @@ func (u *UIServer) dummyHash() string {
|
|||||||
|
|
||||||
// New constructs a UIServer, parses all templates, and returns it.
|
// New constructs a UIServer, parses all templates, and returns it.
|
||||||
// Returns an error if template parsing fails.
|
// Returns an error if template parsing fails.
|
||||||
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) (*UIServer, error) {
|
//
|
||||||
csrf := newCSRFManager(masterKey)
|
// The CSRFManager is created lazily from vault key material when the vault
|
||||||
|
// is unsealed. When sealed, CSRF operations fail, but the sealed middleware
|
||||||
|
// prevents reaching CSRF-protected routes (chicken-and-egg resolution).
|
||||||
|
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) (*UIServer, error) {
|
||||||
|
csrf := newCSRFManagerFromVault(v)
|
||||||
|
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"formatTime": func(t time.Time) string {
|
"formatTime": func(t time.Time) string {
|
||||||
@@ -191,6 +211,8 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
"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",
|
"templates/fragments/password_reset_form.html",
|
||||||
|
"templates/fragments/password_change_form.html",
|
||||||
|
"templates/fragments/token_delegates.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 {
|
||||||
@@ -208,6 +230,9 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
"audit_detail": "templates/audit_detail.html",
|
"audit_detail": "templates/audit_detail.html",
|
||||||
"policies": "templates/policies.html",
|
"policies": "templates/policies.html",
|
||||||
"pgcreds": "templates/pgcreds.html",
|
"pgcreds": "templates/pgcreds.html",
|
||||||
|
"profile": "templates/profile.html",
|
||||||
|
"unseal": "templates/unseal.html",
|
||||||
|
"service_accounts": "templates/service_accounts.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 {
|
||||||
@@ -221,16 +246,91 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
tmpls[name] = clone
|
tmpls[name] = clone
|
||||||
}
|
}
|
||||||
|
|
||||||
return &UIServer{
|
srv := &UIServer{
|
||||||
db: database,
|
db: database,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
pubKey: pub,
|
vault: v,
|
||||||
privKey: priv,
|
|
||||||
masterKey: masterKey,
|
|
||||||
logger: logger,
|
logger: logger,
|
||||||
csrf: csrf,
|
csrf: csrf,
|
||||||
tmpls: tmpls,
|
tmpls: tmpls,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Security (DEF-02): launch a background goroutine to evict expired TOTP
|
||||||
|
// nonces from pendingLogins. consumeTOTPNonce deletes entries on use, but
|
||||||
|
// entries abandoned by users who never complete step 2 would otherwise
|
||||||
|
// accumulate indefinitely, enabling a memory-exhaustion attack.
|
||||||
|
go srv.cleanupPendingLogins()
|
||||||
|
go srv.cleanupTokenDownloads()
|
||||||
|
|
||||||
|
return srv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupPendingLogins periodically evicts expired entries from pendingLogins.
|
||||||
|
// It runs every 5 minutes, which is well within the 90-second nonce TTL, so
|
||||||
|
// stale entries are removed before they can accumulate to any significant size.
|
||||||
|
func (u *UIServer) cleanupPendingLogins() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
u.pendingLogins.Range(func(key, value any) bool {
|
||||||
|
pl, ok := value.(*pendingLogin)
|
||||||
|
if !ok || now.After(pl.expiresAt) {
|
||||||
|
u.pendingLogins.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeTokenDownload saves a just-issued token string in the short-lived
|
||||||
|
// download store and returns a random single-use nonce the caller can include
|
||||||
|
// in the response. The download nonce expires after tokenDownloadTTL.
|
||||||
|
func (u *UIServer) storeTokenDownload(tokenStr, accountID string) (string, error) {
|
||||||
|
raw := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", fmt.Errorf("ui: generate download nonce: %w", err)
|
||||||
|
}
|
||||||
|
nonce := hex.EncodeToString(raw)
|
||||||
|
u.tokenDownloads.Store(nonce, &tokenDownload{
|
||||||
|
token: tokenStr,
|
||||||
|
accountID: accountID,
|
||||||
|
expiresAt: time.Now().Add(tokenDownloadTTL),
|
||||||
|
})
|
||||||
|
return nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeTokenDownload looks up, validates, and deletes the download nonce.
|
||||||
|
// Returns the token string and account UUID, or ("", "", false) if the nonce
|
||||||
|
// is unknown or expired.
|
||||||
|
//
|
||||||
|
// Security: single-use deletion prevents replay; expiry bounds the window.
|
||||||
|
func (u *UIServer) consumeTokenDownload(nonce string) (tokenStr, accountID string, ok bool) {
|
||||||
|
v, loaded := u.tokenDownloads.LoadAndDelete(nonce)
|
||||||
|
if !loaded {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
td, valid := v.(*tokenDownload)
|
||||||
|
if !valid || time.Now().After(td.expiresAt) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return td.token, td.accountID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupTokenDownloads periodically evicts expired entries from tokenDownloads.
|
||||||
|
func (u *UIServer) cleanupTokenDownloads() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
u.tokenDownloads.Range(func(key, value any) bool {
|
||||||
|
td, ok := value.(*tokenDownload)
|
||||||
|
if !ok || now.After(td.expiresAt) {
|
||||||
|
u.tokenDownloads.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register attaches all UI routes to mux, wrapped with security headers.
|
// Register attaches all UI routes to mux, wrapped with security headers.
|
||||||
@@ -246,7 +346,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("ui: static sub-FS: %v", err))
|
panic(fmt.Sprintf("ui: static sub-FS: %v", err))
|
||||||
}
|
}
|
||||||
uiMux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
// Security (SEC-07): wrap the file server to suppress directory listings.
|
||||||
|
// Without this, GET /static/ returns an index of all static assets,
|
||||||
|
// revealing framework details to an attacker.
|
||||||
|
uiMux.Handle("GET /static/", http.StripPrefix("/static/", noDirListing(http.FileServerFS(staticSubFS))))
|
||||||
|
|
||||||
// Redirect root to login.
|
// Redirect root to login.
|
||||||
uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -257,21 +360,40 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Security (DEF-01, DEF-03): apply the same per-IP rate limit as the REST
|
||||||
|
// /v1/auth/login endpoint, using the same proxy-aware IP extraction so
|
||||||
|
// the rate limit is applied to real client IPs behind a reverse proxy.
|
||||||
|
var trustedProxy net.IP
|
||||||
|
if u.cfg.Server.TrustedProxy != "" {
|
||||||
|
trustedProxy = net.ParseIP(u.cfg.Server.TrustedProxy)
|
||||||
|
}
|
||||||
|
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
|
||||||
|
|
||||||
|
// Vault unseal routes (no session required, no CSRF — vault is sealed).
|
||||||
|
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
|
||||||
|
uiMux.HandleFunc("GET /unseal", u.handleUnsealPage)
|
||||||
|
uiMux.Handle("POST /unseal", unsealRateLimit(http.HandlerFunc(u.handleUnsealPost)))
|
||||||
|
|
||||||
// Auth routes (no session required).
|
// Auth routes (no session required).
|
||||||
uiMux.HandleFunc("GET /login", u.handleLoginPage)
|
uiMux.HandleFunc("GET /login", u.handleLoginPage)
|
||||||
uiMux.HandleFunc("POST /login", u.handleLoginPost)
|
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
|
||||||
uiMux.HandleFunc("POST /logout", u.handleLogout)
|
uiMux.HandleFunc("POST /logout", u.handleLogout)
|
||||||
|
|
||||||
// Protected routes.
|
// Protected routes.
|
||||||
auth := u.requireCookieAuth
|
//
|
||||||
|
// Security: three distinct access levels:
|
||||||
|
// - authed: any valid session cookie (authenticated user)
|
||||||
|
// - admin: authed + admin role in JWT claims (mutating admin ops)
|
||||||
|
// - adminGet: authed + admin role (read-only admin pages, no CSRF)
|
||||||
|
authed := u.requireCookieAuth
|
||||||
admin := func(h http.HandlerFunc) http.Handler {
|
admin := func(h http.HandlerFunc) http.Handler {
|
||||||
return auth(u.requireCSRF(http.HandlerFunc(h)))
|
return authed(u.requireAdminRole(u.requireCSRF(http.HandlerFunc(h))))
|
||||||
}
|
}
|
||||||
adminGet := func(h http.HandlerFunc) http.Handler {
|
adminGet := func(h http.HandlerFunc) http.Handler {
|
||||||
return auth(http.HandlerFunc(h))
|
return authed(u.requireAdminRole(http.HandlerFunc(h)))
|
||||||
}
|
}
|
||||||
|
|
||||||
uiMux.Handle("GET /dashboard", adminGet(u.handleDashboard))
|
uiMux.Handle("GET /dashboard", authed(http.HandlerFunc(u.handleDashboard)))
|
||||||
uiMux.Handle("GET /accounts", adminGet(u.handleAccountsList))
|
uiMux.Handle("GET /accounts", adminGet(u.handleAccountsList))
|
||||||
uiMux.Handle("POST /accounts", admin(u.handleCreateAccount))
|
uiMux.Handle("POST /accounts", admin(u.handleCreateAccount))
|
||||||
uiMux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
|
uiMux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
|
||||||
@@ -280,7 +402,14 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
|||||||
uiMux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
|
uiMux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
|
||||||
uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
|
uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
|
||||||
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))
|
// Token issuance is accessible to both admins and delegates; the handler
|
||||||
|
// enforces the admin-or-delegate check internally.
|
||||||
|
uiMux.Handle("POST /accounts/{id}/token", authed(u.requireCSRF(http.HandlerFunc(u.handleIssueSystemToken))))
|
||||||
|
// Token download uses a one-time nonce issued at token-issuance time.
|
||||||
|
uiMux.Handle("GET /token/download/{nonce}", authed(http.HandlerFunc(u.handleDownloadToken)))
|
||||||
|
// Token issue delegate management — admin only.
|
||||||
|
uiMux.Handle("POST /accounts/{id}/token/delegates", admin(u.handleGrantTokenDelegate))
|
||||||
|
uiMux.Handle("DELETE /accounts/{id}/token/delegates/{grantee}", admin(u.handleRevokeTokenDelegate))
|
||||||
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("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess))
|
||||||
uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
|
uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
|
||||||
@@ -296,6 +425,14 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
|||||||
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))
|
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
|
||||||
|
|
||||||
|
// Service accounts page — accessible to any authenticated user; shows only
|
||||||
|
// the service accounts for which the current user is a token-issue delegate.
|
||||||
|
uiMux.Handle("GET /service-accounts", authed(http.HandlerFunc(u.handleServiceAccountsPage)))
|
||||||
|
|
||||||
|
// Profile routes — accessible to any authenticated user (not admin-only).
|
||||||
|
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
|
||||||
|
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
|
||||||
|
|
||||||
// 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
|
||||||
// on the parent mux continue to take precedence per Go's routing rules.
|
// on the parent mux continue to take precedence per Go's routing rules.
|
||||||
@@ -314,7 +451,12 @@ func (u *UIServer) requireCookieAuth(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
pubKey, err := u.vault.PubKey()
|
||||||
|
if err != nil {
|
||||||
|
u.redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := validateSessionToken(pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.clearSessionCookie(w)
|
u.clearSessionCookie(w)
|
||||||
u.redirectToLogin(w, r)
|
u.redirectToLogin(w, r)
|
||||||
@@ -363,6 +505,25 @@ func (u *UIServer) requireCSRF(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireAdminRole checks that the authenticated user holds the "admin" role.
|
||||||
|
// Must be placed after requireCookieAuth in the middleware chain so that
|
||||||
|
// claims are available in the context.
|
||||||
|
//
|
||||||
|
// Security: This is the authoritative server-side check that prevents
|
||||||
|
// non-admin users from accessing admin-only UI endpoints. The JWT claims
|
||||||
|
// are populated from the database at login/renewal and signed with the
|
||||||
|
// server's Ed25519 private key, so they cannot be forged client-side.
|
||||||
|
func (u *UIServer) requireAdminRole(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
if claims == nil || !claims.HasRole("admin") {
|
||||||
|
u.renderError(w, r, http.StatusForbidden, "admin role required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
// isHTMX reports whether the request was initiated by HTMX.
|
// isHTMX reports whether the request was initiated by HTMX.
|
||||||
@@ -464,6 +625,21 @@ func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status in
|
|||||||
// Security: prevents memory exhaustion from oversized POST bodies (gosec G120).
|
// Security: prevents memory exhaustion from oversized POST bodies (gosec G120).
|
||||||
const maxFormBytes = 1 << 20
|
const maxFormBytes = 1 << 20
|
||||||
|
|
||||||
|
// noDirListing wraps an http.Handler (typically http.FileServerFS) to return
|
||||||
|
// 404 for directory requests instead of an auto-generated directory index.
|
||||||
|
//
|
||||||
|
// Security (SEC-07): directory listings expose the names of all static assets,
|
||||||
|
// leaking framework and version information to attackers.
|
||||||
|
func noDirListing(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/") || r.URL.Path == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// securityHeaders returns middleware that adds defensive HTTP headers to every
|
// securityHeaders returns middleware that adds defensive HTTP headers to every
|
||||||
// UI response.
|
// UI response.
|
||||||
//
|
//
|
||||||
@@ -479,6 +655,9 @@ const maxFormBytes = 1 << 20
|
|||||||
// requests to this origin for two years, preventing TLS-strip on revisit.
|
// requests to this origin for two years, preventing TLS-strip on revisit.
|
||||||
// - Referrer-Policy: suppresses the Referer header on outbound navigations so
|
// - Referrer-Policy: suppresses the Referer header on outbound navigations so
|
||||||
// JWTs or session identifiers embedded in URLs are not leaked to third parties.
|
// JWTs or session identifiers embedded in URLs are not leaked to third parties.
|
||||||
|
// - Permissions-Policy: disables browser features (camera, microphone,
|
||||||
|
// geolocation, payment) that this application does not use, reducing the
|
||||||
|
// attack surface if a content-injection vulnerability is exploited.
|
||||||
func securityHeaders(next http.Handler) http.Handler {
|
func securityHeaders(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
@@ -488,17 +667,27 @@ func securityHeaders(next http.Handler) http.Handler {
|
|||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||||
h.Set("Referrer-Policy", "no-referrer")
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
|
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientIP extracts the client IP from RemoteAddr (best effort).
|
// clientIP returns the real client IP for the request, respecting the
|
||||||
func clientIP(r *http.Request) string {
|
// server's trusted-proxy setting (DEF-03). Delegates to middleware.ClientIP
|
||||||
addr := r.RemoteAddr
|
// so the same extraction logic is used for rate limiting and audit logging.
|
||||||
if idx := strings.LastIndex(addr, ":"); idx != -1 {
|
func (u *UIServer) clientIP(r *http.Request) string {
|
||||||
return addr[:idx]
|
var proxyIP net.IP
|
||||||
|
if u.cfg.Server.TrustedProxy != "" {
|
||||||
|
proxyIP = net.ParseIP(u.cfg.Server.TrustedProxy)
|
||||||
}
|
}
|
||||||
return addr
|
return middleware.ClientIP(r, proxyIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAdmin reports whether the authenticated user holds the "admin" role.
|
||||||
|
// Returns false if claims are absent.
|
||||||
|
func isAdmin(r *http.Request) bool {
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
return claims != nil && claims.HasRole("admin")
|
||||||
}
|
}
|
||||||
|
|
||||||
// actorName resolves the username of the currently authenticated user from the
|
// actorName resolves the username of the currently authenticated user from the
|
||||||
@@ -526,6 +715,10 @@ type PageData struct {
|
|||||||
// ActorName is the username of the currently logged-in user, populated by
|
// ActorName is the username of the currently logged-in user, populated by
|
||||||
// handlers so the base template can display it in the navigation bar.
|
// handlers so the base template can display it in the navigation bar.
|
||||||
ActorName string
|
ActorName string
|
||||||
|
// IsAdmin is true when the logged-in user holds the "admin" role.
|
||||||
|
// Used by the base template to conditionally render admin-only navigation
|
||||||
|
// links (SEC-09: non-admin users must not see links they cannot access).
|
||||||
|
IsAdmin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginData is the view model for the login page.
|
// LoginData is the view model for the login page.
|
||||||
@@ -565,11 +758,38 @@ type AccountDetailData struct {
|
|||||||
// ActorID is the DB id of the currently logged-in user; used in templates
|
// ActorID is the DB id of the currently logged-in user; used in templates
|
||||||
// to decide whether to show the owner-only management controls.
|
// to decide whether to show the owner-only management controls.
|
||||||
ActorID *int64
|
ActorID *int64
|
||||||
|
// TokenDelegates lists accounts that may issue tokens for this service account.
|
||||||
|
// Only populated for system accounts when viewed by an admin.
|
||||||
|
TokenDelegates []*model.ServiceAccountDelegate
|
||||||
|
// DelegatableAccounts is the list of human accounts available for the
|
||||||
|
// "add delegate" dropdown. Only populated for admins.
|
||||||
|
DelegatableAccounts []*model.Account
|
||||||
|
// DownloadNonce is a one-time nonce for downloading the just-issued token.
|
||||||
|
// Populated by handleIssueSystemToken; empty otherwise.
|
||||||
|
DownloadNonce string
|
||||||
PageData
|
PageData
|
||||||
Roles []string
|
Roles []string
|
||||||
AllRoles []string
|
AllRoles []string
|
||||||
Tags []string
|
Tags []string
|
||||||
Tokens []*model.TokenRecord
|
Tokens []*model.TokenRecord
|
||||||
|
// CanIssueToken is true when the viewing actor may issue tokens for this
|
||||||
|
// system account (admin role or explicit delegate grant).
|
||||||
|
// Placed last to minimise GC scan area.
|
||||||
|
CanIssueToken bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceAccountsData is the view model for the /service-accounts page.
|
||||||
|
// It shows the system accounts for which the current user has delegate access,
|
||||||
|
// plus the just-issued token download nonce (if a token was just issued).
|
||||||
|
type ServiceAccountsData struct {
|
||||||
|
// Accounts is the list of system accounts the actor may issue tokens for.
|
||||||
|
Accounts []*model.Account
|
||||||
|
// DownloadNonce is a one-time nonce for downloading the just-issued token.
|
||||||
|
// Non-empty immediately after a successful token issuance.
|
||||||
|
DownloadNonce string
|
||||||
|
// IssuedFor is the UUID of the account whose token was just issued.
|
||||||
|
IssuedFor string
|
||||||
|
PageData
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditData is the view model for the audit log page.
|
// AuditData is the view model for the audit log page.
|
||||||
@@ -611,6 +831,11 @@ type PoliciesData struct {
|
|||||||
AllActions []string
|
AllActions []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProfileData is the view model for the profile/settings page.
|
||||||
|
type ProfileData struct {
|
||||||
|
PageData
|
||||||
|
}
|
||||||
|
|
||||||
// PGCredsData is the view model for the "My PG Credentials" list page.
|
// 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:
|
// It shows all pg_credentials sets accessible to the currently logged-in user:
|
||||||
// those they own and those they have been granted access to.
|
// those they own and those they have been granted access to.
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
"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"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testIssuer = "https://auth.example.com"
|
const testIssuer = "https://auth.example.com"
|
||||||
@@ -47,7 +48,8 @@ func newTestUIServer(t *testing.T) *UIServer {
|
|||||||
cfg := config.NewTestConfig(testIssuer)
|
cfg := config.NewTestConfig(testIssuer)
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
|
||||||
uiSrv, err := New(database, cfg, priv, pub, masterKey, logger)
|
v := vault.NewUnsealed(masterKey, priv, pub)
|
||||||
|
uiSrv, err := New(database, cfg, v, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new UIServer: %v", err)
|
t.Fatalf("new UIServer: %v", err)
|
||||||
}
|
}
|
||||||
@@ -79,6 +81,7 @@ func assertSecurityHeaders(t *testing.T, h http.Header, label string) {
|
|||||||
{"X-Frame-Options", "DENY"},
|
{"X-Frame-Options", "DENY"},
|
||||||
{"Strict-Transport-Security", "max-age="},
|
{"Strict-Transport-Security", "max-age="},
|
||||||
{"Referrer-Policy", "no-referrer"},
|
{"Referrer-Policy", "no-referrer"},
|
||||||
|
{"Permissions-Policy", "camera=()"},
|
||||||
}
|
}
|
||||||
for _, c := range checks {
|
for _, c := range checks {
|
||||||
val := h.Get(c.header)
|
val := h.Get(c.header)
|
||||||
@@ -317,7 +320,7 @@ func issueAdminSession(t *testing.T, u *UIServer) (tokenStr, accountUUID string,
|
|||||||
if err := u.db.SetRoles(acct.ID, []string{"admin"}, nil); err != nil {
|
if err := u.db.SetRoles(acct.ID, []string{"admin"}, nil); err != nil {
|
||||||
t.Fatalf("SetRoles: %v", err)
|
t.Fatalf("SetRoles: %v", err)
|
||||||
}
|
}
|
||||||
tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"admin"}, time.Hour)
|
tok, claims, err := u.issueToken(acct.UUID, []string{"admin"}, time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("IssueToken: %v", err)
|
t.Fatalf("IssueToken: %v", err)
|
||||||
}
|
}
|
||||||
@@ -355,6 +358,34 @@ func authenticatedGET(t *testing.T, sessionToken string, path string) *http.Requ
|
|||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStaticDirectoryListingDisabled verifies that GET /static/ returns 404
|
||||||
|
// instead of a directory listing (SEC-07).
|
||||||
|
func TestStaticDirectoryListingDisabled(t *testing.T) {
|
||||||
|
mux := newTestMux(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/static/", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET /static/ status = %d, want %d (directory listing must be disabled)", rr.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStaticFileStillServed verifies that individual static files are still
|
||||||
|
// served normally after the directory listing fix (SEC-07).
|
||||||
|
func TestStaticFileStillServed(t *testing.T) {
|
||||||
|
mux := newTestMux(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/static/style.css", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /static/style.css status = %d, want %d", rr.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSetPGCredsRejectsHumanAccount verifies that the PUT /accounts/{id}/pgcreds
|
// TestSetPGCredsRejectsHumanAccount verifies that the PUT /accounts/{id}/pgcreds
|
||||||
// endpoint returns 400 when the target account is a human (not system) account.
|
// endpoint returns 400 when the target account is a human (not system) account.
|
||||||
func TestSetPGCredsRejectsHumanAccount(t *testing.T) {
|
func TestSetPGCredsRejectsHumanAccount(t *testing.T) {
|
||||||
@@ -527,3 +558,195 @@ func TestAccountDetailShowsPGCredsSection(t *testing.T) {
|
|||||||
t.Error("human account detail page must not include pgcreds-section")
|
t.Error("human account detail page must not include pgcreds-section")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoginLockedAccountShowsInvalidCredentials verifies that a locked-out
|
||||||
|
// account gets the same "invalid credentials" error as a wrong-password
|
||||||
|
// attempt in the UI login form, preventing user-enumeration via lockout
|
||||||
|
// differentiation (SEC-02).
|
||||||
|
func TestLoginLockedAccountShowsInvalidCredentials(t *testing.T) {
|
||||||
|
u := newTestUIServer(t)
|
||||||
|
|
||||||
|
// Create an account with a known password.
|
||||||
|
hash, err := auth.HashPassword("testpass123", auth.ArgonParams{Time: 3, Memory: 65536, Threads: 4})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash password: %v", err)
|
||||||
|
}
|
||||||
|
acct, err := u.db.CreateAccount("lockuiuser", model.AccountTypeHuman, hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lower the lockout threshold so we don't need 10 failures.
|
||||||
|
origThreshold := db.LockoutThreshold
|
||||||
|
db.LockoutThreshold = 3
|
||||||
|
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
|
||||||
|
|
||||||
|
for range db.LockoutThreshold {
|
||||||
|
if err := u.db.RecordLoginFailure(acct.ID); err != nil {
|
||||||
|
t.Fatalf("RecordLoginFailure: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, err := u.db.IsLockedOut(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsLockedOut: %v", err)
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
t.Fatal("expected account to be locked out after threshold failures")
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
u.Register(mux)
|
||||||
|
|
||||||
|
// POST login for the locked account.
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("username", "lockuiuser")
|
||||||
|
form.Set("password", "testpass123")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
lockedRR := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(lockedRR, req)
|
||||||
|
|
||||||
|
// POST login with wrong password for comparison.
|
||||||
|
form2 := url.Values{}
|
||||||
|
form2.Set("username", "lockuiuser")
|
||||||
|
form2.Set("password", "wrongpassword")
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form2.Encode()))
|
||||||
|
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
wrongRR := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(wrongRR, req2)
|
||||||
|
|
||||||
|
lockedBody := lockedRR.Body.String()
|
||||||
|
wrongBody := wrongRR.Body.String()
|
||||||
|
|
||||||
|
// Neither response should mention "locked" or "try again".
|
||||||
|
if strings.Contains(lockedBody, "locked") || strings.Contains(lockedBody, "try again") {
|
||||||
|
t.Error("locked account response leaks lockout state")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both must contain "invalid credentials".
|
||||||
|
if !strings.Contains(lockedBody, "invalid credentials") {
|
||||||
|
t.Error("locked account response does not contain 'invalid credentials'")
|
||||||
|
}
|
||||||
|
if !strings.Contains(wrongBody, "invalid credentials") {
|
||||||
|
t.Error("wrong password response does not contain 'invalid credentials'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SEC-09: admin nav link visibility tests ----
|
||||||
|
|
||||||
|
// issueUserSession creates a human account with the "user" role (non-admin),
|
||||||
|
// issues a JWT, tracks it, and returns the raw token string.
|
||||||
|
func issueUserSession(t *testing.T, u *UIServer) string {
|
||||||
|
t.Helper()
|
||||||
|
acct, err := u.db.CreateAccount("regular-user", model.AccountTypeHuman, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if err := u.db.SetRoles(acct.ID, []string{"user"}, nil); err != nil {
|
||||||
|
t.Fatalf("SetRoles: %v", err)
|
||||||
|
}
|
||||||
|
tok, claims, err := u.issueToken(acct.UUID, []string{"user"}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueToken: %v", err)
|
||||||
|
}
|
||||||
|
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
|
t.Fatalf("TrackToken: %v", err)
|
||||||
|
}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNonAdminDashboardHidesAdminNavLinks verifies that a non-admin user's
|
||||||
|
// dashboard does not contain links to admin-only pages (SEC-09).
|
||||||
|
func TestNonAdminDashboardHidesAdminNavLinks(t *testing.T) {
|
||||||
|
u := newTestUIServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
u.Register(mux)
|
||||||
|
|
||||||
|
userToken := issueUserSession(t, u)
|
||||||
|
|
||||||
|
req := authenticatedGET(t, userToken, "/dashboard")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, adminPath := range []string{
|
||||||
|
`href="/accounts"`,
|
||||||
|
`href="/audit"`,
|
||||||
|
`href="/policies"`,
|
||||||
|
`href="/pgcreds"`,
|
||||||
|
} {
|
||||||
|
if strings.Contains(body, adminPath) {
|
||||||
|
t.Errorf("non-admin dashboard contains admin link %s — SEC-09 violation", adminPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard link should still be present.
|
||||||
|
if !strings.Contains(body, `href="/dashboard"`) {
|
||||||
|
t.Error("dashboard link missing from non-admin nav")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminDashboardShowsAdminNavLinks verifies that an admin user's
|
||||||
|
// dashboard contains all admin navigation links.
|
||||||
|
func TestAdminDashboardShowsAdminNavLinks(t *testing.T) {
|
||||||
|
u := newTestUIServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
u.Register(mux)
|
||||||
|
|
||||||
|
adminToken, _, _ := issueAdminSession(t, u)
|
||||||
|
|
||||||
|
req := authenticatedGET(t, adminToken, "/dashboard")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, adminPath := range []string{
|
||||||
|
`href="/accounts"`,
|
||||||
|
`href="/audit"`,
|
||||||
|
`href="/policies"`,
|
||||||
|
`href="/pgcreds"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, adminPath) {
|
||||||
|
t.Errorf("admin dashboard missing admin link %s", adminPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNonAdminProfileHidesAdminNavLinks verifies that the profile page
|
||||||
|
// also hides admin nav links for non-admin users (SEC-09).
|
||||||
|
func TestNonAdminProfileHidesAdminNavLinks(t *testing.T) {
|
||||||
|
u := newTestUIServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
u.Register(mux)
|
||||||
|
|
||||||
|
userToken := issueUserSession(t, u)
|
||||||
|
|
||||||
|
req := authenticatedGET(t, userToken, "/profile")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, adminPath := range []string{
|
||||||
|
`href="/accounts"`,
|
||||||
|
`href="/audit"`,
|
||||||
|
`href="/policies"`,
|
||||||
|
`href="/pgcreds"`,
|
||||||
|
} {
|
||||||
|
if strings.Contains(body, adminPath) {
|
||||||
|
t.Errorf("non-admin profile page contains admin link %s — SEC-09 violation", adminPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,11 +45,22 @@ func Username(username string) error {
|
|||||||
// password.
|
// password.
|
||||||
const MinPasswordLen = 12
|
const MinPasswordLen = 12
|
||||||
|
|
||||||
// Password returns nil if the plaintext password meets the minimum length
|
// MaxPasswordLen is the maximum acceptable plaintext password length.
|
||||||
// requirement, or a descriptive error if not.
|
//
|
||||||
|
// Security (SEC-05): Argon2id processes the full password input. Without
|
||||||
|
// an upper bound an attacker could submit a multi-megabyte password and
|
||||||
|
// force expensive hashing. 128 characters is generous for any real
|
||||||
|
// password or passphrase while capping the cost.
|
||||||
|
const MaxPasswordLen = 128
|
||||||
|
|
||||||
|
// Password returns nil if the plaintext password meets the length
|
||||||
|
// requirements, or a descriptive error if not.
|
||||||
func Password(password string) error {
|
func Password(password string) error {
|
||||||
if len(password) < MinPasswordLen {
|
if len(password) < MinPasswordLen {
|
||||||
return fmt.Errorf("password must be at least %d characters", MinPasswordLen)
|
return fmt.Errorf("password must be at least %d characters", MinPasswordLen)
|
||||||
}
|
}
|
||||||
|
if len(password) > MaxPasswordLen {
|
||||||
|
return fmt.Errorf("password must be at most %d characters", MaxPasswordLen)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ func TestPasswordTooShort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordTooLong(t *testing.T) {
|
||||||
|
// Exactly MaxPasswordLen should be accepted.
|
||||||
|
if err := Password(strings.Repeat("a", MaxPasswordLen)); err != nil {
|
||||||
|
t.Errorf("Password(len=%d) = %v, want nil", MaxPasswordLen, err)
|
||||||
|
}
|
||||||
|
// One over the limit should be rejected.
|
||||||
|
if err := Password(strings.Repeat("a", MaxPasswordLen+1)); err == nil {
|
||||||
|
t.Errorf("Password(len=%d) = nil, want error", MaxPasswordLen+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUsernameValid(t *testing.T) {
|
func TestUsernameValid(t *testing.T) {
|
||||||
valid := []string{
|
valid := []string{
|
||||||
"alice",
|
"alice",
|
||||||
|
|||||||
67
internal/vault/derive.go
Normal file
67
internal/vault/derive.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeriveFromPassphrase derives the master encryption key from a passphrase
|
||||||
|
// using the Argon2id KDF with a salt stored in the database.
|
||||||
|
//
|
||||||
|
// Security: The Argon2id parameters used by crypto.DeriveKey exceed OWASP 2023
|
||||||
|
// minimums (time=3, memory=128MiB, threads=4). The salt is 32 random bytes
|
||||||
|
// stored in the database on first run.
|
||||||
|
func DeriveFromPassphrase(passphrase string, database *db.DB) ([]byte, error) {
|
||||||
|
salt, err := database.ReadMasterKeySalt()
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("no master key salt in database (first-run requires startup passphrase)")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read master key salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := crypto.DeriveKey(passphrase, salt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("derive master key: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptSigningKey decrypts the Ed25519 signing key pair from the database
|
||||||
|
// using the provided master key.
|
||||||
|
//
|
||||||
|
// Security: The private key is stored AES-256-GCM encrypted in the database.
|
||||||
|
// A fresh random nonce is used for each encryption. The plaintext key only
|
||||||
|
// exists in memory during the process lifetime.
|
||||||
|
func DecryptSigningKey(database *db.DB, masterKey []byte) (ed25519.PrivateKey, ed25519.PublicKey, error) {
|
||||||
|
enc, nonce, err := database.ReadServerConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("read server config: %w", err)
|
||||||
|
}
|
||||||
|
if enc == nil || nonce == nil {
|
||||||
|
return nil, nil, fmt.Errorf("no signing key in database (first-run requires startup passphrase)")
|
||||||
|
}
|
||||||
|
|
||||||
|
privPEM, err := crypto.OpenAESGCM(masterKey, nonce, enc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("decrypt signing key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
priv, err := crypto.ParsePrivateKeyPEM(privPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse signing key PEM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: ed25519.PrivateKey.Public() always returns ed25519.PublicKey,
|
||||||
|
// but we use the ok form to make the type assertion explicit and safe.
|
||||||
|
pub, ok := priv.Public().(ed25519.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("signing key has unexpected public key type")
|
||||||
|
}
|
||||||
|
|
||||||
|
return priv, pub, nil
|
||||||
|
}
|
||||||
127
internal/vault/vault.go
Normal file
127
internal/vault/vault.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Package vault provides a thread-safe container for the server's
|
||||||
|
// cryptographic key material with seal/unseal lifecycle management.
|
||||||
|
//
|
||||||
|
// Security design:
|
||||||
|
// - The Vault holds the master encryption key and Ed25519 signing key pair.
|
||||||
|
// - All accessors return ErrSealed when the vault is sealed, ensuring that
|
||||||
|
// callers cannot use key material that has been zeroed.
|
||||||
|
// - Seal() explicitly zeroes all key material before nilling the slices,
|
||||||
|
// reducing the window in which secrets remain in memory after seal.
|
||||||
|
// - All state transitions are protected by sync.RWMutex. Readers (IsSealed,
|
||||||
|
// MasterKey, PrivKey, PubKey) take a read lock; writers (Seal, Unseal)
|
||||||
|
// take a write lock.
|
||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrSealed is returned by accessor methods when the vault is sealed.
|
||||||
|
var ErrSealed = errors.New("vault is sealed")
|
||||||
|
|
||||||
|
// Vault holds the server's cryptographic key material behind a mutex.
|
||||||
|
// All three servers (REST, UI, gRPC) share a single Vault by pointer.
|
||||||
|
type Vault struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
masterKey []byte
|
||||||
|
privKey ed25519.PrivateKey
|
||||||
|
pubKey ed25519.PublicKey
|
||||||
|
sealed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSealed creates a Vault in the sealed state. No key material is held.
|
||||||
|
func NewSealed() *Vault {
|
||||||
|
return &Vault{sealed: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUnsealed creates a Vault in the unsealed state with the given key material.
|
||||||
|
// This is the backward-compatible path used when the passphrase is available at
|
||||||
|
// startup.
|
||||||
|
func NewUnsealed(masterKey []byte, privKey ed25519.PrivateKey, pubKey ed25519.PublicKey) *Vault {
|
||||||
|
return &Vault{
|
||||||
|
masterKey: masterKey,
|
||||||
|
privKey: privKey,
|
||||||
|
pubKey: pubKey,
|
||||||
|
sealed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSealed reports whether the vault is currently sealed.
|
||||||
|
func (v *Vault) IsSealed() bool {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
return v.sealed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MasterKey returns the master encryption key, or ErrSealed if sealed.
|
||||||
|
func (v *Vault) MasterKey() ([]byte, error) {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
if v.sealed {
|
||||||
|
return nil, ErrSealed
|
||||||
|
}
|
||||||
|
return v.masterKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivKey returns the Ed25519 private signing key, or ErrSealed if sealed.
|
||||||
|
func (v *Vault) PrivKey() (ed25519.PrivateKey, error) {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
if v.sealed {
|
||||||
|
return nil, ErrSealed
|
||||||
|
}
|
||||||
|
return v.privKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PubKey returns the Ed25519 public key, or ErrSealed if sealed.
|
||||||
|
func (v *Vault) PubKey() (ed25519.PublicKey, error) {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
if v.sealed {
|
||||||
|
return nil, ErrSealed
|
||||||
|
}
|
||||||
|
return v.pubKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unseal transitions the vault from sealed to unsealed, storing the provided
|
||||||
|
// key material. Returns an error if the vault is already unsealed.
|
||||||
|
func (v *Vault) Unseal(masterKey []byte, privKey ed25519.PrivateKey, pubKey ed25519.PublicKey) error {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
if !v.sealed {
|
||||||
|
return errors.New("vault is already unsealed")
|
||||||
|
}
|
||||||
|
v.masterKey = masterKey
|
||||||
|
v.privKey = privKey
|
||||||
|
v.pubKey = pubKey
|
||||||
|
v.sealed = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal transitions the vault from unsealed to sealed. All key material is
|
||||||
|
// zeroed before being released to minimize the window of memory exposure.
|
||||||
|
//
|
||||||
|
// Security: explicit zeroing loops ensure the key bytes are overwritten even
|
||||||
|
// if the garbage collector has not yet reclaimed the backing arrays.
|
||||||
|
func (v *Vault) Seal() {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
// Zero master key.
|
||||||
|
for i := range v.masterKey {
|
||||||
|
v.masterKey[i] = 0
|
||||||
|
}
|
||||||
|
v.masterKey = nil
|
||||||
|
// Zero private key.
|
||||||
|
for i := range v.privKey {
|
||||||
|
v.privKey[i] = 0
|
||||||
|
}
|
||||||
|
v.privKey = nil
|
||||||
|
// Zero public key (not secret, but consistent cleanup).
|
||||||
|
for i := range v.pubKey {
|
||||||
|
v.pubKey[i] = 0
|
||||||
|
}
|
||||||
|
v.pubKey = nil
|
||||||
|
v.sealed = true
|
||||||
|
}
|
||||||
149
internal/vault/vault_test.go
Normal file
149
internal/vault/vault_test.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateTestKeys(t *testing.T) ([]byte, ed25519.PrivateKey, ed25519.PublicKey) {
|
||||||
|
t.Helper()
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
mk := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(mk); err != nil {
|
||||||
|
t.Fatalf("generate master key: %v", err)
|
||||||
|
}
|
||||||
|
return mk, priv, pub
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSealed(t *testing.T) {
|
||||||
|
v := NewSealed()
|
||||||
|
if !v.IsSealed() {
|
||||||
|
t.Fatal("NewSealed() should be sealed")
|
||||||
|
}
|
||||||
|
if _, err := v.MasterKey(); err != ErrSealed {
|
||||||
|
t.Fatalf("MasterKey() error = %v, want ErrSealed", err)
|
||||||
|
}
|
||||||
|
if _, err := v.PrivKey(); err != ErrSealed {
|
||||||
|
t.Fatalf("PrivKey() error = %v, want ErrSealed", err)
|
||||||
|
}
|
||||||
|
if _, err := v.PubKey(); err != ErrSealed {
|
||||||
|
t.Fatalf("PubKey() error = %v, want ErrSealed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewUnsealed(t *testing.T) {
|
||||||
|
mk, priv, pub := generateTestKeys(t)
|
||||||
|
v := NewUnsealed(mk, priv, pub)
|
||||||
|
if v.IsSealed() {
|
||||||
|
t.Fatal("NewUnsealed() should not be sealed")
|
||||||
|
}
|
||||||
|
gotMK, err := v.MasterKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MasterKey() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(gotMK) != 32 {
|
||||||
|
t.Fatalf("MasterKey() len = %d, want 32", len(gotMK))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsealFromSealed(t *testing.T) {
|
||||||
|
mk, priv, pub := generateTestKeys(t)
|
||||||
|
v := NewSealed()
|
||||||
|
if err := v.Unseal(mk, priv, pub); err != nil {
|
||||||
|
t.Fatalf("Unseal() error = %v", err)
|
||||||
|
}
|
||||||
|
if v.IsSealed() {
|
||||||
|
t.Fatal("should be unsealed after Unseal()")
|
||||||
|
}
|
||||||
|
gotPriv, err := v.PrivKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PrivKey() error = %v", err)
|
||||||
|
}
|
||||||
|
if !priv.Equal(gotPriv) {
|
||||||
|
t.Fatal("PrivKey() mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsealAlreadyUnsealed(t *testing.T) {
|
||||||
|
mk, priv, pub := generateTestKeys(t)
|
||||||
|
v := NewUnsealed(mk, priv, pub)
|
||||||
|
if err := v.Unseal(mk, priv, pub); err == nil {
|
||||||
|
t.Fatal("Unseal() on unsealed vault should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSealZeroesKeys(t *testing.T) {
|
||||||
|
mk, priv, pub := generateTestKeys(t)
|
||||||
|
// Keep references to the backing arrays so we can verify zeroing.
|
||||||
|
mkRef := mk
|
||||||
|
privRef := priv
|
||||||
|
v := NewUnsealed(mk, priv, pub)
|
||||||
|
v.Seal()
|
||||||
|
|
||||||
|
if !v.IsSealed() {
|
||||||
|
t.Fatal("should be sealed after Seal()")
|
||||||
|
}
|
||||||
|
// Verify the original backing arrays were zeroed.
|
||||||
|
for i, b := range mkRef {
|
||||||
|
if b != 0 {
|
||||||
|
t.Fatalf("masterKey[%d] = %d, want 0", i, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, b := range privRef {
|
||||||
|
if b != 0 {
|
||||||
|
t.Fatalf("privKey[%d] = %d, want 0", i, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSealUnsealCycle(t *testing.T) {
|
||||||
|
mk, priv, pub := generateTestKeys(t)
|
||||||
|
v := NewUnsealed(mk, priv, pub)
|
||||||
|
v.Seal()
|
||||||
|
|
||||||
|
mk2, priv2, pub2 := generateTestKeys(t)
|
||||||
|
if err := v.Unseal(mk2, priv2, pub2); err != nil {
|
||||||
|
t.Fatalf("Unseal() after Seal() error = %v", err)
|
||||||
|
}
|
||||||
|
gotPub, err := v.PubKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PubKey() error = %v", err)
|
||||||
|
}
|
||||||
|
if !pub2.Equal(gotPub) {
|
||||||
|
t.Fatal("PubKey() mismatch after re-unseal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentAccess(t *testing.T) {
|
||||||
|
mk, priv, pub := generateTestKeys(t)
|
||||||
|
v := NewUnsealed(mk, priv, pub)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
// Concurrent readers.
|
||||||
|
for range 50 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = v.IsSealed()
|
||||||
|
_, _ = v.MasterKey()
|
||||||
|
_, _ = v.PrivKey()
|
||||||
|
_, _ = v.PubKey()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Concurrent seal/unseal cycles.
|
||||||
|
for range 10 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
v.Seal()
|
||||||
|
mk2, priv2, pub2 := generateTestKeys(t)
|
||||||
|
_ = v.Unseal(mk2, priv2, pub2)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ environment variable.
|
|||||||
.It Fl server Ar url
|
.It Fl server Ar url
|
||||||
Base URL of the mciassrv instance.
|
Base URL of the mciassrv instance.
|
||||||
Default:
|
Default:
|
||||||
.Qq https://localhost:8443 .
|
.Qq https://mcias.metacircular.net:8443 .
|
||||||
Can also be set with the
|
Can also be set with the
|
||||||
.Ev MCIAS_SERVER
|
.Ev MCIAS_SERVER
|
||||||
environment variable.
|
environment variable.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.Dd March 11, 2026
|
.Dd March 12, 2026
|
||||||
.Dt MCIASGRPCCTL 1
|
.Dt MCIASGRPCCTL 1
|
||||||
.Os
|
.Os
|
||||||
.Sh NAME
|
.Sh NAME
|
||||||
@@ -37,7 +37,7 @@ gRPC server address in
|
|||||||
.Ar host:port
|
.Ar host:port
|
||||||
format.
|
format.
|
||||||
Default:
|
Default:
|
||||||
.Qq localhost:9443 .
|
.Qq mcias.metacircular.net:9443 .
|
||||||
.It Fl token Ar jwt
|
.It Fl token Ar jwt
|
||||||
Bearer token for authentication.
|
Bearer token for authentication.
|
||||||
Can also be set with the
|
Can also be set with the
|
||||||
@@ -58,6 +58,18 @@ and exits 0 if the server is healthy.
|
|||||||
.It Nm Ic pubkey
|
.It Nm Ic pubkey
|
||||||
Returns the server's Ed25519 public key as a JWK.
|
Returns the server's Ed25519 public key as a JWK.
|
||||||
.El
|
.El
|
||||||
|
.Ss auth
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Nm Ic auth Ic login Fl username Ar name Op Fl totp Ar code
|
||||||
|
Authenticates with the server and prints the bearer token to stdout.
|
||||||
|
The password is always prompted interactively.
|
||||||
|
Suitable for use in scripts:
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
export MCIAS_TOKEN=$(mciasgrpcctl auth login -username alice)
|
||||||
|
.Ed
|
||||||
|
.It Nm Ic auth Ic logout
|
||||||
|
Revokes the current bearer token.
|
||||||
|
.El
|
||||||
.Ss account
|
.Ss account
|
||||||
.Bl -tag -width Ds
|
.Bl -tag -width Ds
|
||||||
.It Nm Ic account Ic list
|
.It Nm Ic account Ic list
|
||||||
@@ -94,6 +106,21 @@ Returns the Postgres credentials for the account.
|
|||||||
.It Nm Ic pgcreds Ic set Fl id Ar uuid Fl host Ar host Op Fl port Ar port Fl db Ar db Fl user Ar user Fl password Ar pass
|
.It Nm Ic pgcreds Ic set Fl id Ar uuid Fl host Ar host Op Fl port Ar port Fl db Ar db Fl user Ar user Fl password Ar pass
|
||||||
Sets Postgres credentials for the account.
|
Sets Postgres credentials for the account.
|
||||||
.El
|
.El
|
||||||
|
.Ss policy
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Nm Ic policy Ic list
|
||||||
|
Lists all policy rules.
|
||||||
|
.It Nm Ic policy Ic create Fl description Ar str Fl json Ar file Op Fl priority Ar n Op Fl not-before Ar rfc3339 Op Fl expires-at Ar rfc3339
|
||||||
|
Creates a new policy rule.
|
||||||
|
.Ar file
|
||||||
|
must be a path to a file containing a JSON rule body.
|
||||||
|
.It Nm Ic policy Ic get Fl id Ar id
|
||||||
|
Returns the policy rule with the given ID.
|
||||||
|
.It Nm Ic policy Ic update Fl id Ar id Op Fl priority Ar n Op Fl enabled Ar true|false Op Fl not-before Ar rfc3339 Op Fl expires-at Ar rfc3339 Op Fl clear-not-before Op Fl clear-expires-at
|
||||||
|
Applies a partial update to a policy rule.
|
||||||
|
.It Nm Ic policy Ic delete Fl id Ar id
|
||||||
|
Permanently removes a policy rule.
|
||||||
|
.El
|
||||||
.Sh ENVIRONMENT
|
.Sh ENVIRONMENT
|
||||||
.Bl -tag -width Ds
|
.Bl -tag -width Ds
|
||||||
.It Ev MCIAS_TOKEN
|
.It Ev MCIAS_TOKEN
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ WAL mode and foreign key enforcement are enabled automatically.
|
|||||||
Issuer claim embedded in every JWT.
|
Issuer claim embedded in every JWT.
|
||||||
Use the base URL of your MCIAS server.
|
Use the base URL of your MCIAS server.
|
||||||
.It Sy default_expiry
|
.It Sy default_expiry
|
||||||
.Pq optional, default 720h
|
.Pq optional, default 168h
|
||||||
Token expiry for interactive logins.
|
Token expiry for interactive logins.
|
||||||
Go duration string.
|
Go duration string.
|
||||||
.It Sy admin_expiry
|
.It Sy admin_expiry
|
||||||
|
|||||||
298
openapi.yaml
298
openapi.yaml
@@ -221,8 +221,8 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
description: |
|
description: |
|
||||||
Time after which the rule is no longer active. NULL means no
|
Time after which the rule is no longer active. NULL means no
|
||||||
constraint (never expires). Rules where `expires_at <= now()` are
|
constraint (never expires). Rules where expires_at is in the past
|
||||||
skipped during evaluation.
|
are skipped during evaluation.
|
||||||
example: "2026-06-01T00:00:00Z"
|
example: "2026-06-01T00:00:00Z"
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
@@ -307,6 +307,18 @@ components:
|
|||||||
error: rate limit exceeded
|
error: rate limit exceeded
|
||||||
code: rate_limited
|
code: rate_limited
|
||||||
|
|
||||||
|
VaultSealed:
|
||||||
|
description: |
|
||||||
|
The vault is sealed. The server is running but has no key material.
|
||||||
|
Unseal via `POST /v1/vault/unseal` before retrying.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
example:
|
||||||
|
error: vault is sealed
|
||||||
|
code: vault_sealed
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
|
|
||||||
# ── Public ────────────────────────────────────────────────────────────────
|
# ── Public ────────────────────────────────────────────────────────────────
|
||||||
@@ -314,12 +326,17 @@ paths:
|
|||||||
/v1/health:
|
/v1/health:
|
||||||
get:
|
get:
|
||||||
summary: Health check
|
summary: Health check
|
||||||
description: Returns `{"status":"ok"}` if the server is running. No auth required.
|
description: |
|
||||||
|
Returns server health status. Always returns HTTP 200, even when the
|
||||||
|
vault is sealed. No auth required.
|
||||||
|
|
||||||
|
When the vault is sealed, `status` is `"sealed"` and most other
|
||||||
|
endpoints return 503. When healthy, `status` is `"ok"`.
|
||||||
operationId: getHealth
|
operationId: getHealth
|
||||||
tags: [Public]
|
tags: [Public]
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Server is healthy.
|
description: Server is running (check `status` for sealed state).
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -327,6 +344,7 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
|
enum: [ok, sealed]
|
||||||
example: ok
|
example: ok
|
||||||
|
|
||||||
/v1/keys/public:
|
/v1/keys/public:
|
||||||
@@ -369,6 +387,121 @@ paths:
|
|||||||
description: Base64url-encoded public key bytes.
|
description: Base64url-encoded public key bytes.
|
||||||
example: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo
|
example: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo
|
||||||
|
|
||||||
|
/v1/vault/status:
|
||||||
|
get:
|
||||||
|
summary: Vault seal status
|
||||||
|
description: |
|
||||||
|
Returns whether the vault is currently sealed. Always accessible,
|
||||||
|
even when sealed. No auth required.
|
||||||
|
|
||||||
|
Clients should poll this after startup or after a 503 `vault_sealed`
|
||||||
|
response to determine when to attempt an unseal.
|
||||||
|
operationId: getVaultStatus
|
||||||
|
tags: [Public]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Current vault seal state.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [sealed]
|
||||||
|
properties:
|
||||||
|
sealed:
|
||||||
|
type: boolean
|
||||||
|
example: false
|
||||||
|
|
||||||
|
/v1/vault/unseal:
|
||||||
|
post:
|
||||||
|
summary: Unseal the vault
|
||||||
|
description: |
|
||||||
|
Provide the master passphrase to derive the encryption key, decrypt
|
||||||
|
the Ed25519 signing key, and unseal the vault. Once unsealed, all
|
||||||
|
other endpoints become available.
|
||||||
|
|
||||||
|
Rate limited to 3 requests per second per IP (burst 5) to limit
|
||||||
|
brute-force attempts against the passphrase.
|
||||||
|
|
||||||
|
The passphrase is never logged. A generic `"unseal failed"` error
|
||||||
|
is returned for any failure (wrong passphrase, vault already unsealed
|
||||||
|
mid-flight, etc.) to avoid leaking information.
|
||||||
|
operationId: unsealVault
|
||||||
|
tags: [Public]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [passphrase]
|
||||||
|
properties:
|
||||||
|
passphrase:
|
||||||
|
type: string
|
||||||
|
description: Master passphrase used to derive the encryption key.
|
||||||
|
example: correct-horse-battery-staple
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Vault unsealed (or was already unsealed).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [unsealed, already unsealed]
|
||||||
|
example: unsealed
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
description: Wrong passphrase or key decryption failure.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
example:
|
||||||
|
error: unseal failed
|
||||||
|
code: unauthorized
|
||||||
|
"429":
|
||||||
|
$ref: "#/components/responses/RateLimited"
|
||||||
|
|
||||||
|
/v1/vault/seal:
|
||||||
|
post:
|
||||||
|
summary: Seal the vault (admin)
|
||||||
|
description: |
|
||||||
|
Zero all key material in memory and transition the server to the
|
||||||
|
sealed state. After this call:
|
||||||
|
|
||||||
|
- All subsequent requests (except health, vault status, and unseal)
|
||||||
|
return 503 `vault_sealed`.
|
||||||
|
- The caller's own JWT is immediately invalidated because the public
|
||||||
|
key needed to verify it is no longer held in memory.
|
||||||
|
- The server can be unsealed again via `POST /v1/vault/unseal`.
|
||||||
|
|
||||||
|
This is an emergency operation. Use it to protect key material if a
|
||||||
|
compromise is suspected. It does **not** restart the server or wipe
|
||||||
|
the database.
|
||||||
|
operationId: sealVault
|
||||||
|
tags: [Admin — Vault]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Vault sealed (or was already sealed).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [sealed, already sealed]
|
||||||
|
example: sealed
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
|
||||||
/v1/auth/login:
|
/v1/auth/login:
|
||||||
post:
|
post:
|
||||||
summary: Login
|
summary: Login
|
||||||
@@ -473,6 +606,10 @@ paths:
|
|||||||
format: uuid
|
format: uuid
|
||||||
description: Subject (account UUID). Present when valid=true.
|
description: Subject (account UUID). Present when valid=true.
|
||||||
example: 550e8400-e29b-41d4-a716-446655440000
|
example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Account username. Present when valid=true and the account exists.
|
||||||
|
example: alice
|
||||||
roles:
|
roles:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -486,7 +623,7 @@ paths:
|
|||||||
example: "2026-04-10T12:34:56Z"
|
example: "2026-04-10T12:34:56Z"
|
||||||
examples:
|
examples:
|
||||||
valid:
|
valid:
|
||||||
value: {valid: true, sub: "550e8400-...", roles: [editor], expires_at: "2026-04-10T12:34:56Z"}
|
value: {valid: true, sub: "550e8400-...", username: alice, roles: [editor], expires_at: "2026-04-10T12:34:56Z"}
|
||||||
invalid:
|
invalid:
|
||||||
value: {valid: false}
|
value: {valid: false}
|
||||||
"429":
|
"429":
|
||||||
@@ -550,6 +687,17 @@ paths:
|
|||||||
tags: [Auth]
|
tags: [Auth]
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [password]
|
||||||
|
properties:
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: Current account password (required to prevent session-theft escalation).
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: TOTP secret generated.
|
description: TOTP secret generated.
|
||||||
@@ -995,6 +1143,76 @@ paths:
|
|||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
post:
|
||||||
|
summary: Grant a role to an account (admin)
|
||||||
|
description: |
|
||||||
|
Add a single role to an account's role set. If the role already exists,
|
||||||
|
this is a no-op. Roles take effect in the **next** token issued or
|
||||||
|
renewed; existing tokens continue to carry the roles embedded at
|
||||||
|
issuance time.
|
||||||
|
operationId: grantRole
|
||||||
|
tags: [Admin — Accounts]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [role]
|
||||||
|
properties:
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
example: editor
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Role granted.
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/v1/accounts/{id}/roles/{role}:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
- name: role
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: editor
|
||||||
|
|
||||||
|
delete:
|
||||||
|
summary: Revoke a role from an account (admin)
|
||||||
|
description: |
|
||||||
|
Remove a single role from an account's role set. Roles take effect in
|
||||||
|
the **next** token issued or renewed; existing tokens continue to carry
|
||||||
|
the roles embedded at issuance time.
|
||||||
|
operationId: revokeRole
|
||||||
|
tags: [Admin — Accounts]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Role revoked.
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
/v1/accounts/{id}/pgcreds:
|
/v1/accounts/{id}/pgcreds:
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@@ -1053,6 +1271,70 @@ paths:
|
|||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/v1/pgcreds:
|
||||||
|
get:
|
||||||
|
summary: List accessible Postgres credentials
|
||||||
|
description: |
|
||||||
|
Return all Postgres credentials accessible to the authenticated account:
|
||||||
|
credentials owned by the account plus any explicitly granted by an admin.
|
||||||
|
|
||||||
|
The `id` field is the credential record ID; use it together with the
|
||||||
|
`service_account_id` to fetch full details via
|
||||||
|
`GET /v1/accounts/{id}/pgcreds`. Passwords are **not** returned by this
|
||||||
|
endpoint.
|
||||||
|
operationId: listAccessiblePGCreds
|
||||||
|
tags: [Admin — Credentials]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Array of accessible Postgres credential summaries.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [id, service_account_id, host, port, database, username, created_at, updated_at]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
description: Credential record ID.
|
||||||
|
example: 7
|
||||||
|
service_account_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: UUID of the system account that owns these credentials.
|
||||||
|
example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
service_account_name:
|
||||||
|
type: string
|
||||||
|
description: Username of the owning system account (omitted if unavailable).
|
||||||
|
example: payments-api
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
example: db.example.com
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
example: 5432
|
||||||
|
database:
|
||||||
|
type: string
|
||||||
|
example: mydb
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
example: myuser
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: "2026-03-11T09:00:00Z"
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: "2026-03-11T09:00:00Z"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/VaultSealed"
|
||||||
|
|
||||||
/v1/audit:
|
/v1/audit:
|
||||||
get:
|
get:
|
||||||
summary: Query audit log (admin)
|
summary: Query audit log (admin)
|
||||||
@@ -1067,7 +1349,7 @@ paths:
|
|||||||
`pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`,
|
`pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`,
|
||||||
`pgcred_access_revoked`, `tag_added`, `tag_removed`,
|
`pgcred_access_revoked`, `tag_added`, `tag_removed`,
|
||||||
`policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`,
|
`policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`,
|
||||||
`policy_deny`.
|
`policy_deny`, `vault_sealed`, `vault_unsealed`.
|
||||||
operationId: listAudit
|
operationId: listAudit
|
||||||
tags: [Admin — Audit]
|
tags: [Admin — Audit]
|
||||||
security:
|
security:
|
||||||
@@ -1257,7 +1539,7 @@ paths:
|
|||||||
summary: List policy rules (admin)
|
summary: List policy rules (admin)
|
||||||
description: |
|
description: |
|
||||||
Return all operator-defined policy rules ordered by priority (ascending).
|
Return all operator-defined policy rules ordered by priority (ascending).
|
||||||
Built-in default rules (IDs -1 to -6) are not included.
|
Built-in default rules (IDs -1 to -7) are not included.
|
||||||
operationId: listPolicyRules
|
operationId: listPolicyRules
|
||||||
tags: [Admin — Policy]
|
tags: [Admin — Policy]
|
||||||
security:
|
security:
|
||||||
@@ -1449,3 +1731,5 @@ tags:
|
|||||||
description: Requires admin role.
|
description: Requires admin role.
|
||||||
- name: Admin — Policy
|
- name: Admin — Policy
|
||||||
description: Requires admin role. Manage policy rules and account tags.
|
description: Requires admin role. Manage policy rules and account tags.
|
||||||
|
- name: Admin — Vault
|
||||||
|
description: Requires admin role. Emergency vault seal operation.
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
//
|
//
|
||||||
// Prerequisites: protoc, protoc-gen-go, protoc-gen-go-grpc must be in PATH.
|
// Prerequisites: protoc, protoc-gen-go, protoc-gen-go-grpc must be in PATH.
|
||||||
//
|
//
|
||||||
//go:generate protoc --proto_path=../proto --go_out=../gen --go_opt=paths=source_relative --go-grpc_out=../gen --go-grpc_opt=paths=source_relative mcias/v1/common.proto mcias/v1/admin.proto mcias/v1/auth.proto mcias/v1/token.proto mcias/v1/account.proto
|
//go:generate protoc --proto_path=../proto --go_out=../gen --go_opt=paths=source_relative --go-grpc_out=../gen --go-grpc_opt=paths=source_relative mcias/v1/common.proto mcias/v1/admin.proto mcias/v1/auth.proto mcias/v1/token.proto mcias/v1/account.proto mcias/v1/policy.proto
|
||||||
package proto
|
package proto
|
||||||
|
|||||||
@@ -78,6 +78,24 @@ message SetRolesRequest {
|
|||||||
// SetRolesResponse confirms the update.
|
// SetRolesResponse confirms the update.
|
||||||
message SetRolesResponse {}
|
message SetRolesResponse {}
|
||||||
|
|
||||||
|
// GrantRoleRequest adds a single role to an account.
|
||||||
|
message GrantRoleRequest {
|
||||||
|
string id = 1; // UUID
|
||||||
|
string role = 2; // role name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantRoleResponse confirms the grant.
|
||||||
|
message GrantRoleResponse {}
|
||||||
|
|
||||||
|
// RevokeRoleRequest removes a single role from an account.
|
||||||
|
message RevokeRoleRequest {
|
||||||
|
string id = 1; // UUID
|
||||||
|
string role = 2; // role name
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeRoleResponse confirms the revocation.
|
||||||
|
message RevokeRoleResponse {}
|
||||||
|
|
||||||
// AccountService manages accounts and roles. All RPCs require admin role.
|
// AccountService manages accounts and roles. All RPCs require admin role.
|
||||||
service AccountService {
|
service AccountService {
|
||||||
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
|
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
|
||||||
@@ -87,6 +105,8 @@ service AccountService {
|
|||||||
rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse);
|
rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse);
|
||||||
rpc GetRoles(GetRolesRequest) returns (GetRolesResponse);
|
rpc GetRoles(GetRolesRequest) returns (GetRolesResponse);
|
||||||
rpc SetRoles(SetRolesRequest) returns (SetRolesResponse);
|
rpc SetRoles(SetRolesRequest) returns (SetRolesResponse);
|
||||||
|
rpc GrantRole(GrantRoleRequest) returns (GrantRoleResponse);
|
||||||
|
rpc RevokeRole(RevokeRoleRequest) returns (RevokeRoleResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PG credentials ---
|
// --- PG credentials ---
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user