33 Commits

Author SHA1 Message Date
2a85d4bf2b Update AUDIT.md: all SEC findings remediated
- Mark SEC-01 through SEC-12 as fixed with fix descriptions
- Update executive summary to reflect full remediation
- Move original finding descriptions to collapsible section
- Replace remediation priority table with status section

Security: documentation-only change, no code modifications

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:31:30 -07:00
8f09e0e81a Rename Go client package from mciasgoclient to mcias
- Update package declaration in client.go
- Update error message strings to reference new package name
- Update test package and imports to use new name
- Update README.md documentation and examples with new package name
- All tests pass
2026-03-14 19:01:07 -07:00
7e5fc9f111 Fix flaky gRPC renewal test timing
Increase token lifetime from 2s to 4s in TestRenewToken to prevent
the token from expiring before the gRPC call completes through bufconn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:08:44 -07:00
cf02b8e2d8 Merge SEC-01: require password for TOTP enrollment 2026-03-13 01:07:39 -07:00
fe780bf873 Merge SEC-03: require token proximity for renewal
# Conflicts:
#	internal/server/server_test.go
2026-03-13 01:07:34 -07:00
cb96650e59 Merge SEC-11: use json.Marshal for audit details 2026-03-13 01:06:55 -07:00
bef5a3269d Merge SEC-09: hide admin nav links from non-admin users
# Conflicts:
#	internal/ui/ui_test.go
2026-03-13 01:06:50 -07:00
6191c5e00a Merge SEC-02: normalize lockout response
# Conflicts:
#	internal/grpcserver/grpcserver_test.go
#	internal/server/server_test.go
2026-03-13 01:05:56 -07:00
fa45836612 Merge SEC-08: atomic system token issuance 2026-03-13 00:50:39 -07:00
0bc7943d8f Merge SEC-06: gRPC proxy-aware rate limiting 2026-03-13 00:50:32 -07:00
97ba7ab74c Merge SEC-04: API security headers 2026-03-13 00:50:27 -07:00
582645f9d6 Merge SEC-05: body size limit and max password length 2026-03-13 00:49:39 -07:00
8840317cce Merge SEC-10: add Permissions-Policy header 2026-03-13 00:49:34 -07:00
482300b8b1 Merge SEC-12: reduce default token expiry to 7 days 2026-03-13 00:49:29 -07:00
8545473703 Fix SEC-01: require password for TOTP enroll
- REST handleTOTPEnroll now requires password field in request body
- gRPC EnrollTOTP updated with password field in proto message
- Both handlers check lockout status and record failures on bad password
- Updated Go, Python, and Rust client libraries to pass password
- Updated OpenAPI specs with new requestBody schema
- Added TestTOTPEnrollRequiresPassword with no-password, wrong-password,
  and correct-password sub-tests

Security: TOTP enrollment now requires the current password to prevent
session-theft escalation to persistent account takeover. Lockout and
failure recording use the same Argon2id constant-time path as login.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:48:31 -07:00
3b17f7f70b Fix SEC-11: use json.Marshal for audit details
- Add internal/audit package with JSON() and JSONWithRoles() helpers
  that use json.Marshal instead of fmt.Sprintf with %q
- Replace all fmt.Sprintf audit detail construction in:
  - internal/server/server.go (10 occurrences)
  - internal/ui/handlers_auth.go (4 occurrences)
  - internal/grpcserver/auth.go (4 occurrences)
- Add tests for the helpers including edge-case Unicode,
  null bytes, special characters, and odd argument counts
- Fix broken {"roles":%v} formatting that produced invalid JSON

Security: Audit log detail strings are now constructed via
json.Marshal, which correctly handles all Unicode edge cases
(U+2028, U+2029, null bytes, etc.) that fmt.Sprintf with %q
may mishandle. This prevents potential log injection or parsing
issues in audit event consumers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:46:00 -07:00
eef7d1bc1a Fix SEC-03: require token proximity for renewal
- Add 50% lifetime elapsed check to REST handleRenew and gRPC RenewToken
- Reject renewal attempts before 50% of token lifetime has elapsed
- Update existing renewal tests to use short-lived tokens with sleep
- Add TestRenewTokenTooEarly tests for both REST and gRPC

Security: Tokens can only be renewed after 50% of their lifetime has
elapsed, preventing indefinite renewal of stolen tokens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:45:35 -07:00
d7d7ba21d9 Fix SEC-09: hide admin nav links from non-admin users
- Add IsAdmin bool to PageData (embedded in all page view structs)
- Remove redundant IsAdmin from DashboardData
- Add isAdmin() helper to derive admin status from request claims
- Set IsAdmin in all page-level handlers that populate PageData
- Wrap admin-only nav links in base.html with {{if .IsAdmin}}
- Add tests: non-admin dashboard/profile hide admin links,
  admin dashboard shows them

Security: navigation links to /accounts, /audit, /policies,
and /pgcreds are now only rendered for admin users. Server-side
authorization (requireAdminRole middleware) was already in place;
this change removes the information leak of showing links that
return 403 to non-admin users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:44:30 -07:00
4d3d438253 Fix SEC-02: normalize lockout response
- REST login: change locked account response from HTTP 429
  "account_locked" to HTTP 401 "invalid credentials"
- gRPC login: change from ResourceExhausted to Unauthenticated
  with "invalid credentials" message
- UI login: change from "account temporarily locked" to
  "invalid credentials"
- REST password-change endpoint: same normalization
- Audit logs still record "account_locked" internally
- Added tests in all three layers verifying locked-account
  responses are indistinguishable from wrong-password responses

Security: lockout responses now return identical status codes and
messages as wrong-password failures across REST, gRPC, and UI,
preventing user-enumeration via lockout differentiation. Internal
audit logging of lockout events is preserved for operational use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:43:57 -07:00
7cc2c86300 Fix SEC-12: reduce default token expiry to 7 days
- Change default_expiry from 720h (30 days) to 168h (7 days)
  in dist/mcias.conf.example and dist/mcias.conf.docker.example
- Update man page, ARCHITECTURE.md, and config.go comment
- Max ceiling validation remains at 30 days (unchanged)

Security: Shorter default token lifetime reduces the window of
exposure if a token is leaked. 7 days balances convenience and
security for a personal SSO. The 30-day max ceiling is preserved
so operators can still override if needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:43:20 -07:00
51a5277062 Fix SEC-08: make system token issuance atomic
- Add IssueSystemToken() method in internal/db/accounts.go that wraps
  revoke-old, track-new, and upsert-system_tokens in a single SQLite
  transaction
- Update handleTokenIssue in internal/server/server.go to use the new
  atomic method instead of three separate DB calls
- Update IssueServiceToken in internal/grpcserver/tokenservice.go with
  the same fix
- Add TestIssueSystemTokenAtomic test covering first issue and rotation

Security: token issuance now uses a single transaction to prevent
inconsistent state (e.g., old token revoked but new token not tracked)
if a crash occurs between operations. Follows the same pattern as
RenewToken which was already correctly transactional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:43:13 -07:00
d3b63b1f87 Fix SEC-06: proxy-aware gRPC rate limiting
- Add grpcClientIP() helper that mirrors middleware.ClientIP
  for proxy-aware IP extraction from gRPC metadata
- Update rateLimitInterceptor to use grpcClientIP with the
  TrustedProxy config setting
- Only trust x-forwarded-for/x-real-ip metadata when the
  peer address matches the configured trusted proxy
- Add 7 unit tests covering: no proxy, xff, x-real-ip
  preference, untrusted peer ignoring headers, no headers
  fallback, invalid header fallback, and no peer

Security: gRPC rate limiter now extracts real client IPs
behind a reverse proxy using the same trust model as the
REST middleware (DEF-03). Headers from untrusted peers are
ignored, preventing IP-spoofing for rate-limit bypass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:43:09 -07:00
70e4f715f7 Fix SEC-05: add body size limit to REST API and max password length
- Wrap r.Body with http.MaxBytesReader (1 MiB) in decodeJSON so all
  REST API endpoints reject oversized JSON payloads
- Add MaxPasswordLen = 128 constant and enforce it in validate.Password()
  to prevent Argon2id DoS via multi-MB passwords
- Add test for oversized JSON body rejection (>1 MiB -> 400)
- Add test for password max length enforcement

Security: decodeJSON now applies the same body size limit the UI layer
already uses, closing the asymmetry. MaxPasswordLen caps Argon2id input
to a reasonable length, preventing CPU-exhaustion attacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:42:11 -07:00
3f09d5eb4f Fix SEC-04: add security headers to API
- Add globalSecurityHeaders middleware wrapping root handler
- Sets X-Content-Type-Options, Strict-Transport-Security, Cache-Control
  on all responses (API and UI)
- Add tests verifying headers on /v1/health and /v1/auth/login

Security: API responses previously lacked HSTS, nosniff, and
cache-control headers. The new middleware applies these universally.
Headers are safe for all content types and do not conflict with
the UI's existing securityHeaders middleware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:41:48 -07:00
036a0b8be4 Fix SEC-07: disable static file directory listing
- Add noDirListing handler wrapper that returns 404 for directory
  requests (paths ending with "/" or empty path) instead of delegating
  to http.FileServerFS which would render an index page
- Wrap the static file server in Register() with noDirListing
- Add tests verifying GET /static/ returns 404 and GET /static/style.css
  still returns 200

Security: directory listings exposed the names of all static assets,
leaking framework details. The wrapper blocks directory index responses
while preserving normal file serving.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:41:46 -07:00
30fc3470fa Fix SEC-10: add Permissions-Policy header
- Add Permissions-Policy header disabling camera, microphone,
  geolocation, and payment browser features
- Update assertSecurityHeaders test helper to verify the new header

Security: Permissions-Policy restricts browser APIs that this
application does not use, reducing attack surface from content
injection vulnerabilities. No crypto or auth flow changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:41:20 -07:00
586d4e3355 Allow non-admin users to access dashboard
- Change dashboard route from adminGet to authed middleware
- Show account counts and audit events only for admin users
- Show welcome message for non-admin authenticated users

Security: non-admin users cannot access account lists or audit
events; admin-only data is gated by claims.HasRole("admin") in
the handler, not just at the route level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:40:21 -07:00
394a9fb754 Update docs for recent changes
- ARCHITECTURE.md: add gRPC listener, mciasgrpcctl, new roles,
  granular role endpoints, profile page, audit events, policy actions,
  trusted_proxy config, validate package, schema force command
- PROGRESS.md: document role expansion and UI privilege escalation fix
- PROJECT_PLAN.md: align mciasctl subcommands with implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:07:41 -07:00
1c16354725 fix UI privilege escalation vulnerability
- Add requireAdminRole middleware to web UI that checks
  claims.HasRole("admin") and returns 403 if absent
- Apply middleware to all admin routes (accounts, policies,
  audit, dashboard, credentials)
- Remove redundant inline admin check from handleAdminResetPassword
- Profile routes correctly require only authentication, not admin

Security: The admin/adminGet middleware wrappers only called
requireCookieAuth (JWT validation) but never verified the admin
role. Any authenticated user could access admin endpoints
including role assignment. Fixed by inserting requireAdminRole
into the middleware chain for all admin routes.
2026-03-12 21:59:02 -07:00
89f78a38dd Update web UI to support all compile-time roles
- Update knownRoles to include guest, viewer, editor, and commenter
- Replace hardcoded role strings with model constants
- Remove obsolete 'service' role from UI
- All tests pass
2026-03-12 21:14:22 -07:00
4d6c5cb67c Add guest, viewer, editor, and commenter roles to compile-time allowlist
- Add RoleGuest, RoleViewer, RoleEditor, and RoleCommenter constants
- Update allowedRoles map to include new roles
- Update ValidateRole error message with complete role list
- All tests pass; build verified
2026-03-12 21:03:24 -07:00
f880bbb6de Add granular role grant/revoke endpoints to REST and gRPC APIs
- Add POST /v1/accounts/{id}/roles and DELETE /v1/accounts/{id}/roles/{role} REST endpoints
- Add GrantRole and RevokeRole RPCs to AccountService in gRPC API
- Update OpenAPI specification with new endpoints
- Add grant and revoke subcommands to mciasctl
- Add grant and revoke subcommands to mciasgrpcctl
- Regenerate proto files with new message types and RPCs
- Implement gRPC server methods for granular role management
- All existing tests pass; build verified with goimports
Security: Role changes are audited via EventRoleGranted and EventRoleRevoked events,
consistent with existing SetRoles implementation.
2026-03-12 20:55:49 -07:00
d3d656a23f grpcctl: add auth login and policy commands
- Add auth/login and auth/logout to mciasgrpcctl, calling
  the existing AuthService.Login/Logout RPCs; password is
  always prompted interactively (term.ReadPassword), never
  accepted as a flag, raw bytes zeroed after use
- Add proto/mcias/v1/policy.proto with PolicyService
  (List, Create, Get, Update, Delete policy rules)
- Regenerate gen/mcias/v1/ stubs to include policy
- Implement internal/grpcserver/policyservice.go delegating
  to the same db layer as the REST policy handlers
- Register PolicyService in grpcserver.go
- Add policy list/create/get/update/delete to mciasgrpcctl
- Update mciasgrpcctl man page with new commands

Security: auth login uses the same interactive password
prompt pattern as mciasctl; password never appears in
process args, shell history, or logs; raw bytes zeroed
after string conversion (same as REST CLI and REST server).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:51:10 -07:00
66 changed files with 4310 additions and 418 deletions

View File

