Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c16354725 | |||
| 89f78a38dd | |||
| 4d6c5cb67c | |||
| f880bbb6de | |||
| d3d656a23f | |||
| 28bc33a96d | |||
| 98ed858c67 | |||
| 35f27b7c4f |
@@ -5,7 +5,26 @@
|
|||||||
"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:*)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "go build ./... 2>&1 | head -20"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
332
AUDIT.md
332
AUDIT.md
@@ -1,258 +1,202 @@
|
|||||||
# 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-12
|
||||||
**Auditor:** Comprehensive source review of all Go source files, protobuf definitions, Dockerfile, systemd unit, and client libraries
|
**Scope:** Full codebase — authentication flows, token lifecycle, cryptography, database layer, REST/gRPC/UI servers, authorization, and operational security.
|
||||||
**Classification:** Findings rated as **CRITICAL**, **HIGH**, **MEDIUM**, **LOW**, or **INFORMATIONAL**
|
**Methodology:** Static code analysis of all source files with adversarial focus on auth flows, crypto usage, input handling, and inter-component trust boundaries.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 demonstrates strong security awareness throughout. The cryptographic foundations are sound, credential handling is careful, and the most common web/API authentication vulnerabilities have been explicitly addressed. The codebase shows consistent attention to defense-in-depth: constant-time comparisons, dummy Argon2 operations for unknown users, algorithm-confusion prevention in JWT validation, parameterized SQL, audit logging, and CSRF protection with HMAC-signed double-submit.
|
||||||
|
|
||||||
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.
|
**Two confirmed bugs with real security impact were found**, along with several defense-in-depth gaps that should be addressed before production deployment. The overall security posture is well above average for this class of system.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FINDINGS
|
## Confirmed Vulnerabilities
|
||||||
|
|
||||||
### F-01: TOTP Enrollment Sets `totp_required=1` Before Confirmation (MEDIUM)
|
### CRIT-01 — TOTP Replay Attack (Medium-High)
|
||||||
|
|
||||||
**Location:** `internal/db/accounts.go:131-141`, `internal/server/server.go:651-658`
|
**File:** `internal/auth/auth.go:208-230`, `internal/grpcserver/auth.go:84`, `internal/ui/handlers_auth.go:152`
|
||||||
|
|
||||||
`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.
|
`ValidateTOTP` accepts any code falling in the current ±1 time-step window (±30 seconds, so a given code is valid for ~90 seconds) but **never records which codes have already been used**. The same valid TOTP code can be submitted an unlimited number of times within that window. There is no `last_used_totp_counter` or `last_used_totp_at` field in the schema.
|
||||||
|
|
||||||
**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.
|
**Attack scenario:** An attacker who has observed a valid TOTP code (e.g. from a compromised session, shoulder surfing, or a MITM that delayed delivery) can reuse that code to authenticate within its validity window.
|
||||||
|
|
||||||
|
**Fix:** Track the last accepted TOTP counter per account in the database. Reject any counter ≤ the last accepted one. This requires a new column (`last_totp_counter INTEGER`) on the `accounts` table and a check-and-update in `ValidateTOTP`'s callers (or within it, with a DB reference passed in).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-02: Password Embedded in HTML Hidden Fields During TOTP Step (MEDIUM)
|
### CRIT-02 — gRPC `EnrollTOTP` Enables TOTP Before Confirmation (Medium)
|
||||||
|
|
||||||
**Location:** `internal/ui/handlers_auth.go:74-84`
|
**File:** `internal/grpcserver/auth.go:202` vs `internal/server/server.go:724-728`
|
||||||
|
|
||||||
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:
|
The REST `EnrollTOTP` handler explicitly uses `StorePendingTOTP` (which keeps `totp_required=0`) and a comment at line 724 explains why:
|
||||||
1. The password exists in the DOM and is accessible to any browser extension or XSS-via-extension vector.
|
|
||||||
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.
|
|
||||||
|
|
||||||
**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.
|
```go
|
||||||
|
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required
|
||||||
---
|
// is not enabled until the user confirms the code.
|
||||||
|
|
||||||
### 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 gRPC `EnrollTOTP` handler at line 202 calls `SetTOTP` directly, which immediately sets `totp_required=1`. Any user who initiates TOTP enrollment over gRPC but does not immediately confirm will have their account locked out — they cannot log in because TOTP is required, but no working TOTP secret is confirmed.
|
||||||
|
|
||||||
### F-12: No Input Validation on Username Length or Character Set (LOW)
|
**Fix:** Change `grpcserver/auth.go:202` from `a.s.db.SetTOTP(...)` to `a.s.db.StorePendingTOTP(...)`, matching the REST server's behavior and the documented intent of those two DB methods.
|
||||||
|
|
||||||
**Location:** `internal/server/server.go:465-507`
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
**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)
|
## Defense-in-Depth Gaps
|
||||||
|
|
||||||
**Location:** `internal/auth/auth.go:63-66`
|
### DEF-01 — No Rate Limiting on the UI Login Endpoint (Medium)
|
||||||
|
|
||||||
`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.
|
**File:** `internal/ui/ui.go:264`
|
||||||
|
|
||||||
**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.
|
```go
|
||||||
|
uiMux.HandleFunc("POST /login", u.handleLoginPost)
|
||||||
|
```
|
||||||
|
|
||||||
|
The REST `/v1/auth/login` endpoint is wrapped with `loginRateLimit` (10 req/s per IP). The UI `/login` endpoint has no equivalent middleware. Account lockout (10 failures per 15 minutes) partially mitigates brute force, but an attacker can still enumerate whether accounts exist at full network speed before triggering lockout, and can trigger lockout against many accounts in parallel with no rate friction.
|
||||||
|
|
||||||
|
**Fix:** Apply the same `middleware.RateLimit(10, 10)` to `POST /login` in the UI mux. A simpler option is to wrap the entire `uiMux` with the rate limiter since the UI is also a sensitive surface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-14: Passphrase Not Zeroed After Use in `loadMasterKey` (LOW)
|
### DEF-02 — `pendingLogins` Map Has No Expiry Cleanup (Low)
|
||||||
|
|
||||||
**Location:** `cmd/mciassrv/main.go:246-272`
|
**File:** `internal/ui/ui.go:57`
|
||||||
|
|
||||||
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 `pendingLogins sync.Map` stores short-lived TOTP nonces (90-second TTL). When consumed via `consumeTOTPNonce`, entries are deleted via `LoadAndDelete`. However, entries that are created but never consumed (user abandons login at the TOTP step, closes browser) **accumulate indefinitely** — they are checked for expiry on read but never proactively deleted.
|
||||||
|
|
||||||
**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.
|
In normal operation this is a minor memory leak. Under adversarial conditions — an attacker repeatedly sending username+password to step 1 without proceeding to step 2 — the map grows without bound. At scale this could be used for memory exhaustion.
|
||||||
|
|
||||||
|
**Fix:** Add a background goroutine (matching the pattern in `middleware.RateLimit`) that periodically iterates the map and deletes expired entries. A 5-minute cleanup interval is sufficient given the 90-second TTL.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-15: `extractBearerFromRequest` Does Not Verify "Bearer" Prefix Case-Insensitively (INFORMATIONAL)
|
### DEF-03 — Rate Limiter Uses `RemoteAddr`, Not `X-Forwarded-For` (Low)
|
||||||
|
|
||||||
**Location:** `internal/server/server.go:932-942`
|
**File:** `internal/middleware/middleware.go:200`
|
||||||
|
|
||||||
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 comment already acknowledges this: the rate limiter extracts the client IP from `r.RemoteAddr`. When the server is deployed behind a reverse proxy (nginx, Caddy, a load balancer), `RemoteAddr` will be the proxy's IP for all requests, collapsing all clients into a single rate-limit bucket. This effectively disables per-IP rate limiting in proxy deployments.
|
||||||
|
|
||||||
**Recommendation:** Use `strings.EqualFold` for the prefix check in `extractBearerFromRequest` for consistency.
|
**Fix:** Add a configurable `TrustedProxy` setting. When set, extract the real client IP from `X-Forwarded-For` or `X-Real-IP` headers only for requests coming from that proxy address. Never trust those headers unconditionally — doing so allows IP spoofing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-16: UI System Token Issuance Does Not Revoke Previous System Token (LOW)
|
### DEF-04 — Missing `nbf` (Not Before) Claim on Issued Tokens (Low)
|
||||||
|
|
||||||
**Location:** `internal/ui/handlers_accounts.go:334-403`
|
**File:** `internal/token/token.go:73-82`
|
||||||
|
|
||||||
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.
|
`IssueToken` sets `iss`, `sub`, `iat`, `exp`, and `jti`, but not `nbf`. Without a not-before constraint, a token is valid from the moment of issuance and a slightly clock-skewed client or intermediate could present it early. This is a defense-in-depth measure, not a practical attack at the moment, but it costs nothing to add.
|
||||||
|
|
||||||
**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")`.
|
**Fix:** Add `NotBefore: jwt.NewNumericDate(now)` to the `RegisteredClaims` struct. Add the corresponding validation step in `ValidateToken` (using `jwt.WithNotBefore()` or a manual check).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Positive Findings (Things Done Well)
|
### DEF-05 — No Maximum Token Expiry Ceiling in Config Validation (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/config/config.go:150-158`
|
||||||
|
|
||||||
2. **Constant-time comparisons** are consistently used for password verification, TOTP validation, and CSRF token validation via `crypto/subtle.ConstantTimeCompare`.
|
The config validator enforces that expiry durations are positive but not that they are bounded above. An operator misconfiguration (e.g. `service_expiry = "876000h"`) would issue tokens valid for 100 years. For human sessions (`default_expiry`, `admin_expiry`) this is a significant risk in the event of token theft.
|
||||||
|
|
||||||
3. **Timing uniformity** for failed logins: dummy Argon2 operations run for unknown users and inactive accounts, preventing username enumeration via timing differences.
|
**Fix:** Add upper-bound checks in `validate()`. Suggested maximums: 30 days for `default_expiry`, 24 hours for `admin_expiry`, 5 years for `service_expiry`. At minimum, log a warning when values exceed reasonable thresholds.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
5. **Parameterized SQL** is used consistently throughout. No string concatenation in queries. The dynamic query builder in `ListAuditEvents`/`ListAuditEventsPaged` correctly uses parameter placeholders.
|
|
||||||
|
|
||||||
6. **TLS configuration** is solid: TLS 1.2 minimum, X25519/P256 curves, enforced at the listener level with no plaintext fallback.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
### DEF-06 — `GetAccountByUsername` Comment Incorrect re: Case Sensitivity (Informational)
|
||||||
|
|
||||||
| Fixed? | ID | Severity | Title | Effort |
|
**File:** `internal/db/accounts.go:73`
|
||||||
|--------|----|----------|-------|--------|
|
|
||||||
| Yes | F-01 | MEDIUM | TOTP enrollment sets required=1 before confirmation | Small |
|
The comment reads "case-insensitive" but the query uses `WHERE username = ?` with SQLite's default BINARY collation, which is **case-sensitive**. This means `admin` and `Admin` would be treated as distinct accounts. This is not a security bug by itself, but it contradicts the comment and could mask confusion.
|
||||||
| Yes | F-02 | MEDIUM | Password in HTML hidden fields during TOTP step | Medium |
|
|
||||||
| Yes | F-03 | MEDIUM | Token renewal not atomic (race window) | Small |
|
**Fix:** If case-insensitive matching is intended, add `COLLATE NOCASE` to the column definition or the query. If case-sensitive is correct (more common for SSO systems), remove the word "case-insensitive" from the comment.
|
||||||
| Yes | F-04 | MEDIUM | Rate limiter not applied to REST login endpoint | Small |
|
|
||||||
| Yes | F-11 | MEDIUM | Missing security headers on UI responses | Small |
|
|
||||||
| 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
|
### DEF-07 — SQLite `synchronous=NORMAL` in WAL Mode (Low)
|
||||||
|
|
||||||
**Immediate (before production deployment):**
|
**File:** `internal/db/db.go:68`
|
||||||
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:**
|
With `PRAGMA synchronous=NORMAL` and `journal_mode=WAL`, SQLite syncs the WAL file on checkpoints but not on every write. A power failure between a write and the next checkpoint could lose the most recent transactions. For an authentication system — where token issuance and revocation records must be durable — this is a meaningful risk.
|
||||||
4. F-03 — Make token renewal atomic.
|
|
||||||
5. F-02 — Replace password-in-hidden-field with a server-side nonce.
|
|
||||||
6. F-16 — Fix UI system token issuance to revoke old tokens.
|
|
||||||
7. F-07 — Use a real dummy hash with matching parameters.
|
|
||||||
|
|
||||||
**Medium-term:**
|
**Fix:** Change to `PRAGMA synchronous=FULL`. For a single-node personal SSO the performance impact is negligible; durability of token revocations is worth it.
|
||||||
8. F-08 — Implement account lockout.
|
|
||||||
9. F-12, F-13 — Input validation for usernames and passwords.
|
---
|
||||||
10. Remaining LOW/INFO items at maintainer discretion.
|
|
||||||
|
### DEF-08 — gRPC `Login` Counts TOTP-Missing as a Login Failure (Low)
|
||||||
|
|
||||||
|
**File:** `internal/grpcserver/auth.go:76-77`
|
||||||
|
|
||||||
|
When TOTP is required but no code is provided (`req.TotpCode == ""`), the gRPC handler calls `RecordLoginFailure`. In the two-step UI flow this is defensible, but via the gRPC single-step `Login` RPC, a well-behaved client that has not yet obtained the TOTP code (not an attacker) will increment the failure counter. Repeated retries could trigger account lockout unintentionally.
|
||||||
|
|
||||||
|
**Fix:** Either document that gRPC clients must always include the TOTP code and treat its omission as a deliberate attempt, or do not count "TOTP code required" as a failure (since the password was verified successfully at that point).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DEF-09 — Security Headers Missing on REST API Docs Endpoints (Informational)
|
||||||
|
|
||||||
|
**File:** `internal/server/server.go:85-94`
|
||||||
|
|
||||||
|
The `/docs` and `/docs/openapi.yaml` endpoints are served from the parent `mux` and therefore do not receive the `securityHeaders` middleware applied to the UI sub-mux. The Swagger UI page at `/docs` is served without `X-Frame-Options`, `Content-Security-Policy`, etc.
|
||||||
|
|
||||||
|
**Fix:** Apply a security-headers middleware to the docs handlers, or move them into the UI sub-mux.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DEF-10 — Role Strings Not Validated Against an Allowlist (Low)
|
||||||
|
|
||||||
|
**File:** `internal/db/accounts.go:302-311` (`GrantRole`)
|
||||||
|
|
||||||
|
There is no allowlist for role strings written to the `account_roles` table. Any string can be stored. While the admin-only constraint prevents non-admins from calling these endpoints, a typo by an admin (e.g. `"admim"`) would silently create an unknown role that silently grants nothing. The `RequireRole` check would never match it, causing a confusing failure mode.
|
||||||
|
|
||||||
|
**Fix:** Maintain a compile-time allowlist of valid roles (e.g. `"admin"`, `"user"`) and reject unknown role names at the handler layer before writing to the database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Positive Findings
|
||||||
|
|
||||||
|
The following implementation details are exemplary and should be preserved:
|
||||||
|
|
||||||
|
| Area | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| JWT alg confusion | `ValidateToken` enforces `alg=EdDSA` in the key function, before signature verification — the only correct place |
|
||||||
|
| Constant-time comparisons | `crypto/subtle.ConstantTimeCompare` used consistently for password hashes, TOTP codes, and CSRF tokens |
|
||||||
|
| Timing uniformity | Dummy Argon2 computed (once, with full production parameters via `sync.Once`) for unknown/inactive users on both REST and gRPC paths |
|
||||||
|
| Token revocation | Every token is tracked by JTI; unknown tokens are rejected (fail-closed) rather than silently accepted |
|
||||||
|
| Token renewal atomicity | `RenewToken` wraps revocation + insertion in a single SQLite transaction |
|
||||||
|
| TOTP nonce design | Two-step UI login uses a 128-bit single-use server-side nonce to avoid transmitting the password twice |
|
||||||
|
| CSRF protection | HMAC-SHA256 signed double-submit cookie with `SameSite=Strict` and constant-time validation |
|
||||||
|
| Credential exclusion | `json:"-"` tags on all credential fields; proto messages omit them too |
|
||||||
|
| Security headers | All UI responses receive CSP, `X-Content-Type-Options`, `X-Frame-Options`, HSTS, and `Referrer-Policy` |
|
||||||
|
| Account lockout | 10-attempt, 15-minute rolling lockout checked before Argon2 to prevent timing oracle |
|
||||||
|
| Argon2id parameters | Config validator enforces OWASP 2023 minimums and rejects weakening |
|
||||||
|
| SQL injection | All queries use parameterized statements; no string concatenation anywhere |
|
||||||
|
| Audit log | Append-only with actor/target/IP; no delete path provided |
|
||||||
|
| Master key handling | Env var cleared after reading; signing key zeroed on shutdown |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remediation Priority
|
||||||
|
|
||||||
|
| Fixed | Priority | ID | Severity | Action |
|
||||||
|
|-------|----------|----|----------|--------|
|
||||||
|
| Yes | 1 | CRIT-02 | Medium | Change `grpcserver/auth.go:202` to call `StorePendingTOTP` instead of `SetTOTP` |
|
||||||
|
| Yes | 2 | CRIT-01 | Medium | Add `last_totp_counter` tracking to prevent TOTP replay within the validity window |
|
||||||
|
| Yes | 3 | DEF-01 | Medium | Apply IP rate limiting to the UI `POST /login` endpoint |
|
||||||
|
| Yes | 4 | DEF-02 | Low | Add background cleanup goroutine for the `pendingLogins` map |
|
||||||
|
| Yes | 5 | DEF-03 | Low | Support trusted-proxy IP extraction for accurate per-client rate limiting |
|
||||||
|
| Yes | 6 | DEF-04 | Low | Add `nbf` claim to issued tokens and validate it on receipt |
|
||||||
|
| Yes | 7 | DEF-05 | Low | Add upper-bound caps on token expiry durations in config validation |
|
||||||
|
| Yes | 8 | DEF-07 | Low | Change SQLite to `PRAGMA synchronous=FULL` |
|
||||||
|
| Yes | 9 | DEF-08 | Low | Do not count gRPC TOTP-missing as a login failure |
|
||||||
|
| Yes | 10 | DEF-10 | Low | Validate role strings against an allowlist before writing to the DB |
|
||||||
|
| Yes | 11 | DEF-09 | Info | Apply security headers to `/docs` endpoints |
|
||||||
|
| Yes | 12 | DEF-06 | Info | Correct the misleading "case-insensitive" comment in `GetAccountByUsername` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Observations
|
||||||
|
|
||||||
|
The migration chain (migrations 001–006) is sound. Foreign key cascades are appropriate. Indexes are present on all commonly-queried columns. The `failed_logins` table uses a rolling window query approach which is correct.
|
||||||
|
|
||||||
|
One note: the `accounts` table has no unique index enforcing `COLLATE NOCASE` on `username`. This is consistent with treating usernames as case-sensitive but should be documented explicitly to avoid future ambiguity.
|
||||||
|
|||||||
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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
60
PROGRESS.md
60
PROGRESS.md
@@ -4,6 +4,59 @@ Source of truth for current development state.
|
|||||||
---
|
---
|
||||||
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
||||||
|
|
||||||
|
### 2026-03-12 — 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
|
||||||
|
|
||||||
Added the ability for users to change their own password and for admins to
|
Added the ability for users to change their own password and for admins to
|
||||||
@@ -394,9 +447,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*,
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -149,7 +149,7 @@ MCIAS_MASTER_PASSPHRASE=your-passphrase mciassrv -config /etc/mcias/mcias.conf
|
|||||||
### 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
|
||||||
@@ -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:
|
||||||
@@ -278,7 +278,7 @@ docker run -d \
|
|||||||
-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.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// 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 mciasgoclient
|
||||||
|
|
||||||
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("mciasgoclient: 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,6 +72,7 @@ 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"`
|
||||||
@@ -66,6 +80,7 @@ type TokenClaims struct {
|
|||||||
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 +89,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 +185,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 +195,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 +229,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 +302,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 +320,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 +338,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 +357,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 +382,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 +396,63 @@ 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",
|
func (c *Client) EnrollTOTP() (*TOTPEnrollResponse, error) {
|
||||||
map[string]string{"token": token}, &claims); err != nil {
|
var resp TOTPEnrollResponse
|
||||||
|
if err := c.do(http.MethodPost, "/v1/auth/totp/enroll", nil, &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 +469,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 +478,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 +504,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 +559,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 +577,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 +589,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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// All tests use inline httptest.NewServer mocks to keep this module
|
// All tests use inline httptest.NewServer mocks to keep this module
|
||||||
// self-contained (no cross-module imports).
|
// self-contained (no cross-module imports).
|
||||||
package mciasgoclient_test
|
package mciasgoclient_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -9,12 +10,14 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go"
|
mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// helpers
|
// helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// newTestClient creates a client pointed at the given test server URL.
|
|
||||||
func newTestClient(t *testing.T, serverURL string) *mciasgoclient.Client {
|
func newTestClient(t *testing.T, serverURL string) *mciasgoclient.Client {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := mciasgoclient.New(serverURL, mciasgoclient.Options{})
|
c, err := mciasgoclient.New(serverURL, mciasgoclient.Options{})
|
||||||
@@ -23,19 +26,21 @@ func newTestClient(t *testing.T, serverURL string) *mciasgoclient.Client {
|
|||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
// writeJSON is a shorthand for writing a JSON response.
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
// writeError writes a JSON error body with the given status code.
|
|
||||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestNew
|
// TestNew
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew(t *testing.T) {
|
||||||
c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{})
|
c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -45,6 +50,7 @@ func TestNew(t *testing.T) {
|
|||||||
t.Fatal("expected non-nil client")
|
t.Fatal("expected non-nil client")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewWithPresetToken(t *testing.T) {
|
func TestNewWithPresetToken(t *testing.T) {
|
||||||
c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{Token: "preset-tok"})
|
c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{Token: "preset-tok"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,15 +60,18 @@ func TestNewWithPresetToken(t *testing.T) {
|
|||||||
t.Errorf("expected preset-tok, got %q", c.Token())
|
t.Errorf("expected preset-tok, got %q", c.Token())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewBadCACert(t *testing.T) {
|
func TestNewBadCACert(t *testing.T) {
|
||||||
_, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{CACertPath: "/nonexistent/ca.pem"})
|
_, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{CACertPath: "/nonexistent/ca.pem"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for missing CA cert file")
|
t.Fatal("expected error for missing CA cert file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestHealth
|
// TestHealth
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestHealth(t *testing.T) {
|
func TestHealth(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/v1/health" || r.Method != http.MethodGet {
|
if r.URL.Path != "/v1/health" || r.Method != http.MethodGet {
|
||||||
@@ -77,9 +86,7 @@ func TestHealth(t *testing.T) {
|
|||||||
t.Fatalf("Health: unexpected error: %v", err)
|
t.Fatalf("Health: unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TestHealthError
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
func TestHealthError(t *testing.T) {
|
func TestHealthError(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusServiceUnavailable, "service unavailable")
|
writeError(w, http.StatusServiceUnavailable, "service unavailable")
|
||||||
@@ -98,9 +105,11 @@ func TestHealthError(t *testing.T) {
|
|||||||
t.Errorf("expected StatusCode 503, got %d", srvErr.StatusCode)
|
t.Errorf("expected StatusCode 503, got %d", srvErr.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestGetPublicKey
|
// TestGetPublicKey
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestGetPublicKey(t *testing.T) {
|
func TestGetPublicKey(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/v1/keys/public" {
|
if r.URL.Path != "/v1/keys/public" {
|
||||||
@@ -131,9 +140,11 @@ func TestGetPublicKey(t *testing.T) {
|
|||||||
t.Error("expected non-empty x")
|
t.Error("expected non-empty x")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestLogin
|
// TestLogin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestLogin(t *testing.T) {
|
func TestLogin(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/v1/auth/login" || r.Method != http.MethodPost {
|
if r.URL.Path != "/v1/auth/login" || r.Method != http.MethodPost {
|
||||||
@@ -157,14 +168,11 @@ func TestLogin(t *testing.T) {
|
|||||||
if exp == "" {
|
if exp == "" {
|
||||||
t.Error("expected non-empty expires_at")
|
t.Error("expected non-empty expires_at")
|
||||||
}
|
}
|
||||||
// Token must be stored in the client.
|
|
||||||
if c.Token() != "tok-abc123" {
|
if c.Token() != "tok-abc123" {
|
||||||
t.Errorf("Token() = %q, want tok-abc123", c.Token())
|
t.Errorf("Token() = %q, want tok-abc123", c.Token())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TestLoginUnauthorized
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
func TestLoginUnauthorized(t *testing.T) {
|
func TestLoginUnauthorized(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
@@ -180,16 +188,17 @@ func TestLoginUnauthorized(t *testing.T) {
|
|||||||
t.Errorf("expected MciasAuthError, got %T: %v", err, err)
|
t.Errorf("expected MciasAuthError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestLogout
|
// TestLogout
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestLogout(t *testing.T) {
|
func TestLogout(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/v1/auth/login":
|
case "/v1/auth/login":
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
"token": "tok-logout",
|
"token": "tok-logout", "expires_at": "2099-01-01T00:00:00Z",
|
||||||
"expires_at": "2099-01-01T00:00:00Z",
|
|
||||||
})
|
})
|
||||||
case "/v1/auth/logout":
|
case "/v1/auth/logout":
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -212,21 +221,21 @@ func TestLogout(t *testing.T) {
|
|||||||
t.Errorf("expected empty token after logout, got %q", c.Token())
|
t.Errorf("expected empty token after logout, got %q", c.Token())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestRenewToken
|
// TestRenewToken
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestRenewToken(t *testing.T) {
|
func TestRenewToken(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/v1/auth/login":
|
case "/v1/auth/login":
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
"token": "tok-old",
|
"token": "tok-old", "expires_at": "2099-01-01T00:00:00Z",
|
||||||
"expires_at": "2099-01-01T00:00:00Z",
|
|
||||||
})
|
})
|
||||||
case "/v1/auth/renew":
|
case "/v1/auth/renew":
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
"token": "tok-new",
|
"token": "tok-new", "expires_at": "2099-06-01T00:00:00Z",
|
||||||
"expires_at": "2099-06-01T00:00:00Z",
|
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
@@ -248,9 +257,125 @@ func TestRenewToken(t *testing.T) {
|
|||||||
t.Errorf("Token() = %q, want tok-new", c.Token())
|
t.Errorf("Token() = %q, want tok-new", c.Token())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestEnrollTOTP / TestConfirmTOTP
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestEnrollTOTP(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/auth/totp/enroll" || r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"secret": "JBSWY3DPEHPK3PXP",
|
||||||
|
"otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
resp, err := c.EnrollTOTP()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnrollTOTP: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Secret != "JBSWY3DPEHPK3PXP" {
|
||||||
|
t.Errorf("expected secret=JBSWY3DPEHPK3PXP, got %q", resp.Secret)
|
||||||
|
}
|
||||||
|
if resp.OTPAuthURI == "" {
|
||||||
|
t.Error("expected non-empty otpauth_uri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmTOTP(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/auth/totp/confirm" || r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
if err := c.ConfirmTOTP("123456"); err != nil {
|
||||||
|
t.Fatalf("ConfirmTOTP: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmTOTPBadCode(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid TOTP code")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
err := c.ConfirmTOTP("000000")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for bad TOTP code")
|
||||||
|
}
|
||||||
|
var inputErr *mciasgoclient.MciasInputError
|
||||||
|
if !errors.As(err, &inputErr) {
|
||||||
|
t.Errorf("expected MciasInputError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestChangePassword
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestChangePassword(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/auth/password" || r.Method != http.MethodPut {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
if err := c.ChangePassword("old-s3cr3t", "new-s3cr3t-long"); err != nil {
|
||||||
|
t.Fatalf("ChangePassword: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePasswordWrongCurrent(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeError(w, http.StatusUnauthorized, "current password is incorrect")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
err := c.ChangePassword("wrong", "new-s3cr3t-long")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for wrong current password")
|
||||||
|
}
|
||||||
|
var authErr *mciasgoclient.MciasAuthError
|
||||||
|
if !errors.As(err, &authErr) {
|
||||||
|
t.Errorf("expected MciasAuthError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestRemoveTOTP
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRemoveTOTP(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/auth/totp" || r.Method != http.MethodDelete {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
if err := c.RemoveTOTP("acct-uuid-42"); err != nil {
|
||||||
|
t.Fatalf("RemoveTOTP: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestValidateToken
|
// TestValidateToken
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestValidateToken(t *testing.T) {
|
func TestValidateToken(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/v1/token/validate" {
|
if r.URL.Path != "/v1/token/validate" {
|
||||||
@@ -258,10 +383,8 @@ func TestValidateToken(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"valid": true,
|
"valid": true, "sub": "user-uuid-1",
|
||||||
"sub": "user-uuid-1",
|
"roles": []string{"admin"}, "expires_at": "2099-01-01T00:00:00Z",
|
||||||
"roles": []string{"admin"},
|
|
||||||
"expires_at": "2099-01-01T00:00:00Z",
|
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -277,15 +400,10 @@ func TestValidateToken(t *testing.T) {
|
|||||||
t.Errorf("expected sub=user-uuid-1, got %q", claims.Sub)
|
t.Errorf("expected sub=user-uuid-1, got %q", claims.Sub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TestValidateTokenInvalid
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
func TestValidateTokenInvalid(t *testing.T) {
|
func TestValidateTokenInvalid(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Server returns 200 with valid=false for an expired/revoked token.
|
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false})
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"valid": false,
|
|
||||||
})
|
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
c := newTestClient(t, srv.URL)
|
c := newTestClient(t, srv.URL)
|
||||||
@@ -297,9 +415,11 @@ func TestValidateTokenInvalid(t *testing.T) {
|
|||||||
t.Error("expected claims.Valid = false")
|
t.Error("expected claims.Valid = false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestCreateAccount
|
// TestCreateAccount
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestCreateAccount(t *testing.T) {
|
func TestCreateAccount(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/v1/accounts" || r.Method != http.MethodPost {
|
if r.URL.Path != "/v1/accounts" || r.Method != http.MethodPost {
|
||||||
@@ -307,13 +427,9 @@ func TestCreateAccount(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
"id": "acct-uuid-1",
|
"id": "acct-uuid-1", "username": "bob", "account_type": "human",
|
||||||
"username": "bob",
|
"status": "active", "created_at": "2024-01-01T00:00:00Z",
|
||||||
"account_type": "human",
|
"updated_at": "2024-01-01T00:00:00Z", "totp_enabled": false,
|
||||||
"status": "active",
|
|
||||||
"created_at": "2024-01-01T00:00:00Z",
|
|
||||||
"updated_at": "2024-01-01T00:00:00Z",
|
|
||||||
"totp_enabled": false,
|
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -329,9 +445,7 @@ func TestCreateAccount(t *testing.T) {
|
|||||||
t.Errorf("expected username=bob, got %q", acct.Username)
|
t.Errorf("expected username=bob, got %q", acct.Username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TestCreateAccountConflict
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
func TestCreateAccountConflict(t *testing.T) {
|
func TestCreateAccountConflict(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusConflict, "username already exists")
|
writeError(w, http.StatusConflict, "username already exists")
|
||||||
@@ -347,21 +461,19 @@ func TestCreateAccountConflict(t *testing.T) {
|
|||||||
t.Errorf("expected MciasConflictError, got %T: %v", err, err)
|
t.Errorf("expected MciasConflictError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestListAccounts
|
// TestListAccounts
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestListAccounts(t *testing.T) {
|
func TestListAccounts(t *testing.T) {
|
||||||
accounts := []map[string]interface{}{
|
accounts := []map[string]interface{}{
|
||||||
{
|
{"id": "acct-1", "username": "alice", "account_type": "human",
|
||||||
"id": "acct-1", "username": "alice", "account_type": "human",
|
|
||||||
"status": "active", "created_at": "2024-01-01T00:00:00Z",
|
"status": "active", "created_at": "2024-01-01T00:00:00Z",
|
||||||
"updated_at": "2024-01-01T00:00:00Z", "totp_enabled": false,
|
"updated_at": "2024-01-01T00:00:00Z", "totp_enabled": false},
|
||||||
},
|
{"id": "acct-2", "username": "bob", "account_type": "human",
|
||||||
{
|
|
||||||
"id": "acct-2", "username": "bob", "account_type": "human",
|
|
||||||
"status": "active", "created_at": "2024-01-02T00:00:00Z",
|
"status": "active", "created_at": "2024-01-02T00:00:00Z",
|
||||||
"updated_at": "2024-01-02T00:00:00Z", "totp_enabled": false,
|
"updated_at": "2024-01-02T00:00:00Z", "totp_enabled": false},
|
||||||
},
|
|
||||||
}
|
}
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/v1/accounts" || r.Method != http.MethodGet {
|
if r.URL.Path != "/v1/accounts" || r.Method != http.MethodGet {
|
||||||
@@ -383,27 +495,21 @@ func TestListAccounts(t *testing.T) {
|
|||||||
t.Errorf("expected alice, got %q", list[0].Username)
|
t.Errorf("expected alice, got %q", list[0].Username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestGetAccount
|
// TestGetAccount
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestGetAccount(t *testing.T) {
|
func TestGetAccount(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/v1/accounts/") {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(r.URL.Path, "/v1/accounts/") {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"id": "acct-uuid-42",
|
"id": "acct-uuid-42", "username": "carol", "account_type": "human",
|
||||||
"username": "carol",
|
"status": "active", "created_at": "2024-01-01T00:00:00Z",
|
||||||
"account_type": "human",
|
"updated_at": "2024-01-01T00:00:00Z", "totp_enabled": false,
|
||||||
"status": "active",
|
|
||||||
"created_at": "2024-01-01T00:00:00Z",
|
|
||||||
"updated_at": "2024-01-01T00:00:00Z",
|
|
||||||
"totp_enabled": false,
|
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -416,38 +522,30 @@ func TestGetAccount(t *testing.T) {
|
|||||||
t.Errorf("expected acct-uuid-42, got %q", acct.ID)
|
t.Errorf("expected acct-uuid-42, got %q", acct.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestUpdateAccount
|
// TestUpdateAccount
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestUpdateAccount(t *testing.T) {
|
func TestUpdateAccount(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPatch {
|
if r.Method != http.MethodPatch {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
w.WriteHeader(http.StatusNoContent)
|
||||||
"id": "acct-uuid-42",
|
|
||||||
"username": "carol",
|
|
||||||
"account_type": "human",
|
|
||||||
"status": "disabled",
|
|
||||||
"created_at": "2024-01-01T00:00:00Z",
|
|
||||||
"updated_at": "2024-02-01T00:00:00Z",
|
|
||||||
"totp_enabled": false,
|
|
||||||
})
|
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
c := newTestClient(t, srv.URL)
|
c := newTestClient(t, srv.URL)
|
||||||
acct, err := c.UpdateAccount("acct-uuid-42", "disabled")
|
if err := c.UpdateAccount("acct-uuid-42", "inactive"); err != nil {
|
||||||
if err != nil {
|
t.Fatalf("UpdateAccount: unexpected error: %v", err)
|
||||||
t.Fatalf("UpdateAccount: %v", err)
|
|
||||||
}
|
|
||||||
if acct.Status != "disabled" {
|
|
||||||
t.Errorf("expected status=disabled, got %q", acct.Status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestDeleteAccount
|
// TestDeleteAccount
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestDeleteAccount(t *testing.T) {
|
func TestDeleteAccount(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodDelete {
|
if r.Method != http.MethodDelete {
|
||||||
@@ -462,16 +560,33 @@ func TestDeleteAccount(t *testing.T) {
|
|||||||
t.Fatalf("DeleteAccount: unexpected error: %v", err)
|
t.Fatalf("DeleteAccount: unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestGetRoles
|
// TestAdminSetPassword
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
func TestGetRoles(t *testing.T) {
|
|
||||||
|
func TestAdminSetPassword(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodPut || !strings.HasSuffix(r.URL.Path, "/password") {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(r.URL.Path, "/roles") {
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
if err := c.AdminSetPassword("acct-uuid-42", "new-s3cr3t-long"); err != nil {
|
||||||
|
t.Fatalf("AdminSetPassword: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestGetRoles / TestSetRoles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGetRoles(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/roles") {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -492,9 +607,7 @@ func TestGetRoles(t *testing.T) {
|
|||||||
t.Errorf("expected roles[0]=admin, got %q", roles[0])
|
t.Errorf("expected roles[0]=admin, got %q", roles[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TestSetRoles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
func TestSetRoles(t *testing.T) {
|
func TestSetRoles(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPut {
|
if r.Method != http.MethodPut {
|
||||||
@@ -509,9 +622,79 @@ func TestSetRoles(t *testing.T) {
|
|||||||
t.Fatalf("SetRoles: unexpected error: %v", err)
|
t.Fatalf("SetRoles: unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestIssueServiceToken
|
// TestGetAccountTags / TestSetAccountTags
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGetAccountTags(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/tags") {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"tags": []string{"env:production", "svc:payments-api"},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
tags, err := c.GetAccountTags("acct-uuid-42")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccountTags: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 2 {
|
||||||
|
t.Errorf("expected 2 tags, got %d", len(tags))
|
||||||
|
}
|
||||||
|
if tags[0] != "env:production" {
|
||||||
|
t.Errorf("expected tags[0]=env:production, got %q", tags[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAccountTags(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPut || !strings.HasSuffix(r.URL.Path, "/tags") {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"tags": []string{"env:staging"},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
tags, err := c.SetAccountTags("acct-uuid-42", []string{"env:staging"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetAccountTags: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 1 || tags[0] != "env:staging" {
|
||||||
|
t.Errorf("expected [env:staging], got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAccountTagsClear(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPut {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"tags": []string{}})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
tags, err := c.SetAccountTags("acct-uuid-42", []string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetAccountTags (clear): unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 0 {
|
||||||
|
t.Errorf("expected empty tags, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestIssueServiceToken / TestRevokeToken
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestIssueServiceToken(t *testing.T) {
|
func TestIssueServiceToken(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/v1/token/issue" || r.Method != http.MethodPost {
|
if r.URL.Path != "/v1/token/issue" || r.Method != http.MethodPost {
|
||||||
@@ -519,8 +702,7 @@ func TestIssueServiceToken(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
"token": "svc-tok-xyz",
|
"token": "svc-tok-xyz", "expires_at": "2099-01-01T00:00:00Z",
|
||||||
"expires_at": "2099-01-01T00:00:00Z",
|
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -536,16 +718,10 @@ func TestIssueServiceToken(t *testing.T) {
|
|||||||
t.Error("expected non-empty expires_at")
|
t.Error("expected non-empty expires_at")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TestRevokeToken
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
func TestRevokeToken(t *testing.T) {
|
func TestRevokeToken(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodDelete {
|
if r.Method != http.MethodDelete || !strings.HasPrefix(r.URL.Path, "/v1/token/") {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(r.URL.Path, "/v1/token/") {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -557,25 +733,20 @@ func TestRevokeToken(t *testing.T) {
|
|||||||
t.Fatalf("RevokeToken: unexpected error: %v", err)
|
t.Fatalf("RevokeToken: unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestGetPGCreds
|
// TestGetPGCreds / TestSetPGCreds
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestGetPGCreds(t *testing.T) {
|
func TestGetPGCreds(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/pgcreds") {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(r.URL.Path, "/pgcreds") {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"host": "db.example.com",
|
"host": "db.example.com", "port": 5432,
|
||||||
"port": 5432,
|
"database": "myapp", "username": "appuser", "password": "secretpw",
|
||||||
"database": "myapp",
|
|
||||||
"username": "appuser",
|
|
||||||
"password": "secretpw",
|
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -594,16 +765,10 @@ func TestGetPGCreds(t *testing.T) {
|
|||||||
t.Errorf("expected password=secretpw, got %q", creds.Password)
|
t.Errorf("expected password=secretpw, got %q", creds.Password)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TestSetPGCreds
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
func TestSetPGCreds(t *testing.T) {
|
func TestSetPGCreds(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPut {
|
if r.Method != http.MethodPut || !strings.HasSuffix(r.URL.Path, "/pgcreds") {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(r.URL.Path, "/pgcreds") {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -611,14 +776,238 @@ func TestSetPGCreds(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
c := newTestClient(t, srv.URL)
|
c := newTestClient(t, srv.URL)
|
||||||
err := c.SetPGCreds("acct-uuid-42", "db.example.com", 5432, "myapp", "appuser", "secretpw")
|
if err := c.SetPGCreds("acct-uuid-42", "db.example.com", 5432, "myapp", "appuser", "secretpw"); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SetPGCreds: unexpected error: %v", err)
|
t.Fatalf("SetPGCreds: unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestListAudit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestListAudit(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/v1/audit") || r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"events": []map[string]interface{}{
|
||||||
|
{"id": 42, "event_type": "login_ok", "event_time": "2026-03-11T09:01:23Z",
|
||||||
|
"actor_id": "acct-uuid-1", "ip_address": "192.0.2.1"},
|
||||||
|
},
|
||||||
|
"total": 1, "limit": 50, "offset": 0,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
resp, err := c.ListAudit(mciasgoclient.AuditFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAudit: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Total != 1 {
|
||||||
|
t.Errorf("expected total=1, got %d", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.Events) != 1 {
|
||||||
|
t.Fatalf("expected 1 event, got %d", len(resp.Events))
|
||||||
|
}
|
||||||
|
if resp.Events[0].EventType != "login_ok" {
|
||||||
|
t.Errorf("expected event_type=login_ok, got %q", resp.Events[0].EventType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditWithFilter(t *testing.T) {
|
||||||
|
var capturedQuery string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedQuery = r.URL.RawQuery
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"events": []map[string]interface{}{},
|
||||||
|
"total": 0, "limit": 10, "offset": 5,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
_, err := c.ListAudit(mciasgoclient.AuditFilter{
|
||||||
|
Limit: 10, Offset: 5, EventType: "login_fail", ActorID: "acct-uuid-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAudit: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"limit=10", "offset=5", "event_type=login_fail", "actor_id=acct-uuid-1"} {
|
||||||
|
if !strings.Contains(capturedQuery, want) {
|
||||||
|
t.Errorf("expected %q in query string, got %q", want, capturedQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestListPolicyRules
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestListPolicyRules(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/policy/rules" || r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": 1, "priority": 100,
|
||||||
|
"description": "Allow payments-api to read its own pgcreds",
|
||||||
|
"rule": map[string]interface{}{"effect": "allow", "actions": []string{"pgcreds:read"}},
|
||||||
|
"enabled": true,
|
||||||
|
"created_at": "2026-03-11T09:00:00Z", "updated_at": "2026-03-11T09:00:00Z",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
rules, err := c.ListPolicyRules()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPolicyRules: %v", err)
|
||||||
|
}
|
||||||
|
if len(rules) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule, got %d", len(rules))
|
||||||
|
}
|
||||||
|
if rules[0].ID != 1 {
|
||||||
|
t.Errorf("expected id=1, got %d", rules[0].ID)
|
||||||
|
}
|
||||||
|
if rules[0].Description != "Allow payments-api to read its own pgcreds" {
|
||||||
|
t.Errorf("unexpected description: %q", rules[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestCreatePolicyRule
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCreatePolicyRule(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/policy/rules" || r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": 7, "priority": 50, "description": "Test rule",
|
||||||
|
"rule": map[string]interface{}{"effect": "deny"},
|
||||||
|
"enabled": true,
|
||||||
|
"created_at": "2026-03-11T09:00:00Z", "updated_at": "2026-03-11T09:00:00Z",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
rule, err := c.CreatePolicyRule(mciasgoclient.CreatePolicyRuleRequest{
|
||||||
|
Description: "Test rule",
|
||||||
|
Priority: 50,
|
||||||
|
Rule: mciasgoclient.PolicyRuleBody{Effect: "deny"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
if rule.ID != 7 {
|
||||||
|
t.Errorf("expected id=7, got %d", rule.ID)
|
||||||
|
}
|
||||||
|
if rule.Priority != 50 {
|
||||||
|
t.Errorf("expected priority=50, got %d", rule.Priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestGetPolicyRule
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGetPolicyRule(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet || r.URL.Path != "/v1/policy/rules/7" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"id": 7, "priority": 50, "description": "Test rule",
|
||||||
|
"rule": map[string]interface{}{"effect": "allow"},
|
||||||
|
"enabled": true,
|
||||||
|
"created_at": "2026-03-11T09:00:00Z", "updated_at": "2026-03-11T09:00:00Z",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
rule, err := c.GetPolicyRule(7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
if rule.ID != 7 {
|
||||||
|
t.Errorf("expected id=7, got %d", rule.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPolicyRuleNotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeError(w, http.StatusNotFound, "rule not found")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
_, err := c.GetPolicyRule(999)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 404")
|
||||||
|
}
|
||||||
|
var notFoundErr *mciasgoclient.MciasNotFoundError
|
||||||
|
if !errors.As(err, ¬FoundErr) {
|
||||||
|
t.Errorf("expected MciasNotFoundError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestUpdatePolicyRule
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestUpdatePolicyRule(t *testing.T) {
|
||||||
|
enabled := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPatch || r.URL.Path != "/v1/policy/rules/7" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"id": 7, "priority": 50, "description": "Test rule",
|
||||||
|
"rule": map[string]interface{}{"effect": "allow"},
|
||||||
|
"enabled": false,
|
||||||
|
"created_at": "2026-03-11T09:00:00Z", "updated_at": "2026-03-12T10:00:00Z",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
rule, err := c.UpdatePolicyRule(7, mciasgoclient.UpdatePolicyRuleRequest{Enabled: &enabled})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdatePolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
if rule.Enabled {
|
||||||
|
t.Error("expected enabled=false after update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestDeletePolicyRule
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestDeletePolicyRule(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete || r.URL.Path != "/v1/policy/rules/7" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(t, srv.URL)
|
||||||
|
if err := c.DeletePolicyRule(7); err != nil {
|
||||||
|
t.Fatalf("DeletePolicyRule: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TestIntegration: full login → validate → logout flow
|
// TestIntegration: full login → validate → logout flow
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestIntegration(t *testing.T) {
|
func TestIntegration(t *testing.T) {
|
||||||
const sessionToken = "integration-tok-999"
|
const sessionToken = "integration-tok-999"
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -640,8 +1029,7 @@ func TestIntegration(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
"token": sessionToken,
|
"token": sessionToken, "expires_at": "2099-01-01T00:00:00Z",
|
||||||
"expires_at": "2099-01-01T00:00:00Z",
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/v1/token/validate", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/v1/token/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -658,15 +1046,11 @@ func TestIntegration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if body.Token == sessionToken {
|
if body.Token == sessionToken {
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"valid": true,
|
"valid": true, "sub": "alice-uuid",
|
||||||
"sub": "alice-uuid",
|
"roles": []string{"user"}, "expires_at": "2099-01-01T00:00:00Z",
|
||||||
"roles": []string{"user"},
|
|
||||||
"expires_at": "2099-01-01T00:00:00Z",
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false})
|
||||||
"valid": false,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/v1/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/v1/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -674,9 +1058,7 @@ func TestIntegration(t *testing.T) {
|
|||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Verify Authorization header is present.
|
if r.Header.Get("Authorization") == "" {
|
||||||
auth := r.Header.Get("Authorization")
|
|
||||||
if auth == "" {
|
|
||||||
writeError(w, http.StatusUnauthorized, "missing token")
|
writeError(w, http.StatusUnauthorized, "missing token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -685,7 +1067,8 @@ func TestIntegration(t *testing.T) {
|
|||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
c := newTestClient(t, srv.URL)
|
c := newTestClient(t, srv.URL)
|
||||||
// Step 1: login with wrong credentials should fail.
|
|
||||||
|
// Step 1: wrong credentials → MciasAuthError.
|
||||||
_, _, err := c.Login("alice", "wrong-password", "")
|
_, _, err := c.Login("alice", "wrong-password", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for wrong credentials")
|
t.Fatal("expected error for wrong credentials")
|
||||||
@@ -694,7 +1077,8 @@ func TestIntegration(t *testing.T) {
|
|||||||
if !errors.As(err, &authErr) {
|
if !errors.As(err, &authErr) {
|
||||||
t.Errorf("expected MciasAuthError, got %T", err)
|
t.Errorf("expected MciasAuthError, got %T", err)
|
||||||
}
|
}
|
||||||
// Step 2: login with correct credentials.
|
|
||||||
|
// Step 2: correct login.
|
||||||
tok, _, err := c.Login("alice", "correct-horse", "")
|
tok, _, err := c.Login("alice", "correct-horse", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Login: %v", err)
|
t.Fatalf("Login: %v", err)
|
||||||
@@ -702,7 +1086,8 @@ func TestIntegration(t *testing.T) {
|
|||||||
if tok != sessionToken {
|
if tok != sessionToken {
|
||||||
t.Errorf("expected %q, got %q", sessionToken, tok)
|
t.Errorf("expected %q, got %q", sessionToken, tok)
|
||||||
}
|
}
|
||||||
// Step 3: validate the returned token.
|
|
||||||
|
// Step 3: validate → valid=true.
|
||||||
claims, err := c.ValidateToken(tok)
|
claims, err := c.ValidateToken(tok)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateToken: %v", err)
|
t.Fatalf("ValidateToken: %v", err)
|
||||||
@@ -713,7 +1098,8 @@ func TestIntegration(t *testing.T) {
|
|||||||
if claims.Sub != "alice-uuid" {
|
if claims.Sub != "alice-uuid" {
|
||||||
t.Errorf("expected sub=alice-uuid, got %q", claims.Sub)
|
t.Errorf("expected sub=alice-uuid, got %q", claims.Sub)
|
||||||
}
|
}
|
||||||
// Step 4: validate an unknown token returns Valid=false, not an error.
|
|
||||||
|
// Step 4: garbage token → valid=false (not an error).
|
||||||
claims2, err := c.ValidateToken("garbage-token")
|
claims2, err := c.ValidateToken("garbage-token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateToken(garbage): unexpected error: %v", err)
|
t.Fatalf("ValidateToken(garbage): unexpected error: %v", err)
|
||||||
@@ -721,7 +1107,8 @@ func TestIntegration(t *testing.T) {
|
|||||||
if claims2.Valid {
|
if claims2.Valid {
|
||||||
t.Error("expected Valid=false for garbage token")
|
t.Error("expected Valid=false for garbage token")
|
||||||
}
|
}
|
||||||
// Step 5: logout clears the stored token.
|
|
||||||
|
// Step 5: logout clears stored token.
|
||||||
if err := c.Logout(); err != nil {
|
if err := c.Logout(); err != nil {
|
||||||
t.Fatalf("Logout: %v", err)
|
t.Fatalf("Logout: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,45 @@ 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) -> 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})
|
Returns (secret, otpauth_uri). The secret is shown only once.
|
||||||
|
"""
|
||||||
|
data = self._request("POST", "/v1/auth/totp/enroll")
|
||||||
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 +194,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 +204,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 +214,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 +260,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 +269,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()
|
||||||
|
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,142 @@ 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.
|
||||||
|
pub async fn enroll_totp(&self) -> Result<(String, String), MciasError> {
|
||||||
|
let resp: TotpEnrollResponse =
|
||||||
|
self.post("/v1/auth/totp/enroll", &serde_json::json!({})).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 +641,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 +708,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 +737,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 +761,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 +786,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(
|
||||||
token: Some("admin-token".to_string()),
|
&server.uri(),
|
||||||
..Default::default()
|
ClientOptions {
|
||||||
})
|
token: Some("admin-token".to_string()),
|
||||||
|
..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(
|
||||||
token: Some("existing-token".to_string()),
|
&server.uri(),
|
||||||
..Default::default()
|
ClientOptions {
|
||||||
})
|
token: Some("existing-token".to_string()),
|
||||||
|
..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(
|
||||||
token: Some("old-token".to_string()),
|
&server.uri(),
|
||||||
..Default::default()
|
ClientOptions {
|
||||||
})
|
token: Some("old-token".to_string()),
|
||||||
|
..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().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(),
|
||||||
token: Some("user-token".to_string()),
|
ClientOptions {
|
||||||
..Default::default()
|
token: Some("user-token".to_string()),
|
||||||
})
|
..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,6 +28,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 issue -id UUID
|
// token issue -id UUID
|
||||||
// token revoke -jti JTI
|
// token revoke -jti JTI
|
||||||
@@ -61,7 +63,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 +388,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 +443,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) {
|
||||||
@@ -871,7 +912,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
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
//
|
//
|
||||||
// schema verify
|
// schema verify
|
||||||
// schema migrate
|
// schema migrate
|
||||||
|
// schema force --version N
|
||||||
//
|
//
|
||||||
// account list
|
// account list
|
||||||
// account get --id UUID
|
// account get --id UUID
|
||||||
@@ -62,7 +63,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 +92,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)
|
||||||
@@ -111,6 +124,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 +149,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 +233,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,14 +19,19 @@
|
|||||||
// 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
|
||||||
// account update -id UUID -status active|inactive
|
// account update -id UUID -status active|inactive
|
||||||
// account delete -id UUID
|
// account delete -id UUID
|
||||||
//
|
//
|
||||||
// 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
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
1
dist/mcias-dev.conf.example
vendored
1
dist/mcias-dev.conf.example
vendored
@@ -22,6 +22,7 @@ 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"
|
||||||
|
|||||||
4
dist/mcias.conf.docker.example
vendored
4
dist/mcias.conf.docker.example
vendored
@@ -25,6 +25,10 @@ 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 = "/etc/mcias/server.crt"
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/etc/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.
|
# VOLUME /data is declared in the Dockerfile; map a named volume here.
|
||||||
|
|||||||
15
dist/mcias.conf.example
vendored
15
dist/mcias.conf.example
vendored
@@ -32,6 +32,21 @@ tls_cert = "/etc/mcias/server.crt"
|
|||||||
# Permissions: mode 0640, owner root:mcias.
|
# Permissions: mode 0640, owner root:mcias.
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/etc/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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
if err := m.Force(legacyVersion); err != nil {
|
// already has an entry — including a dirty entry from a previously
|
||||||
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err)
|
// 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 {
|
||||||
|
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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,8 +72,14 @@ 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)
|
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||||
@@ -81,12 +87,19 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
|||||||
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.
|
||||||
@@ -199,7 +212,12 @@ func (a *authServiceServer) EnrollTOTP(ctx context.Context, _ *mciasv1.EnrollTOT
|
|||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,10 +250,15 @@ func (a *authServiceServer) ConfirmTOTP(ctx context.Context, req *mciasv1.Confir
|
|||||||
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 {
|
||||||
|
|||||||
@@ -120,6 +120,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -542,7 +542,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)
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -176,15 +176,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 +244,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")
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -271,7 +272,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 +290,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 +341,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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ const (
|
|||||||
ActionEnrollTOTP Action = "totp:enroll" // self-service
|
ActionEnrollTOTP Action = "totp:enroll" // self-service
|
||||||
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"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||||
@@ -56,10 +57,19 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Security (DEF-03): parse the optional trusted-proxy address once here
|
||||||
|
// so RateLimit and audit-log helpers use consistent IP extraction.
|
||||||
|
// net.ParseIP returns nil for an empty string, which disables proxy
|
||||||
|
// trust and falls back to r.RemoteAddr.
|
||||||
|
var trustedProxy net.IP
|
||||||
|
if s.cfg.Server.TrustedProxy != "" {
|
||||||
|
trustedProxy = net.ParseIP(s.cfg.Server.TrustedProxy)
|
||||||
|
}
|
||||||
|
|
||||||
// Security: per-IP rate limiting on public auth endpoints to prevent
|
// Security: per-IP rate limiting on public auth endpoints to prevent
|
||||||
// brute-force login attempts and token-validation abuse. Parameters match
|
// brute-force login attempts and token-validation abuse. Parameters match
|
||||||
// the gRPC rate limiter (10 req/s sustained, burst 10).
|
// the gRPC rate limiter (10 req/s sustained, burst 10).
|
||||||
loginRateLimit := middleware.RateLimit(10, 10)
|
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
|
||||||
|
|
||||||
// Public endpoints (no authentication required).
|
// Public endpoints (no authentication required).
|
||||||
mux.HandleFunc("GET /v1/health", s.handleHealth)
|
mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||||
@@ -82,16 +92,20 @@ func (s *Server) Handler() http.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
|
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
|
||||||
}
|
}
|
||||||
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
|
// Security (DEF-09): apply defensive HTTP headers to the docs handlers.
|
||||||
|
// The Swagger UI page at /docs loads JavaScript from the same origin
|
||||||
|
// and renders untrusted content (API descriptions), so it benefits from
|
||||||
|
// CSP, X-Frame-Options, and the other headers applied to the UI sub-mux.
|
||||||
|
mux.Handle("GET /docs", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(docsHTML)
|
_, _ = w.Write(docsHTML)
|
||||||
})
|
})))
|
||||||
mux.HandleFunc("GET /docs/openapi.yaml", func(w http.ResponseWriter, _ *http.Request) {
|
mux.Handle("GET /docs/openapi.yaml", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/yaml")
|
w.Header().Set("Content-Type", "application/yaml")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(specYAML)
|
_, _ = w.Write(specYAML)
|
||||||
})
|
})))
|
||||||
|
|
||||||
// Authenticated endpoints.
|
// Authenticated endpoints.
|
||||||
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
|
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
|
||||||
@@ -116,6 +130,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount)))
|
mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount)))
|
||||||
mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles)))
|
mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles)))
|
||||||
mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles)))
|
mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles)))
|
||||||
|
mux.Handle("POST /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGrantRole)))
|
||||||
|
mux.Handle("DELETE /v1/accounts/{id}/roles/{role}", requireAdmin(http.HandlerFunc(s.handleRevokeRole)))
|
||||||
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
|
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
|
||||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
||||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
||||||
@@ -251,13 +267,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
valid, err := auth.ValidateTOTP(secret, req.TOTPCode)
|
valid, totpCounter, err := auth.ValidateTOTP(secret, req.TOTPCode)
|
||||||
if err != nil || !valid {
|
if err != nil || !valid {
|
||||||
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
||||||
_ = s.db.RecordLoginFailure(acct.ID)
|
_ = s.db.RecordLoginFailure(acct.ID)
|
||||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Security (CRIT-01): reject replay of a code already used within
|
||||||
|
// its ±30-second validity window.
|
||||||
|
if err := s.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
|
||||||
|
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"totp_replay"}`)
|
||||||
|
_ = s.db.RecordLoginFailure(acct.ID)
|
||||||
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login succeeded: clear any outstanding failure counter.
|
// Login succeeded: clear any outstanding failure counter.
|
||||||
@@ -644,6 +668,10 @@ type setRolesRequest struct {
|
|||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type grantRoleRequest struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) {
|
||||||
acct, ok := s.loadAccount(w, r)
|
acct, ok := s.loadAccount(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -688,6 +716,68 @@ func (s *Server) handleSetRoles(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGrantRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acct, ok := s.loadAccount(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req grantRoleRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Role == "" {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "role is required", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var grantedBy *int64
|
||||||
|
if actor != nil {
|
||||||
|
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
||||||
|
grantedBy = &a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.GrantRole(acct.ID, req.Role, grantedBy); err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "invalid role", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, fmt.Sprintf(`{"role":"%s"}`, req.Role))
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRevokeRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acct, ok := s.loadAccount(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role := r.PathValue("role")
|
||||||
|
if role == "" {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "role is required", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var revokedBy *int64
|
||||||
|
if actor != nil {
|
||||||
|
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
||||||
|
revokedBy = &a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.RevokeRole(acct.ID, role); err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeAudit(r, model.EventRoleRevoked, revokedBy, &acct.ID, fmt.Sprintf(`{"role":"%s"}`, role))
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- TOTP endpoints ----
|
// ---- TOTP endpoints ----
|
||||||
|
|
||||||
type totpEnrollResponse struct {
|
type totpEnrollResponse struct {
|
||||||
@@ -764,11 +854,18 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
valid, err := auth.ValidateTOTP(secret, req.Code)
|
valid, totpCounter, err := auth.ValidateTOTP(secret, req.Code)
|
||||||
if err != nil || !valid {
|
if err != nil || !valid {
|
||||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid TOTP code", "unauthorized")
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid TOTP code", "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Security (CRIT-01): record the counter even during enrollment
|
||||||
|
// confirmation so the same code cannot be replayed immediately after
|
||||||
|
// confirming.
|
||||||
|
if err := s.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid TOTP code", "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Mark TOTP as confirmed and required.
|
// Mark TOTP as confirmed and required.
|
||||||
if err := s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
|
if err := s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
|
||||||
@@ -1149,8 +1246,14 @@ func (s *Server) loadAccount(w http.ResponseWriter, r *http.Request) (*model.Acc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeAudit appends an audit log entry, logging errors but not failing the request.
|
// writeAudit appends an audit log entry, logging errors but not failing the request.
|
||||||
|
// The logged IP honours the trusted-proxy setting so the real client address
|
||||||
|
// is recorded rather than the proxy's address (DEF-03).
|
||||||
func (s *Server) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
|
func (s *Server) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
|
||||||
ip := r.RemoteAddr
|
var proxyIP net.IP
|
||||||
|
if s.cfg.Server.TrustedProxy != "" {
|
||||||
|
proxyIP = net.ParseIP(s.cfg.Server.TrustedProxy)
|
||||||
|
}
|
||||||
|
ip := middleware.ClientIP(r, proxyIP)
|
||||||
if err := s.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
|
if err := s.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
|
||||||
s.logger.Error("write audit event", "error", err, "event_type", eventType)
|
s.logger.Error("write audit event", "error", err, "event_type", eventType)
|
||||||
}
|
}
|
||||||
@@ -1191,6 +1294,25 @@ func extractBearerFromRequest(r *http.Request) (string, error) {
|
|||||||
return auth[len(prefix):], nil
|
return auth[len(prefix):], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// docsSecurityHeaders adds the same defensive HTTP headers as the UI sub-mux
|
||||||
|
// to the /docs and /docs/openapi.yaml endpoints.
|
||||||
|
//
|
||||||
|
// Security (DEF-09): without these headers the Swagger UI HTML page is
|
||||||
|
// served without CSP, X-Frame-Options, or HSTS, leaving it susceptible
|
||||||
|
// to clickjacking and MIME-type confusion in browsers.
|
||||||
|
func docsSecurityHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Content-Security-Policy",
|
||||||
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'")
|
||||||
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
h.Set("X-Frame-Options", "DENY")
|
||||||
|
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||||
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// encodeBase64URL encodes bytes as base64url without padding.
|
// encodeBase64URL encodes bytes as base64url without padding.
|
||||||
func encodeBase64URL(b []byte) string {
|
func encodeBase64URL(b []byte) string {
|
||||||
const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||||
|
|||||||
@@ -376,7 +376,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())
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -901,10 +908,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")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"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.
|
||||||
@@ -148,7 +149,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
|
|||||||
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 +166,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)
|
||||||
}
|
}
|
||||||
@@ -250,8 +268,132 @@ 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),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
Flash: "Password updated successfully. Other active sessions have been revoked.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,14 +22,15 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/web"
|
"git.wntrmute.dev/kyle/mcias/web"
|
||||||
)
|
)
|
||||||
@@ -191,6 +192,7 @@ 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",
|
||||||
}
|
}
|
||||||
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 +210,7 @@ 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",
|
||||||
}
|
}
|
||||||
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,7 +224,7 @@ 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,
|
pubKey: pub,
|
||||||
@@ -230,7 +233,33 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
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()
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register attaches all UI routes to mux, wrapped with security headers.
|
// Register attaches all UI routes to mux, wrapped with security headers.
|
||||||
@@ -257,18 +286,32 @@ 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)
|
||||||
|
|
||||||
// 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", adminGet(u.handleDashboard))
|
||||||
@@ -296,6 +339,10 @@ 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))
|
||||||
|
|
||||||
|
// 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.
|
||||||
@@ -363,6 +410,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.
|
||||||
@@ -492,13 +558,15 @@ func securityHeaders(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// actorName resolves the username of the currently authenticated user from the
|
// actorName resolves the username of the currently authenticated user from the
|
||||||
@@ -611,6 +679,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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
72
openapi.yaml
72
openapi.yaml
@@ -995,6 +995,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
|
||||||
@@ -1257,7 +1327,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:
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
104
proto/mcias/v1/policy.proto
Normal file
104
proto/mcias/v1/policy.proto
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// PolicyService: CRUD management of policy rules.
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package mcias.v1;
|
||||||
|
|
||||||
|
option go_package = "git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1";
|
||||||
|
|
||||||
|
// PolicyRule is the wire representation of a policy rule record.
|
||||||
|
message PolicyRule {
|
||||||
|
int64 id = 1;
|
||||||
|
string description = 2;
|
||||||
|
int32 priority = 3;
|
||||||
|
bool enabled = 4;
|
||||||
|
string rule_json = 5; // JSON-encoded RuleBody
|
||||||
|
string created_at = 6; // RFC3339
|
||||||
|
string updated_at = 7; // RFC3339
|
||||||
|
string not_before = 8; // RFC3339; empty if unset
|
||||||
|
string expires_at = 9; // RFC3339; empty if unset
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- List ---
|
||||||
|
|
||||||
|
message ListPolicyRulesRequest {}
|
||||||
|
|
||||||
|
message ListPolicyRulesResponse {
|
||||||
|
repeated PolicyRule rules = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create ---
|
||||||
|
|
||||||
|
message CreatePolicyRuleRequest {
|
||||||
|
string description = 1; // required
|
||||||
|
string rule_json = 2; // required; JSON-encoded RuleBody
|
||||||
|
int32 priority = 3; // default 100 when zero
|
||||||
|
string not_before = 4; // RFC3339; optional
|
||||||
|
string expires_at = 5; // RFC3339; optional
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreatePolicyRuleResponse {
|
||||||
|
PolicyRule rule = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Get ---
|
||||||
|
|
||||||
|
message GetPolicyRuleRequest {
|
||||||
|
int64 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetPolicyRuleResponse {
|
||||||
|
PolicyRule rule = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update ---
|
||||||
|
|
||||||
|
// 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".
|
||||||
|
message UpdatePolicyRuleRequest {
|
||||||
|
int64 id = 1;
|
||||||
|
optional int32 priority = 2; // omit to leave unchanged
|
||||||
|
optional bool enabled = 3; // omit to leave unchanged
|
||||||
|
string not_before = 4; // RFC3339; ignored when clear_not_before=true
|
||||||
|
string expires_at = 5; // RFC3339; ignored when clear_expires_at=true
|
||||||
|
bool clear_not_before = 6;
|
||||||
|
bool clear_expires_at = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdatePolicyRuleResponse {
|
||||||
|
PolicyRule rule = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete ---
|
||||||
|
|
||||||
|
message DeletePolicyRuleRequest {
|
||||||
|
int64 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeletePolicyRuleResponse {}
|
||||||
|
|
||||||
|
// PolicyService manages policy rules (admin only).
|
||||||
|
service PolicyService {
|
||||||
|
// ListPolicyRules returns all policy rules.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
rpc ListPolicyRules(ListPolicyRulesRequest) returns (ListPolicyRulesResponse);
|
||||||
|
|
||||||
|
// CreatePolicyRule creates a new policy rule.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
rpc CreatePolicyRule(CreatePolicyRuleRequest) returns (CreatePolicyRuleResponse);
|
||||||
|
|
||||||
|
// GetPolicyRule returns a single policy rule by ID.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
rpc GetPolicyRule(GetPolicyRuleRequest) returns (GetPolicyRuleResponse);
|
||||||
|
|
||||||
|
// UpdatePolicyRule applies a partial update to a policy rule.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
rpc UpdatePolicyRule(UpdatePolicyRuleRequest) returns (UpdatePolicyRuleResponse);
|
||||||
|
|
||||||
|
// DeletePolicyRule permanently removes a policy rule.
|
||||||
|
// Requires: admin JWT.
|
||||||
|
rpc DeletePolicyRule(DeletePolicyRuleRequest) returns (DeletePolicyRuleResponse);
|
||||||
|
}
|
||||||
@@ -303,7 +303,7 @@ func TestE2EAdminAccountManagement(t *testing.T) {
|
|||||||
|
|
||||||
// Set roles.
|
// Set roles.
|
||||||
resp3 := e.do(t, "PUT", "/v1/accounts/"+carolUUID+"/roles", map[string][]string{
|
resp3 := e.do(t, "PUT", "/v1/accounts/"+carolUUID+"/roles", map[string][]string{
|
||||||
"roles": {"reader"},
|
"roles": {"user"},
|
||||||
}, adminToken)
|
}, adminToken)
|
||||||
mustStatus(t, resp3, http.StatusNoContent)
|
mustStatus(t, resp3, http.StatusNoContent)
|
||||||
_ = resp3.Body.Close()
|
_ = resp3.Body.Close()
|
||||||
@@ -315,8 +315,8 @@ func TestE2EAdminAccountManagement(t *testing.T) {
|
|||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
}
|
}
|
||||||
decodeJSON(t, resp4, &rolesResp)
|
decodeJSON(t, resp4, &rolesResp)
|
||||||
if len(rolesResp.Roles) != 1 || rolesResp.Roles[0] != "reader" {
|
if len(rolesResp.Roles) != 1 || rolesResp.Roles[0] != "user" {
|
||||||
t.Errorf("roles = %v, want [reader]", rolesResp.Roles)
|
t.Errorf("roles = %v, want [user]", rolesResp.Roles)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete account.
|
// Delete account.
|
||||||
|
|||||||
26
web/static/mcias.js
Normal file
26
web/static/mcias.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// mcias.js — HTMX event wiring for the MCIAS web UI.
|
||||||
|
|
||||||
|
// Show server error responses in the global #htmx-error-banner.
|
||||||
|
//
|
||||||
|
// HTMX 2.x fires htmx:responseError for 4xx/5xx responses and does not swap
|
||||||
|
// the body into the target by default. The server's renderError() always
|
||||||
|
// returns a <div class="alert alert-error"> fragment whose message is
|
||||||
|
// HTML-escaped server-side, so setting innerHTML here is safe.
|
||||||
|
document.body.addEventListener('htmx:responseError', function (evt) {
|
||||||
|
var banner = document.getElementById('htmx-error-banner');
|
||||||
|
if (!banner) { return; }
|
||||||
|
var body = (evt.detail.xhr && evt.detail.xhr.responseText) || 'An unexpected error occurred.';
|
||||||
|
banner.innerHTML = body;
|
||||||
|
banner.style.display = '';
|
||||||
|
banner.scrollIntoView({ behavior: 'instant', block: 'nearest' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the error banner whenever a successful HTMX swap completes so
|
||||||
|
// stale errors do not persist after the user corrects their input.
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function () {
|
||||||
|
var banner = document.getElementById('htmx-error-banner');
|
||||||
|
if (banner) {
|
||||||
|
banner.style.display = 'none';
|
||||||
|
banner.innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -16,19 +16,21 @@
|
|||||||
<li><a href="/audit">Audit</a></li>
|
<li><a href="/audit">Audit</a></li>
|
||||||
<li><a href="/policies">Policies</a></li>
|
<li><a href="/policies">Policies</a></li>
|
||||||
<li><a href="/pgcreds">PG Creds</a></li>
|
<li><a href="/pgcreds">PG Creds</a></li>
|
||||||
{{if .ActorName}}<li><span class="nav-actor">{{.ActorName}}</span></li>{{end}}
|
{{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}}
|
||||||
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div id="htmx-error-banner" role="alert" style="display:none"></div>
|
||||||
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||||
{{if .Flash}}<div class="alert alert-success" role="status">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="alert alert-success" role="status">{{.Flash}}</div>{{end}}
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/mcias.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
53
web/templates/fragments/password_change_form.html
Normal file
53
web/templates/fragments/password_change_form.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{{define "password_change_form"}}
|
||||||
|
<form id="password-change-form"
|
||||||
|
hx-put="/profile/password"
|
||||||
|
hx-target="#password-change-section"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'
|
||||||
|
onsubmit="return mciasPwChangeConfirm(this)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="current_password">Current Password</label>
|
||||||
|
<input type="password" id="current_password" name="current_password"
|
||||||
|
class="form-control" autocomplete="current-password"
|
||||||
|
placeholder="Your current password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:.5rem">
|
||||||
|
<label for="new_password">New Password</label>
|
||||||
|
<input type="password" id="new_password" name="new_password"
|
||||||
|
class="form-control" autocomplete="new-password"
|
||||||
|
placeholder="Minimum 12 characters" required minlength="12">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:.5rem">
|
||||||
|
<label for="confirm_password">Confirm New Password</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password"
|
||||||
|
class="form-control" autocomplete="new-password"
|
||||||
|
placeholder="Repeat new password" required minlength="12">
|
||||||
|
</div>
|
||||||
|
<div id="pw-change-error" role="alert"
|
||||||
|
style="display:none;color:var(--color-danger,#c0392b);font-size:.85rem;margin-top:.35rem"></div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.75rem">
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
function mciasPwChangeConfirm(form) {
|
||||||
|
var pw = form.querySelector('#new_password').value;
|
||||||
|
var cfm = form.querySelector('#confirm_password').value;
|
||||||
|
var err = form.querySelector('#pw-change-error');
|
||||||
|
if (pw !== cfm) {
|
||||||
|
err.textContent = 'Passwords do not match.';
|
||||||
|
err.style.display = 'block';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
err.style.display = 'none';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "password_change_result"}}
|
||||||
|
{{if .Flash}}
|
||||||
|
<div class="alert alert-success" role="alert">{{.Flash}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{template "password_change_form" .}}
|
||||||
|
{{end}}
|
||||||
16
web/templates/profile.html
Normal file
16
web/templates/profile.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{{define "profile"}}{{template "base" .}}{{end}}
|
||||||
|
{{define "title"}}Profile — MCIAS{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Profile</h1>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Change Password</h2>
|
||||||
|
<p class="text-muted text-small" style="margin-bottom:.75rem">
|
||||||
|
Enter your current password and choose a new one. Other active sessions will be revoked.
|
||||||
|
</p>
|
||||||
|
<div id="password-change-section">
|
||||||
|
{{template "password_change_form" .}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user