@@ -11,7 +11,8 @@
"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 \"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(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 run:*)",
"Bash(go list:*)" "Bash(go list:*)",
"Bash(go vet:*)"
] ]
}, },
"hooks": { "hooks": {

Binary file not shown.

View File

@@ -15,7 +15,7 @@ parties that delegate authentication decisions to it.
### Components ### Components
``` ```
┌────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────
│ MCIAS Server (mciassrv) │ │ MCIAS Server (mciassrv) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Auth │ │ Token │ │ Account / Role │ │ │ │ Auth │ │ Token │ │ Account / Role │ │
@@ -26,25 +26,35 @@ parties that delegate authentication decisions to it.
│ ┌─────────▼──────────┐ │ │ ┌─────────▼──────────┐ │
│ │ SQLite Database │ │ │ │ SQLite Database │ │
│ └────────────────────┘ │ │ └────────────────────┘ │
└────────────────────────────────────────────────────┘ │ │
▲ ▲ ┌──────────────────┐ ┌──────────────────────┐
│ HTTPS/REST │ HTTPS/REST │ direct file I/O REST listener │ │ gRPC listener │ │
│ │ (net/http) (google.golang.org/ │
┌──────┴──────┐ ┌────┴─────┐ ┌──────┴──────┐ │ │ :8443 │ │ grpc) :9443 │ │
Personal │ │ mciasctl │ │ mciasdb └──────────────────┘ └──────────────────────┘
│ Apps │ │ (admin │ │ (DB tool) │ └──────────────────────────────────────────────────────────┘
└─────────────┘ │ CLI) └─────────────┘ ▲ ▲ ▲
└──────────┘ │ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O
│ │ │ │
┌────┴──────┐ ┌────┴─────┐ ┌─────┴────────┐ ┌───┴────────┐
│ Personal │ │ mciasctl │ │ mciasgrpcctl │ │ mciasdb │
│ Apps │ │ (admin │ │ (gRPC admin │ │ (DB tool) │
└───────────┘ │ CLI) │ │ CLI) │ └────────────┘
└──────────┘ └──────────────┘
``` ```
**mciassrv** — The authentication server. Exposes a REST API over HTTPS/TLS. **mciassrv** — The authentication server. Exposes a REST API and gRPC API over
Handles login, token issuance, token validation, token renewal, and token HTTPS/TLS (dual-stack; see §17). Handles login, token issuance, token
revocation. validation, token renewal, and token revocation.
**mciasctl** — The administrator CLI. Communicates with mciassrv's REST API **mciasctl** — The administrator CLI. Communicates with mciassrv's REST API
using an admin JWT. Creates/manages human accounts, system accounts, roles, using an admin JWT. Creates/manages human accounts, system accounts, roles,
and Postgres credential records. and Postgres credential records.
**mciasgrpcctl** — The gRPC administrator CLI. Mirrors mciasctl's subcommands
but communicates over gRPC/TLS instead of REST. Both CLIs can coexist; neither
depends on the other.
**mciasdb** — The database maintenance tool. Operates directly on the SQLite **mciasdb** — The database maintenance tool. Operates directly on the SQLite
file, bypassing the server API. Intended for break-glass recovery, offline file, bypassing the server API. Intended for break-glass recovery, offline
inspection, schema verification, and maintenance tasks that cannot be inspection, schema verification, and maintenance tasks that cannot be
@@ -127,13 +137,21 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
### Roles ### Roles
Roles are simple string labels stored in the `account_roles` table. Roles are simple string labels stored in the `account_roles` table. Only
compile-time allowlisted role names are accepted; attempting to grant an
unknown role returns an error (prevents typos like "admim" from silently
creating a useless role).
Reserved roles: Compile-time allowlisted roles:
- `admin` — superuser; can manage all accounts, tokens, and credentials - `admin` — superuser; can manage all accounts, tokens, and credentials
- `user` — standard user role
- `guest` — limited read-only access
- `viewer` — read-only access
- `editor` — create/modify access
- `commenter` — comment/annotate access
- Any role named identically to a system account — grants that human account - Any role named identically to a system account — grants that human account
the ability to issue/revoke tokens and retrieve Postgres credentials for that the ability to issue/revoke tokens and retrieve Postgres credentials for that
system account system account (via policy rules, not the allowlist)
Role assignment requires admin privileges. Role assignment requires admin privileges.
@@ -340,7 +358,6 @@ All endpoints use JSON request/response bodies. All responses include a
| POST | `/v1/auth/login` | none | Username/password (+TOTP) login → JWT | | POST | `/v1/auth/login` | none | Username/password (+TOTP) login → JWT |
| POST | `/v1/auth/logout` | bearer JWT | Revoke current token | | POST | `/v1/auth/logout` | bearer JWT | Revoke current token |
| POST | `/v1/auth/renew` | bearer JWT | Exchange token for new token | | POST | `/v1/auth/renew` | bearer JWT | Exchange token for new token |
| PUT | `/v1/auth/password` | bearer JWT | Self-service password change (requires current password) |
### Token Endpoints ### Token Endpoints
@@ -372,7 +389,9 @@ All endpoints use JSON request/response bodies. All responses include a
| Method | Path | Auth required | Description | | Method | Path | Auth required | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/v1/accounts/{id}/roles` | admin JWT | List roles for account | | GET | `/v1/accounts/{id}/roles` | admin JWT | List roles for account |
| PUT | `/v1/accounts/{id}/roles` | admin JWT | Replace role set | | PUT | `/v1/accounts/{id}/roles` | admin JWT | Replace role set (atomic) |
| POST | `/v1/accounts/{id}/roles` | admin JWT | Grant a single role |
| DELETE | `/v1/accounts/{id}/roles/{role}` | admin JWT | Revoke a single role |
### TOTP Endpoints ### TOTP Endpoints
@@ -446,6 +465,7 @@ cookie pattern (`mcias_csrf`).
| `/pgcreds` | Postgres credentials list (owned + granted) with create form | | `/pgcreds` | Postgres credentials list (owned + granted) with create form |
| `/policies` | Policy rules management — create, enable/disable, delete | | `/policies` | Policy rules management — create, enable/disable, delete |
| `/audit` | Audit log viewer | | `/audit` | Audit log viewer |
| `/profile` | User profile — self-service password change (any authenticated user) |
**HTMX fragments:** Mutating operations (role updates, tag edits, credential **HTMX fragments:** Mutating operations (role updates, tag edits, credential
saves, policy toggles, access grants) use HTMX partial-page updates for a saves, policy toggles, access grants) use HTMX partial-page updates for a
@@ -490,6 +510,9 @@ CREATE TABLE accounts (
-- AES-256-GCM encrypted TOTP secret; NULL if not enrolled -- AES-256-GCM encrypted TOTP secret; NULL if not enrolled
totp_secret_enc BLOB, totp_secret_enc BLOB,
totp_secret_nonce BLOB, totp_secret_nonce BLOB,
-- Last accepted TOTP counter value; prevents replay attacks within the
-- ±1 time-step window (RFC 6238 §5.2). NULL = no code accepted yet.
last_totp_counter INTEGER DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
deleted_at TEXT deleted_at TEXT
@@ -665,13 +688,16 @@ listen_addr = "0.0.0.0:8443"
grpc_addr = "0.0.0.0:9443" # optional; omit to disable gRPC grpc_addr = "0.0.0.0:9443" # optional; omit to disable gRPC
tls_cert = "/etc/mcias/server.crt" tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key" tls_key = "/etc/mcias/server.key"
# trusted_proxy = "127.0.0.1" # optional; IP of reverse proxy — when set,
# X-Forwarded-For is trusted only from this IP
# for rate limiting and audit log IP extraction
[database] [database]
path = "/var/lib/mcias/mcias.db" path = "/var/lib/mcias/mcias.db"
[tokens] [tokens]
issuer = "https://auth.example.com" issuer = "https://auth.example.com"
default_expiry = "720h" # 30 days default_expiry = "168h" # 7 days
admin_expiry = "8h" admin_expiry = "8h"
service_expiry = "8760h" # 365 days service_expiry = "8760h" # 365 days
@@ -711,7 +737,8 @@ mcias/
│ ├── policy/ # in-process authorization policy engine (§20) │ ├── policy/ # in-process authorization policy engine (§20)
│ ├── server/ # HTTP handlers, router setup │ ├── server/ # HTTP handlers, router setup
│ ├── token/ # JWT issuance, validation, revocation │ ├── token/ # JWT issuance, validation, revocation
── ui/ # web UI context, CSRF, session, template handlers ── ui/ # web UI context, CSRF, session, template handlers
│ └── validate/ # input validation helpers (username, password strength)
├── web/ ├── web/
│ ├── static/ # CSS and static assets │ ├── static/ # CSS and static assets
│ └── templates/ # HTML templates (base layout, pages, HTMX fragments) │ └── templates/ # HTML templates (base layout, pages, HTMX fragments)
@@ -761,6 +788,9 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into
| `totp_removed` | TOTP removed from account | | `totp_removed` | TOTP removed from account |
| `pgcred_accessed` | Postgres credentials retrieved | | `pgcred_accessed` | Postgres credentials retrieved |
| `pgcred_updated` | Postgres credentials stored/updated | | `pgcred_updated` | Postgres credentials stored/updated |
| `pgcred_access_granted` | Read access to PG credentials granted to another account |
| `pgcred_access_revoked` | Read access to PG credentials revoked from an account |
| `password_changed` | Account password changed (self-service or admin reset) |
| `tag_added` | Tag added to account | | `tag_added` | Tag added to account |
| `tag_removed` | Tag removed from account | | `tag_removed` | Tag removed from account |
| `policy_rule_created` | Policy rule created | | `policy_rule_created` | Policy rule created |
@@ -838,6 +868,7 @@ mciasdb --config PATH <subcommand> [flags]
|---|---| |---|---|
| `mciasdb schema verify` | Open DB, run migrations in dry-run mode, report version | | `mciasdb schema verify` | Open DB, run migrations in dry-run mode, report version |
| `mciasdb schema migrate` | Apply any pending migrations and exit | | `mciasdb schema migrate` | Apply any pending migrations and exit |
| `mciasdb schema force --version N` | Force schema version (clears dirty state); break-glass recovery |
| `mciasdb prune tokens` | Delete expired rows from `token_revocation` and `system_tokens` | | `mciasdb prune tokens` | Delete expired rows from `token_revocation` and `system_tokens` |
**Account management (offline):** **Account management (offline):**
@@ -943,7 +974,7 @@ in `proto/generate.go` using `protoc-gen-go` and `protoc-gen-go-grpc`.
|---|---| |---|---|
| `AuthService` | `Login`, `Logout`, `RenewToken`, `EnrollTOTP`, `ConfirmTOTP`, `RemoveTOTP` | | `AuthService` | `Login`, `Logout`, `RenewToken`, `EnrollTOTP`, `ConfirmTOTP`, `RemoveTOTP` |
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` | | `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles` | | `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
| `CredentialService` | `GetPGCreds`, `SetPGCreds` | | `CredentialService` | `GetPGCreds`, `SetPGCreds` |
| `AdminService` | `Health`, `GetPublicKey` | | `AdminService` | `Health`, `GetPublicKey` |
@@ -1376,6 +1407,7 @@ const (
ActionRemoveTOTP Action = "totp:remove" // admin ActionRemoveTOTP Action = "totp:remove" // admin
ActionLogin Action = "auth:login" // public ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service ActionLogout Action = "auth:logout" // self-service
ActionChangePassword Action = "auth:change_password" // self-service
ActionListRules Action = "policy:list" ActionListRules Action = "policy:list"
ActionManageRules Action = "policy:manage" ActionManageRules Action = "policy:manage"
@@ -1476,8 +1508,10 @@ at the same priority level.
``` ```
Priority 0, Allow: roles=[admin], actions=<all> — admin wildcard Priority 0, Allow: roles=[admin], actions=<all> — admin wildcard
Priority 0, Allow: actions=[tokens:renew, auth:logout] — self-service logout/renew Priority 0, Allow: actions=[auth:logout, tokens:renew] — self-service logout/renew
Priority 0, Allow: actions=[totp:enroll] — self-service TOTP enrollment Priority 0, Allow: actions=[totp:enroll] — self-service TOTP enrollment
Priority 0, Allow: accountTypes=[human], actions=[auth:change_password]
— self-service password change
Priority 0, Allow: accountTypes=[system], actions=[pgcreds:read], Priority 0, Allow: accountTypes=[system], actions=[pgcreds:read],
resourceType=pgcreds, ownerMatchesSubject=true resourceType=pgcreds, ownerMatchesSubject=true
— system account reads own creds — system account reads own creds

230
AUDIT.md
View File

@@ -1,202 +1,194 @@
# MCIAS Security Audit Report # MCIAS Security Audit Report
**Date:** 2026-03-12 **Date:** 2026-03-14 (updated — all findings remediated)
**Scope:** Full codebase — authentication flows, token lifecycle, cryptography, database layer, REST/gRPC/UI servers, authorization, and operational security. **Original audit date:** 2026-03-13
**Methodology:** Static code analysis of all source files with adversarial focus on auth flows, crypto usage, input handling, and inter-component trust boundaries. **Auditor role:** Penetration tester (code review + live instance probing)
**Scope:** Full codebase and running instance at localhost:8443 — authentication flows, token lifecycle, cryptography, database layer, REST/gRPC/UI servers, authorization, headers, and operational security.
**Methodology:** Static code analysis, live HTTP probing, architectural review.
--- ---
## Executive Summary ## Executive Summary
MCIAS 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. MCIAS has a strong security posture. All findings from three audit rounds (CRIT-01/CRIT-02, DEF-01 through DEF-10, and SEC-01 through SEC-12) have been remediated. The cryptographic foundations are sound, JWT validation is correct, SQL injection is not possible, XSS is prevented by Go's html/template auto-escaping, and CSRF protection is well-implemented.
**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. **All findings from this audit have been remediated.** See the remediation table below for details.
--- ---
## Confirmed Vulnerabilities ## Remediated Findings (SEC-01 through SEC-12)
### CRIT-01 — TOTP Replay Attack (Medium-High) All findings from this audit have been remediated. The original descriptions are preserved below for reference.
**File:** `internal/auth/auth.go:208-230`, `internal/grpcserver/auth.go:84`, `internal/ui/handlers_auth.go:152` | ID | Severity | Finding | Status |
|----|----------|---------|--------|
| SEC-01 | Medium | TOTP enrollment did not require password re-authentication | **Fixed** — both REST and gRPC now require current password, with lockout counter on failure |
| SEC-02 | Medium | Account lockout response leaked account existence | **Fixed** — locked accounts now return same 401 `"invalid credentials"` as wrong password, with dummy Argon2 for timing uniformity |
| SEC-03 | Medium | Token renewal had no proximity or re-auth check | **Fixed** — renewal requires token to have consumed ≥50% of its lifetime |
| SEC-04 | Low-Med | REST API responses lacked security headers | **Fixed**`globalSecurityHeaders` middleware applies `X-Content-Type-Options`, HSTS, and `Cache-Control: no-store` to all routes |
| SEC-05 | Low | No request body size limit on REST API | **Fixed**`decodeJSON` wraps body with `http.MaxBytesReader` (1 MiB); max password length enforced |
| SEC-06 | Low | gRPC rate limiter ignored TrustedProxy | **Fixed**`grpcClientIP` extracts real client IP via metadata when peer matches trusted proxy |
| SEC-07 | Low | Static file directory listing enabled | **Fixed**`noDirListing` wrapper returns 404 for directory requests |
| SEC-08 | Low | System token issuance was not atomic | **Fixed**`IssueSystemToken` wraps revoke+track in a single SQLite transaction |
| SEC-09 | Info | Navigation bar exposed admin UI structure to non-admin users | **Fixed** — nav links conditionally rendered with `{{if .IsAdmin}}` |
| SEC-10 | Info | No `Permissions-Policy` header | **Fixed**`Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()` added |
| SEC-11 | Info | Audit log details used `fmt.Sprintf` instead of `json.Marshal` | **Fixed**`audit.JSON` and `audit.JSONWithRoles` helpers use `json.Marshal` |
| SEC-12 | Info | Default token expiry was 30 days | **Fixed** — default reduced to 7 days (168h); renewal proximity check (SEC-03) further limits exposure |
`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. <details>
<summary>Original finding descriptions (click to expand)</summary>
**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. ### SEC-01 — TOTP Enrollment Does Not Require Password Re-authentication (Medium)
**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). **Files:** `internal/server/server.go`, `internal/grpcserver/auth.go`
`POST /v1/auth/totp/enroll` and the gRPC `EnrollTOTP` RPC originally required only a valid JWT — no password confirmation. If an attacker stole a session token, they could enroll TOTP on the victim's account.
**Fix:** Both endpoints now require the current password, with lockout counter incremented on failure.
--- ---
### CRIT-02 — gRPC `EnrollTOTP` Enables TOTP Before Confirmation (Medium) ### SEC-02 — Account Lockout Response Leaks Account Existence (Medium)
**File:** `internal/grpcserver/auth.go:202` vs `internal/server/server.go:724-728` Locked accounts originally returned HTTP 429 / gRPC `ResourceExhausted` with `"account temporarily locked"`, distinguishable from the HTTP 401 `"invalid credentials"` returned for wrong passwords.
The REST `EnrollTOTP` handler explicitly uses `StorePendingTOTP` (which keeps `totp_required=0`) and a comment at line 724 explains why: **Fix:** All login paths now return the same `"invalid credentials"` response for locked accounts, with dummy Argon2 to maintain timing uniformity.
```go
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required
// is not enabled until the user confirms the code.
```
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.
**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.
--- ---
## Defense-in-Depth Gaps ### SEC-03 — Token Renewal Has No Proximity or Re-auth Check (Medium)
### DEF-01 — No Rate Limiting on the UI Login Endpoint (Medium) `POST /v1/auth/renew` originally accepted any valid token regardless of remaining lifetime.
**File:** `internal/ui/ui.go:264` **Fix:** Renewal now requires the token to have consumed ≥50% of its lifetime before it can be renewed.
```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.
--- ---
### DEF-02`pendingLogins` Map Has No Expiry Cleanup (Low) ### SEC-04REST API Responses Lack Security Headers (Low-Medium)
**File:** `internal/ui/ui.go:57` API endpoints originally returned only `Content-Type` — no `Cache-Control`, `X-Content-Type-Options`, or HSTS.
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. **Fix:** `globalSecurityHeaders` middleware applies these headers to all routes (API and UI).
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.
--- ---
### DEF-03Rate Limiter Uses `RemoteAddr`, Not `X-Forwarded-For` (Low) ### SEC-05No Request Body Size Limit on REST API Endpoints (Low)
**File:** `internal/middleware/middleware.go:200` `decodeJSON` originally read from `r.Body` without any size limit.
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. **Fix:** `http.MaxBytesReader` with 1 MiB limit added to `decodeJSON`. Maximum password length also enforced.
**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.
--- ---
### DEF-04Missing `nbf` (Not Before) Claim on Issued Tokens (Low) ### SEC-06gRPC Rate Limiter Ignores TrustedProxy (Low)
**File:** `internal/token/token.go:73-82` The gRPC rate limiter originally used `peer.FromContext` directly, always getting the proxy IP behind a reverse proxy.
`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. **Fix:** `grpcClientIP` now reads from gRPC metadata headers when the peer matches the trusted proxy.
**Fix:** Add `NotBefore: jwt.NewNumericDate(now)` to the `RegisteredClaims` struct. Add the corresponding validation step in `ValidateToken` (using `jwt.WithNotBefore()` or a manual check).
--- ---
### DEF-05No Maximum Token Expiry Ceiling in Config Validation (Low) ### SEC-07Static File Directory Listing Enabled (Low)
**File:** `internal/config/config.go:150-158` `http.FileServerFS` served directory listings by default.
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. **Fix:** `noDirListing` wrapper returns 404 for directory requests.
**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.
--- ---
### DEF-06`GetAccountByUsername` Comment Incorrect re: Case Sensitivity (Informational) ### SEC-08System Token Issuance Is Not Atomic (Low)
**File:** `internal/db/accounts.go:73` `handleTokenIssue` originally performed three sequential non-transactional operations.
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. **Fix:** `IssueSystemToken` wraps all operations in a single SQLite transaction.
**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.
--- ---
### DEF-07SQLite `synchronous=NORMAL` in WAL Mode (Low) ### SEC-09Navigation Bar Exposes Admin UI Structure to Non-Admin Users (Informational)
**File:** `internal/db/db.go:68` Nav links were rendered for all authenticated users.
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. **Fix:** Admin nav links wrapped in `{{if .IsAdmin}}` conditional.
**Fix:** Change to `PRAGMA synchronous=FULL`. For a single-node personal SSO the performance impact is negligible; durability of token revocations is worth it.
--- ---
### DEF-08 — gRPC `Login` Counts TOTP-Missing as a Login Failure (Low) ### SEC-10 — No `Permissions-Policy` Header (Informational)
**File:** `internal/grpcserver/auth.go:76-77` The security headers middleware did not include `Permissions-Policy`.
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:** `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()` added.
**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) ### SEC-11 — Audit Log Details Use `fmt.Sprintf` Instead of `json.Marshal` (Informational)
**File:** `internal/server/server.go:85-94` Audit details were constructed with `fmt.Sprintf` and `%q`, which is fragile for JSON.
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:** `audit.JSON` and `audit.JSONWithRoles` helpers use `json.Marshal`.
**Fix:** Apply a security-headers middleware to the docs handlers, or move them into the UI sub-mux.
--- ---
### DEF-10Role Strings Not Validated Against an Allowlist (Low) ### SEC-12Default Token Expiry Is 30 Days (Informational / Configuration)
**File:** `internal/db/accounts.go:302-311` (`GrantRole`) Default expiry was 720h (30 days).
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:** Reduced to 168h (7 days). Combined with SEC-03's renewal proximity check, exposure window is significantly reduced.
**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. </details>
--- ---
## Positive Findings ## Previously Remediated Findings (CRIT/DEF series)
The following implementation details are exemplary and should be preserved: The following findings from the initial audit (2026-03-12) were confirmed fixed in the 2026-03-13 audit:
| ID | Finding | Status |
|----|---------|--------|
| CRIT-01 | TOTP replay attack — no counter tracking | **Fixed**`CheckAndUpdateTOTPCounter` with atomic SQL, migration 000007 |
| CRIT-02 | gRPC `EnrollTOTP` called `SetTOTP` instead of `StorePendingTOTP` | **Fixed** — now calls `StorePendingTOTP` |
| DEF-01 | No rate limiting on UI login | **Fixed**`loginRateLimit` applied to `POST /login` |
| DEF-02 | `pendingLogins` map had no expiry cleanup | **Fixed**`cleanupPendingLogins` goroutine runs every 5 minutes |
| DEF-03 | Rate limiter ignored `X-Forwarded-For` | **Fixed**`ClientIP()` respects `TrustedProxy` config |
| DEF-04 | Missing `nbf` claim on tokens | **Fixed**`NotBefore: jwt.NewNumericDate(now)` added |
| DEF-05 | No max token expiry ceiling | **Fixed** — upper bounds enforced in config validation |
| DEF-06 | Incorrect case-sensitivity comment | **Fixed** — comment corrected |
| DEF-07 | SQLite `synchronous=NORMAL` | **Fixed** — changed to `PRAGMA synchronous=FULL` |
| DEF-08 | gRPC counted TOTP-missing as failure | **Fixed** — no longer increments lockout counter |
| DEF-09 | Security headers missing on docs endpoints | **Fixed**`docsSecurityHeaders` wrapper added |
| DEF-10 | Role strings not validated | **Fixed**`model.ValidateRole()` with compile-time allowlist |
---
## Positive Findings (Preserved)
These implementation details are exemplary and should be maintained:
| Area | Detail | | Area | Detail |
|------|--------| |------|--------|
| JWT alg confusion | `ValidateToken` enforces `alg=EdDSA` in the key function, before signature verification — the only correct place | | JWT alg confusion | `ValidateToken` enforces `alg=EdDSA` in the key function before signature verification |
| Constant-time comparisons | `crypto/subtle.ConstantTimeCompare` used consistently for password hashes, TOTP codes, and CSRF tokens | | Constant-time operations | `crypto/subtle.ConstantTimeCompare` for password hashes, CSRF tokens; all three TOTP windows evaluated without early exit |
| Timing uniformity | Dummy Argon2 computed (once, with full production parameters via `sync.Once`) for unknown/inactive users on both REST and gRPC paths | | Timing uniformity | Dummy Argon2 via `sync.Once` for unknown/inactive users on all login paths |
| Token revocation | Every token is tracked by JTI; unknown tokens are rejected (fail-closed) rather than silently accepted | | Token revocation | Fail-closed: untracked tokens are rejected, not silently accepted |
| Token renewal atomicity | `RenewToken` wraps revocation + insertion in a single SQLite transaction | | Token renewal atomicity | `RenewToken` wraps revoke+track 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 | | TOTP replay prevention | Counter-based replay detection with atomic SQL UPDATE/WHERE |
| CSRF protection | HMAC-SHA256 signed double-submit cookie with `SameSite=Strict` and constant-time validation | | TOTP nonce design | 128-bit single-use server-side nonce; password never retransmitted in step 2 |
| Credential exclusion | `json:"-"` tags on all credential fields; proto messages omit them too | | CSRF protection | HMAC-SHA256 double-submit cookie, domain-separated key derivation, SameSite=Strict, constant-time validation |
| Security headers | All UI responses receive CSP, `X-Content-Type-Options`, `X-Frame-Options`, HSTS, and `Referrer-Policy` | | Credential exclusion | `json:"-"` on all credential fields; password hash never in API responses |
| Account lockout | 10-attempt, 15-minute rolling lockout checked before Argon2 to prevent timing oracle | | Security headers (UI) | CSP (no unsafe-inline), X-Content-Type-Options, X-Frame-Options DENY, HSTS 2yr, Referrer-Policy no-referrer |
| Argon2id parameters | Config validator enforces OWASP 2023 minimums and rejects weakening | | Cookie hardening | HttpOnly + Secure + SameSite=Strict on session cookie |
| SQL injection | All queries use parameterized statements; no string concatenation anywhere | | Account lockout | 10-attempt rolling window, checked before Argon2, with timing-safe dummy hash |
| Audit log | Append-only with actor/target/IP; no delete path provided | | Argon2id parameters | Config validator enforces OWASP 2023 minimums; rejects weakening |
| Master key handling | Env var cleared after reading; signing key zeroed on shutdown | | SQL injection | Zero string concatenation — all queries parameterized |
| Input validation | Username regex + length, password min length, account type enum, role allowlist, JSON strict decoder |
| Audit logging | Append-only, no delete path, credentials never logged, actor/target/IP captured |
| Master key hygiene | Env var cleared after read, key zeroed on shutdown, AES-256-GCM at rest |
| TLS | MinVersion TLS 1.2, X25519 preferred, no plaintext listener, read/write/idle timeouts set |
--- ---
## Remediation Priority ## Remediation Status
| Fixed | Priority | ID | Severity | Action | **All findings remediated.** No open items remain. Next audit should focus on:
|-------|----------|----|----------|--------| - Any new features added since 2026-03-14
| Yes | 1 | CRIT-02 | Medium | Change `grpcserver/auth.go:202` to call `StorePendingTOTP` instead of `SetTOTP` | - Dependency updates and CVE review
| Yes | 2 | CRIT-01 | Medium | Add `last_totp_counter` tracking to prevent TOTP replay within the validity window | - Live penetration testing of remediated endpoints
| 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 001006) 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.

View File

@@ -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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -4,6 +4,65 @@ 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-13 — Make pgcreds discoverable via CLI and UI
**Problem:** Users had no way to discover which pgcreds were available to them or what their credential IDs were, making it functionally impossible to use the system without manual database inspection.
**Solution:** Added two complementary discovery paths:
**REST API:**
- New `GET /v1/pgcreds` endpoint (requires authentication) returns all accessible credentials (owned + explicitly granted) with their IDs, host, port, database, username, and timestamps
- Response includes `id` field so users can then fetch full credentials via `GET /v1/accounts/{id}/pgcreds`
**CLI (`cmd/mciasctl/main.go`):**
- New `pgcreds list` subcommand calls `GET /v1/pgcreds` and displays accessible credentials with IDs
- Updated usage documentation to include `pgcreds list`
**Web UI (`web/templates/pgcreds.html`):**
- Credential ID now displayed in a `<code>` element at the top of each credential's metadata block
- Styled with monospace font for easy copying and reference
**Files modified:**
- `internal/server/server.go`: Added route `GET /v1/pgcreds` (requires auth, not admin) + handler `handleListAccessiblePGCreds`
- `cmd/mciasctl/main.go`: Added `pgCredsList` function and switch case
- `web/templates/pgcreds.html`: Display credential ID in the credentials list
- Struct field alignment fixed in `pgCredResponse` to pass `go vet`
All tests pass; `go vet ./...` clean.
### 2026-03-12 — Update web UI and model for all compile-time roles
- `internal/model/model.go`: added `RoleGuest`, `RoleViewer`, `RoleEditor`, and
`RoleCommenter` constants; updated `allowedRoles` map and `ValidateRole` error
message to include the full set of recognised roles.
- `internal/ui/`: updated `knownRoles` to include guest, viewer, editor, and
commenter; replaced hardcoded role strings with model constants; removed
obsolete "service" role from UI dropdowns.
- All tests pass; build verified.
### 2026-03-12 — Fix UI privilege escalation vulnerability
**internal/ui/ui.go**
- Added `requireAdminRole` middleware that checks `claims.HasRole("admin")`
and returns 403 if absent
- Updated `admin` and `adminGet` middleware wrappers to include
`requireAdminRole` in the chain — previously only `requireCookieAuth`
was applied, allowing any authenticated user to access admin endpoints
- Profile routes correctly use only `requireCookieAuth` (not admin-gated)
**internal/ui/handlers_accounts.go**
- Removed redundant inline admin check from `handleAdminResetPassword`
(now handled by route-level middleware)
**Full audit performed across all three API surfaces:**
- REST (`internal/server/server.go`): all admin routes use
`requireAuth → RequireRole("admin")` — correct
- gRPC (all service files): every admin RPC calls `requireAdmin(ctx)` as
first statement — correct
- UI: was vulnerable, now fixed with `requireAdminRole` middleware
All tests pass; `go vet ./...` clean.
### 2026-03-12 — Checkpoint: password change UI enforcement + migration recovery ### 2026-03-12 — Checkpoint: password change UI enforcement + migration recovery
**internal/ui/handlers_accounts.go** **internal/ui/handlers_accounts.go**

View File

@@ -165,18 +165,27 @@ See ARCHITECTURE.md for design rationale.
### Step 4.1: `cmd/mciasctl` — admin CLI ### Step 4.1: `cmd/mciasctl` — admin CLI
**Acceptance criteria:** **Acceptance criteria:**
- Subcommands: - Subcommands:
- `mciasctl account create --username NAME --type human|system` - `mciasctl account create -username NAME -type human|system`
- `mciasctl account list` - `mciasctl account list`
- `mciasctl account suspend --id UUID` - `mciasctl account update -id UUID -status active|inactive`
- `mciasctl account delete --id UUID` - `mciasctl account delete -id UUID`
- `mciasctl role grant --account UUID --role ROLE` - `mciasctl account get -id UUID`
- `mciasctl role revoke --account UUID --role ROLE` - `mciasctl account set-password -id UUID`
- `mciasctl token issue --account UUID` (system accounts) - `mciasctl role list -id UUID`
- `mciasctl token revoke --jti JTI` - `mciasctl role set -id UUID -roles role1,role2`
- `mciasctl pgcreds set --account UUID --host H --port P --db D --user U --password P` - `mciasctl role grant -id UUID -role ROLE`
- `mciasctl pgcreds get --account UUID` - `mciasctl role revoke -id UUID -role ROLE`
- CLI reads admin JWT from `MCIAS_ADMIN_TOKEN` env var or `--token` flag - `mciasctl token issue -id UUID` (system accounts)
- All commands make HTTPS requests to mciassrv (base URL from `--server` flag - `mciasctl token revoke -jti JTI`
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
- `mciasctl pgcreds get -id UUID`
- `mciasctl auth login`
- `mciasctl auth change-password`
- `mciasctl tag list -id UUID`
- `mciasctl tag set -id UUID -tags tag1,tag2`
- `mciasctl policy list|create|get|update|delete`
- CLI reads admin JWT from `MCIAS_TOKEN` env var or `-token` flag
- All commands make HTTPS requests to mciassrv (base URL from `-server` flag
or `MCIAS_SERVER` env var) or `MCIAS_SERVER` env var)
- Tests: flag parsing; missing required flags → error; help text complete - Tests: flag parsing; missing required flags → error; help text complete

View File

@@ -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.

View File

@@ -15,10 +15,10 @@ go get git.wntrmute.dev/kyle/mcias/clients/go
## Quick Start ## Quick Start
```go ```go
import mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go" import "git.wntrmute.dev/kyle/mcias/clients/go/mcias"
// Connect to the MCIAS server. // Connect to the MCIAS server.
client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{}) client, err := mcias.New("https://auth.example.com", mcias.Options{})
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -43,7 +43,7 @@ if err := client.Logout(); err != nil {
## Custom CA Certificate ## Custom CA Certificate
```go ```go
client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{ client, err := mcias.New("https://auth.example.com", mcias.Options{
CACertPath: "/etc/mcias/ca.pem", CACertPath: "/etc/mcias/ca.pem",
}) })
``` ```
@@ -55,17 +55,17 @@ All methods return typed errors:
```go ```go
_, _, err := client.Login("alice", "wrongpass", "") _, _, err := client.Login("alice", "wrongpass", "")
switch { switch {
case errors.Is(err, new(mciasgoclient.MciasAuthError)): case errors.Is(err, new(mcias.MciasAuthError)):
// 401 — wrong credentials or token invalid // 401 — wrong credentials or token invalid
case errors.Is(err, new(mciasgoclient.MciasForbiddenError)): case errors.Is(err, new(mcias.MciasForbiddenError)):
// 403 — insufficient role // 403 — insufficient role
case errors.Is(err, new(mciasgoclient.MciasNotFoundError)): case errors.Is(err, new(mcias.MciasNotFoundError)):
// 404 — resource not found // 404 — resource not found
case errors.Is(err, new(mciasgoclient.MciasInputError)): case errors.Is(err, new(mcias.MciasInputError)):
// 400 — malformed request // 400 — malformed request
case errors.Is(err, new(mciasgoclient.MciasConflictError)): case errors.Is(err, new(mcias.MciasConflictError)):
// 409 — conflict (e.g. duplicate username) // 409 — conflict (e.g. duplicate username)
case errors.Is(err, new(mciasgoclient.MciasServerError)): case errors.Is(err, new(mcias.MciasServerError)):
// 5xx — unexpected server error // 5xx — unexpected server error
} }
``` ```

View File

@@ -1,8 +1,8 @@
// Package mciasgoclient provides a thread-safe Go client for the MCIAS REST API. // Package mcias provides a thread-safe Go client for the MCIAS REST API.
// //
// Security: bearer tokens are stored under a sync.RWMutex and are never written // Security: bearer tokens are stored under a sync.RWMutex and are never written
// to logs or included in error messages anywhere in this package. // to logs or included in error messages anywhere in this package.
package mciasgoclient package mcias
import ( import (
"bytes" "bytes"
@@ -28,7 +28,7 @@ type MciasError struct {
} }
func (e *MciasError) Error() string { func (e *MciasError) Error() string {
return fmt.Sprintf("mciasgoclient: HTTP %d: %s", e.StatusCode, e.Message) return fmt.Sprintf("mcias: HTTP %d: %s", e.StatusCode, e.Message)
} }
// MciasAuthError is returned for 401 Unauthorized responses. // MciasAuthError is returned for 401 Unauthorized responses.
@@ -401,9 +401,15 @@ func (c *Client) RenewToken() (token, expiresAt string, err error) {
// Returns a base32 secret and an otpauth:// URI for QR-code generation. // Returns a base32 secret and an otpauth:// URI for QR-code generation.
// The secret is shown once; it is not retrievable after this call. // The secret is shown once; it is not retrievable after this call.
// TOTP is not enforced until confirmed via ConfirmTOTP. // TOTP is not enforced until confirmed via ConfirmTOTP.
func (c *Client) EnrollTOTP() (*TOTPEnrollResponse, error) { //
// Security (SEC-01): the current password is required to prevent a stolen
// session token from being used to enroll attacker-controlled TOTP.
func (c *Client) EnrollTOTP(password string) (*TOTPEnrollResponse, error) {
var resp TOTPEnrollResponse var resp TOTPEnrollResponse
if err := c.do(http.MethodPost, "/v1/auth/totp/enroll", nil, &resp); err != nil { body := struct {
Password string `json:"password"`
}{Password: password}
if err := c.do(http.MethodPost, "/v1/auth/totp/enroll", body, &resp); err != nil {
return nil, err return nil, err
} }
return &resp, nil return &resp, nil

View File

@@ -1,7 +1,7 @@
// Package mciasgoclient_test provides tests for the MCIAS Go client. // Package mcias_test provides tests for the MCIAS Go client.
// 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 mcias_test
import ( import (
"encoding/json" "encoding/json"
@@ -11,16 +11,16 @@ import (
"strings" "strings"
"testing" "testing"
mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go" mcias "git.wntrmute.dev/kyle/mcias/clients/go"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// helpers // helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func newTestClient(t *testing.T, serverURL string) *mciasgoclient.Client { func newTestClient(t *testing.T, serverURL string) *mcias.Client {
t.Helper() t.Helper()
c, err := mciasgoclient.New(serverURL, mciasgoclient.Options{}) c, err := mcias.New(serverURL, mcias.Options{})
if err != nil { if err != nil {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }
@@ -42,7 +42,7 @@ func writeError(w http.ResponseWriter, status int, msg string) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{}) c, err := mcias.New("https://example.com", mcias.Options{})
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -52,7 +52,7 @@ func TestNew(t *testing.T) {
} }
func TestNewWithPresetToken(t *testing.T) { func TestNewWithPresetToken(t *testing.T) {
c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{Token: "preset-tok"}) c, err := mcias.New("https://example.com", mcias.Options{Token: "preset-tok"})
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -62,7 +62,7 @@ func TestNewWithPresetToken(t *testing.T) {
} }
func TestNewBadCACert(t *testing.T) { func TestNewBadCACert(t *testing.T) {
_, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{CACertPath: "/nonexistent/ca.pem"}) _, err := mcias.New("https://example.com", mcias.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")
} }
@@ -97,7 +97,7 @@ func TestHealthError(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for 503") t.Fatal("expected error for 503")
} }
var srvErr *mciasgoclient.MciasServerError var srvErr *mcias.MciasServerError
if !errors.As(err, &srvErr) { if !errors.As(err, &srvErr) {
t.Errorf("expected MciasServerError, got %T: %v", err, err) t.Errorf("expected MciasServerError, got %T: %v", err, err)
} }
@@ -183,7 +183,7 @@ func TestLoginUnauthorized(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for 401") t.Fatal("expected error for 401")
} }
var authErr *mciasgoclient.MciasAuthError var authErr *mcias.MciasAuthError
if !errors.As(err, &authErr) { if !errors.As(err, &authErr) {
t.Errorf("expected MciasAuthError, got %T: %v", err, err) t.Errorf("expected MciasAuthError, got %T: %v", err, err)
} }
@@ -275,7 +275,7 @@ func TestEnrollTOTP(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := newTestClient(t, srv.URL) c := newTestClient(t, srv.URL)
resp, err := c.EnrollTOTP() resp, err := c.EnrollTOTP("testpass123")
if err != nil { if err != nil {
t.Fatalf("EnrollTOTP: %v", err) t.Fatalf("EnrollTOTP: %v", err)
} }
@@ -312,7 +312,7 @@ func TestConfirmTOTPBadCode(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for bad TOTP code") t.Fatal("expected error for bad TOTP code")
} }
var inputErr *mciasgoclient.MciasInputError var inputErr *mcias.MciasInputError
if !errors.As(err, &inputErr) { if !errors.As(err, &inputErr) {
t.Errorf("expected MciasInputError, got %T: %v", err, err) t.Errorf("expected MciasInputError, got %T: %v", err, err)
} }
@@ -347,7 +347,7 @@ func TestChangePasswordWrongCurrent(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for wrong current password") t.Fatal("expected error for wrong current password")
} }
var authErr *mciasgoclient.MciasAuthError var authErr *mcias.MciasAuthError
if !errors.As(err, &authErr) { if !errors.As(err, &authErr) {
t.Errorf("expected MciasAuthError, got %T: %v", err, err) t.Errorf("expected MciasAuthError, got %T: %v", err, err)
} }
@@ -456,7 +456,7 @@ func TestCreateAccountConflict(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for 409") t.Fatal("expected error for 409")
} }
var conflictErr *mciasgoclient.MciasConflictError var conflictErr *mcias.MciasConflictError
if !errors.As(err, &conflictErr) { if !errors.As(err, &conflictErr) {
t.Errorf("expected MciasConflictError, got %T: %v", err, err) t.Errorf("expected MciasConflictError, got %T: %v", err, err)
} }
@@ -801,7 +801,7 @@ func TestListAudit(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := newTestClient(t, srv.URL) c := newTestClient(t, srv.URL)
resp, err := c.ListAudit(mciasgoclient.AuditFilter{}) resp, err := c.ListAudit(mcias.AuditFilter{})
if err != nil { if err != nil {
t.Fatalf("ListAudit: %v", err) t.Fatalf("ListAudit: %v", err)
} }
@@ -827,7 +827,7 @@ func TestListAuditWithFilter(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := newTestClient(t, srv.URL) c := newTestClient(t, srv.URL)
_, err := c.ListAudit(mciasgoclient.AuditFilter{ _, err := c.ListAudit(mcias.AuditFilter{
Limit: 10, Offset: 5, EventType: "login_fail", ActorID: "acct-uuid-1", Limit: 10, Offset: 5, EventType: "login_fail", ActorID: "acct-uuid-1",
}) })
if err != nil { if err != nil {
@@ -896,10 +896,10 @@ func TestCreatePolicyRule(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := newTestClient(t, srv.URL) c := newTestClient(t, srv.URL)
rule, err := c.CreatePolicyRule(mciasgoclient.CreatePolicyRuleRequest{ rule, err := c.CreatePolicyRule(mcias.CreatePolicyRuleRequest{
Description: "Test rule", Description: "Test rule",
Priority: 50, Priority: 50,
Rule: mciasgoclient.PolicyRuleBody{Effect: "deny"}, Rule: mcias.PolicyRuleBody{Effect: "deny"},
}) })
if err != nil { if err != nil {
t.Fatalf("CreatePolicyRule: %v", err) t.Fatalf("CreatePolicyRule: %v", err)
@@ -950,7 +950,7 @@ func TestGetPolicyRuleNotFound(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for 404") t.Fatal("expected error for 404")
} }
var notFoundErr *mciasgoclient.MciasNotFoundError var notFoundErr *mcias.MciasNotFoundError
if !errors.As(err, &notFoundErr) { if !errors.As(err, &notFoundErr) {
t.Errorf("expected MciasNotFoundError, got %T: %v", err, err) t.Errorf("expected MciasNotFoundError, got %T: %v", err, err)
} }
@@ -976,7 +976,7 @@ func TestUpdatePolicyRule(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := newTestClient(t, srv.URL) c := newTestClient(t, srv.URL)
rule, err := c.UpdatePolicyRule(7, mciasgoclient.UpdatePolicyRuleRequest{Enabled: &enabled}) rule, err := c.UpdatePolicyRule(7, mcias.UpdatePolicyRuleRequest{Enabled: &enabled})
if err != nil { if err != nil {
t.Fatalf("UpdatePolicyRule: %v", err) t.Fatalf("UpdatePolicyRule: %v", err)
} }
@@ -1073,7 +1073,7 @@ func TestIntegration(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for wrong credentials") t.Fatal("expected error for wrong credentials")
} }
var authErr *mciasgoclient.MciasAuthError var authErr *mcias.MciasAuthError
if !errors.As(err, &authErr) { if !errors.As(err, &authErr) {
t.Errorf("expected MciasAuthError, got %T", err) t.Errorf("expected MciasAuthError, got %T", err)
} }

View File

@@ -148,11 +148,15 @@ 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 enroll_totp(self) -> tuple[str, str]: def enroll_totp(self, password: str) -> tuple[str, str]:
"""POST /v1/auth/totp/enroll — begin TOTP enrollment. """POST /v1/auth/totp/enroll — begin TOTP enrollment.
Security (SEC-01): current password is required to prevent session-theft
escalation to persistent account takeover.
Returns (secret, otpauth_uri). The secret is shown only once. Returns (secret, otpauth_uri). The secret is shown only once.
""" """
data = self._request("POST", "/v1/auth/totp/enroll") data = self._request("POST", "/v1/auth/totp/enroll", json={"password": password})
assert data is not None assert data is not None
return str(data["secret"]), str(data["otpauth_uri"]) return str(data["secret"]), str(data["otpauth_uri"])
def confirm_totp(self, code: str) -> None: def confirm_totp(self, code: str) -> None:

View File

@@ -191,7 +191,7 @@ def test_enroll_totp(admin_client: Client) -> None:
json={"secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"}, json={"secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"},
) )
) )
secret, uri = admin_client.enroll_totp() secret, uri = admin_client.enroll_totp("testpass123")
assert secret == "JBSWY3DPEHPK3PXP" assert secret == "JBSWY3DPEHPK3PXP"
assert "otpauth://totp/" in uri assert "otpauth://totp/" in uri
@respx.mock @respx.mock

View File

@@ -484,9 +484,12 @@ impl Client {
/// Begin TOTP enrollment. Returns `(secret, otpauth_uri)`. /// Begin TOTP enrollment. Returns `(secret, otpauth_uri)`.
/// The secret is shown once; store it in an authenticator app immediately. /// The secret is shown once; store it in an authenticator app immediately.
pub async fn enroll_totp(&self) -> Result<(String, String), MciasError> { ///
/// Security (SEC-01): current password is required to prevent session-theft
/// escalation to persistent account takeover.
pub async fn enroll_totp(&self, password: &str) -> Result<(String, String), MciasError> {
let resp: TotpEnrollResponse = let resp: TotpEnrollResponse =
self.post("/v1/auth/totp/enroll", &serde_json::json!({})).await?; self.post("/v1/auth/totp/enroll", &serde_json::json!({"password": password})).await?;
Ok((resp.secret, resp.otpauth_uri)) Ok((resp.secret, resp.otpauth_uri))
} }

View File

@@ -449,7 +449,7 @@ async fn test_enroll_totp() {
.await; .await;
let c = admin_client(&server).await; let c = admin_client(&server).await;
let (secret, uri) = c.enroll_totp().await.unwrap(); let (secret, uri) = c.enroll_totp("testpass123").await.unwrap();
assert_eq!(secret, "JBSWY3DPEHPK3PXP"); assert_eq!(secret, "JBSWY3DPEHPK3PXP");
assert!(uri.starts_with("otpauth://totp/")); assert!(uri.starts_with("otpauth://totp/"));
} }

View File

@@ -10,7 +10,7 @@
// //
// Global flags: // Global flags:
// //
// -server URL of the mciassrv instance (default: https://localhost:8443) // -server URL of the mciassrv instance (default: https://mcias.metacircular.net:8443)
// -token Bearer token for authentication (or set MCIAS_TOKEN env var) // -token Bearer token for authentication (or set MCIAS_TOKEN env var)
// -cacert Path to CA certificate for TLS verification (optional) // -cacert Path to CA certificate for TLS verification (optional)
// //
@@ -28,10 +28,13 @@
// //
// role list -id UUID // role list -id UUID
// role set -id UUID -roles role1,role2,... // role set -id UUID -roles role1,role2,...
// role grant -id UUID -role ROLE
// role revoke -id UUID -role ROLE
// //
// token issue -id UUID // token issue -id UUID
// token revoke -jti JTI // token revoke -jti JTI
// //
// pgcreds list
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS] // pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
// pgcreds get -id UUID // pgcreds get -id UUID
// //
@@ -61,7 +64,7 @@ import (
func main() { func main() {
// Global flags. // Global flags.
serverURL := flag.String("server", "https://localhost:8443", "mciassrv base URL") serverURL := flag.String("server", "https://mcias.metacircular.net:8443", "mciassrv base URL")
tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)") tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)")
caCert := flag.String("cacert", "", "path to CA certificate for TLS") caCert := flag.String("cacert", "", "path to CA certificate for TLS")
flag.Usage = usage flag.Usage = usage
@@ -386,13 +389,17 @@ func (c *controller) accountSetPassword(args []string) {
func (c *controller) runRole(args []string) { func (c *controller) runRole(args []string) {
if len(args) == 0 { if len(args) == 0 {
fatalf("role requires a subcommand: list, set") fatalf("role requires a subcommand: list, set, grant, revoke")
} }
switch args[0] { switch args[0] {
case "list": case "list":
c.roleList(args[1:]) c.roleList(args[1:])
case "set": case "set":
c.roleSet(args[1:]) c.roleSet(args[1:])
case "grant":
c.roleGrant(args[1:])
case "revoke":
c.roleRevoke(args[1:])
default: default:
fatalf("unknown role subcommand %q", args[0]) fatalf("unknown role subcommand %q", args[0])
} }
@@ -437,6 +444,41 @@ func (c *controller) roleSet(args []string) {
fmt.Printf("roles set: %v\n", roles) fmt.Printf("roles set: %v\n", roles)
} }
func (c *controller) roleGrant(args []string) {
fs := flag.NewFlagSet("role grant", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
role := fs.String("role", "", "role name (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("role grant: -id is required")
}
if *role == "" {
fatalf("role grant: -role is required")
}
body := map[string]string{"role": *role}
c.doRequest("POST", "/v1/accounts/"+*id+"/roles", body, nil)
fmt.Printf("role granted: %s\n", *role)
}
func (c *controller) roleRevoke(args []string) {
fs := flag.NewFlagSet("role revoke", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
role := fs.String("role", "", "role name (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("role revoke: -id is required")
}
if *role == "" {
fatalf("role revoke: -role is required")
}
c.doRequest("DELETE", "/v1/accounts/"+*id+"/roles/"+*role, nil, nil)
fmt.Printf("role revoked: %s\n", *role)
}
// ---- token subcommands ---- // ---- token subcommands ----
func (c *controller) runToken(args []string) { func (c *controller) runToken(args []string) {
@@ -485,9 +527,11 @@ func (c *controller) tokenRevoke(args []string) {
func (c *controller) runPGCreds(args []string) { func (c *controller) runPGCreds(args []string) {
if len(args) == 0 { if len(args) == 0 {
fatalf("pgcreds requires a subcommand: get, set") fatalf("pgcreds requires a subcommand: list, get, set")
} }
switch args[0] { switch args[0] {
case "list":
c.pgCredsList(args[1:])
case "get": case "get":
c.pgCredsGet(args[1:]) c.pgCredsGet(args[1:])
case "set": case "set":
@@ -497,6 +541,15 @@ func (c *controller) runPGCreds(args []string) {
} }
} }
func (c *controller) pgCredsList(args []string) {
fs := flag.NewFlagSet("pgcreds list", flag.ExitOnError)
_ = fs.Parse(args)
var result json.RawMessage
c.doRequest("GET", "/v1/pgcreds", nil, &result)
printJSON(result)
}
func (c *controller) pgCredsGet(args []string) { func (c *controller) pgCredsGet(args []string) {
fs := flag.NewFlagSet("pgcreds get", flag.ExitOnError) fs := flag.NewFlagSet("pgcreds get", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)") id := fs.String("id", "", "account UUID (required)")
@@ -871,7 +924,7 @@ func usage() {
Usage: mciasctl [global flags] <command> [args] Usage: mciasctl [global flags] <command> [args]
Global flags: Global flags:
-server URL of the mciassrv instance (default: https://localhost:8443) -server URL of the mciassrv instance (default: https://mcias.metacircular.net:8443)
-token Bearer token (or set MCIAS_TOKEN env var) -token Bearer token (or set MCIAS_TOKEN env var)
-cacert Path to CA certificate for TLS verification -cacert Path to CA certificate for TLS verification
@@ -902,6 +955,7 @@ Commands:
token issue -id UUID token issue -id UUID
token revoke -jti JTI token revoke -jti JTI
pgcreds list
pgcreds get -id UUID pgcreds get -id UUID
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS] pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]

View File

@@ -1,7 +1,8 @@
// Command mciasgrpcctl is the MCIAS gRPC admin CLI. // Command mciasgrpcctl is the MCIAS gRPC admin CLI.
// //
// It connects to a running mciassrv gRPC listener and provides subcommands for // It connects to a running mciassrv gRPC listener and provides subcommands for
// managing accounts, roles, tokens, and Postgres credentials via the gRPC API. // managing accounts, roles, tokens, Postgres credentials, and policy rules via
// the gRPC API.
// //
// Usage: // Usage:
// //
@@ -9,7 +10,7 @@
// //
// Global flags: // Global flags:
// //
// -server gRPC server address (default: localhost:9443) // -server gRPC server address (default: mcias.metacircular.net:9443)
// -token Bearer token for authentication (or set MCIAS_TOKEN env var) // -token Bearer token for authentication (or set MCIAS_TOKEN env var)
// -cacert Path to CA certificate for TLS verification (optional) // -cacert Path to CA certificate for TLS verification (optional)
// //
@@ -18,6 +19,9 @@
// health // health
// pubkey // pubkey
// //
// auth login -username NAME [-totp CODE]
// auth logout
//
// account list // account list
// account create -username NAME -password PASS [-type human|system] // account create -username NAME -password PASS [-type human|system]
// account get -id UUID // account get -id UUID
@@ -26,6 +30,8 @@
// //
// role list -id UUID // role list -id UUID
// role set -id UUID -roles role1,role2,... // role set -id UUID -roles role1,role2,...
// role grant -id UUID -role ROLE
// role revoke -id UUID -role ROLE
// //
// token validate -token TOKEN // token validate -token TOKEN
// token issue -id UUID // token issue -id UUID
@@ -33,6 +39,12 @@
// //
// pgcreds get -id UUID // pgcreds get -id UUID
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER -password PASS // pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER -password PASS
//
// policy list
// policy create -description STR -json FILE [-priority N] [-not-before RFC3339] [-expires-at RFC3339]
// policy get -id ID
// policy update -id ID [-priority N] [-enabled true|false] [-not-before RFC3339] [-expires-at RFC3339] [-clear-not-before] [-clear-expires-at]
// policy delete -id ID
package main package main
import ( import (
@@ -43,9 +55,11 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
"golang.org/x/term"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
@@ -55,7 +69,7 @@ import (
func main() { func main() {
// Global flags. // Global flags.
serverAddr := flag.String("server", "localhost:9443", "gRPC server address (host:port)") serverAddr := flag.String("server", "mcias.metacircular.net:9443", "gRPC server address (host:port)")
tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)") tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)")
caCert := flag.String("cacert", "", "path to CA certificate for TLS") caCert := flag.String("cacert", "", "path to CA certificate for TLS")
flag.Usage = usage flag.Usage = usage
@@ -93,6 +107,8 @@ func main() {
ctl.runHealth() ctl.runHealth()
case "pubkey": case "pubkey":
ctl.runPubKey() ctl.runPubKey()
case "auth":
ctl.runAuth(subArgs)
case "account": case "account":
ctl.runAccount(subArgs) ctl.runAccount(subArgs)
case "role": case "role":
@@ -101,6 +117,8 @@ func main() {
ctl.runToken(subArgs) ctl.runToken(subArgs)
case "pgcreds": case "pgcreds":
ctl.runPGCreds(subArgs) ctl.runPGCreds(subArgs)
case "policy":
ctl.runPolicy(subArgs)
default: default:
fatalf("unknown command %q; run with no args to see usage", command) fatalf("unknown command %q; run with no args to see usage", command)
} }
@@ -162,6 +180,89 @@ func (c *controller) runPubKey() {
}) })
} }
// ---- auth subcommands ----
func (c *controller) runAuth(args []string) {
if len(args) == 0 {
fatalf("auth requires a subcommand: login, logout")
}
switch args[0] {
case "login":
c.authLogin(args[1:])
case "logout":
c.authLogout()
default:
fatalf("unknown auth subcommand %q", args[0])
}
}
// authLogin authenticates with the gRPC server using username and password,
// then prints the resulting bearer token to stdout. The password is always
// prompted interactively; it is never accepted as a command-line flag to
// prevent it from appearing in shell history, ps output, and process argument
// lists.
//
// Security: terminal echo is disabled during password entry
// (golang.org/x/term.ReadPassword); the raw byte slice is zeroed after use.
func (c *controller) authLogin(args []string) {
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
username := fs.String("username", "", "username (required)")
totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
_ = fs.Parse(args)
if *username == "" {
fatalf("auth login: -username is required")
}
// Security: always prompt interactively; never accept password as a flag.
// This prevents the credential from appearing in shell history, ps output,
// and /proc/PID/cmdline.
fmt.Fprint(os.Stderr, "Password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read password: %v", err)
}
passwd := string(raw)
// Zero the raw byte slice once copied into the string.
for i := range raw {
raw[i] = 0
}
authCl := mciasv1.NewAuthServiceClient(c.conn)
// Login is a public RPC — no auth context needed.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := authCl.Login(ctx, &mciasv1.LoginRequest{
Username: *username,
Password: passwd,
TotpCode: *totpCode,
})
if err != nil {
fatalf("auth login: %v", err)
}
// Print token to stdout so it can be captured by scripts, e.g.:
// export MCIAS_TOKEN=$(mciasgrpcctl auth login -username alice)
fmt.Println(resp.Token)
if resp.ExpiresAt != nil {
fmt.Fprintf(os.Stderr, "expires: %s\n", resp.ExpiresAt.AsTime().UTC().Format(time.RFC3339))
}
}
// authLogout revokes the caller's current JWT via the gRPC AuthService.
func (c *controller) authLogout() {
authCl := mciasv1.NewAuthServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
if _, err := authCl.Logout(ctx, &mciasv1.LogoutRequest{}); err != nil {
fatalf("auth logout: %v", err)
}
fmt.Println("logged out")
}
// ---- account subcommands ---- // ---- account subcommands ----
func (c *controller) runAccount(args []string) { func (c *controller) runAccount(args []string) {
@@ -293,13 +394,17 @@ func (c *controller) accountDelete(args []string) {
func (c *controller) runRole(args []string) { func (c *controller) runRole(args []string) {
if len(args) == 0 { if len(args) == 0 {
fatalf("role requires a subcommand: list, set") fatalf("role requires a subcommand: list, set, grant, revoke")
} }
switch args[0] { switch args[0] {
case "list": case "list":
c.roleList(args[1:]) c.roleList(args[1:])
case "set": case "set":
c.roleSet(args[1:]) c.roleSet(args[1:])
case "grant":
c.roleGrant(args[1:])
case "revoke":
c.roleRevoke(args[1:])
default: default:
fatalf("unknown role subcommand %q", args[0]) fatalf("unknown role subcommand %q", args[0])
} }
@@ -356,6 +461,54 @@ func (c *controller) roleSet(args []string) {
fmt.Printf("roles set: %v\n", roles) fmt.Printf("roles set: %v\n", roles)
} }
func (c *controller) roleGrant(args []string) {
fs := flag.NewFlagSet("role grant", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
role := fs.String("role", "", "role name (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("role grant: -id is required")
}
if *role == "" {
fatalf("role grant: -role is required")
}
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
_, err := cl.GrantRole(ctx, &mciasv1.GrantRoleRequest{Id: *id, Role: *role})
if err != nil {
fatalf("role grant: %v", err)
}
fmt.Printf("role granted: %s\n", *role)
}
func (c *controller) roleRevoke(args []string) {
fs := flag.NewFlagSet("role revoke", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
role := fs.String("role", "", "role name (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("role revoke: -id is required")
}
if *role == "" {
fatalf("role revoke: -role is required")
}
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
_, err := cl.RevokeRole(ctx, &mciasv1.RevokeRoleRequest{Id: *id, Role: *role})
if err != nil {
fatalf("role revoke: %v", err)
}
fmt.Printf("role revoked: %s\n", *role)
}
// ---- token subcommands ---- // ---- token subcommands ----
func (c *controller) runToken(args []string) { func (c *controller) runToken(args []string) {
@@ -518,6 +671,208 @@ func (c *controller) pgCredsSet(args []string) {
fmt.Println("credentials stored") fmt.Println("credentials stored")
} }
// ---- policy subcommands ----
func (c *controller) runPolicy(args []string) {
if len(args) == 0 {
fatalf("policy requires a subcommand: list, create, get, update, delete")
}
switch args[0] {
case "list":
c.policyList()
case "create":
c.policyCreate(args[1:])
case "get":
c.policyGet(args[1:])
case "update":
c.policyUpdate(args[1:])
case "delete":
c.policyDelete(args[1:])
default:
fatalf("unknown policy subcommand %q", args[0])
}
}
func (c *controller) policyList() {
cl := mciasv1.NewPolicyServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.ListPolicyRules(ctx, &mciasv1.ListPolicyRulesRequest{})
if err != nil {
fatalf("policy list: %v", err)
}
printJSON(resp.Rules)
}
func (c *controller) policyCreate(args []string) {
fs := flag.NewFlagSet("policy create", flag.ExitOnError)
description := fs.String("description", "", "rule description (required)")
jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)")
priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)")
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339, optional)")
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339, optional)")
_ = fs.Parse(args)
if *description == "" {
fatalf("policy create: -description is required")
}
if *jsonFile == "" {
fatalf("policy create: -json is required (path to rule body JSON file)")
}
// G304: path comes from a CLI flag supplied by the operator.
ruleBytes, err := os.ReadFile(*jsonFile) //nolint:gosec
if err != nil {
fatalf("policy create: read %s: %v", *jsonFile, err)
}
// Validate that the file contains valid JSON before sending.
var ruleBody interface{}
if err := json.Unmarshal(ruleBytes, &ruleBody); err != nil {
fatalf("policy create: invalid JSON in %s: %v", *jsonFile, err)
}
if *notBefore != "" {
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
fatalf("policy create: -not-before must be RFC3339: %v", err)
}
}
if *expiresAt != "" {
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
fatalf("policy create: -expires-at must be RFC3339: %v", err)
}
}
cl := mciasv1.NewPolicyServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.CreatePolicyRule(ctx, &mciasv1.CreatePolicyRuleRequest{
Description: *description,
RuleJson: string(ruleBytes),
Priority: int32(*priority), //nolint:gosec // priority is a small positive integer
NotBefore: *notBefore,
ExpiresAt: *expiresAt,
})
if err != nil {
fatalf("policy create: %v", err)
}
printJSON(resp.Rule)
}
func (c *controller) policyGet(args []string) {
fs := flag.NewFlagSet("policy get", flag.ExitOnError)
idStr := fs.String("id", "", "rule ID (required)")
_ = fs.Parse(args)
if *idStr == "" {
fatalf("policy get: -id is required")
}
id, err := strconv.ParseInt(*idStr, 10, 64)
if err != nil {
fatalf("policy get: -id must be an integer")
}
cl := mciasv1.NewPolicyServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.GetPolicyRule(ctx, &mciasv1.GetPolicyRuleRequest{Id: id})
if err != nil {
fatalf("policy get: %v", err)
}
printJSON(resp.Rule)
}
func (c *controller) policyUpdate(args []string) {
fs := flag.NewFlagSet("policy update", flag.ExitOnError)
idStr := fs.String("id", "", "rule ID (required)")
priority := fs.Int("priority", -1, "new priority (-1 = no change)")
enabled := fs.String("enabled", "", "true or false")
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339)")
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339)")
clearNotBefore := fs.Bool("clear-not-before", false, "remove not_before constraint")
clearExpiresAt := fs.Bool("clear-expires-at", false, "remove expires_at constraint")
_ = fs.Parse(args)
if *idStr == "" {
fatalf("policy update: -id is required")
}
id, err := strconv.ParseInt(*idStr, 10, 64)
if err != nil {
fatalf("policy update: -id must be an integer")
}
req := &mciasv1.UpdatePolicyRuleRequest{
Id: id,
ClearNotBefore: *clearNotBefore,
ClearExpiresAt: *clearExpiresAt,
}
if *priority >= 0 {
v := int32(*priority) //nolint:gosec // priority is a small positive integer
req.Priority = &v
}
if *enabled != "" {
switch *enabled {
case "true":
b := true
req.Enabled = &b
case "false":
b := false
req.Enabled = &b
default:
fatalf("policy update: -enabled must be true or false")
}
}
if !*clearNotBefore && *notBefore != "" {
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
fatalf("policy update: -not-before must be RFC3339: %v", err)
}
req.NotBefore = *notBefore
}
if !*clearExpiresAt && *expiresAt != "" {
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
fatalf("policy update: -expires-at must be RFC3339: %v", err)
}
req.ExpiresAt = *expiresAt
}
cl := mciasv1.NewPolicyServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.UpdatePolicyRule(ctx, req)
if err != nil {
fatalf("policy update: %v", err)
}
printJSON(resp.Rule)
}
func (c *controller) policyDelete(args []string) {
fs := flag.NewFlagSet("policy delete", flag.ExitOnError)
idStr := fs.String("id", "", "rule ID (required)")
_ = fs.Parse(args)
if *idStr == "" {
fatalf("policy delete: -id is required")
}
id, err := strconv.ParseInt(*idStr, 10, 64)
if err != nil {
fatalf("policy delete: -id must be an integer")
}
cl := mciasv1.NewPolicyServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
if _, err := cl.DeletePolicyRule(ctx, &mciasv1.DeletePolicyRuleRequest{Id: id}); err != nil {
fatalf("policy delete: %v", err)
}
fmt.Println("policy rule deleted")
}
// ---- gRPC connection ---- // ---- gRPC connection ----
// newGRPCConn dials the gRPC server with TLS. // newGRPCConn dials the gRPC server with TLS.
@@ -575,7 +930,7 @@ func usage() {
Usage: mciasgrpcctl [global flags] <command> [args] Usage: mciasgrpcctl [global flags] <command> [args]
Global flags: Global flags:
-server gRPC server address (default: localhost:9443) -server gRPC server address (default: mcias.metacircular.net:9443)
-token Bearer token (or set MCIAS_TOKEN env var) -token Bearer token (or set MCIAS_TOKEN env var)
-cacert Path to CA certificate for TLS verification -cacert Path to CA certificate for TLS verification
@@ -583,6 +938,12 @@ Commands:
health health
pubkey pubkey
auth login -username NAME [-totp CODE]
Obtain a bearer token. Password is always prompted interactively.
Token is written to stdout; expiry to stderr.
Example: export MCIAS_TOKEN=$(mciasgrpcctl auth login -username alice)
auth logout Revoke the current bearer token.
account list account list
account create -username NAME -password PASS [-type human|system] account create -username NAME -password PASS [-type human|system]
account get -id UUID account get -id UUID
@@ -598,5 +959,16 @@ Commands:
pgcreds get -id UUID pgcreds get -id UUID
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER -password PASS pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER -password PASS
policy list
policy create -description STR -json FILE [-priority N]
[-not-before RFC3339] [-expires-at RFC3339]
FILE must contain a JSON rule body, e.g.:
{"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true}
policy get -id ID
policy update -id ID [-priority N] [-enabled true|false]
[-not-before RFC3339] [-expires-at RFC3339]
[-clear-not-before] [-clear-expires-at]
policy delete -id ID
`) `)
} }

View File

@@ -36,7 +36,7 @@ path = "/data/mcias.db"
[tokens] [tokens]
issuer = "https://auth.example.com" issuer = "https://auth.example.com"
default_expiry = "720h" default_expiry = "168h"
admin_expiry = "8h" admin_expiry = "8h"
service_expiry = "8760h" service_expiry = "8760h"

View File

@@ -69,8 +69,8 @@ issuer = "https://auth.example.com"
# OPTIONAL. Default token expiry for interactive (human) logins. # OPTIONAL. Default token expiry for interactive (human) logins.
# Go duration string: "h" hours, "m" minutes, "s" seconds. # Go duration string: "h" hours, "m" minutes, "s" seconds.
# Default: 720h (30 days). Reduce for higher-security deployments. # Default: 168h (7 days). The maximum allowed value is 720h (30 days).
default_expiry = "720h" default_expiry = "168h"
# OPTIONAL. Expiry for admin tokens (tokens with the "admin" role). # OPTIONAL. Expiry for admin tokens (tokens with the "admin" role).
# Should be shorter than default_expiry to limit the blast radius of # Should be shorter than default_expiry to limit the blast radius of

View File

@@ -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,
}, },

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v6.33.4 // protoc v3.20.3
// source: mcias/v1/auth.proto // source: mcias/v1/auth.proto
package mciasv1 package mciasv1
@@ -304,9 +304,12 @@ func (x *RenewTokenResponse) GetExpiresAt() *timestamppb.Timestamp {
return nil return nil
} }
// EnrollTOTPRequest carries no body; the acting account is from the JWT. // EnrollTOTPRequest carries the current password for re-authentication.
// Security (SEC-01): password is required to prevent a stolen session token
// from being used to enroll attacker-controlled TOTP on the victim's account.
type EnrollTOTPRequest struct { type EnrollTOTPRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` // security: current password required; never logged
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -341,6 +344,13 @@ func (*EnrollTOTPRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{6} return file_mcias_v1_auth_proto_rawDescGZIP(), []int{6}
} }
func (x *EnrollTOTPRequest) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
// EnrollTOTPResponse returns the TOTP secret and otpauth URI for display. // EnrollTOTPResponse returns the TOTP secret and otpauth URI for display.
// Security: the secret is shown once; it is stored only in encrypted form. // Security: the secret is shown once; it is stored only in encrypted form.
type EnrollTOTPResponse struct { type EnrollTOTPResponse struct {
@@ -578,8 +588,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"\x12RenewTokenResponse\x12\x14\n" + "\x12RenewTokenResponse\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\x129\n" + "\x05token\x18\x01 \x01(\tR\x05token\x129\n" +
"\n" + "\n" +
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\x13\n" + "expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"/\n" +
"\x11EnrollTOTPRequest\"M\n" + "\x11EnrollTOTPRequest\x12\x1a\n" +
"\bpassword\x18\x01 \x01(\tR\bpassword\"M\n" +
"\x12EnrollTOTPResponse\x12\x16\n" + "\x12EnrollTOTPResponse\x12\x16\n" +
"\x06secret\x18\x01 \x01(\tR\x06secret\x12\x1f\n" + "\x06secret\x18\x01 \x01(\tR\x06secret\x12\x1f\n" +
"\votpauth_uri\x18\x02 \x01(\tR\n" + "\votpauth_uri\x18\x02 \x01(\tR\n" +

View File

@@ -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

View File

@@ -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
View 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
}

View 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",
}

View File

@@ -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

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v6.33.4 // - protoc v3.20.3
// source: mcias/v1/token.proto // source: mcias/v1/token.proto
package mciasv1 package mciasv1

33
internal/audit/detail.go Normal file
View File

@@ -0,0 +1,33 @@
// Package audit provides helpers for constructing audit log detail strings.
package audit
import "encoding/json"
// JSON builds a JSON details string from key-value pairs for audit logging.
// Uses json.Marshal for safe encoding rather than fmt.Sprintf with %q,
// which is fragile for edge-case Unicode.
func JSON(pairs ...string) string {
if len(pairs)%2 != 0 {
return "{}"
}
m := make(map[string]string, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
m[pairs[i]] = pairs[i+1]
}
b, err := json.Marshal(m)
if err != nil {
return "{}"
}
return string(b)
}
// JSONWithRoles builds a JSON details string that includes a "roles" key
// mapped to a string slice. This produces a proper JSON array for the value.
func JSONWithRoles(roles []string) string {
m := map[string][]string{"roles": roles}
b, err := json.Marshal(m)
if err != nil {
return "{}"
}
return string(b)
}

View File

@@ -0,0 +1,163 @@
package audit
import (
"encoding/json"
"testing"
)
func TestJSON(t *testing.T) {
tests := []struct {
name string
pairs []string
verify func(t *testing.T, result string)
}{
{
name: "single pair",
pairs: []string{"username", "alice"},
verify: func(t *testing.T, result string) {
var m map[string]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if m["username"] != "alice" {
t.Fatalf("expected alice, got %s", m["username"])
}
},
},
{
name: "multiple pairs",
pairs: []string{"jti", "abc-123", "reason", "logout"},
verify: func(t *testing.T, result string) {
var m map[string]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if m["jti"] != "abc-123" {
t.Fatalf("expected abc-123, got %s", m["jti"])
}
if m["reason"] != "logout" {
t.Fatalf("expected logout, got %s", m["reason"])
}
},
},
{
name: "special characters in values",
pairs: []string{"username", "user\"with\\quotes"},
verify: func(t *testing.T, result string) {
var m map[string]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON for special chars: %v", err)
}
if m["username"] != "user\"with\\quotes" {
t.Fatalf("unexpected value: %s", m["username"])
}
},
},
{
name: "unicode edge cases",
pairs: []string{"username", "user\u2028line\u2029sep"},
verify: func(t *testing.T, result string) {
var m map[string]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON for unicode: %v", err)
}
if m["username"] != "user\u2028line\u2029sep" {
t.Fatalf("unexpected value: %s", m["username"])
}
},
},
{
name: "null bytes in value",
pairs: []string{"data", "before\x00after"},
verify: func(t *testing.T, result string) {
var m map[string]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON for null bytes: %v", err)
}
if m["data"] != "before\x00after" {
t.Fatalf("unexpected value: %q", m["data"])
}
},
},
{
name: "odd number of args returns empty object",
pairs: []string{"key"},
verify: func(t *testing.T, result string) {
if result != "{}" {
t.Fatalf("expected {}, got %s", result)
}
},
},
{
name: "no args returns empty object",
pairs: nil,
verify: func(t *testing.T, result string) {
if result != "{}" {
t.Fatalf("expected {}, got %s", result)
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := JSON(tc.pairs...)
tc.verify(t, result)
})
}
}
func TestJSONWithRoles(t *testing.T) {
tests := []struct {
name string
roles []string
verify func(t *testing.T, result string)
}{
{
name: "multiple roles",
roles: []string{"admin", "editor"},
verify: func(t *testing.T, result string) {
var m map[string][]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(m["roles"]) != 2 || m["roles"][0] != "admin" || m["roles"][1] != "editor" {
t.Fatalf("unexpected roles: %v", m["roles"])
}
},
},
{
name: "empty roles",
roles: []string{},
verify: func(t *testing.T, result string) {
var m map[string][]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(m["roles"]) != 0 {
t.Fatalf("expected empty roles, got %v", m["roles"])
}
},
},
{
name: "roles with special characters",
roles: []string{"role\"special"},
verify: func(t *testing.T, result string) {
var m map[string][]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if m["roles"][0] != "role\"special" {
t.Fatalf("unexpected role: %s", m["roles"][0])
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := JSONWithRoles(tc.roles)
tc.verify(t, result)
})
}
}

View File

@@ -75,7 +75,7 @@ type MasterKeyConfig struct {
} }
// duration is a wrapper around time.Duration that supports TOML string parsing // duration is a wrapper around time.Duration that supports TOML string parsing
// (e.g. "720h", "8h"). // (e.g. "168h", "8h").
type duration struct { type duration struct {
time.Duration time.Duration
} }

View File

@@ -692,6 +692,70 @@ func (db *DB) RenewToken(oldJTI, reason, newJTI string, accountID int64, issuedA
return nil return nil
} }
// IssueSystemToken atomically revokes an existing system token (if oldJTI is
// non-empty), tracks the new token in token_revocation, and upserts the
// system_tokens table — all within a single SQLite transaction.
//
// Security: these three operations must be atomic so that a crash between them
// cannot leave the database in an inconsistent state (e.g., old token revoked
// but new token not tracked, or token tracked but system_tokens not updated).
// With MaxOpenConns(1) and SQLite's serialised write path, BEGIN IMMEDIATE
// acquires the write lock immediately and prevents any other writer from
// interleaving.
func (db *DB) IssueSystemToken(oldJTI, newJTI string, accountID int64, issuedAt, expiresAt time.Time) error {
tx, err := db.sql.Begin()
if err != nil {
return fmt.Errorf("db: issue system token begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
n := now()
// If there is an existing token, revoke it.
if oldJTI != "" {
_, err := tx.Exec(`
UPDATE token_revocation
SET revoked_at = ?, revoke_reason = ?
WHERE jti = ? AND revoked_at IS NULL
`, n, nullString("rotated"), oldJTI)
if err != nil {
return fmt.Errorf("db: issue system token revoke old %q: %w", oldJTI, err)
}
// We do not require rows affected > 0 because the old token may
// already be revoked or expired; the important thing is that we
// proceed to track the new token regardless.
}
// Track the new token in token_revocation.
_, err = tx.Exec(`
INSERT INTO token_revocation (jti, account_id, issued_at, expires_at)
VALUES (?, ?, ?, ?)
`, newJTI, accountID,
issuedAt.UTC().Format(time.RFC3339),
expiresAt.UTC().Format(time.RFC3339))
if err != nil {
return fmt.Errorf("db: issue system token track new %q: %w", newJTI, err)
}
// Upsert the system_tokens table so GetSystemToken returns the new JTI.
_, err = tx.Exec(`
INSERT INTO system_tokens (account_id, jti, expires_at, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
jti = excluded.jti,
expires_at = excluded.expires_at,
created_at = excluded.created_at
`, accountID, newJTI, expiresAt.UTC().Format(time.RFC3339), n)
if err != nil {
return fmt.Errorf("db: issue system token set system token for account %d: %w", accountID, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: issue system token commit: %w", err)
}
return nil
}
// RevokeAllUserTokens revokes all non-expired, non-revoked tokens for an account. // RevokeAllUserTokens revokes all non-expired, non-revoked tokens for an account.
func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error { func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error {
n := now() n := now()

View File

@@ -445,6 +445,79 @@ func TestSystemTokenRotationRevokesOld(t *testing.T) {
} }
} }
// TestIssueSystemTokenAtomic verifies that IssueSystemToken atomically
// revokes an old token, tracks the new token, and upserts system_tokens.
func TestIssueSystemTokenAtomic(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("svc-atomic", model.AccountTypeSystem, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
now := time.Now().UTC()
exp := now.Add(time.Hour)
// Issue first system token with no old JTI.
jti1 := "atomic-sys-tok-1"
if err := db.IssueSystemToken("", jti1, acct.ID, now, exp); err != nil {
t.Fatalf("IssueSystemToken first: %v", err)
}
// Verify the first token is tracked and not revoked.
rec1, err := db.GetTokenRecord(jti1)
if err != nil {
t.Fatalf("GetTokenRecord jti1: %v", err)
}
if rec1.IsRevoked() {
t.Error("first token should not be revoked")
}
// Verify system_tokens points to the first token.
st1, err := db.GetSystemToken(acct.ID)
if err != nil {
t.Fatalf("GetSystemToken after first issue: %v", err)
}
if st1.JTI != jti1 {
t.Errorf("system token JTI = %q, want %q", st1.JTI, jti1)
}
// Issue second token, which should atomically revoke the first.
jti2 := "atomic-sys-tok-2"
if err := db.IssueSystemToken(jti1, jti2, acct.ID, now, exp); err != nil {
t.Fatalf("IssueSystemToken second: %v", err)
}
// First token must be revoked.
rec1After, err := db.GetTokenRecord(jti1)
if err != nil {
t.Fatalf("GetTokenRecord jti1 after rotation: %v", err)
}
if !rec1After.IsRevoked() {
t.Error("first token should be revoked after second issue")
}
if rec1After.RevokeReason != "rotated" {
t.Errorf("revoke reason = %q, want %q", rec1After.RevokeReason, "rotated")
}
// Second token must be tracked and not revoked.
rec2, err := db.GetTokenRecord(jti2)
if err != nil {
t.Fatalf("GetTokenRecord jti2: %v", err)
}
if rec2.IsRevoked() {
t.Error("second token should not be revoked")
}
// system_tokens must point to the second token.
st2, err := db.GetSystemToken(acct.ID)
if err != nil {
t.Fatalf("GetSystemToken after second issue: %v", err)
}
if st2.JTI != jti2 {
t.Errorf("system token JTI = %q, want %q", st2.JTI, jti2)
}
}
func TestRevokeAllUserTokens(t *testing.T) { func TestRevokeAllUserTokens(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
acct, err := db.CreateAccount("ivan", model.AccountTypeHuman, "hash") acct, err := db.CreateAccount("ivan", model.AccountTypeHuman, "hash")

View File

@@ -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
}

View File

@@ -6,6 +6,7 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"time"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/peer" "google.golang.org/grpc/peer"
@@ -13,6 +14,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/model"
@@ -42,7 +44,7 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
// Security: run dummy Argon2 to equalise timing for unknown users. // Security: run dummy Argon2 to equalise timing for unknown users.
_, _ = auth.VerifyPassword("dummy", auth.DummyHash()) _, _ = auth.VerifyPassword("dummy", auth.DummyHash())
a.s.db.WriteAuditEvent(model.EventLoginFail, nil, nil, ip, //nolint:errcheck // audit failure is non-fatal a.s.db.WriteAuditEvent(model.EventLoginFail, nil, nil, ip, //nolint:errcheck // audit failure is non-fatal
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, req.Username)) audit.JSON("username", req.Username, "reason", "unknown_user"))
return nil, status.Error(codes.Unauthenticated, "invalid credentials") return nil, status.Error(codes.Unauthenticated, "invalid credentials")
} }
@@ -60,7 +62,9 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
if locked { if locked {
_, _ = auth.VerifyPassword("dummy", auth.DummyHash()) _, _ = auth.VerifyPassword("dummy", auth.DummyHash())
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"account_locked"}`) //nolint:errcheck a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"account_locked"}`) //nolint:errcheck
return nil, status.Error(codes.ResourceExhausted, "account temporarily locked") // Security: return the same Unauthenticated / "invalid credentials" as wrong-password
// to prevent user-enumeration via lockout differentiation (SEC-02).
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
} }
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash) ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
@@ -129,7 +133,7 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
a.s.db.WriteAuditEvent(model.EventLoginOK, &acct.ID, nil, ip, "") //nolint:errcheck a.s.db.WriteAuditEvent(model.EventLoginOK, &acct.ID, nil, ip, "") //nolint:errcheck
a.s.db.WriteAuditEvent(model.EventTokenIssued, &acct.ID, nil, ip, //nolint:errcheck a.s.db.WriteAuditEvent(model.EventTokenIssued, &acct.ID, nil, ip, //nolint:errcheck
fmt.Sprintf(`{"jti":%q}`, claims.JTI)) audit.JSON("jti", claims.JTI))
return &mciasv1.LoginResponse{ return &mciasv1.LoginResponse{
Token: tokenStr, Token: tokenStr,
@@ -145,7 +149,7 @@ func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest
return nil, status.Error(codes.Internal, "internal error") return nil, status.Error(codes.Internal, "internal error")
} }
a.s.db.WriteAuditEvent(model.EventTokenRevoked, nil, nil, peerIP(ctx), //nolint:errcheck a.s.db.WriteAuditEvent(model.EventTokenRevoked, nil, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"jti":%q,"reason":"logout"}`, claims.JTI)) audit.JSON("jti", claims.JTI, "reason", "logout"))
return &mciasv1.LogoutResponse{}, nil return &mciasv1.LogoutResponse{}, nil
} }
@@ -153,6 +157,14 @@ func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest
func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewTokenRequest) (*mciasv1.RenewTokenResponse, error) { func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewTokenRequest) (*mciasv1.RenewTokenResponse, error) {
claims := claimsFromContext(ctx) claims := claimsFromContext(ctx)
// Security: only allow renewal when the token has consumed at least 50% of
// its lifetime. This prevents indefinite renewal of stolen tokens (SEC-03).
totalLifetime := claims.ExpiresAt.Sub(claims.IssuedAt)
elapsed := time.Since(claims.IssuedAt)
if elapsed < totalLifetime/2 {
return nil, status.Error(codes.InvalidArgument, "token is not yet eligible for renewal")
}
acct, err := a.s.db.GetAccountByUUID(claims.Subject) acct, err := a.s.db.GetAccountByUUID(claims.Subject)
if err != nil { if err != nil {
return nil, status.Error(codes.Unauthenticated, "account not found") return nil, status.Error(codes.Unauthenticated, "account not found")
@@ -186,7 +198,7 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke
} }
a.s.db.WriteAuditEvent(model.EventTokenRenewed, &acct.ID, nil, peerIP(ctx), //nolint:errcheck a.s.db.WriteAuditEvent(model.EventTokenRenewed, &acct.ID, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"old_jti":%q,"new_jti":%q}`, claims.JTI, newClaims.JTI)) audit.JSON("old_jti", claims.JTI, "new_jti", newClaims.JTI))
return &mciasv1.RenewTokenResponse{ return &mciasv1.RenewTokenResponse{
Token: newTokenStr, Token: newTokenStr,
@@ -195,13 +207,39 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke
} }
// EnrollTOTP begins TOTP enrollment for the calling account. // EnrollTOTP begins TOTP enrollment for the calling account.
func (a *authServiceServer) EnrollTOTP(ctx context.Context, _ *mciasv1.EnrollTOTPRequest) (*mciasv1.EnrollTOTPResponse, error) { //
// Security (SEC-01): the current password is required to prevent a stolen
// session token from being used to enroll attacker-controlled TOTP on the
// victim's account. Lockout is checked and failures are recorded.
func (a *authServiceServer) EnrollTOTP(ctx context.Context, req *mciasv1.EnrollTOTPRequest) (*mciasv1.EnrollTOTPResponse, error) {
claims := claimsFromContext(ctx) claims := claimsFromContext(ctx)
acct, err := a.s.db.GetAccountByUUID(claims.Subject) acct, err := a.s.db.GetAccountByUUID(claims.Subject)
if err != nil { if err != nil {
return nil, status.Error(codes.Unauthenticated, "account not found") return nil, status.Error(codes.Unauthenticated, "account not found")
} }
if req.Password == "" {
return nil, status.Error(codes.InvalidArgument, "password is required")
}
// Security: check lockout before verifying (same as login flow).
locked, lockErr := a.s.db.IsLockedOut(acct.ID)
if lockErr != nil {
a.s.logger.Error("lockout check (gRPC TOTP enroll)", "error", lockErr)
}
if locked {
a.s.db.WriteAuditEvent(model.EventTOTPEnrolled, &acct.ID, &acct.ID, peerIP(ctx), `{"result":"locked"}`) //nolint:errcheck
return nil, status.Error(codes.ResourceExhausted, "account temporarily locked")
}
// Security: verify the current password with Argon2id (constant-time).
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = a.s.db.RecordLoginFailure(acct.ID)
a.s.db.WriteAuditEvent(model.EventTOTPEnrolled, &acct.ID, &acct.ID, peerIP(ctx), `{"result":"wrong_password"}`) //nolint:errcheck
return nil, status.Error(codes.Unauthenticated, "password is incorrect")
}
rawSecret, b32Secret, err := auth.GenerateTOTPSecret() rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "internal error") return nil, status.Error(codes.Internal, "internal error")

View File

@@ -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
} }
@@ -288,28 +289,75 @@ func (l *grpcRateLimiter) cleanup() {
// rateLimitInterceptor applies per-IP rate limiting using the same token-bucket // rateLimitInterceptor applies per-IP rate limiting using the same token-bucket
// parameters as the REST rate limiter (10 req/s, burst 10). // parameters as the REST rate limiter (10 req/s, burst 10).
//
// Security (SEC-06): uses grpcClientIP to extract the real client IP when
// behind a trusted reverse proxy, matching the REST middleware behaviour.
func (s *Server) rateLimitInterceptor( func (s *Server) rateLimitInterceptor(
ctx context.Context, ctx context.Context,
req interface{}, req interface{},
info *grpc.UnaryServerInfo, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler, handler grpc.UnaryHandler,
) (interface{}, error) { ) (interface{}, error) {
ip := "" var trustedProxy net.IP
if p, ok := peer.FromContext(ctx); ok { if s.cfg.Server.TrustedProxy != "" {
host, _, err := net.SplitHostPort(p.Addr.String()) trustedProxy = net.ParseIP(s.cfg.Server.TrustedProxy)
if err == nil {
ip = host
} else {
ip = p.Addr.String()
}
} }
ip := grpcClientIP(ctx, trustedProxy)
if ip != "" && !s.rateLimiter.allow(ip) { if ip != "" && !s.rateLimiter.allow(ip) {
return nil, status.Error(codes.ResourceExhausted, "rate limit exceeded") return nil, status.Error(codes.ResourceExhausted, "rate limit exceeded")
} }
return handler(ctx, req) return handler(ctx, req)
} }
// grpcClientIP extracts the real client IP from gRPC context, optionally
// honouring proxy headers when the peer matches the trusted proxy.
//
// Security (SEC-06): mirrors middleware.ClientIP for the REST server.
// X-Forwarded-For and X-Real-IP metadata are only trusted when the immediate
// peer address matches trustedProxy exactly, preventing IP-spoofing attacks.
// Only the first (leftmost) value in x-forwarded-for is used (original client).
// gRPC lowercases all metadata keys, so we look up "x-forwarded-for" and
// "x-real-ip".
func grpcClientIP(ctx context.Context, trustedProxy net.IP) string {
peerIP := ""
if p, ok := peer.FromContext(ctx); ok {
host, _, err := net.SplitHostPort(p.Addr.String())
if err == nil {
peerIP = host
} else {
peerIP = p.Addr.String()
}
}
if trustedProxy != nil && peerIP != "" {
remoteIP := net.ParseIP(peerIP)
if remoteIP != nil && remoteIP.Equal(trustedProxy) {
// Peer is the trusted proxy — extract real client IP from metadata.
// Prefer x-real-ip (single value) over x-forwarded-for (may be a
// comma-separated list when multiple proxies are chained).
md, ok := metadata.FromIncomingContext(ctx)
if ok {
if vals := md.Get("x-real-ip"); len(vals) > 0 {
if ip := net.ParseIP(strings.TrimSpace(vals[0])); ip != nil {
return ip.String()
}
}
if vals := md.Get("x-forwarded-for"); len(vals) > 0 {
// Take the first (leftmost) address — the original client.
first, _, _ := strings.Cut(vals[0], ",")
if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil {
return ip.String()
}
}
}
}
}
return peerIP
}
// extractBearerFromMD extracts the Bearer token from gRPC metadata. // extractBearerFromMD extracts the Bearer token from gRPC metadata.
// The key lookup is case-insensitive per gRPC metadata convention (all keys // The key lookup is case-insensitive per gRPC metadata convention (all keys
// are lowercased by the framework; we match on "authorization"). // are lowercased by the framework; we match on "authorization").

View File

@@ -12,6 +12,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"net" "net"
"strings"
"testing" "testing"
"time" "time"
@@ -19,6 +20,7 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn" "google.golang.org/grpc/test/bufconn"
@@ -143,7 +145,12 @@ func (e *testEnv) issueAdminToken(t *testing.T, username string) (string, *model
// issueUserToken issues a regular (non-admin) token for an account. // issueUserToken issues a regular (non-admin) token for an account.
func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string { func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string {
t.Helper() t.Helper()
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, time.Hour) return e.issueShortToken(t, acct, time.Hour)
}
func (e *testEnv) issueShortToken(t *testing.T, acct *model.Account, expiry time.Duration) string {
t.Helper()
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, expiry)
if err != nil { if err != nil {
t.Fatalf("issue token: %v", err) t.Fatalf("issue token: %v", err)
} }
@@ -357,11 +364,17 @@ func TestLogout(t *testing.T) {
} }
} }
// TestRenewToken verifies that a valid token can be renewed. // TestRenewToken verifies that a valid token can be renewed after 50% of its
// lifetime has elapsed (SEC-03).
func TestRenewToken(t *testing.T) { func TestRenewToken(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
acct := e.createHumanAccount(t, "renewuser") acct := e.createHumanAccount(t, "renewuser")
tok := e.issueUserToken(t, acct)
// Issue a short-lived token (4s) so we can wait past the 50% threshold.
tok := e.issueShortToken(t, acct, 4*time.Second)
// Wait for >50% of lifetime to elapse.
time.Sleep(2100 * time.Millisecond)
cl := mciasv1.NewAuthServiceClient(e.conn) cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tok) ctx := authCtx(tok)
@@ -377,6 +390,28 @@ func TestRenewToken(t *testing.T) {
} }
} }
// TestRenewTokenTooEarly verifies that a token cannot be renewed before 50%
// of its lifetime has elapsed (SEC-03).
func TestRenewTokenTooEarly(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "renewearlyuser")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tok)
_, err := cl.RenewToken(ctx, &mciasv1.RenewTokenRequest{})
if err == nil {
t.Fatal("RenewToken: expected error for early renewal, got nil")
}
st, ok := status.FromError(err)
if !ok || st.Code() != codes.InvalidArgument {
t.Fatalf("RenewToken: expected InvalidArgument, got %v", err)
}
if !strings.Contains(st.Message(), "not yet eligible for renewal") {
t.Errorf("RenewToken: expected eligibility message, got: %s", st.Message())
}
}
// ---- TokenService tests ---- // ---- TokenService tests ----
// TestValidateToken verifies the public ValidateToken RPC returns valid=true for // TestValidateToken verifies the public ValidateToken RPC returns valid=true for
@@ -650,3 +685,196 @@ func TestCredentialFieldsAbsentFromAccountResponse(t *testing.T) {
} }
} }
} }
// ---- grpcClientIP tests (SEC-06) ----
// fakeAddr implements net.Addr for testing peer contexts.
type fakeAddr struct {
addr string
network string
}
func (a fakeAddr) String() string { return a.addr }
func (a fakeAddr) Network() string { return a.network }
// TestGRPCClientIP_NoProxy verifies that when no trusted proxy is configured
// the function returns the peer IP directly.
func TestGRPCClientIP_NoProxy(t *testing.T) {
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "10.0.0.5:54321", network: "tcp"},
})
got := grpcClientIP(ctx, nil)
if got != "10.0.0.5" {
t.Errorf("grpcClientIP(no proxy) = %q, want %q", got, "10.0.0.5")
}
}
// TestGRPCClientIP_TrustedProxy_XForwardedFor verifies that when the peer
// matches the trusted proxy, the real client IP is extracted from
// x-forwarded-for metadata.
func TestGRPCClientIP_TrustedProxy_XForwardedFor(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
md := metadata.Pairs("x-forwarded-for", "203.0.113.50, 10.0.0.1")
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "203.0.113.50" {
t.Errorf("grpcClientIP(xff) = %q, want %q", got, "203.0.113.50")
}
}
// TestGRPCClientIP_TrustedProxy_XRealIP verifies that x-real-ip is preferred
// over x-forwarded-for when both are present.
func TestGRPCClientIP_TrustedProxy_XRealIP(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
md := metadata.Pairs(
"x-real-ip", "198.51.100.10",
"x-forwarded-for", "203.0.113.50",
)
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "198.51.100.10" {
t.Errorf("grpcClientIP(x-real-ip preferred) = %q, want %q", got, "198.51.100.10")
}
}
// TestGRPCClientIP_UntrustedPeer_IgnoresHeaders verifies that forwarded
// headers are ignored when the peer does NOT match the trusted proxy.
// Security: This prevents IP-spoofing by untrusted clients.
func TestGRPCClientIP_UntrustedPeer_IgnoresHeaders(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
// Peer is NOT the trusted proxy.
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "10.0.0.99:54321", network: "tcp"},
})
md := metadata.Pairs(
"x-forwarded-for", "203.0.113.50",
"x-real-ip", "198.51.100.10",
)
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "10.0.0.99" {
t.Errorf("grpcClientIP(untrusted peer) = %q, want %q", got, "10.0.0.99")
}
}
// TestGRPCClientIP_TrustedProxy_NoHeaders verifies that when the peer matches
// the proxy but no forwarded headers are set, the peer IP is returned as fallback.
func TestGRPCClientIP_TrustedProxy_NoHeaders(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
got := grpcClientIP(ctx, proxyIP)
if got != "192.168.1.1" {
t.Errorf("grpcClientIP(proxy, no headers) = %q, want %q", got, "192.168.1.1")
}
}
// TestGRPCClientIP_TrustedProxy_InvalidHeader verifies that invalid IPs in
// headers are ignored and the peer IP is returned.
func TestGRPCClientIP_TrustedProxy_InvalidHeader(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
md := metadata.Pairs("x-forwarded-for", "not-an-ip")
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "192.168.1.1" {
t.Errorf("grpcClientIP(invalid header) = %q, want %q", got, "192.168.1.1")
}
}
// TestGRPCClientIP_NoPeer verifies that an empty string is returned when
// there is no peer in the context.
func TestGRPCClientIP_NoPeer(t *testing.T) {
got := grpcClientIP(context.Background(), nil)
if got != "" {
t.Errorf("grpcClientIP(no peer) = %q, want %q", got, "")
}
}
// TestLoginLockedAccountReturnsUnauthenticated verifies that a locked-out
// account gets the same gRPC Unauthenticated / "invalid credentials" as a
// wrong-password attempt, preventing user-enumeration via lockout
// differentiation (SEC-02).
func TestLoginLockedAccountReturnsUnauthenticated(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "lockgrpc")
// Lower the lockout threshold so we don't need 10 failures.
origThreshold := db.LockoutThreshold
db.LockoutThreshold = 3
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
for range db.LockoutThreshold {
if err := e.db.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure: %v", err)
}
}
locked, err := e.db.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if !locked {
t.Fatal("expected account to be locked out after threshold failures")
}
cl := mciasv1.NewAuthServiceClient(e.conn)
// Attempt login on the locked account.
_, lockedErr := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "lockgrpc",
Password: "testpass123",
})
if lockedErr == nil {
t.Fatal("Login on locked account: expected error, got nil")
}
// Attempt login with wrong password for comparison.
_, wrongErr := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "lockgrpc",
Password: "wrongpassword",
})
if wrongErr == nil {
t.Fatal("Login with wrong password: expected error, got nil")
}
lockedSt, _ := status.FromError(lockedErr)
wrongSt, _ := status.FromError(wrongErr)
// Both must return Unauthenticated, not ResourceExhausted.
if lockedSt.Code() != codes.Unauthenticated {
t.Errorf("locked: got code %v, want Unauthenticated", lockedSt.Code())
}
if wrongSt.Code() != codes.Unauthenticated {
t.Errorf("wrong password: got code %v, want Unauthenticated", wrongSt.Code())
}
// Messages must be identical.
if lockedSt.Message() != wrongSt.Message() {
t.Errorf("locked message %q differs from wrong-password message %q",
lockedSt.Message(), wrongSt.Message())
}
if lockedSt.Message() != "invalid credentials" {
t.Errorf("locked message = %q, want %q", lockedSt.Message(), "invalid credentials")
}
}

View 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
}

View File

@@ -72,16 +72,15 @@ func (ts *tokenServiceServer) IssueServiceToken(ctx context.Context, req *mciasv
return nil, status.Error(codes.Internal, "internal error") return nil, status.Error(codes.Internal, "internal error")
} }
// Revoke existing system token if any. // Atomically revoke existing system token (if any), track the new token,
// and update system_tokens — all in a single transaction.
// Security: prevents inconsistent state if a crash occurs mid-operation.
var oldJTI string
existing, err := ts.s.db.GetSystemToken(acct.ID) existing, err := ts.s.db.GetSystemToken(acct.ID)
if err == nil && existing != nil { if err == nil && existing != nil {
_ = ts.s.db.RevokeToken(existing.JTI, "rotated") oldJTI = existing.JTI
} }
if err := ts.s.db.IssueSystemToken(oldJTI, claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
if err := ts.s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
if err := ts.s.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil {
return nil, status.Error(codes.Internal, "internal error") return nil, status.Error(codes.Internal, "internal error")
} }

View File

@@ -53,12 +53,20 @@ type Account struct {
const ( const (
RoleAdmin = "admin" RoleAdmin = "admin"
RoleUser = "user" RoleUser = "user"
RoleGuest = "guest"
RoleViewer = "viewer"
RoleEditor = "editor"
RoleCommenter = "commenter"
) )
// allowedRoles is the compile-time set of recognised role names. // allowedRoles is the compile-time set of recognised role names.
var allowedRoles = map[string]struct{}{ var allowedRoles = map[string]struct{}{
RoleAdmin: {}, RoleAdmin: {},
RoleUser: {}, RoleUser: {},
RoleGuest: {},
RoleViewer: {},
RoleEditor: {},
RoleCommenter: {},
} }
// ValidateRole returns nil if role is an allowlisted role name, or an error // ValidateRole returns nil if role is an allowlisted role name, or an error
@@ -68,7 +76,7 @@ var allowedRoles = map[string]struct{}{
// roles (e.g. "admim") by enforcing a compile-time allowlist. // roles (e.g. "admim") by enforcing a compile-time allowlist.
func ValidateRole(role string) error { func ValidateRole(role string) error {
if _, ok := allowedRoles[role]; !ok { if _, ok := allowedRoles[role]; !ok {
return fmt.Errorf("model: unknown role %q; allowed roles: admin, user", role) return fmt.Errorf("model: unknown role %q; allowed roles: admin, user, guest, viewer, editor, commenter", role)
} }
return nil return nil
} }

View File

@@ -18,7 +18,9 @@ import (
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"time"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/kyle/mcias/internal/crypto"
@@ -130,6 +132,9 @@ 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/pgcreds", requireAuth(http.HandlerFunc(s.handleListAccessiblePGCreds)))
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)))
@@ -152,10 +157,20 @@ func (s *Server) Handler() http.Handler {
} }
uiSrv.Register(mux) uiSrv.Register(mux)
// Apply global middleware: request logging. // Apply global middleware: request logging and security headers.
// Rate limiting is applied per-route above (login, token/validate). // Rate limiting is applied per-route above (login, token/validate).
var root http.Handler = mux var root http.Handler = mux
root = middleware.RequestLogger(s.logger)(root) root = middleware.RequestLogger(s.logger)(root)
// Security (SEC-04): apply baseline security headers to ALL responses
// (both API and UI). These headers are safe for every content type:
// - X-Content-Type-Options prevents MIME-sniffing attacks.
// - Strict-Transport-Security enforces HTTPS for 2 years.
// - Cache-Control prevents caching of authenticated responses.
// The UI sub-mux already sets these plus CSP/X-Frame-Options/Referrer-Policy
// which will override where needed (last Set wins before WriteHeader).
root = globalSecurityHeaders(root)
return root return root
} }
@@ -212,7 +227,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Security: return a generic error whether the user exists or not. // Security: return a generic error whether the user exists or not.
// Always run a dummy Argon2 check to prevent timing-based user enumeration. // Always run a dummy Argon2 check to prevent timing-based user enumeration.
_, _ = auth.VerifyPassword("dummy", auth.DummyHash()) _, _ = auth.VerifyPassword("dummy", auth.DummyHash())
s.writeAudit(r, model.EventLoginFail, nil, nil, fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, req.Username)) s.writeAudit(r, model.EventLoginFail, nil, nil, audit.JSON("username", req.Username, "reason", "unknown_user"))
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return return
} }
@@ -236,7 +251,9 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if locked { if locked {
_, _ = auth.VerifyPassword("dummy", auth.DummyHash()) _, _ = auth.VerifyPassword("dummy", auth.DummyHash())
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`) s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked") // Security: return the same 401 "invalid credentials" as wrong-password
// to prevent user-enumeration via lockout differentiation (SEC-02).
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return return
} }
@@ -313,7 +330,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
} }
s.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "") s.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, fmt.Sprintf(`{"jti":%q}`, claims.JTI)) s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, audit.JSON("jti", claims.JTI))
writeJSON(w, http.StatusOK, loginResponse{ writeJSON(w, http.StatusOK, loginResponse{
Token: tokenStr, Token: tokenStr,
@@ -328,13 +345,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return return
} }
s.writeAudit(r, model.EventTokenRevoked, nil, nil, fmt.Sprintf(`{"jti":%q,"reason":"logout"}`, claims.JTI)) s.writeAudit(r, model.EventTokenRevoked, nil, nil, audit.JSON("jti", claims.JTI, "reason", "logout"))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
// Security: only allow renewal when the token has consumed at least 50% of
// its lifetime. This prevents indefinite renewal of stolen tokens (SEC-03).
totalLifetime := claims.ExpiresAt.Sub(claims.IssuedAt)
elapsed := time.Since(claims.IssuedAt)
if elapsed < totalLifetime/2 {
middleware.WriteError(w, http.StatusBadRequest, "token is not yet eligible for renewal", "renewal_too_early")
return
}
// Load account to get current roles (they may have changed since token issuance). // Load account to get current roles (they may have changed since token issuance).
acct, err := s.db.GetAccountByUUID(claims.Subject) acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil { if err != nil {
@@ -374,7 +400,7 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
return return
} }
s.writeAudit(r, model.EventTokenRenewed, &acct.ID, nil, fmt.Sprintf(`{"old_jti":%q,"new_jti":%q}`, claims.JTI, newClaims.JTI)) s.writeAudit(r, model.EventTokenRenewed, &acct.ID, nil, audit.JSON("old_jti", claims.JTI, "new_jti", newClaims.JTI))
writeJSON(w, http.StatusOK, loginResponse{ writeJSON(w, http.StatusOK, loginResponse{
Token: newTokenStr, Token: newTokenStr,
@@ -458,17 +484,15 @@ func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
return return
} }
// Revoke existing system token if any. // Atomically revoke existing system token (if any), track the new token,
// and update system_tokens — all in a single transaction.
// Security: prevents inconsistent state if a crash occurs mid-operation.
var oldJTI string
existing, err := s.db.GetSystemToken(acct.ID) existing, err := s.db.GetSystemToken(acct.ID)
if err == nil && existing != nil { if err == nil && existing != nil {
_ = s.db.RevokeToken(existing.JTI, "rotated") oldJTI = existing.JTI
} }
if err := s.db.IssueSystemToken(oldJTI, claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
if err := s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return return
} }
@@ -480,7 +504,7 @@ func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
actorID = &a.ID actorID = &a.ID
} }
} }
s.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID, fmt.Sprintf(`{"jti":%q}`, claims.JTI)) s.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID, audit.JSON("jti", claims.JTI))
writeJSON(w, http.StatusOK, loginResponse{ writeJSON(w, http.StatusOK, loginResponse{
Token: tokenStr, Token: tokenStr,
@@ -500,7 +524,7 @@ func (s *Server) handleTokenRevoke(w http.ResponseWriter, r *http.Request) {
return return
} }
s.writeAudit(r, model.EventTokenRevoked, nil, nil, fmt.Sprintf(`{"jti":%q}`, jti)) s.writeAudit(r, model.EventTokenRevoked, nil, nil, audit.JSON("jti", jti))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
@@ -595,7 +619,7 @@ func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
return return
} }
s.writeAudit(r, model.EventAccountCreated, nil, &acct.ID, fmt.Sprintf(`{"username":%q}`, acct.Username)) s.writeAudit(r, model.EventAccountCreated, nil, &acct.ID, audit.JSON("username", acct.Username))
writeJSON(w, http.StatusCreated, accountToResponse(acct)) writeJSON(w, http.StatusCreated, accountToResponse(acct))
} }
@@ -666,6 +690,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 {
@@ -706,12 +734,78 @@ func (s *Server) handleSetRoles(w http.ResponseWriter, r *http.Request) {
return return
} }
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, fmt.Sprintf(`{"roles":%v}`, req.Roles)) s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, audit.JSONWithRoles(req.Roles))
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, audit.JSON("role", 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, audit.JSON("role", role))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// ---- TOTP endpoints ---- // ---- TOTP endpoints ----
type totpEnrollRequest struct {
Password string `json:"password"` // security: current password required to prevent session-theft escalation
}
type totpEnrollResponse struct { type totpEnrollResponse struct {
Secret string `json:"secret"` // base32-encoded Secret string `json:"secret"` // base32-encoded
OTPAuthURI string `json:"otpauth_uri"` OTPAuthURI string `json:"otpauth_uri"`
@@ -721,6 +815,12 @@ type totpConfirmRequest struct {
Code string `json:"code"` Code string `json:"code"`
} }
// handleTOTPEnroll begins TOTP enrollment for the calling account.
//
// Security (SEC-01): the current password is required in the request body to
// prevent a stolen session token from being used to enroll attacker-controlled
// MFA on the victim's account. Lockout is checked and failures are recorded
// to prevent brute-force use of this endpoint as a password oracle.
func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
acct, err := s.db.GetAccountByUUID(claims.Subject) acct, err := s.db.GetAccountByUUID(claims.Subject)
@@ -729,6 +829,38 @@ func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) {
return return
} }
var req totpEnrollRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Password == "" {
middleware.WriteError(w, http.StatusBadRequest, "password is required", "bad_request")
return
}
// Security: check lockout before verifying (same as login and password-change flows)
// so an attacker cannot use this endpoint to brute-force the current password.
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check (TOTP enroll)", "error", lockErr)
}
if locked {
s.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
return
}
// Security: verify the current password with the same constant-time
// Argon2id path used at login to prevent timing oracles.
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = s.db.RecordLoginFailure(acct.ID)
s.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
middleware.WriteError(w, http.StatusUnauthorized, "password is incorrect", "unauthorized")
return
}
rawSecret, b32Secret, err := auth.GenerateTOTPSecret() rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
if err != nil { if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -958,7 +1090,9 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
} }
if locked { if locked {
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`) s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked") // Security: return the same 401 as wrong-password to prevent
// user-enumeration via lockout differentiation (SEC-02).
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return return
} }
@@ -1090,6 +1224,58 @@ func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// handleListAccessiblePGCreds returns all pg_credentials accessible to the
// authenticated user: those owned + those explicitly granted. The credential ID
// is included so callers can fetch a specific credential via /v1/accounts/{id}/pgcreds.
func (s *Server) handleListAccessiblePGCreds(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
middleware.WriteError(w, http.StatusUnauthorized, "not authenticated", "unauthorized")
return
}
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
return
}
creds, err := s.db.ListAccessiblePGCreds(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Convert credentials to response format with credential ID.
type pgCredResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Port int `json:"port"`
Host string `json:"host"`
Database string `json:"database"`
Username string `json:"username"`
ServiceAccountID string `json:"service_account_id"`
ServiceAccountName string `json:"service_account_name,omitempty"`
}
response := make([]pgCredResponse, len(creds))
for i, cred := range creds {
response[i] = pgCredResponse{
ID: cred.ID,
ServiceAccountID: cred.ServiceAccountUUID,
Host: cred.PGHost,
Port: cred.PGPort,
Database: cred.PGDatabase,
Username: cred.PGUsername,
CreatedAt: cred.CreatedAt,
UpdatedAt: cred.UpdatedAt,
}
}
writeJSON(w, http.StatusOK, response)
}
// ---- Audit endpoints ---- // ---- Audit endpoints ----
// handleListAudit returns paginated audit log entries with resolved usernames. // handleListAudit returns paginated audit log entries with resolved usernames.
@@ -1201,9 +1387,21 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
} }
} }
// maxJSONBytes limits the size of JSON request bodies (1 MiB).
//
// Security (SEC-05): without a size limit an attacker could send a
// multi-gigabyte body and exhaust server memory. The UI layer already
// applies http.MaxBytesReader; this constant gives the REST API the
// same protection.
const maxJSONBytes = 1 << 20
// decodeJSON decodes a JSON request body into v. // decodeJSON decodes a JSON request body into v.
// Returns false and writes a 400 response if decoding fails. // Returns false and writes a 400 response if decoding fails.
//
// Security (SEC-05): the body is wrapped with http.MaxBytesReader so
// that oversized payloads are rejected before they are fully read.
func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool { func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil { if err := dec.Decode(v); err != nil {
@@ -1229,6 +1427,20 @@ func extractBearerFromRequest(r *http.Request) (string, error) {
// docsSecurityHeaders adds the same defensive HTTP headers as the UI sub-mux // docsSecurityHeaders adds the same defensive HTTP headers as the UI sub-mux
// to the /docs and /docs/openapi.yaml endpoints. // to the /docs and /docs/openapi.yaml endpoints.
// //
// globalSecurityHeaders sets baseline security headers on every response.
// Security (SEC-04): API responses previously lacked X-Content-Type-Options,
// HSTS, and Cache-Control. These three headers are safe for all content types
// and do not interfere with JSON API clients or the HTMX UI.
func globalSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
h.Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
// Security (DEF-09): without these headers the Swagger UI HTML page is // Security (DEF-09): without these headers the Swagger UI HTML page is
// served without CSP, X-Frame-Options, or HSTS, leaving it susceptible // served without CSP, X-Frame-Options, or HSTS, leaving it susceptible
// to clickjacking and MIME-type confusion in browsers. // to clickjacking and MIME-type confusion in browsers.

View File

@@ -519,8 +519,10 @@ func TestTOTPEnrollDoesNotRequireTOTP(t *testing.T) {
t.Fatalf("TrackToken: %v", err) t.Fatalf("TrackToken: %v", err)
} }
// Start enrollment. // Start enrollment (password required since SEC-01 fix).
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", nil, tokenStr) rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
Password: "testpass123",
}, tokenStr)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("enroll status = %d, want 200; body: %s", rr.Code, rr.Body.String()) t.Fatalf("enroll status = %d, want 200; body: %s", rr.Code, rr.Body.String())
} }
@@ -558,12 +560,68 @@ func TestTOTPEnrollDoesNotRequireTOTP(t *testing.T) {
} }
} }
// TestTOTPEnrollRequiresPassword verifies that TOTP enrollment (SEC-01)
// requires the current password. A stolen session token alone must not be
// sufficient to add attacker-controlled MFA to the victim's account.
func TestTOTPEnrollRequiresPassword(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
acct := createTestHumanAccount(t, srv, "totp-pw-check")
handler := srv.Handler()
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("TrackToken: %v", err)
}
t.Run("no password", func(t *testing.T) {
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{}, tokenStr)
if rr.Code != http.StatusBadRequest {
t.Errorf("enroll without password: status = %d, want %d; body: %s",
rr.Code, http.StatusBadRequest, rr.Body.String())
}
})
t.Run("wrong password", func(t *testing.T) {
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
Password: "wrong-password",
}, tokenStr)
if rr.Code != http.StatusUnauthorized {
t.Errorf("enroll with wrong password: status = %d, want %d; body: %s",
rr.Code, http.StatusUnauthorized, rr.Body.String())
}
})
t.Run("correct password", func(t *testing.T) {
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
Password: "testpass123",
}, tokenStr)
if rr.Code != http.StatusOK {
t.Fatalf("enroll with correct password: status = %d, want 200; body: %s",
rr.Code, rr.Body.String())
}
var resp totpEnrollResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Secret == "" {
t.Error("expected non-empty TOTP secret")
}
if resp.OTPAuthURI == "" {
t.Error("expected non-empty otpauth URI")
}
})
}
func TestRenewToken(t *testing.T) { func TestRenewToken(t *testing.T) {
srv, _, priv, _ := newTestServer(t) srv, _, priv, _ := newTestServer(t)
acct := createTestHumanAccount(t, srv, "renew-user") acct := createTestHumanAccount(t, srv, "renew-user")
handler := srv.Handler() handler := srv.Handler()
oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour) // Issue a short-lived token (2s) so we can wait past the 50% threshold.
oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, 2*time.Second)
if err != nil { if err != nil {
t.Fatalf("IssueToken: %v", err) t.Fatalf("IssueToken: %v", err)
} }
@@ -572,6 +630,9 @@ func TestRenewToken(t *testing.T) {
t.Fatalf("TrackToken: %v", err) t.Fatalf("TrackToken: %v", err)
} }
// Wait for >50% of the 2s lifetime to elapse.
time.Sleep(1100 * time.Millisecond)
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, oldTokenStr) rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, oldTokenStr)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("renew status = %d, want 200; body: %s", rr.Code, rr.Body.String()) t.Fatalf("renew status = %d, want 200; body: %s", rr.Code, rr.Body.String())
@@ -594,3 +655,164 @@ func TestRenewToken(t *testing.T) {
t.Error("old token should be revoked after renewal") t.Error("old token should be revoked after renewal")
} }
} }
func TestOversizedJSONBodyRejected(t *testing.T) {
srv, _, _, _ := newTestServer(t)
handler := srv.Handler()
// Build a JSON body larger than 1 MiB.
oversized := bytes.Repeat([]byte("A"), (1<<20)+1)
body := []byte(`{"username":"admin","password":"` + string(oversized) + `"}`)
req := httptest.NewRequest("POST", "/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for oversized body, got %d", rr.Code)
}
}
// TestSecurityHeadersOnAPIResponses verifies that the global security-headers
// middleware (SEC-04) sets X-Content-Type-Options, Strict-Transport-Security,
// and Cache-Control on all API responses, not just the UI.
func TestSecurityHeadersOnAPIResponses(t *testing.T) {
srv, _, _, _ := newTestServer(t)
handler := srv.Handler()
wantHeaders := map[string]string{
"X-Content-Type-Options": "nosniff",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
"Cache-Control": "no-store",
}
t.Run("GET /v1/health", func(t *testing.T) {
rr := doRequest(t, handler, "GET", "/v1/health", nil, "")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
for header, want := range wantHeaders {
got := rr.Header().Get(header)
if got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
})
t.Run("POST /v1/auth/login", func(t *testing.T) {
createTestHumanAccount(t, srv, "sec04-user")
rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
"username": "sec04-user",
"password": "testpass123",
}, "")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
for header, want := range wantHeaders {
got := rr.Header().Get(header)
if got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
})
}
// TestLoginLockedAccountReturns401 verifies that a locked-out account gets the
// same HTTP 401 / "invalid credentials" response as a wrong-password attempt,
// preventing user-enumeration via lockout differentiation (SEC-02).
func TestLoginLockedAccountReturns401(t *testing.T) {
srv, _, _, database := newTestServer(t)
acct := createTestHumanAccount(t, srv, "lockuser")
handler := srv.Handler()
// Lower the lockout threshold so we don't need 10 failures.
origThreshold := db.LockoutThreshold
db.LockoutThreshold = 3
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
// Record enough failures to trigger lockout.
for range db.LockoutThreshold {
if err := database.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure: %v", err)
}
}
// Confirm the account is locked.
locked, err := database.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if !locked {
t.Fatal("expected account to be locked out after threshold failures")
}
// Attempt login on the locked account.
lockedRR := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
"username": "lockuser",
"password": "testpass123",
}, "")
// Also attempt login with a wrong password (not locked) for comparison.
wrongRR := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
"username": "lockuser",
"password": "wrongpassword",
}, "")
// Both must return 401, not 429.
if lockedRR.Code != http.StatusUnauthorized {
t.Errorf("locked account: status = %d, want %d", lockedRR.Code, http.StatusUnauthorized)
}
if wrongRR.Code != http.StatusUnauthorized {
t.Errorf("wrong password: status = %d, want %d", wrongRR.Code, http.StatusUnauthorized)
}
// Parse the JSON bodies and compare — they must be identical.
type errResp struct {
Error string `json:"error"`
Code string `json:"code"`
}
var lockedBody, wrongBody errResp
if err := json.Unmarshal(lockedRR.Body.Bytes(), &lockedBody); err != nil {
t.Fatalf("unmarshal locked body: %v", err)
}
if err := json.Unmarshal(wrongRR.Body.Bytes(), &wrongBody); err != nil {
t.Fatalf("unmarshal wrong body: %v", err)
}
if lockedBody != wrongBody {
t.Errorf("locked response %+v differs from wrong-password response %+v", lockedBody, wrongBody)
}
if lockedBody.Code != "unauthorized" {
t.Errorf("locked response code = %q, want %q", lockedBody.Code, "unauthorized")
}
if lockedBody.Error != "invalid credentials" {
t.Errorf("locked response error = %q, want %q", lockedBody.Error, "invalid credentials")
}
}
// TestRenewTokenTooEarly verifies that a token cannot be renewed before 50%
// of its lifetime has elapsed (SEC-03).
func TestRenewTokenTooEarly(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
acct := createTestHumanAccount(t, srv, "renew-early-user")
handler := srv.Handler()
// Issue a long-lived token so 50% is far in the future.
tokStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("TrackToken: %v", err)
}
// Immediately try to renew — should be rejected.
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, tokStr)
if rr.Code != http.StatusBadRequest {
t.Fatalf("renew status = %d, want 400; body: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "not yet eligible for renewal") {
t.Errorf("expected eligibility message, got: %s", rr.Body.String())
}
}

View File

@@ -15,7 +15,14 @@ import (
) )
// knownRoles lists the built-in roles shown as checkboxes in the roles editor. // knownRoles lists the built-in roles shown as checkboxes in the roles editor.
var knownRoles = []string{"admin", "user", "service"} var knownRoles = []string{
model.RoleAdmin,
model.RoleUser,
model.RoleGuest,
model.RoleViewer,
model.RoleEditor,
model.RoleCommenter,
}
// handleAccountsList renders the accounts list page. // handleAccountsList renders the accounts list page.
func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) { func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
@@ -32,7 +39,7 @@ func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
} }
u.render(w, "accounts", AccountsData{ u.render(w, "accounts", AccountsData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Accounts: accounts, Accounts: accounts,
}) })
} }
@@ -176,7 +183,7 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
} }
u.render(w, "account_detail", AccountDetailData{ u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Account: acct, Account: acct,
Roles: roles, Roles: roles,
AllRoles: knownRoles, AllRoles: knownRoles,
@@ -783,7 +790,7 @@ func (u *UIServer) handlePGCredsList(w http.ResponseWriter, r *http.Request) {
} }
u.render(w, "pgcreds", PGCredsData{ u.render(w, "pgcreds", PGCredsData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Creds: creds, Creds: creds,
UncredentialedAccounts: uncredentialed, UncredentialedAccounts: uncredentialed,
CredGrants: credGrants, CredGrants: credGrants,
@@ -907,26 +914,8 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
// storage. The plaintext is never logged or included in any response. // storage. The plaintext is never logged or included in any response.
// Audit event EventPasswordChanged is recorded on success. // 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: enforce admin role; requireCookieAuth only validates the token, // Security: admin role is enforced by the requireAdminRole middleware in
// it does not check roles. A non-admin with a valid session must not be // the route registration (ui.go); no inline check needed here.
// able to reset arbitrary accounts' passwords.
callerClaims := claimsFromContext(r.Context())
if callerClaims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
isAdmin := false
for _, role := range callerClaims.Roles {
if role == "admin" {
isAdmin = true
break
}
}
if !isAdmin {
u.renderError(w, r, http.StatusForbidden, "admin role required")
return
}
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")

View File

@@ -86,7 +86,7 @@ func (u *UIServer) handleAuditDetail(w http.ResponseWriter, r *http.Request) {
} }
u.render(w, "audit_detail", AuditDetailData{ u.render(w, "audit_detail", AuditDetailData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Event: event, Event: event,
}) })
} }
@@ -116,7 +116,7 @@ func (u *UIServer) buildAuditData(r *http.Request, page int, csrfToken string) (
} }
return AuditData{ return AuditData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Events: events, Events: events,
EventTypes: auditEventTypes, EventTypes: auditEventTypes,
FilterType: filterType, FilterType: filterType,

View File

@@ -1,9 +1,9 @@
package ui package ui
import ( import (
"fmt"
"net/http" "net/http"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/model"
@@ -59,7 +59,7 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
// Security: always run dummy Argon2 to prevent timing-based user enumeration. // Security: always run dummy Argon2 to prevent timing-based user enumeration.
_, _ = auth.VerifyPassword("dummy", u.dummyHash()) _, _ = auth.VerifyPassword("dummy", u.dummyHash())
u.writeAudit(r, model.EventLoginFail, nil, nil, u.writeAudit(r, model.EventLoginFail, nil, nil,
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, username)) audit.JSON("username", username, "reason", "unknown_user"))
u.render(w, "login", LoginData{Error: "invalid credentials"}) u.render(w, "login", LoginData{Error: "invalid credentials"})
return return
} }
@@ -80,7 +80,9 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if locked { if locked {
_, _ = auth.VerifyPassword("dummy", u.dummyHash()) _, _ = auth.VerifyPassword("dummy", u.dummyHash())
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`) u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
u.render(w, "login", LoginData{Error: "account temporarily locked, please try again later"}) // Security: return the same "invalid credentials" as wrong-password
// to prevent user-enumeration via lockout differentiation (SEC-02).
u.render(w, "login", LoginData{Error: "invalid credentials"})
return return
} }
@@ -130,7 +132,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
accountID, ok := u.consumeTOTPNonce(nonce) accountID, ok := u.consumeTOTPNonce(nonce)
if !ok { if !ok {
u.writeAudit(r, model.EventLoginFail, nil, nil, u.writeAudit(r, model.EventLoginFail, nil, nil,
fmt.Sprintf(`{"username":%q,"reason":"invalid_totp_nonce"}`, username)) audit.JSON("username", username, "reason", "invalid_totp_nonce"))
u.render(w, "login", LoginData{Error: "session expired, please log in again"}) u.render(w, "login", LoginData{Error: "session expired, please log in again"})
return return
} }
@@ -238,7 +240,7 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
u.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "") u.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
fmt.Sprintf(`{"jti":%q,"via":"ui"}`, claims.JTI)) audit.JSON("jti", claims.JTI, "via", "ui"))
// Redirect to dashboard. // Redirect to dashboard.
if isHTMX(r) { if isHTMX(r) {
@@ -259,7 +261,7 @@ func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
u.logger.Warn("revoke token on UI logout", "error", revokeErr) u.logger.Warn("revoke token on UI logout", "error", revokeErr)
} }
u.writeAudit(r, model.EventTokenRevoked, nil, nil, u.writeAudit(r, model.EventTokenRevoked, nil, nil,
fmt.Sprintf(`{"jti":%q,"reason":"ui_logout"}`, claims.JTI)) audit.JSON("jti", claims.JTI, "reason", "ui_logout"))
} }
} }
u.clearSessionCookie(w) u.clearSessionCookie(w)
@@ -281,6 +283,7 @@ func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
PageData: PageData{ PageData: PageData{
CSRFToken: csrfToken, CSRFToken: csrfToken,
ActorName: u.actorName(r), ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
}, },
}) })
} }
@@ -393,6 +396,7 @@ func (u *UIServer) handleSelfChangePassword(w http.ResponseWriter, r *http.Reque
PageData: PageData{ PageData: PageData{
CSRFToken: csrfToken, CSRFToken: csrfToken,
ActorName: u.actorName(r), ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
Flash: "Password updated successfully. Other active sessions have been revoked.", Flash: "Password updated successfully. Other active sessions have been revoked.",
}, },
}) })

View File

@@ -7,7 +7,8 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/model"
) )
// handleDashboard renders the main dashboard page with account counts and recent events. // handleDashboard renders the main dashboard page. Admin users see account
// counts and recent audit events; non-admin users see a welcome page.
func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) { func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w) csrfToken, err := u.setCSRFCookies(w)
if err != nil { if err != nil {
@@ -16,17 +17,23 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
return return
} }
admin := isAdmin(r)
data := DashboardData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: admin},
}
if admin {
accounts, err := u.db.ListAccounts() accounts, err := u.db.ListAccounts()
if err != nil { if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts") u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
return return
} }
var total, active int
for _, a := range accounts { for _, a := range accounts {
total++ data.TotalAccounts++
if a.Status == model.AccountStatusActive { if a.Status == model.AccountStatusActive {
active++ data.ActiveAccounts++
} }
} }
@@ -35,11 +42,8 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
u.logger.Warn("load recent audit events", "error", err) u.logger.Warn("load recent audit events", "error", err)
events = nil events = nil
} }
data.RecentEvents = events
u.render(w, "dashboard", DashboardData{ }
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
TotalAccounts: total, u.render(w, "dashboard", data)
ActiveAccounts: active,
RecentEvents: events,
})
} }

View File

@@ -61,7 +61,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
} }
data := PoliciesData{ data := PoliciesData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Rules: views, Rules: views,
AllActions: allActionStrings, AllActions: allActionStrings,
} }

View File

@@ -24,6 +24,7 @@ import (
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
@@ -275,7 +276,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
if err != nil { if err != nil {
panic(fmt.Sprintf("ui: static sub-FS: %v", err)) panic(fmt.Sprintf("ui: static sub-FS: %v", err))
} }
uiMux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS))) // Security (SEC-07): wrap the file server to suppress directory listings.
// Without this, GET /static/ returns an index of all static assets,
// revealing framework details to an attacker.
uiMux.Handle("GET /static/", http.StripPrefix("/static/", noDirListing(http.FileServerFS(staticSubFS))))
// Redirect root to login. // Redirect root to login.
uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
@@ -301,15 +305,20 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.HandleFunc("POST /logout", u.handleLogout) uiMux.HandleFunc("POST /logout", u.handleLogout)
// Protected routes. // Protected routes.
auth := u.requireCookieAuth //
// Security: three distinct access levels:
// - authed: any valid session cookie (authenticated user)
// - admin: authed + admin role in JWT claims (mutating admin ops)
// - adminGet: authed + admin role (read-only admin pages, no CSRF)
authed := u.requireCookieAuth
admin := func(h http.HandlerFunc) http.Handler { admin := func(h http.HandlerFunc) http.Handler {
return auth(u.requireCSRF(http.HandlerFunc(h))) return authed(u.requireAdminRole(u.requireCSRF(http.HandlerFunc(h))))
} }
adminGet := func(h http.HandlerFunc) http.Handler { adminGet := func(h http.HandlerFunc) http.Handler {
return auth(http.HandlerFunc(h)) return authed(u.requireAdminRole(http.HandlerFunc(h)))
} }
uiMux.Handle("GET /dashboard", adminGet(u.handleDashboard)) uiMux.Handle("GET /dashboard", authed(http.HandlerFunc(u.handleDashboard)))
uiMux.Handle("GET /accounts", adminGet(u.handleAccountsList)) uiMux.Handle("GET /accounts", adminGet(u.handleAccountsList))
uiMux.Handle("POST /accounts", admin(u.handleCreateAccount)) uiMux.Handle("POST /accounts", admin(u.handleCreateAccount))
uiMux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail)) uiMux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
@@ -335,8 +344,8 @@ func (u *UIServer) Register(mux *http.ServeMux) {
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). // Profile routes — accessible to any authenticated user (not admin-only).
uiMux.Handle("GET /profile", adminGet(u.handleProfilePage)) uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
uiMux.Handle("PUT /profile/password", auth(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword)))) 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
@@ -405,6 +414,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.
@@ -506,6 +534,21 @@ func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status in
// Security: prevents memory exhaustion from oversized POST bodies (gosec G120). // Security: prevents memory exhaustion from oversized POST bodies (gosec G120).
const maxFormBytes = 1 << 20 const maxFormBytes = 1 << 20
// noDirListing wraps an http.Handler (typically http.FileServerFS) to return
// 404 for directory requests instead of an auto-generated directory index.
//
// Security (SEC-07): directory listings expose the names of all static assets,
// leaking framework and version information to attackers.
func noDirListing(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") || r.URL.Path == "" {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// securityHeaders returns middleware that adds defensive HTTP headers to every // securityHeaders returns middleware that adds defensive HTTP headers to every
// UI response. // UI response.
// //
@@ -521,6 +564,9 @@ const maxFormBytes = 1 << 20
// requests to this origin for two years, preventing TLS-strip on revisit. // requests to this origin for two years, preventing TLS-strip on revisit.
// - Referrer-Policy: suppresses the Referer header on outbound navigations so // - Referrer-Policy: suppresses the Referer header on outbound navigations so
// JWTs or session identifiers embedded in URLs are not leaked to third parties. // JWTs or session identifiers embedded in URLs are not leaked to third parties.
// - Permissions-Policy: disables browser features (camera, microphone,
// geolocation, payment) that this application does not use, reducing the
// attack surface if a content-injection vulnerability is exploited.
func securityHeaders(next http.Handler) http.Handler { func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header() h := w.Header()
@@ -530,6 +576,7 @@ func securityHeaders(next http.Handler) http.Handler {
h.Set("X-Frame-Options", "DENY") h.Set("X-Frame-Options", "DENY")
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
h.Set("Referrer-Policy", "no-referrer") h.Set("Referrer-Policy", "no-referrer")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
@@ -545,6 +592,13 @@ func (u *UIServer) clientIP(r *http.Request) string {
return middleware.ClientIP(r, proxyIP) return middleware.ClientIP(r, proxyIP)
} }
// isAdmin reports whether the authenticated user holds the "admin" role.
// Returns false if claims are absent.
func isAdmin(r *http.Request) bool {
claims := claimsFromContext(r.Context())
return claims != nil && claims.HasRole("admin")
}
// actorName resolves the username of the currently authenticated user from the // actorName resolves the username of the currently authenticated user from the
// request context. Returns an empty string if claims are absent or the account // request context. Returns an empty string if claims are absent or the account
// cannot be found; callers should treat an empty string as "not logged in". // cannot be found; callers should treat an empty string as "not logged in".
@@ -570,6 +624,10 @@ type PageData struct {
// ActorName is the username of the currently logged-in user, populated by // ActorName is the username of the currently logged-in user, populated by
// handlers so the base template can display it in the navigation bar. // handlers so the base template can display it in the navigation bar.
ActorName string ActorName string
// IsAdmin is true when the logged-in user holds the "admin" role.
// Used by the base template to conditionally render admin-only navigation
// links (SEC-09: non-admin users must not see links they cannot access).
IsAdmin bool
} }
// LoginData is the view model for the login page. // LoginData is the view model for the login page.

View File

@@ -13,6 +13,7 @@ import (
"testing" "testing"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/model"
@@ -79,6 +80,7 @@ func assertSecurityHeaders(t *testing.T, h http.Header, label string) {
{"X-Frame-Options", "DENY"}, {"X-Frame-Options", "DENY"},
{"Strict-Transport-Security", "max-age="}, {"Strict-Transport-Security", "max-age="},
{"Referrer-Policy", "no-referrer"}, {"Referrer-Policy", "no-referrer"},
{"Permissions-Policy", "camera=()"},
} }
for _, c := range checks { for _, c := range checks {
val := h.Get(c.header) val := h.Get(c.header)
@@ -355,6 +357,34 @@ func authenticatedGET(t *testing.T, sessionToken string, path string) *http.Requ
return req return req
} }
// TestStaticDirectoryListingDisabled verifies that GET /static/ returns 404
// instead of a directory listing (SEC-07).
func TestStaticDirectoryListingDisabled(t *testing.T) {
mux := newTestMux(t)
req := httptest.NewRequest(http.MethodGet, "/static/", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("GET /static/ status = %d, want %d (directory listing must be disabled)", rr.Code, http.StatusNotFound)
}
}
// TestStaticFileStillServed verifies that individual static files are still
// served normally after the directory listing fix (SEC-07).
func TestStaticFileStillServed(t *testing.T) {
mux := newTestMux(t)
req := httptest.NewRequest(http.MethodGet, "/static/style.css", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("GET /static/style.css status = %d, want %d", rr.Code, http.StatusOK)
}
}
// TestSetPGCredsRejectsHumanAccount verifies that the PUT /accounts/{id}/pgcreds // TestSetPGCredsRejectsHumanAccount verifies that the PUT /accounts/{id}/pgcreds
// endpoint returns 400 when the target account is a human (not system) account. // endpoint returns 400 when the target account is a human (not system) account.
func TestSetPGCredsRejectsHumanAccount(t *testing.T) { func TestSetPGCredsRejectsHumanAccount(t *testing.T) {
@@ -527,3 +557,195 @@ func TestAccountDetailShowsPGCredsSection(t *testing.T) {
t.Error("human account detail page must not include pgcreds-section") t.Error("human account detail page must not include pgcreds-section")
} }
} }
// TestLoginLockedAccountShowsInvalidCredentials verifies that a locked-out
// account gets the same "invalid credentials" error as a wrong-password
// attempt in the UI login form, preventing user-enumeration via lockout
// differentiation (SEC-02).
func TestLoginLockedAccountShowsInvalidCredentials(t *testing.T) {
u := newTestUIServer(t)
// Create an account with a known password.
hash, err := auth.HashPassword("testpass123", auth.ArgonParams{Time: 3, Memory: 65536, Threads: 4})
if err != nil {
t.Fatalf("hash password: %v", err)
}
acct, err := u.db.CreateAccount("lockuiuser", model.AccountTypeHuman, hash)
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
// Lower the lockout threshold so we don't need 10 failures.
origThreshold := db.LockoutThreshold
db.LockoutThreshold = 3
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
for range db.LockoutThreshold {
if err := u.db.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure: %v", err)
}
}
locked, err := u.db.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if !locked {
t.Fatal("expected account to be locked out after threshold failures")
}
mux := http.NewServeMux()
u.Register(mux)
// POST login for the locked account.
form := url.Values{}
form.Set("username", "lockuiuser")
form.Set("password", "testpass123")
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
lockedRR := httptest.NewRecorder()
mux.ServeHTTP(lockedRR, req)
// POST login with wrong password for comparison.
form2 := url.Values{}
form2.Set("username", "lockuiuser")
form2.Set("password", "wrongpassword")
req2 := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form2.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
wrongRR := httptest.NewRecorder()
mux.ServeHTTP(wrongRR, req2)
lockedBody := lockedRR.Body.String()
wrongBody := wrongRR.Body.String()
// Neither response should mention "locked" or "try again".
if strings.Contains(lockedBody, "locked") || strings.Contains(lockedBody, "try again") {
t.Error("locked account response leaks lockout state")
}
// Both must contain "invalid credentials".
if !strings.Contains(lockedBody, "invalid credentials") {
t.Error("locked account response does not contain 'invalid credentials'")
}
if !strings.Contains(wrongBody, "invalid credentials") {
t.Error("wrong password response does not contain 'invalid credentials'")
}
}
// ---- SEC-09: admin nav link visibility tests ----
// issueUserSession creates a human account with the "user" role (non-admin),
// issues a JWT, tracks it, and returns the raw token string.
func issueUserSession(t *testing.T, u *UIServer) string {
t.Helper()
acct, err := u.db.CreateAccount("regular-user", model.AccountTypeHuman, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if err := u.db.SetRoles(acct.ID, []string{"user"}, nil); err != nil {
t.Fatalf("SetRoles: %v", err)
}
tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"user"}, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("TrackToken: %v", err)
}
return tok
}
// TestNonAdminDashboardHidesAdminNavLinks verifies that a non-admin user's
// dashboard does not contain links to admin-only pages (SEC-09).
func TestNonAdminDashboardHidesAdminNavLinks(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
userToken := issueUserSession(t, u)
req := authenticatedGET(t, userToken, "/dashboard")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, adminPath := range []string{
`href="/accounts"`,
`href="/audit"`,
`href="/policies"`,
`href="/pgcreds"`,
} {
if strings.Contains(body, adminPath) {
t.Errorf("non-admin dashboard contains admin link %s — SEC-09 violation", adminPath)
}
}
// Dashboard link should still be present.
if !strings.Contains(body, `href="/dashboard"`) {
t.Error("dashboard link missing from non-admin nav")
}
}
// TestAdminDashboardShowsAdminNavLinks verifies that an admin user's
// dashboard contains all admin navigation links.
func TestAdminDashboardShowsAdminNavLinks(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
adminToken, _, _ := issueAdminSession(t, u)
req := authenticatedGET(t, adminToken, "/dashboard")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, adminPath := range []string{
`href="/accounts"`,
`href="/audit"`,
`href="/policies"`,
`href="/pgcreds"`,
} {
if !strings.Contains(body, adminPath) {
t.Errorf("admin dashboard missing admin link %s", adminPath)
}
}
}
// TestNonAdminProfileHidesAdminNavLinks verifies that the profile page
// also hides admin nav links for non-admin users (SEC-09).
func TestNonAdminProfileHidesAdminNavLinks(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
userToken := issueUserSession(t, u)
req := authenticatedGET(t, userToken, "/profile")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, adminPath := range []string{
`href="/accounts"`,
`href="/audit"`,
`href="/policies"`,
`href="/pgcreds"`,
} {
if strings.Contains(body, adminPath) {
t.Errorf("non-admin profile page contains admin link %s — SEC-09 violation", adminPath)
}
}
}

View File

@@ -45,11 +45,22 @@ func Username(username string) error {
// password. // password.
const MinPasswordLen = 12 const MinPasswordLen = 12
// Password returns nil if the plaintext password meets the minimum length // MaxPasswordLen is the maximum acceptable plaintext password length.
// requirement, or a descriptive error if not. //
// Security (SEC-05): Argon2id processes the full password input. Without
// an upper bound an attacker could submit a multi-megabyte password and
// force expensive hashing. 128 characters is generous for any real
// password or passphrase while capping the cost.
const MaxPasswordLen = 128
// Password returns nil if the plaintext password meets the length
// requirements, or a descriptive error if not.
func Password(password string) error { func Password(password string) error {
if len(password) < MinPasswordLen { if len(password) < MinPasswordLen {
return fmt.Errorf("password must be at least %d characters", MinPasswordLen) return fmt.Errorf("password must be at least %d characters", MinPasswordLen)
} }
if len(password) > MaxPasswordLen {
return fmt.Errorf("password must be at most %d characters", MaxPasswordLen)
}
return nil return nil
} }

View File

@@ -32,6 +32,17 @@ func TestPasswordTooShort(t *testing.T) {
} }
} }
func TestPasswordTooLong(t *testing.T) {
// Exactly MaxPasswordLen should be accepted.
if err := Password(strings.Repeat("a", MaxPasswordLen)); err != nil {
t.Errorf("Password(len=%d) = %v, want nil", MaxPasswordLen, err)
}
// One over the limit should be rejected.
if err := Password(strings.Repeat("a", MaxPasswordLen+1)); err == nil {
t.Errorf("Password(len=%d) = nil, want error", MaxPasswordLen+1)
}
}
func TestUsernameValid(t *testing.T) { func TestUsernameValid(t *testing.T) {
valid := []string{ valid := []string{
"alice", "alice",

View File

@@ -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.

View File

@@ -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

View File

@@ -77,7 +77,7 @@ WAL mode and foreign key enforcement are enabled automatically.
Issuer claim embedded in every JWT. Issuer claim embedded in every JWT.
Use the base URL of your MCIAS server. Use the base URL of your MCIAS server.
.It Sy default_expiry .It Sy default_expiry
.Pq optional, default 720h .Pq optional, default 168h
Token expiry for interactive logins. Token expiry for interactive logins.
Go duration string. Go duration string.
.It Sy admin_expiry .It Sy admin_expiry

View File

@@ -550,6 +550,17 @@ paths:
tags: [Auth] tags: [Auth]
security: security:
- bearerAuth: [] - bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
properties:
password:
type: string
description: Current account password (required to prevent session-theft escalation).
responses: responses:
"200": "200":
description: TOTP secret generated. description: TOTP secret generated.
@@ -995,6 +1006,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

View File

@@ -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

View File

@@ -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 ---

View File

@@ -45,8 +45,12 @@ message RenewTokenResponse {
// --- TOTP enrollment --- // --- TOTP enrollment ---
// EnrollTOTPRequest carries no body; the acting account is from the JWT. // EnrollTOTPRequest carries the current password for re-authentication.
message EnrollTOTPRequest {} // Security (SEC-01): password is required to prevent a stolen session token
// from being used to enroll attacker-controlled TOTP on the victim's account.
message EnrollTOTPRequest {
string password = 1; // security: current password required; never logged
}
// EnrollTOTPResponse returns the TOTP secret and otpauth URI for display. // EnrollTOTPResponse returns the TOTP secret and otpauth URI for display.
// Security: the secret is shown once; it is stored only in encrypted form. // Security: the secret is shown once; it is stored only in encrypted form.

104
proto/mcias/v1/policy.proto Normal file
View 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);
}

View File

@@ -223,19 +223,20 @@ func TestE2ELoginLogoutFlow(t *testing.T) {
// TestE2ETokenRenewal verifies that renewal returns a new token and revokes the old one. // TestE2ETokenRenewal verifies that renewal returns a new token and revokes the old one.
func TestE2ETokenRenewal(t *testing.T) { func TestE2ETokenRenewal(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.createAccount(t, "bob") acct := e.createAccount(t, "bob")
// Login. // Issue a short-lived token (2s) directly so we can wait past the 50%
resp := e.do(t, "POST", "/v1/auth/login", map[string]string{ // renewal threshold (SEC-03) without blocking the test for minutes.
"username": "bob", oldToken, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, nil, 2*time.Second)
"password": "testpass123", if err != nil {
}, "") t.Fatalf("IssueToken: %v", err)
mustStatus(t, resp, http.StatusOK)
var lr struct {
Token string `json:"token"`
} }
decodeJSON(t, resp, &lr) if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
oldToken := lr.Token t.Fatalf("TrackToken: %v", err)
}
// Wait for >50% of the 2s lifetime to elapse.
time.Sleep(1100 * time.Millisecond)
// Renew. // Renew.
resp2 := e.do(t, "POST", "/v1/auth/renew", nil, oldToken) resp2 := e.do(t, "POST", "/v1/auth/renew", nil, oldToken)

View File

@@ -435,6 +435,17 @@ paths:
tags: [Auth] tags: [Auth]
security: security:
- bearerAuth: [] - bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
properties:
password:
type: string
description: Current account password (required to prevent session-theft escalation).
responses: responses:
"200": "200":
description: TOTP secret generated. description: TOTP secret generated.

View File

@@ -12,10 +12,10 @@
<span class="nav-brand">MCIAS</span> <span class="nav-brand">MCIAS</span>
<ul class="nav-links"> <ul class="nav-links">
<li><a href="/dashboard">Dashboard</a></li> <li><a href="/dashboard">Dashboard</a></li>
<li><a href="/accounts">Accounts</a></li> {{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li>
<li><a href="/audit">Audit</a></li> <li><a href="/audit">Audit</a></li>
<li><a href="/policies">Policies</a></li> <li><a href="/policies">Policies</a></li>
<li><a href="/pgcreds">PG Creds</a></li> <li><a href="/pgcreds">PG Creds</a></li>{{end}}
{{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></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>

View File

@@ -4,6 +4,7 @@
<div class="page-header"> <div class="page-header">
<h1>Dashboard</h1> <h1>Dashboard</h1>
</div> </div>
{{if .IsAdmin}}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem">
<div class="card" style="text-align:center"> <div class="card" style="text-align:center">
<div style="font-size:2rem;font-weight:700;color:#2563eb">{{.TotalAccounts}}</div> <div style="font-size:2rem;font-weight:700;color:#2563eb">{{.TotalAccounts}}</div>
@@ -33,4 +34,9 @@
</div> </div>
</div> </div>
{{end}} {{end}}
{{else}}
<div class="card">
<p>Welcome, <strong>{{.ActorName}}</strong>. Use the navigation above to access your profile and credentials.</p>
</div>
{{end}}
{{end}} {{end}}

View File

@@ -12,6 +12,7 @@
{{range .Creds}} {{range .Creds}}
<div style="border:1px solid var(--color-border);border-radius:6px;padding:1rem;margin-bottom:1rem"> <div style="border:1px solid var(--color-border);border-radius:6px;padding:1rem;margin-bottom:1rem">
<dl style="display:grid;grid-template-columns:140px 1fr;gap:.35rem .75rem;font-size:.9rem;margin-bottom:.75rem"> <dl style="display:grid;grid-template-columns:140px 1fr;gap:.35rem .75rem;font-size:.9rem;margin-bottom:.75rem">
<dt class="text-muted">Credential ID</dt><dd><code style="font-size:.8rem;color:var(--color-fg-muted)">{{.ID}}</code></dd>
<dt class="text-muted">Service Account</dt><dd>{{.ServiceUsername}}</dd> <dt class="text-muted">Service Account</dt><dd>{{.ServiceUsername}}</dd>
<dt class="text-muted">Host</dt><dd>{{.PGHost}}:{{.PGPort}}</dd> <dt class="text-muted">Host</dt><dd>{{.PGHost}}:{{.PGPort}}</dd>
<dt class="text-muted">Database</dt><dd>{{.PGDatabase}}</dd> <dt class="text-muted">Database</dt><dd>{{.PGDatabase}}</dd>